From 8c32c65cfe5af7602fc7f48a7e6c28a4df64dc87 Mon Sep 17 00:00:00 2001 From: 0xOse <0xosepatrick@gmail.com> Date: Fri, 10 Jul 2026 17:33:30 +0100 Subject: [PATCH 1/4] feat(adapters): implement LayerZero V2 parse_event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decodes PacketSent logs from the LZ V2 Endpoint contract using alloy sol! macro. Slices encodedPacket at fixed offsets per PacketV1Codec layout to extract srcEid, dstEid, sender, receiver, and GUID (stored as correlation_id). Adds endpoint ID → ChainId lookup for all 7 indexed EVM chains. Also adds uuid and chrono as direct indexer deps since parse_event constructs CrossChainEvent values that need Uuid::new_v4() and Utc::now(). --- indexer/Cargo.toml | 4 +- indexer/src/adapters/layerzero_v2.rs | 148 ++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 5 deletions(-) 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..db1d396 100644 --- a/indexer/src/adapters/layerzero_v2.rs +++ b/indexer/src/adapters/layerzero_v2.rs @@ -1,15 +1,155 @@ -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, + } +} From 0d318c987d7fadbabed9ab4f5cc950691af176f3 Mon Sep 17 00:00:00 2001 From: 0xOse <0xosepatrick@gmail.com> Date: Fri, 10 Jul 2026 17:43:49 +0100 Subject: [PATCH 2/4] test(adapters): add unit tests for LayerZero V2 adapter Four tests: happy path (all fields), wrong topic returns None, truncated packet returns None without panic, unknown dst eid produces dest_chain: None. Tests live in a separate file wired via #[path] to keep the adapter module clean. --- indexer/src/adapters/layerzero_v2.rs | 4 + indexer/src/adapters/layerzero_v2_tests.rs | 142 +++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 indexer/src/adapters/layerzero_v2_tests.rs diff --git a/indexer/src/adapters/layerzero_v2.rs b/indexer/src/adapters/layerzero_v2.rs index db1d396..fb33dfb 100644 --- a/indexer/src/adapters/layerzero_v2.rs +++ b/indexer/src/adapters/layerzero_v2.rs @@ -153,3 +153,7 @@ fn eid_to_chain(eid: u32) -> Option { _ => None, } } + +#[cfg(test)] +#[path = "layerzero_v2_tests.rs"] +mod tests; diff --git a/indexer/src/adapters/layerzero_v2_tests.rs b/indexer/src/adapters/layerzero_v2_tests.rs new file mode 100644 index 0000000..b67f74f --- /dev/null +++ b/indexer/src/adapters/layerzero_v2_tests.rs @@ -0,0 +1,142 @@ +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()); +} From 4144148550df62eb4d2730c1128369bdf404314b Mon Sep 17 00:00:00 2001 From: 0xOse <0xosepatrick@gmail.com> Date: Fri, 10 Jul 2026 17:45:54 +0100 Subject: [PATCH 3/4] refactor(tests): move LayerZero V2 tests into adapters/tests/ subfolder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocates layerzero_v2_tests.rs → src/adapters/tests/layerzero_v2.rs and updates the #[path] reference. All 4 tests still pass. Also commits Cargo.lock update from adding uuid and chrono as direct indexer deps. --- Cargo.lock | 2 ++ indexer/src/adapters/layerzero_v2.rs | 2 +- .../adapters/{layerzero_v2_tests.rs => tests/layerzero_v2.rs} | 0 3 files changed, 3 insertions(+), 1 deletion(-) rename indexer/src/adapters/{layerzero_v2_tests.rs => tests/layerzero_v2.rs} (100%) 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/src/adapters/layerzero_v2.rs b/indexer/src/adapters/layerzero_v2.rs index fb33dfb..a06bc87 100644 --- a/indexer/src/adapters/layerzero_v2.rs +++ b/indexer/src/adapters/layerzero_v2.rs @@ -155,5 +155,5 @@ fn eid_to_chain(eid: u32) -> Option { } #[cfg(test)] -#[path = "layerzero_v2_tests.rs"] +#[path = "tests/layerzero_v2.rs"] mod tests; diff --git a/indexer/src/adapters/layerzero_v2_tests.rs b/indexer/src/adapters/tests/layerzero_v2.rs similarity index 100% rename from indexer/src/adapters/layerzero_v2_tests.rs rename to indexer/src/adapters/tests/layerzero_v2.rs From 5068b915269765dc75684a6e3427cac79e295ba7 Mon Sep 17 00:00:00 2001 From: 0xOse <0xosepatrick@gmail.com> Date: Fri, 10 Jul 2026 17:48:59 +0100 Subject: [PATCH 4/4] style(tests): format code for improved readability in LayerZero V2 tests --- indexer/src/adapters/tests/layerzero_v2.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/indexer/src/adapters/tests/layerzero_v2.rs b/indexer/src/adapters/tests/layerzero_v2.rs index b67f74f..82620e3 100644 --- a/indexer/src/adapters/tests/layerzero_v2.rs +++ b/indexer/src/adapters/tests/layerzero_v2.rs @@ -15,7 +15,7 @@ fn make_packet( 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[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); @@ -81,7 +81,9 @@ fn parses_valid_packet_sent() { 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"); + 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());