Files
serai/processor/ethereum/erc20/src/lib.rs
Luke Parker 184c02714a alloy-core 1.0, alloy 0.14, revm 0.22 (001)
This moves to Rust 1.86 as were prior on Rust 1.81, and the new alloy
dependencies require 1.82.

The revm API changes were notable for us. Instead of relying on a modified call
instruction (with deep introspection into the EVM design), we now use the more
recent and now more prominent Inspector API. This:

1) Lets us perform far less introspection
2) Forces us to rewrite the gas estimation code we just had audited

Thankfully, it itself should be much easier to read/review, and our existing
test suite has extensively validated it.

This resolves 001 which was a concern for if/when this upgrade occurs. By doing
it now, with a dedicated test case ensuring the issue we would have had with
alloy-core 0.8 and `validate=false` isn't actively an issue, we resolve it.
2025-04-12 08:09:09 -04:00

250 lines
9.0 KiB
Rust

#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
use core::ops::RangeInclusive;
use std::collections::HashMap;
use alloy_core::primitives::{Address, U256};
use alloy_sol_types::{SolInterface, SolEvent};
use alloy_rpc_types_eth::{Log, Filter, TransactionTrait};
use alloy_transport::{TransportErrorKind, RpcError};
use alloy_provider::{Provider, RootProvider};
use ethereum_primitives::LogIndex;
use futures_util::stream::{StreamExt, FuturesUnordered};
#[rustfmt::skip]
#[expect(warnings)]
#[expect(needless_pass_by_value)]
#[expect(missing_docs)]
#[expect(clippy::all)]
#[expect(clippy::ignored_unit_patterns)]
#[expect(clippy::redundant_closure_for_method_calls)]
mod abi {
alloy_sol_macro::sol!("contracts/IERC20.sol");
}
use abi::IERC20::{IERC20Calls, transferCall, transferFromCall};
use abi::SeraiIERC20::SeraiIERC20Calls;
pub use abi::IERC20::Transfer;
pub use abi::SeraiIERC20::{
transferWithInInstruction01BB244A8ACall as transferWithInInstructionCall,
transferFromWithInInstruction00081948E0Call as transferFromWithInInstructionCall,
};
#[cfg(test)]
mod tests;
/// A top-level ERC20 transfer
///
/// This does not include `token`, `to` fields. Those are assumed contextual to the creation of
/// this.
#[derive(Clone, Debug)]
pub struct TopLevelTransfer {
/// The ID of the event for this transfer.
pub id: LogIndex,
/// The hash of the transaction which caused this transfer.
pub transaction_hash: [u8; 32],
/// The address which made the transfer.
pub from: Address,
/// The amount transferred.
pub amount: U256,
/// The data appended after the call itself.
pub data: Vec<u8>,
}
/// The result of `Erc20::top_level_transfers_unordered`.
pub struct TopLevelTransfers {
/// Every `Transfer` log of the contextual ERC20 to the contextual account, indexed by
/// their transaction.
///
/// The ERC20/account is labelled contextual as it isn't directly named here. Instead, they're
/// assumed contextual to how this was created.
pub logs: HashMap<[u8; 32], Vec<Log>>,
/// All of the top-level transfers of the contextual ERC20 to the contextual account.
///
/// The ERC20/account is labelled contextual as it isn't directly named here. Instead, they're
/// assumed contextual to how this was created.
pub transfers: Vec<TopLevelTransfer>,
}
/// A view for an ERC20 contract.
#[derive(Clone, Debug)]
pub struct Erc20;
impl Erc20 {
/// The filter for transfer logs of the specified ERC20, to the specified recipient.
fn transfer_filter(blocks: RangeInclusive<u64>, erc20: Address, to: Address) -> Filter {
let filter = Filter::new().select(blocks);
filter.address(erc20).event_signature(Transfer::SIGNATURE_HASH).topic2(to.into_word())
}
/// Yield the top-level transfer for the specified transaction (if one exists).
///
/// The passed-in logs MUST be the logs for this transaction. The logs MUST be filtered to the
/// `Transfer` events of the intended token and the intended `to` transferred to. These
/// properties are completely unchecked and assumed to be the case.
///
/// This does NOT yield THE top-level transfer. If multiple `Transfer` events have identical
/// structure to the top-level transfer call, the first `Transfer` event present in the logs is
/// considered the top-level transfer.
// Yielding THE top-level transfer would require tracing the transaction execution and isn't
// worth the effort.
async fn top_level_transfer(
provider: &RootProvider,
erc20: Address,
transaction_hash: [u8; 32],
transfer_logs: &[Log],
) -> Result<Option<TopLevelTransfer>, RpcError<TransportErrorKind>> {
// Fetch the transaction
let transaction =
provider.get_transaction_by_hash(transaction_hash.into()).await?.ok_or_else(|| {
TransportErrorKind::Custom(
"node didn't have the transaction which emitted a log it had".to_string().into(),
)
})?;
// If this transaction didn't call this ERC20 at a top-level, return
if transaction.inner.to() != Some(erc20) {
return Ok(None);
}
let Ok(call) = IERC20Calls::abi_decode(transaction.inner.input()) else {
return Ok(None);
};
// Extract the top-level call's from/to/value
let (from, to, value) = match call {
IERC20Calls::transfer(transferCall { to, value }) => (transaction.inner.signer(), to, value),
IERC20Calls::transferFrom(transferFromCall { from, to, value }) => (from, to, value),
// Treat any other function selectors as unrecognized
_ => return Ok(None),
};
// Find the log for this top-level transfer
for log in transfer_logs {
// Since the caller is responsible for filtering these to `Transfer` events, we can assume
// this is a non-compliant ERC20 or an error with the logs fetched. We assume ERC20
// compliance here, making this an RPC error
let log = log.log_decode::<Transfer>().map_err(|_| {
TransportErrorKind::Custom("log didn't include a valid transfer event".to_string().into())
})?;
let block_hash = log.block_hash.ok_or_else(|| {
TransportErrorKind::Custom("log didn't have its block hash set".to_string().into())
})?;
let log_index = log.log_index.ok_or_else(|| {
TransportErrorKind::Custom("log didn't have its index set".to_string().into())
})?;
let log = log.inner.data;
// Ensure the top-level transfer is equivalent to the transfer this log represents
if !((log.from == from) && (log.to == to) && (log.value == value)) {
continue;
}
// Read the data appended after
let data = if let Ok(call) = SeraiIERC20Calls::abi_decode(transaction.inner.input()) {
match call {
SeraiIERC20Calls::transferWithInInstruction01BB244A8A(
transferWithInInstructionCall { inInstruction, .. },
) |
SeraiIERC20Calls::transferFromWithInInstruction00081948E0(
transferFromWithInInstructionCall { inInstruction, .. },
) => Vec::from(inInstruction),
}
} else {
// If there was no additional data appended, use an empty Vec (which has no data)
// This has a slight information loss in that it's None -> Some(vec![]), but it's fine
vec![]
};
return Ok(Some(TopLevelTransfer {
id: LogIndex { block_hash: *block_hash, index_within_block: log_index },
transaction_hash,
from: log.from,
amount: log.value,
data,
}));
}
Ok(None)
}
/// Fetch all top-level transfers to the specified address for this token.
///
/// The `transfers` in the result are unordered. The `logs` are sorted by index.
pub async fn top_level_transfers_unordered(
provider: &RootProvider,
blocks: RangeInclusive<u64>,
erc20: Address,
to: Address,
) -> Result<TopLevelTransfers, RpcError<TransportErrorKind>> {
let mut logs = {
// Get all transfers within these blocks
let logs = provider.get_logs(&Self::transfer_filter(blocks, erc20, to)).await?;
// The logs, indexed by their transactions
let mut transaction_logs = HashMap::new();
// Index the logs by their transactions
for log in logs {
// Double check the address which emitted this log
if log.address() != erc20 {
Err(TransportErrorKind::Custom(
"node returned logs for a different address than requested".to_string().into(),
))?;
}
// Double check the event signature for this log
if log.topics().first() != Some(&Transfer::SIGNATURE_HASH) {
Err(TransportErrorKind::Custom(
"node returned a log for a different topic than filtered to".to_string().into(),
))?;
}
// Double check the `to` topic
if log.topics().get(2) != Some(&to.into_word()) {
Err(TransportErrorKind::Custom(
"node returned a transfer for a different `to` than filtered to".to_string().into(),
))?;
}
let tx_id = log
.transaction_hash
.ok_or_else(|| {
TransportErrorKind::Custom("log didn't specify its transaction hash".to_string().into())
})?
.0;
transaction_logs.entry(tx_id).or_insert_with(|| Vec::with_capacity(1)).push(log);
}
transaction_logs
};
let mut transfers = vec![];
{
// Use `FuturesUnordered` so these RPC calls run in parallel
let mut futures = FuturesUnordered::new();
for (tx_id, transfer_logs) in &mut logs {
// Sort the logs to ensure the the earliest logs are first
transfer_logs.sort_by_key(|log| log.log_index);
futures.push(Self::top_level_transfer(provider, erc20, *tx_id, transfer_logs));
}
while let Some(transfer) = futures.next().await {
match transfer {
// Top-level transfer
Ok(Some(transfer)) => transfers.push(transfer),
// Not a top-level transfer
Ok(None) => continue,
// Failed to get this transaction's information so abort
Err(e) => Err(e)?,
}
}
}
Ok(TopLevelTransfers { logs, transfers })
}
}