Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion indexer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -15,4 +16,5 @@ serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
152 changes: 148 additions & 4 deletions indexer/src/adapters/layerzero_v2.rs
Original file line number Diff line number Diff line change
@@ -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<CrossChainEvent> {
// Implemented in step 2.2
None
pub fn parse_event(&self, log: &RawLog) -> Option<CrossChainEvent> {
// ── 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<B256> = log
.topics
.iter()
.filter_map(|t| t.parse::<B256>().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<ChainId> {
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;
144 changes: 144 additions & 0 deletions indexer/src/adapters/tests/layerzero_v2.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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<u8> = 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());
}
Loading