wp-evm-base 0.1.6

Base layer (math, evm helpers, base types) for waterpump-evm
Documentation
//! EVM helpers: multicall encoding, provider extensions.
//!
//! Multicall encoding is pure (no async, no provider). Provider helpers
//! that perform actual calls live in family `hydrate` modules instead —
//! this module never reads the chain.

use crate::types::Call;
use alloy_primitives::Bytes;
use alloy_sol_types::{sol, SolCall};

sol! {
    #[derive(Debug)]
    struct Call3 {
        address target;
        bool allowFailure;
        bytes callData;
    }

    function aggregate3(Call3[] calls) external payable returns (bytes[] memory);

    #[derive(Debug)]
    struct Call3Value {
        address target;
        bool allowFailure;
        uint256 value;
        bytes callData;
    }

    function aggregate3Value(Call3Value[] calls) external payable returns (bytes[] memory);
}

/// Encode a `Vec<Call>` as a multicall3 `aggregate3` call (calldata only,
/// caller is responsible for setting `to = MULTICALL3_ADDRESS` and `value`).
///
/// Per-call `Call::value` fields are NOT forwarded — multicall3's `aggregate3`
/// has no per-call value parameter. ETH must be routed via the top-level
/// transaction value.
///
/// An empty `calls` slice is valid and produces the calldata for an empty
/// `aggregate3` invocation; callers that want to avoid sending an empty
/// multicall should gate on `calls.is_empty()` themselves.
///
/// Pure. No I/O.
pub fn encode_multicall3_aggregate3(calls: &[Call]) -> Bytes {
    let call3s: Vec<Call3> = calls
        .iter()
        .map(|c| Call3 { target: c.target, allowFailure: false, callData: c.calldata.clone() })
        .collect();

    aggregate3Call { calls: call3s }.abi_encode().into()
}

/// Encode a `Vec<Call>` as a multicall3 `aggregate3Value` call (calldata only;
/// caller sets `to = MULTICALL3_ADDRESS` and the top-level tx `value`).
///
/// Unlike [`encode_multicall3_aggregate3`], this FORWARDS each `Call::value` as
/// the per-call `value`. Multicall3 requires the transaction's `msg.value` to
/// equal the sum of per-call values; callers should use
/// `wp_evm_compose::wrap_multicall3_value`, which sets that sum.
///
/// `allowFailure` is `false` for every call: any sub-call revert reverts the
/// whole transaction (atomic execution).
///
/// Note: inside Multicall3 each sub-call's `msg.sender` is the Multicall3
/// contract, not the original EOA. Do not encode `approve()` or other
/// `msg.sender`-dependent calls here.
///
/// Pure. No I/O.
pub fn encode_multicall3_aggregate3_value(calls: &[Call]) -> Bytes {
    let call3s: Vec<Call3Value> = calls
        .iter()
        .map(|c| Call3Value {
            target: c.target,
            allowFailure: false,
            value: c.value,
            callData: c.calldata.clone(),
        })
        .collect();

    aggregate3ValueCall { calls: call3s }.abi_encode().into()
}

/// Canonical multicall3 deployment, present on most EVM chains.
pub const MULTICALL3_ADDRESS: alloy_primitives::Address =
    alloy_primitives::address!("0xcA11bde05977b3631167028862bE2a173976CA11");

