diff --git a/package-lock.json b/package-lock.json index 5225d312..e3ed2d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11462,9 +11462,10 @@ } }, "node_modules/binary-layout": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/binary-layout/-/binary-layout-1.0.3.tgz", - "integrity": "sha512-kpXCSOko4wbQaQswZk4IPcjVZwN77TKZgjMacdoX54EvUHAn/CzJclCt25SUmpXfzFrGovoq3LkPJkMy10bZxQ==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/binary-layout/-/binary-layout-1.2.0.tgz", + "integrity": "sha512-K3EHOEEjDLDm3BdT6MXMu/8JQZx7wWMwPlV28ULxHIyW9RFqEp6sQB4Q22bt3u2EBP95H0mWNH6oDz7Cs8iPoA==", + "license": "Apache-2.0" }, "node_modules/bindings": { "version": "1.5.0", @@ -22523,6 +22524,7 @@ "algosdk": "^2.4.0", "anchor-0.29.0": "npm:@coral-xyz/anchor@^0.29.0", "aptos": "^1.4.0", + "binary-layout": "^1.2.0", "bs58": "^5.0.0", "dotenv": "^16.0.3", "firebase-admin": "^11.4.0", diff --git a/watcher/package.json b/watcher/package.json index 4002f168..b4ed455a 100644 --- a/watcher/package.json +++ b/watcher/package.json @@ -45,6 +45,7 @@ "algosdk": "^2.4.0", "anchor-0.29.0": "npm:@coral-xyz/anchor@^0.29.0", "aptos": "^1.4.0", + "binary-layout": "^1.2.0", "bs58": "^5.0.0", "dotenv": "^16.0.3", "firebase-admin": "^11.4.0", diff --git a/watcher/src/watchers/SolanaWatcher.ts b/watcher/src/watchers/SolanaWatcher.ts index cd49f1ee..94ae659d 100644 --- a/watcher/src/watchers/SolanaWatcher.ts +++ b/watcher/src/watchers/SolanaWatcher.ts @@ -18,16 +18,37 @@ import { universalAddress_stripped, } from '@wormhole-foundation/wormhole-monitor-common'; import { Watcher } from './Watcher'; -import { Network, contracts } from '@wormhole-foundation/sdk-base'; +import { Network, contracts, encoding } from '@wormhole-foundation/sdk-base'; import { deserializePostMessage } from '@wormhole-foundation/sdk-solana-core'; import { getAllKeys } from '../utils/solana'; +import { UniversalAddress } from '@wormhole-foundation/sdk-definitions'; +import { DeriveType, deserialize, Layout } from 'binary-layout'; const COMMITMENT: Commitment = 'finalized'; const GET_SIGNATURES_LIMIT = 1000; +const ShimContracts: { [key in Network]: string } = { + Mainnet: '', + Testnet: 'EtZMZM22ViKMo4r5y4Anovs3wKQ2owUmDpjygnMMcdEX', + Devnet: '', +}; + +const POST_MESSAGE_INSTRUCTION_ID = 0x01; +const POST_MESSAGE_UNRELIABLE_INSTRUCTION_ID = 0x08; +const shimMessageEventDiscriminator = 'e445a52e51cb9a1d441b8f004d4c8970'; + +const shimMessageEventLayout = [ + { name: 'discriminator', binary: 'bytes', size: 16 }, + { name: 'emitterAddress', binary: 'bytes', size: 32 }, + { name: 'sequence', binary: 'uint', size: 8, endianness: 'little' }, + { name: 'timestamp', binary: 'uint', size: 4, endianness: 'little' }, +] as const satisfies Layout; +export type ShimMessageEvent = DeriveType; + export class SolanaWatcher extends Watcher { readonly rpc: string; readonly programId: string; + readonly shimProgramId: string; // this is set as a class field so we can modify it in tests getSignaturesLimit = GET_SIGNATURES_LIMIT; // The Solana watcher uses the `getSignaturesForAddress` RPC endpoint to fetch all transactions @@ -43,6 +64,7 @@ export class SolanaWatcher extends Watcher { super(network, 'Solana', mode); this.rpc = RPCS_BY_CHAIN[this.network].Solana!; this.programId = contracts.coreBridge(this.network, 'Solana'); + this.shimProgramId = ShimContracts[this.network]; } getConnection(): Connection { @@ -162,6 +184,9 @@ export class SolanaWatcher extends Watcher { const accountKeys = await getAllKeys(this.getConnection(), res); const programIdIndex = accountKeys.findIndex((i) => i.toBase58() === this.programId); + const shimProgramIdIndex = accountKeys.findIndex( + (i) => i.toBase58() === this.shimProgramId + ); const message: VersionedMessage = res.transaction.message; const instructions = message.compiledInstructions; const innerInstructions = @@ -169,32 +194,65 @@ export class SolanaWatcher extends Watcher { i.instructions.map(normalizeCompileInstruction) ) || []; + // Need to look for Wormhole instructions and shim instructions const whInstructions = innerInstructions .concat(instructions) - .filter((i) => i.programIdIndex === programIdIndex); + .filter( + (i) => i.programIdIndex === programIdIndex || i.programIdIndex === shimProgramIdIndex + ); const blockKey = makeBlockKey( res.slot.toString(), new Date(res.blockTime * 1000).toISOString() ); + let needShim = false; + let gotShim = false; const vaaKeys: string[] = []; for (const instruction of whInstructions) { - // skip if not postMessage instruction const instructionId = instruction.data; - if (instructionId[0] !== 0x08 && instructionId[0] !== 0x01) continue; + if ( + instruction.programIdIndex === programIdIndex && + instructionId[0] === POST_MESSAGE_UNRELIABLE_INSTRUCTION_ID + ) { + // TODO: Do I need to verify that this message has no data? + // This is an unreliable wormhole message. It is only used in conjunction with a shim message. + needShim = true; + continue; + } - const accountId = accountKeys[instruction.accountKeyIndexes[1]]; + let emitterAddress: UniversalAddress; + let sequence: bigint; - const acctInfo = await this.getConnection().getAccountInfo(accountId, COMMITMENT); - if (!acctInfo?.data) throw new Error('No data found in message account'); - const { emitterAddress, sequence } = deserializePostMessage( - new Uint8Array(acctInfo.data) - ); + if (instruction.programIdIndex === programIdIndex) { + if (instructionId[0] !== POST_MESSAGE_INSTRUCTION_ID) { + console.log('Got non-post message instruction'); + continue; + } + const accountId = accountKeys[instruction.accountKeyIndexes[1]]; + + const acctInfo = await this.getConnection().getAccountInfo(accountId, COMMITMENT); + if (!acctInfo?.data) throw new Error('No data found in message account'); + const deserializedMsg = deserializePostMessage(new Uint8Array(acctInfo.data)); + emitterAddress = deserializedMsg.emitterAddress; + sequence = deserializedMsg.sequence; + } else { + // instruction.programIdIndex === shimProgramIdIndex + console.log('Got shim instruction'); + gotShim = true; + const parsedMsg = this.parseShimMessage(instruction.data); + if (!parsedMsg) { + console.log('Failed to parse shim message'); + continue; + } + emitterAddress = parsedMsg.emitterAddress; + sequence = parsedMsg.sequence; + } + // TODO: should I check if needShim === gotShim? vaaKeys.push( makeVaaKey( - res.transaction.signatures[0], + res.transaction.signatures[0], // This is the tx hash this.chain, universalAddress_stripped(emitterAddress), sequence.toString() @@ -217,6 +275,25 @@ export class SolanaWatcher extends Watcher { return { vaasByBlock: { [lastBlockKey]: [], ...vaasByBlock } }; } + parseShimMessage(data: Uint8Array): { + emitterAddress: UniversalAddress; + sequence: bigint; + } | null { + // First step is to convert the data into a hex string + const hexData = encoding.hex.encode(data); + + // Next, we need to look for the discriminator that we care about. + if (hexData.startsWith(shimMessageEventDiscriminator)) { + // Use the binary layout to deserialize the data + const decoded = deserialize(shimMessageEventLayout, data); + const emitterAddress = new UniversalAddress(decoded.emitterAddress); + const sequence = BigInt(decoded.sequence); + + return { emitterAddress, sequence }; + } + return null; + } + isValidVaaKey(key: string) { try { const [txHash, vaaKey] = key.split(':');