diff --git a/indexer/src/adapters/axelar.rs b/indexer/src/adapters/axelar.rs index 8d1bb62..d6c501a 100644 --- a/indexer/src/adapters/axelar.rs +++ b/indexer/src/adapters/axelar.rs @@ -1,11 +1,87 @@ -use seraph_shared::{CrossChainEvent, RawLog}; +use alloy::primitives::B256; +use alloy::sol; +use alloy::sol_types::SolEvent; +use chrono::Utc; +use uuid::Uuid; + +use seraph_shared::{ChainId, CrossChainEvent, RawLog, TxStatus, types::chain}; + +// ── Event signature ─────────────────────────────────────────────────────────── +// +// Axelar is a proof-of-stake network that relays general-purpose cross-chain +// messages (GMP). ContractCall is emitted by the AxelarGateway contract when +// a dApp calls callContract() to send a message to another chain. +// +// Indexed → topics[1..2]: +// sender — the contract that initiated the call +// payloadHash — keccak256 of the payload bytes +// +// Non-indexed → data: +// destinationChain — Axelar chain name (e.g. "ethereum", "arbitrum") +// destinationContractAddress — the target contract on the destination chain +// payload — arbitrary bytes passed to the target +// +// payloadHash is the correlation key: Axelar validators sign over it, and the +// Approved event on the destination gateway carries the same hash. +// +// Note: ContractCallWithToken (which includes asset + amount) is a separate +// event and is not handled by this adapter. It can be added in a follow-up. +// +// Source: https://github.com/axelarnetwork/axelar-cgp-solidity/blob/main/contracts/interfaces/IAxelarGateway.sol +sol! { + event ContractCall( + address indexed sender, + string destinationChain, + string destinationContractAddress, + bytes32 indexed payloadHash, + bytes payload + ); +} pub struct Axelar; impl Axelar { - pub fn parse_event(&self, _log: &RawLog) -> Option { - // Implemented in step 2.9 - None + pub fn parse_event(&self, log: &RawLog) -> Option { + let topics: Vec = log + .topics + .iter() + .filter_map(|t| t.parse::().ok()) + .collect(); + + if topics.first() != Some(&ContractCall::SIGNATURE_HASH) { + return None; + } + + let decoded = ContractCall::decode_raw_log(topics, &log.data).ok()?; + + let correlation_id = decoded.payloadHash.to_string(); + let dest_chain = axelar_chain_to_chain(&decoded.destinationChain); + + let now = Utc::now(); + + Some(CrossChainEvent { + id: Uuid::new_v4(), + source_tx_hash: log.tx_hash.clone().unwrap_or_default(), + source_chain: log.chain_id.clone(), + dest_chain, + sender_address: decoded.sender.to_string(), + // destinationContractAddress is the raw string from the event; + // on EVM chains it is a checksummed address, on Cosmos it may be + // a bech32 address. We store it as-is. + receiver_address: Some(decoded.destinationContractAddress.clone()), + amount: None, + token_address: None, + protocol_id: "axelar".to_string(), + correlation_id, + status: TxStatus::Pending, + metadata: serde_json::json!({ + "destination_chain": decoded.destinationChain, + "destination_contract": decoded.destinationContractAddress, + "payload_len": decoded.payload.len(), + }), + created_at: now, + updated_at: now, + }) } #[allow(dead_code)] @@ -13,3 +89,22 @@ impl Axelar { event.correlation_id.clone() } } + +// Axelar uses its own chain name strings. "binance" maps to our "bsc". +// Source: https://docs.axelar.dev/dev/reference/mainnet-chain-names +fn axelar_chain_to_chain(name: &str) -> Option { + match name { + "ethereum" | "Ethereum" => Some(chain::ethereum()), + "arbitrum" | "arbitrum-sepolia" => Some(chain::arbitrum()), + "optimism" | "optimism-sepolia" => Some(chain::optimism()), + "base" | "base-sepolia" => Some(chain::base()), + "polygon" | "Polygon" => Some(chain::polygon()), + "binance" | "binance-sepolia" => Some(chain::bsc()), + "avalanche" | "Avalanche" => Some(chain::avalanche()), + _ => None, + } +} + +#[cfg(test)] +#[path = "tests/axelar.rs"] +mod tests; diff --git a/indexer/src/adapters/cctp.rs b/indexer/src/adapters/cctp.rs index aa46cf4..400d4d9 100644 --- a/indexer/src/adapters/cctp.rs +++ b/indexer/src/adapters/cctp.rs @@ -1,11 +1,86 @@ -use seraph_shared::{CrossChainEvent, RawLog}; +use alloy::primitives::{Address, B256}; +use alloy::sol; +use alloy::sol_types::SolEvent; +use chrono::Utc; +use uuid::Uuid; + +use seraph_shared::{ChainId, CrossChainEvent, RawLog, TxStatus, types::chain}; + +// ── Event signature ─────────────────────────────────────────────────────────── +// +// CCTP V2 (Circle's Cross-Chain Transfer Protocol) burns USDC/USYC on the +// source chain and mints it natively on the destination. The TokenMessengerV2 +// contract is deployed at the same address on every supported chain. +// +// Indexed → topics[1..3]: +// nonce — monotonically increasing uint64 per source domain +// burnToken — the token being burned (USDC or USYC) +// depositor — address that initiated the burn +// +// Non-indexed → data: +// amount, mintRecipient (bytes32), destinationDomain, +// destinationTokenMessenger, destinationCaller +// +// Source: https://developers.circle.com/stablecoins/cctp-technical-reference +sol! { + event DepositForBurn( + uint64 indexed nonce, + address indexed burnToken, + uint256 amount, + address indexed depositor, + bytes32 mintRecipient, + uint32 destinationDomain, + bytes32 destinationTokenMessenger, + bytes32 destinationCaller + ); +} pub struct Cctp; impl Cctp { - pub fn parse_event(&self, _log: &RawLog) -> Option { - // Implemented in step 2.5 - None + pub fn parse_event(&self, log: &RawLog) -> Option { + let topics: Vec = log + .topics + .iter() + .filter_map(|t| t.parse::().ok()) + .collect(); + + if topics.first() != Some(&DepositForBurn::SIGNATURE_HASH) { + return None; + } + + let decoded = DepositForBurn::decode_raw_log(topics, &log.data).ok()?; + + // nonce is unique per source domain, not globally unique. + // Scoping it to the source chain makes it a safe stitcher join key. + let correlation_id = format!("cctp:{}:{}", log.chain_id, decoded.nonce); + + // mintRecipient is bytes32: EVM addresses are right-aligned (last 20 bytes). + let receiver = Address::from_slice(&decoded.mintRecipient.0[12..]).to_string(); + + let dest_chain = cctp_domain_to_chain(decoded.destinationDomain); + + let now = Utc::now(); + + Some(CrossChainEvent { + id: Uuid::new_v4(), + source_tx_hash: log.tx_hash.clone().unwrap_or_default(), + source_chain: log.chain_id.clone(), + dest_chain, + sender_address: decoded.depositor.to_string(), + receiver_address: Some(receiver), + amount: Some(decoded.amount.to_string()), + token_address: Some(decoded.burnToken.to_string()), + protocol_id: "cctp".to_string(), + correlation_id, + status: TxStatus::Pending, + metadata: serde_json::json!({ + "nonce": decoded.nonce, + "destination_domain": decoded.destinationDomain, + }), + created_at: now, + updated_at: now, + }) } #[allow(dead_code)] @@ -13,3 +88,22 @@ impl Cctp { event.correlation_id.clone() } } + +// CCTP domain ID → internal ChainId. +// Domains are assigned by Circle, distinct from EVM chain IDs. +// Source: https://developers.circle.com/stablecoins/docs/supported-domains +fn cctp_domain_to_chain(domain: u32) -> Option { + match domain { + 0 => Some(chain::ethereum()), + 1 => Some(chain::avalanche()), + 2 => Some(chain::optimism()), + 3 => Some(chain::arbitrum()), + 6 => Some(chain::base()), + 7 => Some(chain::polygon()), + _ => None, + } +} + +#[cfg(test)] +#[path = "tests/cctp.rs"] +mod tests; diff --git a/indexer/src/adapters/connext.rs b/indexer/src/adapters/connext.rs index 20c563f..074af63 100644 --- a/indexer/src/adapters/connext.rs +++ b/indexer/src/adapters/connext.rs @@ -1,11 +1,99 @@ -use seraph_shared::{CrossChainEvent, RawLog}; +use alloy::primitives::B256; +use alloy::sol; +use alloy::sol_types::SolEvent; +use chrono::Utc; +use uuid::Uuid; + +use seraph_shared::{ChainId, CrossChainEvent, RawLog, TxStatus, types::chain}; + +// ── Event signature ─────────────────────────────────────────────────────────── +// +// Connext is a modular interoperability protocol. XCalled is emitted on the +// source chain's Connext diamond contract when a user initiates a cross-chain +// call (with or without asset transfer). +// +// Indexed → topics[1..3]: +// transferId — bytes32 uniquely identifying this cross-chain transfer +// nonce — per-sender monotonic counter +// messageHash — hash of the cross-chain message body +// +// Non-indexed → data (ABI-encoded): +// params (TransferInfo struct), asset, amount, local, messageBody +// +// TransferInfo contains the origin/destination domains, the recipient (to), +// the sender (originSender), the bridged amount, and routing metadata. +// +// Source: https://github.com/connext/monorepo/blob/main/packages/deployments/contracts/contracts/core/connext/interfaces/IConnext.sol +sol! { + struct TransferInfo { + uint32 originDomain; + uint32 destinationDomain; + uint32 canonicalDomain; + address to; + address delegate; + bool receiveLocal; + bytes callData; + uint256 slippage; + address originSender; + uint256 bridgedAmt; + uint256 normalizedIn; + uint256 nonce; + bytes32 canonicalId; + } + + event XCalled( + bytes32 indexed transferId, + uint256 indexed nonce, + bytes32 indexed messageHash, + TransferInfo params, + address asset, + uint256 amount, + address local, + bytes messageBody + ); +} pub struct Connext; impl Connext { - pub fn parse_event(&self, _log: &RawLog) -> Option { - // Implemented in step 2.7 - None + pub fn parse_event(&self, log: &RawLog) -> Option { + let topics: Vec = log + .topics + .iter() + .filter_map(|t| t.parse::().ok()) + .collect(); + + if topics.first() != Some(&XCalled::SIGNATURE_HASH) { + return None; + } + + let decoded = XCalled::decode_raw_log(topics, &log.data).ok()?; + + let correlation_id = decoded.transferId.to_string(); + let dest_chain = connext_domain_to_chain(decoded.params.destinationDomain); + + let now = Utc::now(); + + Some(CrossChainEvent { + id: Uuid::new_v4(), + source_tx_hash: log.tx_hash.clone().unwrap_or_default(), + source_chain: log.chain_id.clone(), + dest_chain, + sender_address: decoded.params.originSender.to_string(), + receiver_address: Some(decoded.params.to.to_string()), + amount: Some(decoded.amount.to_string()), + token_address: Some(decoded.asset.to_string()), + protocol_id: "connext".to_string(), + correlation_id, + status: TxStatus::Pending, + metadata: serde_json::json!({ + "origin_domain": decoded.params.originDomain, + "destination_domain": decoded.params.destinationDomain, + "slippage": decoded.params.slippage.to_string(), + }), + created_at: now, + updated_at: now, + }) } #[allow(dead_code)] @@ -13,3 +101,22 @@ impl Connext { event.correlation_id.clone() } } + +// Connext assigns its own domain IDs, separate from EVM chain IDs. +// Source: https://docs.connext.network/resources/supported-chains +fn connext_domain_to_chain(domain: u32) -> Option { + match domain { + 6_648_936 => Some(chain::ethereum()), + 1_634_886_255 => Some(chain::arbitrum()), + 1_869_640_809 => Some(chain::optimism()), + 1_650_553_709 => Some(chain::base()), + 1_886_350_457 => Some(chain::polygon()), + 6_450_786 => Some(chain::bsc()), + 1_635_148_152 => Some(chain::avalanche()), + _ => None, + } +} + +#[cfg(test)] +#[path = "tests/connext.rs"] +mod tests; diff --git a/indexer/src/adapters/hop.rs b/indexer/src/adapters/hop.rs index bf78b56..4d858e4 100644 --- a/indexer/src/adapters/hop.rs +++ b/indexer/src/adapters/hop.rs @@ -1,11 +1,88 @@ -use seraph_shared::{CrossChainEvent, RawLog}; +use alloy::primitives::{B256, U256}; +use alloy::sol; +use alloy::sol_types::SolEvent; +use chrono::Utc; +use uuid::Uuid; + +use seraph_shared::{ChainId, CrossChainEvent, RawLog, TxStatus, types::chain}; + +// ── Event signature ─────────────────────────────────────────────────────────── +// +// Hop Protocol bridges tokens across rollups by running AMM liquidity pools +// (hTokens) on each chain. TransferSent is emitted by the L2_Bridge contract +// when a user initiates a transfer from an L2 to another chain. +// +// Indexed → topics[1..3]: +// transferId — bytes32 hash uniquely identifying this transfer +// chainId — raw EVM chain ID of the destination +// recipient — the address that will receive funds on the destination +// +// Non-indexed → data: +// amount, transferNonce, bonderFee, amountOutMin, deadline +// +// Limitation: TransferSent does not include the sender address — that is +// msg.sender of the originating transaction, which is not stored in the log. +// We leave sender_address as an empty string; a future RawLog extension to +// carry tx.from would allow us to populate it. +// +// Note: Ethereum L1 uses a different event (TransferSentToL2) with no +// transferId. This adapter covers only the L2-source case. +// +// Source: https://github.com/hop-protocol/contracts/blob/master/contracts/bridges/L2_Bridge.sol +sol! { + event TransferSent( + bytes32 indexed transferId, + uint256 indexed chainId, + address indexed recipient, + uint256 amount, + bytes32 transferNonce, + uint256 bonderFee, + uint256 amountOutMin, + uint256 deadline + ); +} pub struct Hop; impl Hop { - pub fn parse_event(&self, _log: &RawLog) -> Option { - // Implemented in step 2.6 - None + pub fn parse_event(&self, log: &RawLog) -> Option { + let topics: Vec = log + .topics + .iter() + .filter_map(|t| t.parse::().ok()) + .collect(); + + if topics.first() != Some(&TransferSent::SIGNATURE_HASH) { + return None; + } + + let decoded = TransferSent::decode_raw_log(topics, &log.data).ok()?; + + let correlation_id = decoded.transferId.to_string(); + let dest_chain = evm_chain_id_to_chain(decoded.chainId); + + let now = Utc::now(); + + Some(CrossChainEvent { + id: Uuid::new_v4(), + source_tx_hash: log.tx_hash.clone().unwrap_or_default(), + source_chain: log.chain_id.clone(), + dest_chain, + sender_address: String::new(), // not emitted — see module comment + receiver_address: Some(decoded.recipient.to_string()), + amount: Some(decoded.amount.to_string()), + token_address: None, + protocol_id: "hop".to_string(), + correlation_id, + status: TxStatus::Pending, + metadata: serde_json::json!({ + "bonder_fee": decoded.bonderFee.to_string(), + "amount_out_min": decoded.amountOutMin.to_string(), + "deadline": decoded.deadline.to_string(), + }), + created_at: now, + updated_at: now, + }) } #[allow(dead_code)] @@ -13,3 +90,21 @@ impl Hop { event.correlation_id.clone() } } + +// Raw EVM chain ID → internal ChainId (same table as across.rs). +fn evm_chain_id_to_chain(chain_id: U256) -> Option { + match chain_id.to::() { + 1 => Some(chain::ethereum()), + 42161 => Some(chain::arbitrum()), + 10 => Some(chain::optimism()), + 8453 => Some(chain::base()), + 137 => Some(chain::polygon()), + 56 => Some(chain::bsc()), + 43114 => Some(chain::avalanche()), + _ => None, + } +} + +#[cfg(test)] +#[path = "tests/hop.rs"] +mod tests; diff --git a/indexer/src/adapters/stargate.rs b/indexer/src/adapters/stargate.rs index 03cdb5f..6b4d21d 100644 --- a/indexer/src/adapters/stargate.rs +++ b/indexer/src/adapters/stargate.rs @@ -1,11 +1,88 @@ -use seraph_shared::{CrossChainEvent, RawLog}; +use alloy::primitives::B256; +use alloy::sol; +use alloy::sol_types::SolEvent; +use chrono::Utc; +use uuid::Uuid; + +use seraph_shared::{ChainId, CrossChainEvent, RawLog, TxStatus, types::chain}; + +// ── Event signature ─────────────────────────────────────────────────────────── +// +// Stargate V2 is built on top of LayerZero V2's OFT (Omnichain Fungible Token) +// standard. Each token pool (ETH, USDC, USDT, mETH, …) is its own contract, +// all emitting the same OFTSent event when a cross-chain transfer is initiated. +// +// Indexed → topics[1..2]: +// guid — bytes32 derived from the underlying LZ V2 packet GUID +// from — the address that initiated the send +// +// Non-indexed → data: +// dstEid — LayerZero endpoint ID of the destination chain +// amountSentLD — amount locked on source in local decimals +// amountReceivedLD — amount the recipient gets after fees +// +// The recipient address is encoded inside the LZ message payload and is not +// surfaced directly in OFTSent. receiver_address is set to None; it will be +// filled in by the corresponding OFTReceived event on the destination side. +// +// Source: https://github.com/LayerZero-Labs/devtools/blob/main/packages/oft-evm/contracts/OFTCore.sol +sol! { + event OFTSent( + bytes32 indexed guid, + uint32 dstEid, + address indexed from, + uint256 amountSentLD, + uint256 amountReceivedLD + ); +} pub struct Stargate; impl Stargate { - pub fn parse_event(&self, _log: &RawLog) -> Option { - // Implemented in step 2.4 - None + pub fn parse_event(&self, log: &RawLog) -> Option { + let topics: Vec = log + .topics + .iter() + .filter_map(|t| t.parse::().ok()) + .collect(); + + if topics.first() != Some(&OFTSent::SIGNATURE_HASH) { + return None; + } + + let decoded = OFTSent::decode_raw_log(topics, &log.data).ok()?; + + // guid is the LZ V2 packet GUID — the same value appears in the + // OFTReceived event on the destination chain, giving the stitcher a + // direct join key without any secondary lookup. + let correlation_id = decoded.guid.to_string(); + let dest_chain = eid_to_chain(decoded.dstEid); + + let now = Utc::now(); + + Some(CrossChainEvent { + id: Uuid::new_v4(), + source_tx_hash: log.tx_hash.clone().unwrap_or_default(), + source_chain: log.chain_id.clone(), + dest_chain, + sender_address: decoded.from.to_string(), + receiver_address: None, + amount: Some(decoded.amountSentLD.to_string()), + // log.address is the pool contract (e.g. the USDC pool), not the + // underlying ERC-20 token. We store it in metadata so the frontend + // can identify which asset was bridged. + token_address: None, + protocol_id: "stargate".to_string(), + correlation_id, + status: TxStatus::Pending, + metadata: serde_json::json!({ + "dst_eid": decoded.dstEid, + "amount_received_ld": decoded.amountReceivedLD.to_string(), + "pool_address": log.address, + }), + created_at: now, + updated_at: now, + }) } #[allow(dead_code)] @@ -13,3 +90,21 @@ impl Stargate { event.correlation_id.clone() } } + +// LayerZero endpoint ID → internal ChainId (same table as layerzero_v2.rs). +fn eid_to_chain(eid: u32) -> Option { + match eid { + 30101 => Some(chain::ethereum()), + 30110 => Some(chain::arbitrum()), + 30111 => Some(chain::optimism()), + 30184 => Some(chain::base()), + 30109 => Some(chain::polygon()), + 30102 => Some(chain::bsc()), + 30106 => Some(chain::avalanche()), + _ => None, + } +} + +#[cfg(test)] +#[path = "tests/stargate.rs"] +mod tests; diff --git a/indexer/src/adapters/tests/axelar.rs b/indexer/src/adapters/tests/axelar.rs new file mode 100644 index 0000000..042732d --- /dev/null +++ b/indexer/src/adapters/tests/axelar.rs @@ -0,0 +1,120 @@ +use super::*; +use seraph_shared::types::chain; + +// ContractCall: 2 indexed params, 3 non-indexed (all dynamic strings/bytes). +// +// Topics: [SIGNATURE_HASH, sender(address), payloadHash(bytes32)] +// Data ABI layout: +// head (3 × 32 = 96 bytes): +// slot 0: offset to destinationChain = 96 +// slot 1: offset to destinationContractAddress +// slot 2: offset to payload +// tails: each dynamic field as (length, bytes-padded-to-32-multiple) +fn make_log( + sender: [u8; 20], + payload_hash: [u8; 32], + dest_chain_str: &str, + dest_contract: &str, +) -> RawLog { + let mut t_sender = [0u8; 32]; + t_sender[12..].copy_from_slice(&sender); + + let chain_bytes = dest_chain_str.as_bytes(); + let contract_bytes = dest_contract.as_bytes(); + let payload_bytes: &[u8] = &[]; + + // Encode one dynamic field as (length, padded-bytes) + let encode_dyn = |s: &[u8]| -> Vec { + let n = s.len(); + let padded = (n + 31) & !31; + let mut out = Vec::with_capacity(32 + padded); + let mut len_slot = [0u8; 32]; + len_slot[24..].copy_from_slice(&(n as u64).to_be_bytes()); + out.extend_from_slice(&len_slot); + out.extend_from_slice(s); + out.extend_from_slice(&vec![0u8; padded - n]); + out + }; + + let enc_chain = encode_dyn(chain_bytes); + let enc_contract = encode_dyn(contract_bytes); + let enc_payload = encode_dyn(payload_bytes); + + let head_size: u64 = 3 * 32; + let offset_chain = head_size; + let offset_contract = head_size + enc_chain.len() as u64; + let offset_payload = offset_contract + enc_contract.len() as u64; + + let mut data = Vec::::new(); + let mut u256 = |val: u64| { + let mut s = [0u8; 32]; + s[24..].copy_from_slice(&val.to_be_bytes()); + data.extend_from_slice(&s); + }; + + u256(offset_chain); + u256(offset_contract); + u256(offset_payload); + data.extend_from_slice(&enc_chain); + data.extend_from_slice(&enc_contract); + data.extend_from_slice(&enc_payload); + + RawLog { + address: "0x4F4495243837681061C4743b74B3eEdf548D56A5".to_string(), + topics: vec![ + format!("{}", ContractCall::SIGNATURE_HASH), + format!("0x{}", alloy::primitives::hex::encode(t_sender)), + format!("0x{}", alloy::primitives::hex::encode(payload_hash)), + ], + data, + block_number: Some(22_000_000), + tx_hash: Some("0xdeadbeef".to_string()), + log_index: Some(0), + chain_id: chain::ethereum(), + } +} + +#[test] +fn parses_valid_contract_call() { + let sender = [0xAA; 20]; + let payload_hash = [0xBB; 32]; + let dest_contract = "0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000"; + + let log = make_log(sender, payload_hash, "arbitrum", dest_contract); + let event = Axelar.parse_event(&log).expect("should parse ContractCall"); + + assert_eq!(event.protocol_id, "axelar"); + assert_eq!(event.source_chain, chain::ethereum()); + assert_eq!(event.dest_chain, Some(chain::arbitrum())); + assert_eq!(event.correlation_id, format!("0x{}", "bb".repeat(32))); + assert_eq!( + event.sender_address.to_lowercase(), + format!("0x{}", "aa".repeat(20)) + ); + assert_eq!(event.receiver_address.as_deref(), Some(dest_contract)); + assert!(event.amount.is_none()); + assert_eq!(event.metadata["destination_chain"], "arbitrum"); +} + +#[test] +fn rejects_wrong_topic() { + let mut log = make_log([0u8; 20], [0u8; 32], "ethereum", "0x0"); + log.topics[0] = + "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(); + assert!(Axelar.parse_event(&log).is_none()); +} + +#[test] +fn unknown_dest_chain_name_gives_none() { + let log = make_log([0u8; 20], [0u8; 32], "solana-mainnet-beta", "someaddress"); + let event = Axelar.parse_event(&log).expect("should still parse"); + assert!(event.dest_chain.is_none()); +} + +#[test] +fn payload_hash_is_correlation_id() { + let hash = [0xCC; 32]; + let log = make_log([0u8; 20], hash, "ethereum", "0x0"); + let event = Axelar.parse_event(&log).unwrap(); + assert_eq!(event.correlation_id, format!("0x{}", "cc".repeat(32))); +} diff --git a/indexer/src/adapters/tests/cctp.rs b/indexer/src/adapters/tests/cctp.rs new file mode 100644 index 0000000..9c773f7 --- /dev/null +++ b/indexer/src/adapters/tests/cctp.rs @@ -0,0 +1,113 @@ +use super::*; +use seraph_shared::types::chain; + +fn push_slot(data: &mut Vec, bytes: &[u8]) { + let mut s = [0u8; 32]; + s[32 - bytes.len()..].copy_from_slice(bytes); + data.extend_from_slice(&s); +} + +// DepositForBurn: 3 indexed params, 5 non-indexed (all static). +// +// Topics: [SIGNATURE_HASH, nonce(uint64), burnToken(address), depositor(address)] +// Data: [amount(uint256), mintRecipient(bytes32), destinationDomain(uint32), +// destinationTokenMessenger(bytes32), destinationCaller(bytes32)] +fn make_log( + nonce: u64, + burn_token: [u8; 20], + depositor: [u8; 20], + amount: u128, + mint_recipient: [u8; 32], + dest_domain: u32, +) -> RawLog { + let mut t_nonce = [0u8; 32]; + t_nonce[24..].copy_from_slice(&nonce.to_be_bytes()); + + let mut t_burn_token = [0u8; 32]; + t_burn_token[12..].copy_from_slice(&burn_token); + + let mut t_depositor = [0u8; 32]; + t_depositor[12..].copy_from_slice(&depositor); + + let mut data = Vec::::new(); + push_slot(&mut data, &amount.to_be_bytes()); // amount (uint256, u128 right-aligned) + data.extend_from_slice(&mint_recipient); // mintRecipient (bytes32, already 32 bytes) + push_slot(&mut data, &dest_domain.to_be_bytes()); // destinationDomain (uint32) + data.extend_from_slice(&[0u8; 32]); // destinationTokenMessenger (bytes32) + data.extend_from_slice(&[0u8; 32]); // destinationCaller (bytes32) + + RawLog { + address: "0xBd3fa81B58Ba92a82136038B25aDec7066af3155".to_string(), + topics: vec![ + format!("{}", DepositForBurn::SIGNATURE_HASH), + format!("0x{}", alloy::primitives::hex::encode(t_nonce)), + format!("0x{}", alloy::primitives::hex::encode(t_burn_token)), + format!("0x{}", alloy::primitives::hex::encode(t_depositor)), + ], + data, + block_number: Some(22_000_000), + tx_hash: Some("0xdeadbeef".to_string()), + log_index: Some(0), + chain_id: chain::ethereum(), + } +} + +#[test] +fn parses_valid_deposit_for_burn() { + let burn_token = [0xAA; 20]; + let depositor = [0xBB; 20]; + let mut mint_recipient = [0u8; 32]; + mint_recipient[12..].copy_from_slice(&[0xCC; 20]); // EVM: right-aligned in bytes32 + + let log = make_log(42, burn_token, depositor, 1_000_000_000, mint_recipient, 3); + let event = Cctp.parse_event(&log).expect("should parse DepositForBurn"); + + assert_eq!(event.protocol_id, "cctp"); + assert_eq!(event.source_chain, chain::ethereum()); + assert_eq!(event.dest_chain, Some(chain::arbitrum())); // domain 3 + assert_eq!(event.correlation_id, "cctp:ethereum:42"); + assert_eq!( + event.sender_address.to_lowercase(), + format!("0x{}", "bb".repeat(20)) + ); + assert_eq!( + event.receiver_address.as_deref().map(str::to_lowercase), + Some(format!("0x{}", "cc".repeat(20))) + ); + assert_eq!(event.amount, Some("1000000000".to_string())); + assert_eq!( + event.token_address.as_deref().map(str::to_lowercase), + Some(format!("0x{}", "aa".repeat(20))) + ); + assert_eq!(event.metadata["nonce"], 42); + assert_eq!(event.metadata["destination_domain"], 3); +} + +#[test] +fn rejects_wrong_topic() { + let mut log = make_log(1, [0u8; 20], [0u8; 20], 100, [0u8; 32], 3); + log.topics[0] = + "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(); + assert!(Cctp.parse_event(&log).is_none()); +} + +#[test] +fn unknown_dest_domain_gives_none_dest_chain() { + let log = make_log(1, [0u8; 20], [0u8; 20], 100, [0u8; 32], 99); + let event = Cctp.parse_event(&log).expect("should still parse"); + assert!(event.dest_chain.is_none()); +} + +#[test] +fn correlation_id_scoped_to_source_chain() { + // Same nonce on different source chains must produce different correlation IDs. + let log_eth = make_log(1, [0u8; 20], [0u8; 20], 100, [0u8; 32], 3); + let mut log_arb = make_log(1, [0u8; 20], [0u8; 20], 100, [0u8; 32], 3); + log_arb.chain_id = chain::arbitrum(); + + let id_eth = Cctp.parse_event(&log_eth).unwrap().correlation_id; + let id_arb = Cctp.parse_event(&log_arb).unwrap().correlation_id; + assert_ne!(id_eth, id_arb); + assert_eq!(id_eth, "cctp:ethereum:1"); + assert_eq!(id_arb, "cctp:arbitrum:1"); +} diff --git a/indexer/src/adapters/tests/connext.rs b/indexer/src/adapters/tests/connext.rs new file mode 100644 index 0000000..0004b7a --- /dev/null +++ b/indexer/src/adapters/tests/connext.rs @@ -0,0 +1,166 @@ +use super::*; +use seraph_shared::types::chain; + +// XCalled: 3 indexed params, 5 non-indexed. +// +// Topics: [SIGNATURE_HASH, transferId(bytes32), nonce(uint256), messageHash(bytes32)] +// +// Non-indexed data encodes (TransferInfo, address asset, uint256 amount, +// address local, bytes messageBody). TransferInfo is dynamic because it +// contains `bytes callData`. Full layout = 640 bytes; see byte-offset +// comments inside make_log for details. +#[allow(clippy::too_many_arguments)] +fn make_log( + transfer_id: [u8; 32], + nonce_indexed: u64, + origin_domain: u32, + dest_domain: u32, + to: [u8; 20], + origin_sender: [u8; 20], + amount: u128, + asset: [u8; 20], +) -> RawLog { + let mut t_nonce = [0u8; 32]; + t_nonce[24..].copy_from_slice(&nonce_indexed.to_be_bytes()); + + // Pre-zeroed; we only write non-zero values. + let mut data = vec![0u8; 640]; + + // Right-align `bytes` into a 32-byte slot starting at `off`. + fn write_slot(data: &mut [u8], off: usize, bytes: &[u8]) { + data[off + 32 - bytes.len()..off + 32].copy_from_slice(bytes); + } + + // ── Top-level head (5 × 32 = 160 B) ──────────────────────────────────── + // [ 0.. 32] offset to params = 160 + write_slot(&mut data, 0, &160u64.to_be_bytes()); + // [ 32.. 64] asset (address, right-aligned 20 B in 32 B) + write_slot(&mut data, 32, &asset); + // [ 64.. 96] amount (uint256) + write_slot(&mut data, 64, &amount.to_be_bytes()); + // [ 96..128] local = zero address (already zeroed) + // [128..160] offset to messageBody = 608 (160 + 448) + write_slot(&mut data, 128, &608u64.to_be_bytes()); + + // ── TransferInfo head (13 × 32 = 416 B, starts at 160) ───────────────── + // [160..192] originDomain (uint32) + write_slot(&mut data, 160, &origin_domain.to_be_bytes()); + // [192..224] destinationDomain (uint32) + write_slot(&mut data, 192, &dest_domain.to_be_bytes()); + // [224..256] canonicalDomain = 0 + // [256..288] to (address) + write_slot(&mut data, 256, &to); + // [288..320] delegate = zero + // [320..352] receiveLocal = false + // [352..384] offset to callData = 416 (relative to TransferInfo start) + write_slot(&mut data, 352, &416u64.to_be_bytes()); + // [384..416] slippage = 0 + // [416..448] originSender (address) + write_slot(&mut data, 416, &origin_sender); + // [448..480] bridgedAmt = 0 + // [480..512] normalizedIn = 0 + // [512..544] nonce = 0 + // [544..576] canonicalId = zero bytes32 + + // ── TransferInfo tail: callData length = 0 at data[576..608] ──────────── + // ── Top-level tail: messageBody length = 0 at data[608..640] ──────────── + // Both already zeroed. + + RawLog { + address: "0x8898B472C54c31894e3B9bb83cEA802a5d0e63C6".to_string(), + topics: vec![ + format!("{}", XCalled::SIGNATURE_HASH), + format!("0x{}", alloy::primitives::hex::encode(transfer_id)), + format!("0x{}", alloy::primitives::hex::encode(t_nonce)), + format!("0x{}", alloy::primitives::hex::encode([0u8; 32])), // messageHash + ], + data, + block_number: Some(22_000_000), + tx_hash: Some("0xdeadbeef".to_string()), + log_index: Some(0), + chain_id: chain::ethereum(), + } +} + +#[test] +fn parses_valid_xcalled() { + let transfer_id = [0xAB; 32]; + let to = [0xCC; 20]; + let origin_sender = [0xDD; 20]; + let asset = [0xEE; 20]; + + let log = make_log( + transfer_id, + 1, + 6_648_936, // Connext domain: ethereum + 1_634_886_255, // Connext domain: arbitrum + to, + origin_sender, + 5_000_000, + asset, + ); + let event = Connext.parse_event(&log).expect("should parse XCalled"); + + assert_eq!(event.protocol_id, "connext"); + assert_eq!(event.source_chain, chain::ethereum()); + assert_eq!(event.dest_chain, Some(chain::arbitrum())); + assert_eq!(event.correlation_id, format!("0x{}", "ab".repeat(32))); + assert_eq!( + event.sender_address.to_lowercase(), + format!("0x{}", "dd".repeat(20)) + ); + assert_eq!( + event.receiver_address.as_deref().map(str::to_lowercase), + Some(format!("0x{}", "cc".repeat(20))) + ); + assert_eq!(event.amount, Some("5000000".to_string())); + assert_eq!( + event.token_address.as_deref().map(str::to_lowercase), + Some(format!("0x{}", "ee".repeat(20))) + ); + assert_eq!(event.metadata["origin_domain"], 6_648_936); + assert_eq!(event.metadata["destination_domain"], 1_634_886_255); +} + +#[test] +fn rejects_wrong_topic() { + let mut log = make_log( + [0u8; 32], + 0, + 6_648_936, + 1_634_886_255, + [0u8; 20], + [0u8; 20], + 0, + [0u8; 20], + ); + log.topics[0] = + "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(); + assert!(Connext.parse_event(&log).is_none()); +} + +#[test] +fn unknown_dest_domain_gives_none_dest_chain() { + let log = make_log( + [0u8; 32], 0, 6_648_936, 99_999, [0u8; 20], [0u8; 20], 0, [0u8; 20], + ); + let event = Connext.parse_event(&log).expect("should still parse"); + assert!(event.dest_chain.is_none()); +} + +#[test] +fn transfer_id_is_correlation_id() { + let id = [0xFF; 32]; + let log = make_log( + id, + 0, + 6_648_936, + 1_634_886_255, + [0u8; 20], + [0u8; 20], + 0, + [0u8; 20], + ); + let event = Connext.parse_event(&log).unwrap(); + assert_eq!(event.correlation_id, format!("0x{}", "ff".repeat(32))); +} diff --git a/indexer/src/adapters/tests/hop.rs b/indexer/src/adapters/tests/hop.rs new file mode 100644 index 0000000..afc4acd --- /dev/null +++ b/indexer/src/adapters/tests/hop.rs @@ -0,0 +1,90 @@ +use super::*; +use seraph_shared::types::chain; + +fn push_slot(data: &mut Vec, bytes: &[u8]) { + let mut s = [0u8; 32]; + s[32 - bytes.len()..].copy_from_slice(bytes); + data.extend_from_slice(&s); +} + +// TransferSent: 3 indexed params, 5 non-indexed (all static). +// +// Topics: [SIGNATURE_HASH, transferId(bytes32), chainId(uint256), recipient(address)] +// Data: [amount(uint256), transferNonce(bytes32), bonderFee(uint256), +// amountOutMin(uint256), deadline(uint256)] +fn make_log(transfer_id: [u8; 32], chain_id_evm: u64, recipient: [u8; 20], amount: u128) -> RawLog { + let mut t_chain_id = [0u8; 32]; + t_chain_id[24..].copy_from_slice(&chain_id_evm.to_be_bytes()); + + let mut t_recipient = [0u8; 32]; + t_recipient[12..].copy_from_slice(&recipient); + + let mut data = Vec::::new(); + push_slot(&mut data, &amount.to_be_bytes()); // amount (uint256) + data.extend_from_slice(&[0u8; 32]); // transferNonce (bytes32) + data.extend_from_slice(&[0u8; 32]); // bonderFee (uint256) + data.extend_from_slice(&[0u8; 32]); // amountOutMin (uint256) + push_slot(&mut data, &u64::MAX.to_be_bytes()); // deadline (uint256, far future) + + RawLog { + address: "0x3d4Cc8A61c7528Fd86C55cfe061a78dCBA48EDd1".to_string(), + topics: vec![ + format!("{}", TransferSent::SIGNATURE_HASH), + format!("0x{}", alloy::primitives::hex::encode(transfer_id)), + format!("0x{}", alloy::primitives::hex::encode(t_chain_id)), + format!("0x{}", alloy::primitives::hex::encode(t_recipient)), + ], + data, + block_number: Some(22_000_000), + tx_hash: Some("0xdeadbeef".to_string()), + log_index: Some(0), + chain_id: chain::arbitrum(), + } +} + +#[test] +fn parses_valid_transfer_sent() { + let transfer_id = [0xAB; 32]; + let recipient = [0xCC; 20]; + + let log = make_log(transfer_id, 1, recipient, 5_000_000_000_000_000_000); // dest = mainnet + let event = Hop.parse_event(&log).expect("should parse TransferSent"); + + assert_eq!(event.protocol_id, "hop"); + assert_eq!(event.source_chain, chain::arbitrum()); + assert_eq!(event.dest_chain, Some(chain::ethereum())); // EVM chain ID 1 + assert_eq!(event.correlation_id, format!("0x{}", "ab".repeat(32))); + assert_eq!( + event.receiver_address.as_deref().map(str::to_lowercase), + Some(format!("0x{}", "cc".repeat(20))) + ); + assert_eq!(event.amount, Some("5000000000000000000".to_string())); + // sender_address is intentionally empty — msg.sender is not in TransferSent + assert!(event.sender_address.is_empty()); + assert!(event.token_address.is_none()); +} + +#[test] +fn rejects_wrong_topic() { + let mut log = make_log([0u8; 32], 1, [0u8; 20], 1_000); + log.topics[0] = + "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(); + assert!(Hop.parse_event(&log).is_none()); +} + +#[test] +fn unknown_dest_chain_id_gives_none_dest_chain() { + let log = make_log([0u8; 32], 99_999, [0u8; 20], 1_000); + let event = Hop.parse_event(&log).expect("should still parse"); + assert!(event.dest_chain.is_none()); +} + +#[test] +fn transfer_id_is_correlation_id() { + let id = [0xDD; 32]; + let log = make_log(id, 42161, [0u8; 20], 1_000); + let event = Hop.parse_event(&log).unwrap(); + assert_eq!(event.correlation_id, format!("0x{}", "dd".repeat(32))); + // dest = arbitrum (chain ID 42161) + assert_eq!(event.dest_chain, Some(chain::arbitrum())); +} diff --git a/indexer/src/adapters/tests/stargate.rs b/indexer/src/adapters/tests/stargate.rs new file mode 100644 index 0000000..bd8f190 --- /dev/null +++ b/indexer/src/adapters/tests/stargate.rs @@ -0,0 +1,85 @@ +use super::*; +use seraph_shared::types::chain; + +fn push_slot(data: &mut Vec, bytes: &[u8]) { + let mut s = [0u8; 32]; + s[32 - bytes.len()..].copy_from_slice(bytes); + data.extend_from_slice(&s); +} + +// OFTSent has 2 indexed params (guid, from) and 3 non-indexed (dstEid, +// amountSentLD, amountReceivedLD). +// +// Topics: [SIGNATURE_HASH, guid (bytes32), from (address)] +// Data: [dstEid (uint32), amountSentLD (uint256), amountReceivedLD (uint256)] +fn make_log(guid: [u8; 32], from: [u8; 20], dst_eid: u32, amount_sent: u128) -> RawLog { + let mut topic1 = [0u8; 32]; + topic1.copy_from_slice(&guid); + + let mut topic2 = [0u8; 32]; + topic2[12..].copy_from_slice(&from); + + let mut data = Vec::::new(); + push_slot(&mut data, &dst_eid.to_be_bytes()); // dstEid (uint32) + push_slot(&mut data, &amount_sent.to_be_bytes()); // amountSentLD (uint256, low 128 bits) + push_slot(&mut data, &(amount_sent - 1000).to_be_bytes()); // amountReceivedLD + + RawLog { + address: "0x77b2043768d28E9C9aB44E1aBfC95944bcE57931".to_string(), + topics: vec![ + format!("{}", OFTSent::SIGNATURE_HASH), + format!("0x{}", alloy::primitives::hex::encode(topic1)), + format!("0x{}", alloy::primitives::hex::encode(topic2)), + ], + data, + block_number: Some(22_000_000), + tx_hash: Some("0xdeadbeef".to_string()), + log_index: Some(0), + chain_id: chain::ethereum(), + } +} + +#[test] +fn parses_valid_oft_sent() { + let guid = [0xAB; 32]; + let from = [0x11; 20]; + let log = make_log(guid, from, 30110, 5_000_000); + + let event = Stargate.parse_event(&log).expect("should parse OFTSent"); + + assert_eq!(event.protocol_id, "stargate"); + assert_eq!(event.source_chain, chain::ethereum()); + assert_eq!(event.dest_chain, Some(chain::arbitrum())); + assert_eq!( + event.sender_address.to_lowercase(), + format!("0x{}", "11".repeat(20)) + ); + // guid bytes32 → "0xabab...ab" + assert_eq!(event.correlation_id, format!("0x{}", "ab".repeat(32))); + assert_eq!(event.amount, Some("5000000".to_string())); + assert!(event.receiver_address.is_none()); + assert_eq!(event.metadata["dst_eid"], 30110); +} + +#[test] +fn rejects_wrong_topic() { + let mut log = make_log([0u8; 32], [0u8; 20], 30110, 1_000); + log.topics[0] = + "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(); + assert!(Stargate.parse_event(&log).is_none()); +} + +#[test] +fn unknown_dst_eid_gives_none_dest_chain() { + let log = make_log([0u8; 32], [0u8; 20], 99_999, 1_000); + let event = Stargate.parse_event(&log).expect("should still parse"); + assert!(event.dest_chain.is_none()); +} + +#[test] +fn guid_is_correlation_id() { + let guid = [0xCC; 32]; + let log = make_log(guid, [0u8; 20], 30110, 1_000); + let event = Stargate.parse_event(&log).unwrap(); + assert_eq!(event.correlation_id, format!("0x{}", "cc".repeat(32))); +} diff --git a/indexer/src/adapters/tests/wormhole.rs b/indexer/src/adapters/tests/wormhole.rs new file mode 100644 index 0000000..bf2a95a --- /dev/null +++ b/indexer/src/adapters/tests/wormhole.rs @@ -0,0 +1,96 @@ +use super::*; +use seraph_shared::types::chain; + +// LogMessagePublished: 1 indexed param, 4 non-indexed (sequence, nonce static; +// payload dynamic; consistencyLevel static). +// +// Topics: [SIGNATURE_HASH, sender(address)] +// Data ABI layout (4-slot head + tail): +// slot 0: sequence (uint64) +// slot 1: nonce (uint32) +// slot 2: offset to payload = 4 * 32 = 128 +// slot 3: consistencyLevel (uint8) +// tail: payload length (0 for empty) +fn make_log(sender: [u8; 20], sequence: u64, nonce: u32, consistency: u8) -> RawLog { + let mut t_sender = [0u8; 32]; + t_sender[12..].copy_from_slice(&sender); + + let mut data = Vec::::new(); + let mut u256_slot = |val: u64| { + let mut s = [0u8; 32]; + s[24..].copy_from_slice(&val.to_be_bytes()); + data.extend_from_slice(&s); + }; + + u256_slot(sequence); // sequence (uint64 right-aligned) + u256_slot(nonce as u64); // nonce (uint32 right-aligned) + u256_slot(4 * 32); // offset to payload = 128 + u256_slot(consistency as u64); // consistencyLevel (uint8 right-aligned) + data.extend_from_slice(&[0u8; 32]); // payload length = 0 (empty) + + RawLog { + address: "0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B".to_string(), + topics: vec![ + format!("{}", LogMessagePublished::SIGNATURE_HASH), + format!("0x{}", alloy::primitives::hex::encode(t_sender)), + ], + data, + block_number: Some(22_000_000), + tx_hash: Some("0xdeadbeef".to_string()), + log_index: Some(0), + chain_id: chain::ethereum(), + } +} + +#[test] +fn parses_valid_log_message_published() { + let sender = [0xAA; 20]; + let log = make_log(sender, 7, 0, 1); + + let event = Wormhole + .parse_event(&log) + .expect("should parse LogMessagePublished"); + + assert_eq!(event.protocol_id, "wormhole"); + assert_eq!(event.source_chain, chain::ethereum()); + // dest_chain is not in the event (opaque payload) + assert!(event.dest_chain.is_none()); + assert_eq!( + event.sender_address.to_lowercase(), + format!("0x{}", "aa".repeat(20)) + ); + // correlation_id = "wormhole:::" + // Ethereum → wormhole chain ID 2 + assert!(event.correlation_id.starts_with("wormhole:2:")); + assert!(event.correlation_id.ends_with(":7")); + assert_eq!(event.metadata["sequence"], 7); + assert_eq!(event.metadata["nonce"], 0); + assert_eq!(event.metadata["consistency_level"], 1); +} + +#[test] +fn rejects_wrong_topic() { + let mut log = make_log([0u8; 20], 1, 0, 1); + log.topics[0] = + "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(); + assert!(Wormhole.parse_event(&log).is_none()); +} + +#[test] +fn correlation_id_includes_wormhole_chain_id() { + // BSC → wormhole chain ID 4 + let mut log = make_log([0xBB; 20], 99, 0, 1); + log.chain_id = chain::bsc(); + let event = Wormhole.parse_event(&log).unwrap(); + assert!(event.correlation_id.starts_with("wormhole:4:")); + assert!(event.correlation_id.ends_with(":99")); +} + +#[test] +fn unknown_evm_chain_gives_wh_id_zero() { + // An unrecognized chain maps to wh_chain 0 but the event still parses. + let mut log = make_log([0u8; 20], 1, 0, 1); + log.chain_id = seraph_shared::ChainId("unknown".to_string()); + let event = Wormhole.parse_event(&log).unwrap(); + assert!(event.correlation_id.starts_with("wormhole:0:")); +} diff --git a/indexer/src/adapters/wormhole.rs b/indexer/src/adapters/wormhole.rs index dd18dca..7794122 100644 --- a/indexer/src/adapters/wormhole.rs +++ b/indexer/src/adapters/wormhole.rs @@ -1,11 +1,94 @@ -use seraph_shared::{CrossChainEvent, RawLog}; +use alloy::primitives::B256; +use alloy::sol; +use alloy::sol_types::SolEvent; +use chrono::Utc; +use uuid::Uuid; + +use seraph_shared::{CrossChainEvent, RawLog, TxStatus}; + +// ── Event signature ─────────────────────────────────────────────────────────── +// +// Wormhole is a generic message-passing protocol. Its Core Bridge emits +// LogMessagePublished for every cross-chain message, regardless of whether it +// carries tokens or arbitrary data. +// +// Indexed → topics[1]: +// sender — the emitter contract address (e.g. the Token Bridge or an OApp) +// +// Non-indexed → data: +// sequence — monotonically increasing per emitter address +// nonce — caller-assigned deduplication nonce +// payload — opaque bytes; encoding is application-specific +// consistencyLevel — finality level required before guardians sign the VAA +// +// A VAA (Verified Action Approval) is uniquely identified by the triple +// (emitter chain, emitter address, sequence). We embed the Wormhole chain ID +// and emitter address in the correlation_id so the stitcher can reconstruct +// the VAA key for the destination-side event lookup. +// +// dest_chain is not directly available in this event — it is encoded inside +// the payload in a format that differs per application (Token Bridge, NFT +// Bridge, OApp). We set it to None; the stitcher can fill it in if needed. +// +// Source: https://github.com/wormhole-foundation/wormhole/blob/main/ethereum/contracts/interfaces/IWormhole.sol +sol! { + event LogMessagePublished( + address indexed sender, + uint64 sequence, + uint32 nonce, + bytes payload, + uint8 consistencyLevel + ); +} pub struct Wormhole; impl Wormhole { - pub fn parse_event(&self, _log: &RawLog) -> Option { - // Implemented in step 2.8 - None + pub fn parse_event(&self, log: &RawLog) -> Option { + let topics: Vec = log + .topics + .iter() + .filter_map(|t| t.parse::().ok()) + .collect(); + + if topics.first() != Some(&LogMessagePublished::SIGNATURE_HASH) { + return None; + } + + let decoded = LogMessagePublished::decode_raw_log(topics, &log.data).ok()?; + + // VAA key = (wormhole_chain_id, emitter_address, sequence). + // Including the Wormhole chain ID ensures uniqueness even if two chains + // happen to have the same emitter address and sequence. + let wh_chain = wormhole_chain_id(log.chain_id.0.as_str()); + let correlation_id = format!( + "wormhole:{}:{}:{}", + wh_chain, decoded.sender, decoded.sequence + ); + + let now = Utc::now(); + + Some(CrossChainEvent { + id: Uuid::new_v4(), + source_tx_hash: log.tx_hash.clone().unwrap_or_default(), + source_chain: log.chain_id.clone(), + dest_chain: None, + sender_address: decoded.sender.to_string(), + receiver_address: None, + amount: None, + token_address: None, + protocol_id: "wormhole".to_string(), + correlation_id, + status: TxStatus::Pending, + metadata: serde_json::json!({ + "sequence": decoded.sequence, + "nonce": decoded.nonce, + "consistency_level": decoded.consistencyLevel, + "payload_len": decoded.payload.len(), + }), + created_at: now, + updated_at: now, + }) } #[allow(dead_code)] @@ -13,3 +96,23 @@ impl Wormhole { event.correlation_id.clone() } } + +// EVM chain → Wormhole chain ID. +// Wormhole maintains its own chain ID registry, distinct from EVM chain IDs. +// Source: https://docs.wormhole.com/wormhole/reference/constants#chain-ids +fn wormhole_chain_id(chain: &str) -> u16 { + match chain { + "ethereum" => 2, + "bsc" => 4, + "polygon" => 5, + "avalanche" => 6, + "arbitrum" => 23, + "optimism" => 24, + "base" => 30, + _ => 0, + } +} + +#[cfg(test)] +#[path = "tests/wormhole.rs"] +mod tests;