/// Sign-extend alloy's `I24` (a wrapping signed 24-bit type) to `i32`.
///
/// The `alloy_primitives::aliases::I24` type is a 3-byte two's-complement
/// integer stored in a wider native word. Direct `as i32` or `.try_into()`
/// would either truncate or fail; this helper performs the canonical
/// sign extension: if the high bit of the 3-byte representation is set,
/// the result's upper bits fill with 1s; otherwise they fill with 0s.
///
/// Used by family hydrate modules when reading `tick` and `tickSpacing`
/// from `sol! { #[sol(rpc)] }` bindings that return `I24`.
pub fn sign_extend_i24(val: alloy_primitives::aliases::I24) -> i32 {
    let bytes = val.to_le_bytes::<3>();
    let sign_byte = if bytes[2] & 0x80 != 0 { 0xFF_u8 } else { 0x00_u8 };
    i32::from_le_bytes([bytes[0], bytes[1], bytes[2], sign_byte])
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::Call;
    use alloy_primitives::{address, bytes, U256};

    #[test]
    fn encode_multicall3_aggregate3_two_calls() {
        let calls = vec![
            Call {
                target: address!("0x0000000000000000000000000000000000000001"),
                calldata: bytes!("aa"),
                value: U256::ZERO,
            },
            Call {
                target: address!("0x0000000000000000000000000000000000000002"),
                calldata: bytes!("bb"),
                value: U256::ZERO,
            },
        ];
        let encoded = encode_multicall3_aggregate3(&calls);
        assert_eq!(&encoded[..4], &[0x82, 0xad, 0x56, 0xcb]);
    }

    #[test]
    fn encode_multicall3_aggregate3_round_trips_two_calls() {
        let input = vec![
            Call {
                target: address!("0x0000000000000000000000000000000000000001"),
                calldata: bytes!("aa"),
                value: U256::ZERO,
            },
            Call {
                target: address!("0x0000000000000000000000000000000000000002"),
                calldata: bytes!("bb"),
                value: U256::ZERO,
            },
        ];
        let encoded = encode_multicall3_aggregate3(&input);
        let decoded = aggregate3Call::abi_decode(&encoded).expect("abi_decode failed");
        assert_eq!(decoded.calls.len(), 2);
        assert_eq!(decoded.calls[0].target, input[0].target);
        assert!(!decoded.calls[0].allowFailure);
        assert_eq!(decoded.calls[0].callData, input[0].calldata);
        assert_eq!(decoded.calls[1].target, input[1].target);
        assert!(!decoded.calls[1].allowFailure);
        assert_eq!(decoded.calls[1].callData, input[1].calldata);
    }

    #[test]
    fn sign_extend_i24_positive_and_negative() {
        use alloy_primitives::aliases::I24;

        // Zero
        assert_eq!(sign_extend_i24(I24::ZERO), 0i32);

        // Small positive
        assert_eq!(sign_extend_i24(I24::try_from(100i32).unwrap()), 100i32);

        // Small negative
        assert_eq!(sign_extend_i24(I24::try_from(-100i32).unwrap()), -100i32);

        // Large positive (close to I24::MAX = 2^23 - 1 = 8_388_607)
        assert_eq!(sign_extend_i24(I24::try_from(8_388_607i32).unwrap()), 8_388_607i32);

        // Large negative (close to I24::MIN = -2^23 = -8_388_608)
        assert_eq!(sign_extend_i24(I24::try_from(-8_388_608i32).unwrap()), -8_388_608i32);

        // Uniswap V3 tick bounds: [-887272, 887272]
        assert_eq!(sign_extend_i24(I24::try_from(887_272i32).unwrap()), 887_272i32);
        assert_eq!(sign_extend_i24(I24::try_from(-887_272i32).unwrap()), -887_272i32);
    }

    #[test]
    fn encode_multicall3_aggregate3_value_forwards_value_and_selector() {
        let calls = vec![
            Call {
                target: address!("0x0000000000000000000000000000000000000001"),
                calldata: bytes!("aa"),
                value: U256::ZERO,
            },
            Call {
                target: address!("0x0000000000000000000000000000000000000002"),
                calldata: bytes!("bb"),
                value: U256::from(1_000_000_000_000_000_000u64), // 1 ETH
            },
        ];
        let encoded = encode_multicall3_aggregate3_value(&calls);

        // aggregate3Value selector.
        assert_eq!(&encoded[..4], &[0x17, 0x4d, 0xea, 0x71]);

        // Round-trip: allowFailure=false and per-call value is forwarded.
        let decoded = aggregate3ValueCall::abi_decode(&encoded).expect("abi_decode failed");
        assert_eq!(decoded.calls.len(), 2);
        assert!(!decoded.calls[0].allowFailure);
        assert_eq!(decoded.calls[0].value, U256::ZERO);
        assert_eq!(decoded.calls[1].value, U256::from(1_000_000_000_000_000_000u64));
        assert_eq!(decoded.calls[1].callData, calls[1].calldata);
    }
}