diff --git a/Cargo.lock b/Cargo.lock index 93d0a52..ef5d978 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2598,6 +2598,7 @@ version = "0.1.0" dependencies = [ "alloy", "anyhow", + "chrono", "dotenvy", "futures", "reqwest 0.12.28", @@ -2608,6 +2609,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/indexer/Cargo.toml b/indexer/Cargo.toml index 39cf5e3..70cf7a5 100644 --- a/indexer/Cargo.toml +++ b/indexer/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" seraph-shared = { workspace = true } alloy = { workspace = true } anyhow = { workspace = true } +chrono = { workspace = true } dotenvy = { workspace = true } futures = "0.3" sqlx = { workspace = true } @@ -15,4 +16,5 @@ serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { workspace = true } \ No newline at end of file +tracing-subscriber = { workspace = true } +uuid = { workspace = true } \ No newline at end of file diff --git a/indexer/src/adapters/layerzero_v2.rs b/indexer/src/adapters/layerzero_v2.rs index 553e6da..a06bc87 100644 --- a/indexer/src/adapters/layerzero_v2.rs +++ b/indexer/src/adapters/layerzero_v2.rs @@ -1,15 +1,159 @@ -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}; + +// ── 2.2.1 Event signature ──────────────────────────────────────────────────── +// +// The sol! macro generates a Rust struct from a Solidity event definition. +// It computes SIGNATURE_HASH (keccak256 of the signature string) at compile +// time and generates an ABI decoder — no runtime parsing of the ABI JSON. +// +// Source: https://github.com/LayerZero-Labs/LayerZero-v2/blob/main/packages/layerzero-v2/evm/protocol/contracts/interfaces/ILayerZeroEndpointV2.sol +sol! { + event PacketSent(bytes encodedPacket, bytes options, address sendLibrary); +} + +// ── 2.2.3 encodedPacket byte layout (PacketV1Codec.sol) ───────────────────── +// +// LayerZero encodes all routing information into a single `bytes` field. +// The layout is versioned; V1 (the only version in production) is: +// +// offset 0 : version (1 byte, u8 = 1) +// offset 1 : nonce (8 bytes, u64, big-endian) +// offset 9 : srcEid (4 bytes, u32, big-endian) ← LZ endpoint ID, not EVM chainId +// offset 13 : sender (32 bytes, bytes32) ← EVM address right-aligned in 32 bytes +// offset 45 : dstEid (4 bytes, u32, big-endian) +// offset 49 : receiver (32 bytes, bytes32) +// offset 81 : guid (32 bytes, bytes32) ← correlation key +// offset 113: message (variable) ← application payload, not needed here +// +// Why bytes32 for addresses? LZ is cross-VM — Solana and Aptos have 32-byte +// addresses. EVM addresses (20 bytes) sit in the rightmost 20 bytes; the +// leading 12 bytes are zero padding. +const MIN_PACKET_LEN: usize = 113; pub struct LayerZeroV2; impl LayerZeroV2 { - pub fn parse_event(&self, _log: &RawLog) -> Option { - // Implemented in step 2.2 - None + pub fn parse_event(&self, log: &RawLog) -> Option { + // ── Step 1: check event signature ───────────────────────────────────── + // + // EVM logs have `topics[0]` = keccak256(event_signature). We decode all + // topic hex strings into B256 values and confirm the first one matches + // PacketSent::SIGNATURE_HASH before doing any further work. + let topics: Vec = log + .topics + .iter() + .filter_map(|t| t.parse::().ok()) + .collect(); + + if topics.first() != Some(&PacketSent::SIGNATURE_HASH) { + return None; + } + + // ── Step 2: ABI-decode the log ──────────────────────────────────────── + // + // decode_raw_log handles the ABI encoding rules for dynamic types + // (bytes, string) which use a two-step indirect encoding: the data + // section starts with a 32-byte offset pointer, then the actual bytes. + // We don't need to think about that — alloy does it for us. + let decoded = PacketSent::decode_raw_log(topics, &log.data).ok()?; + + // ── Step 3: slice the raw packet bytes ──────────────────────────────── + let packet = decoded.encodedPacket.as_ref(); + if packet.len() < MIN_PACKET_LEN { + return None; + } + + let nonce = u64::from_be_bytes(packet[1..9].try_into().ok()?); + let src_eid = u32::from_be_bytes(packet[9..13].try_into().ok()?); + let sender32 = &packet[13..45]; + let dst_eid = u32::from_be_bytes(packet[45..49].try_into().ok()?); + let recv32 = &packet[49..81]; + let guid_bytes = &packet[81..113]; + + // ── Step 4: convert raw bytes to typed values ───────────────────────── + // + // sender/receiver: take the last 20 bytes of the 32-byte field. + // Address::to_string() returns EIP-55 checksum format "0xAbCd...". + // + // guid: wrap in B256 so .to_string() gives the canonical "0x00..." hex. + // This is what the stitcher stores in both source and destination events; + // an exact string match is all it needs to join them. + let sender_addr = Address::from_slice(&sender32[12..]).to_string(); + let receiver_addr = Address::from_slice(&recv32[12..]).to_string(); + let guid_hex = B256::from_slice(guid_bytes).to_string(); + + // ── 2.2.2 Endpoint ID → ChainId ───────────────────────────────────── + let dest_chain = eid_to_chain(dst_eid); + + // ── Step 5: build the event ─────────────────────────────────────────── + // + // source_chain comes from log.chain_id — the runner already knows which + // chain it's indexing and stamps every RawLog with it. We don't re-derive + // it from srcEid to avoid any mismatch if the runner is started on an + // unexpected chain. + // + // amount / token_address are None because PacketSent is a messaging event, + // not a token transfer event. The amount lives inside the `message` payload + // in an application-specific encoding — we don't parse OApp message formats here. + 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: sender_addr, + receiver_address: Some(receiver_addr), + amount: None, + token_address: None, + protocol_id: "layerzero-v2".to_string(), + correlation_id: guid_hex, + status: TxStatus::Pending, + metadata: serde_json::json!({ + "src_eid": src_eid, + "dst_eid": dst_eid, + "nonce": nonce, + }), + created_at: now, + updated_at: now, + }) } + // ── 2.2.5 correlation_id ───────────────────────────────────────────────── + // + // The GUID was already stored in event.correlation_id when parse_event ran. + // The stitcher calls this to get the join key without re-parsing the event. #[allow(dead_code)] pub fn correlation_id(&self, event: &CrossChainEvent) -> String { event.correlation_id.clone() } } + +// ── 2.2.2 Endpoint ID → ChainId lookup ────────────────────────────────────── +// +// LayerZero assigns its own monotonic IDs ("eids") to chains — completely +// separate from EVM chainIds. This table covers our 7 indexed chains. +// Returns None for any eid we don't yet support; the stitcher can handle +// an unknown dest_chain gracefully. +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/layerzero_v2.rs"] +mod tests; diff --git a/indexer/src/adapters/tests/layerzero_v2.rs b/indexer/src/adapters/tests/layerzero_v2.rs new file mode 100644 index 0000000..82620e3 --- /dev/null +++ b/indexer/src/adapters/tests/layerzero_v2.rs @@ -0,0 +1,144 @@ +use super::*; +use seraph_shared::types::chain; + +// Build a valid PacketV1 byte array with the given routing fields. +// sender/receiver are right-aligned in their bytes32 slots (leading 12 zero bytes). +fn make_packet( + nonce: u64, + src_eid: u32, + sender: [u8; 20], + dst_eid: u32, + receiver: [u8; 20], + guid: [u8; 32], +) -> Vec { + let mut p = vec![0u8; 113]; + p[0] = 1; // version + p[1..9].copy_from_slice(&nonce.to_be_bytes()); + p[9..13].copy_from_slice(&src_eid.to_be_bytes()); + p[25..45].copy_from_slice(&sender); // sender bytes32: zeros[0..12] + address[12..32] + p[45..49].copy_from_slice(&dst_eid.to_be_bytes()); + p[61..81].copy_from_slice(&receiver); // receiver bytes32: same layout + p[81..113].copy_from_slice(&guid); + p +} + +// ABI-encode a PacketSent(bytes, bytes, address) log from a raw packet blob. +// +// ABI head section (3 × 32 = 96 bytes): +// slot 0: byte offset to encodedPacket tail (= 96) +// slot 1: byte offset to options tail (= 96 + 32 + padded_len(packet)) +// slot 2: sendLibrary — zero address, static, right-aligned +// +// Tail section: +// length(encodedPacket) | encodedPacket bytes (padded to 32-byte multiple) +// length(options) = 0 +fn make_log(packet: &[u8]) -> RawLog { + let n = packet.len(); + let padded = (n + 31) & !31; + + let offset_packet: u64 = 96; + let offset_options: u64 = 96 + 32 + padded as u64; + + let mut data: Vec = Vec::new(); + let mut slot = [0u8; 32]; + + slot[24..].copy_from_slice(&offset_packet.to_be_bytes()); + data.extend_from_slice(&slot); + + slot[24..].copy_from_slice(&offset_options.to_be_bytes()); + data.extend_from_slice(&slot); + + data.extend_from_slice(&[0u8; 32]); // sendLibrary = zero address + + slot = [0u8; 32]; + slot[24..].copy_from_slice(&(n as u64).to_be_bytes()); + data.extend_from_slice(&slot); + data.extend_from_slice(packet); + data.extend_from_slice(&vec![0u8; padded - n]); + + data.extend_from_slice(&[0u8; 32]); // options length = 0 + + RawLog { + address: "0x1a44076050125825900e736c501f859c50fE728c".to_string(), + topics: vec![format!("{}", PacketSent::SIGNATURE_HASH)], + data, + block_number: Some(22_900_000), + tx_hash: Some("0xdeadbeef".to_string()), + log_index: Some(0), + chain_id: chain::ethereum(), + } +} + +// ── Test 1: happy path ──────────────────────────────────────────────────────── +// Every field sliced from encodedPacket must map to the right CrossChainEvent +// field. This exercises the full parse path end-to-end. +#[test] +fn parses_valid_packet_sent() { + let sender: [u8; 20] = [0xAA; 20]; + let receiver: [u8; 20] = [0xBB; 20]; + let guid: [u8; 32] = [0x01; 32]; + + let packet = make_packet(42, 30101, sender, 30110, receiver, guid); + let log = make_log(&packet); + + let event = LayerZeroV2 + .parse_event(&log) + .expect("should parse valid PacketSent"); + + assert_eq!(event.protocol_id, "layerzero-v2"); + assert_eq!(event.source_chain, chain::ethereum()); + assert_eq!(event.dest_chain, Some(chain::arbitrum())); + + // EIP-55 checksum only changes casing; compare lowercase to avoid flakiness + assert_eq!( + event.sender_address.to_lowercase(), + format!("0x{}", "aa".repeat(20)) + ); + assert_eq!( + event.receiver_address.as_deref().map(str::to_lowercase), + Some(format!("0x{}", "bb".repeat(20))) + ); + + assert_eq!(event.correlation_id, format!("0x{}", "01".repeat(32))); + assert_eq!(event.status, TxStatus::Pending); + assert!(event.amount.is_none()); + assert!(event.token_address.is_none()); + assert_eq!(event.source_tx_hash, "0xdeadbeef"); + assert_eq!(event.metadata["src_eid"], 30101); + assert_eq!(event.metadata["dst_eid"], 30110); + assert_eq!(event.metadata["nonce"], 42); +} + +// ── Test 2: wrong event signature ───────────────────────────────────────────── +// Adapters are tried in sequence by the runner. parse_event must return None +// immediately when topic[0] doesn't match so the runner can try the next one. +#[test] +fn rejects_wrong_topic() { + let packet = make_packet(1, 30101, [0u8; 20], 30110, [0u8; 20], [0u8; 32]); + let mut log = make_log(&packet); + log.topics[0] = + "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(); + + assert!(LayerZeroV2.parse_event(&log).is_none()); +} + +// ── Test 3: truncated encodedPacket ─────────────────────────────────────────── +// If the packet is shorter than 113 bytes the GUID field is missing. parse_event +// must return None without an out-of-bounds slice panic. +#[test] +fn returns_none_for_short_packet() { + let short = vec![1u8; 80]; + assert!(LayerZeroV2.parse_event(&make_log(&short)).is_none()); +} + +// ── Test 4: unknown destination eid ─────────────────────────────────────────── +// An eid we haven't mapped (e.g. a newly launched chain) must still produce a +// parseable event — just with dest_chain: None — rather than dropping the log. +#[test] +fn unknown_eid_gives_none_dest_chain() { + let packet = make_packet(1, 30101, [0u8; 20], 99_999, [0u8; 20], [0u8; 32]); + let event = LayerZeroV2 + .parse_event(&make_log(&packet)) + .expect("should still parse"); + assert!(event.dest_chain.is_none()); +}