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
103 changes: 99 additions & 4 deletions indexer/src/adapters/axelar.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,110 @@
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<CrossChainEvent> {
// Implemented in step 2.9
None
pub fn parse_event(&self, log: &RawLog) -> Option<CrossChainEvent> {
let topics: Vec<B256> = log
.topics
.iter()
.filter_map(|t| t.parse::<B256>().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)]
pub fn correlation_id(&self, event: &CrossChainEvent) -> String {
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<ChainId> {
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;
102 changes: 98 additions & 4 deletions indexer/src/adapters/cctp.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,109 @@
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<CrossChainEvent> {
// Implemented in step 2.5
None
pub fn parse_event(&self, log: &RawLog) -> Option<CrossChainEvent> {
let topics: Vec<B256> = log
.topics
.iter()
.filter_map(|t| t.parse::<B256>().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)]
pub fn correlation_id(&self, event: &CrossChainEvent) -> String {
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<ChainId> {
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;
115 changes: 111 additions & 4 deletions indexer/src/adapters/connext.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,122 @@
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<CrossChainEvent> {
// Implemented in step 2.7
None
pub fn parse_event(&self, log: &RawLog) -> Option<CrossChainEvent> {
let topics: Vec<B256> = log
.topics
.iter()
.filter_map(|t| t.parse::<B256>().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)]
pub fn correlation_id(&self, event: &CrossChainEvent) -> String {
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<ChainId> {
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;
Loading
Loading