r402-evm 0.17.1

EIP-155 (EVM) chain support for the x402 payment protocol.
Documentation
//! EIP-155 `auth-capture` scheme (Base commerce-payments escrow).
//!
//! Client signs ERC-3009 `ReceiveWithAuthorization` or Permit2
//! `PermitTransferFrom` with a nonce derived from the payer-agnostic
//! `PaymentInfo` hash. The facilitator verifies off-chain then calls
//! `AuthCaptureEscrow.authorize` or `.charge`.
//!
//! Spec: `specs/schemes/auth-capture/scheme_auth_capture_evm.md`.

use r402_core::scheme::{AuthCaptureScheme, SchemeId, Sealed};

#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "facilitator")]
pub mod facilitator;
#[cfg(any(feature = "client", feature = "facilitator"))]
pub mod nonce;
#[cfg(feature = "server")]
pub mod server;
pub mod types;
#[cfg(any(feature = "facilitator", feature = "client"))]
pub mod verify;

#[cfg(feature = "client")]
pub use client::{Eip155AuthCaptureClient, sign_auth_capture};
#[cfg(feature = "facilitator")]
pub use facilitator::Eip155AuthCaptureFacilitator;
#[cfg(any(feature = "client", feature = "facilitator"))]
pub use nonce::compute_payer_agnostic_payment_info_hash;
pub use types::{
    AUTH_CAPTURE_CLOCK_SKEW_SECS, AUTH_CAPTURE_ESCROW_ADDRESS, AuthCaptureEip3009Authorization,
    AuthCaptureEip3009Payload, AuthCaptureExtra, AuthCapturePayload,
    AuthCapturePermit2Authorization, AuthCapturePermit2Payload, EIP3009_TOKEN_COLLECTOR_ADDRESS,
    PERMIT2_TOKEN_COLLECTOR_ADDRESS, PaymentInfo,
};

/// EIP-155 auth-capture scheme marker / blueprint.
#[derive(Debug, Clone, Copy, Default)]
pub struct Eip155AuthCapture;

impl SchemeId for Eip155AuthCapture {
    fn namespace(&self) -> &'static str {
        "eip155"
    }

    fn scheme(&self) -> &str {
        AuthCaptureScheme.as_ref()
    }
}

impl Sealed for Eip155AuthCapture {}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scheme_id() {
        assert_eq!(Eip155AuthCapture.namespace(), "eip155");
        assert_eq!(Eip155AuthCapture.scheme(), "auth-capture");
    }
}

#[cfg(test)]
mod client_verify_tests {
    #[cfg(feature = "client")]
    use alloy_primitives::{Address, U256};
    #[cfg(feature = "client")]
    use alloy_signer_local::PrivateKeySigner;
    #[cfg(feature = "client")]
    use r402_core::error::VerificationError;
    #[cfg(feature = "client")]
    use r402_core::scheme::AuthCaptureScheme;
    #[cfg(feature = "client")]
    use r402_core::wire::PaymentPayload;

    #[cfg(feature = "client")]
    use super::client::sign_auth_capture;
    #[cfg(feature = "client")]
    use super::types::v2;
    #[cfg(feature = "client")]
    use super::types::{AuthCaptureExtra, AuthCapturePayload};
    #[cfg(feature = "client")]
    use super::verify::verify_offchain;
    #[cfg(feature = "client")]
    use crate::chain::{ChecksummedAddress, TokenAmount};

    #[cfg(feature = "client")]
    #[tokio::test]
    async fn eip3009_sign_then_verify() {
        let signer = PrivateKeySigner::random();
        let payer = signer.address();
        let now = r402_core::wire::UnixTimestamp::now().as_secs();
        let extra = AuthCaptureExtra {
            name: "USD Coin".into(),
            version: "2".into(),
            capture_authorizer: ChecksummedAddress(Address::repeat_byte(0x11)),
            capture_deadline: now + 3600,
            refund_deadline: now + 86400,
            fee_recipient: ChecksummedAddress(Address::repeat_byte(0x22)),
            min_fee_bps: 0,
            max_fee_bps: 1000,
            auto_capture: Some(false),
            asset_transfer_method: None,
        };
        let asset = Address::repeat_byte(0xAA);
        let pay_to = Address::repeat_byte(0xBB);
        let amount = U256::from(1_000_000_u64);
        let scheme_payload = sign_auth_capture(&signer, 8453, asset, pay_to, amount, 300, &extra)
            .await
            .expect("sign");
        assert!(matches!(scheme_payload, AuthCapturePayload::Eip3009(_)));

        let requirements = v2::PaymentRequirements::new(
            AuthCaptureScheme,
            "eip155:8453".parse().unwrap(),
            TokenAmount::from(amount),
            ChecksummedAddress(pay_to),
            ChecksummedAddress(asset),
            300,
        )
        .with_extra(extra);
        let payment: v2::PaymentPayload = PaymentPayload::new(requirements.clone(), scheme_payload);
        let recovered = verify_offchain(&payment, &requirements, 8453).expect("verify");
        assert_eq!(recovered, payer);
    }

    /// 64-byte signatures are not recovered; reject them.
    #[cfg(feature = "client")]
    #[tokio::test]
    async fn eip3009_rejects_64_byte_signature() {
        let signer = PrivateKeySigner::random();
        let now = r402_core::wire::UnixTimestamp::now().as_secs();
        let extra = AuthCaptureExtra {
            name: "USD Coin".into(),
            version: "2".into(),
            capture_authorizer: ChecksummedAddress(Address::repeat_byte(0x11)),
            capture_deadline: now + 3600,
            refund_deadline: now + 86400,
            fee_recipient: ChecksummedAddress(Address::repeat_byte(0x22)),
            min_fee_bps: 0,
            max_fee_bps: 1000,
            auto_capture: Some(false),
            asset_transfer_method: None,
        };
        let asset = Address::repeat_byte(0xAA);
        let pay_to = Address::repeat_byte(0xBB);
        let amount = U256::from(1_000_000_u64);
        let scheme_payload = sign_auth_capture(&signer, 8453, asset, pay_to, amount, 300, &extra)
            .await
            .expect("sign");

        let mut p = match scheme_payload {
            AuthCapturePayload::Eip3009(p) => p,
            AuthCapturePayload::Permit2(_) => {
                unreachable!("sign_auth_capture with default extra is Eip3009")
            }
        };
        p.authorization.from = Address::repeat_byte(0xCC);
        p.signature = alloy_primitives::Bytes::from(vec![0u8; 64]);
        let forged = AuthCapturePayload::Eip3009(p);

        let requirements = v2::PaymentRequirements::new(
            AuthCaptureScheme,
            "eip155:8453".parse().unwrap(),
            TokenAmount::from(amount),
            ChecksummedAddress(pay_to),
            ChecksummedAddress(asset),
            300,
        )
        .with_extra(extra);
        let payment: v2::PaymentPayload = PaymentPayload::new(requirements.clone(), forged);

        assert!(
            matches!(
                verify_offchain(&payment, &requirements, 8453),
                Err(VerificationError::InvalidSignature(_))
            ),
            "a 64-byte signature must not verify off-chain",
        );
    }

    /// Signatures longer than 65 bytes are not recovered; reject them.
    #[cfg(feature = "client")]
    #[tokio::test]
    async fn eip3009_rejects_66_byte_signature() {
        let signer = PrivateKeySigner::random();
        let now = r402_core::wire::UnixTimestamp::now().as_secs();
        let extra = AuthCaptureExtra {
            name: "USD Coin".into(),
            version: "2".into(),
            capture_authorizer: ChecksummedAddress(Address::repeat_byte(0x11)),
            capture_deadline: now + 3600,
            refund_deadline: now + 86400,
            fee_recipient: ChecksummedAddress(Address::repeat_byte(0x22)),
            min_fee_bps: 0,
            max_fee_bps: 1000,
            auto_capture: Some(false),
            asset_transfer_method: None,
        };
        let asset = Address::repeat_byte(0xAA);
        let pay_to = Address::repeat_byte(0xBB);
        let amount = U256::from(1_000_000_u64);
        let scheme_payload = sign_auth_capture(&signer, 8453, asset, pay_to, amount, 300, &extra)
            .await
            .expect("sign");

        let mut p = match scheme_payload {
            AuthCapturePayload::Eip3009(p) => p,
            AuthCapturePayload::Permit2(_) => {
                unreachable!("sign_auth_capture with default extra is Eip3009")
            }
        };
        p.authorization.from = Address::repeat_byte(0xCC);
        p.signature = alloy_primitives::Bytes::from(vec![0u8; 66]);
        let forged = AuthCapturePayload::Eip3009(p);

        let requirements = v2::PaymentRequirements::new(
            AuthCaptureScheme,
            "eip155:8453".parse().unwrap(),
            TokenAmount::from(amount),
            ChecksummedAddress(pay_to),
            ChecksummedAddress(asset),
            300,
        )
        .with_extra(extra);
        let payment: v2::PaymentPayload = PaymentPayload::new(requirements.clone(), forged);

        assert!(
            matches!(
                verify_offchain(&payment, &requirements, 8453),
                Err(VerificationError::InvalidSignature(_))
            ),
            "a 66-byte signature must not verify off-chain",
        );
    }

    /// A valid EOA signature plus a trailing byte must not be truncated and recovered.
    #[cfg(feature = "client")]
    #[tokio::test]
    async fn eip3009_rejects_trailing_byte_on_valid_signature() {
        let signer = PrivateKeySigner::random();
        let now = r402_core::wire::UnixTimestamp::now().as_secs();
        let extra = AuthCaptureExtra {
            name: "USD Coin".into(),
            version: "2".into(),
            capture_authorizer: ChecksummedAddress(Address::repeat_byte(0x11)),
            capture_deadline: now + 3600,
            refund_deadline: now + 86400,
            fee_recipient: ChecksummedAddress(Address::repeat_byte(0x22)),
            min_fee_bps: 0,
            max_fee_bps: 1000,
            auto_capture: Some(false),
            asset_transfer_method: None,
        };
        let asset = Address::repeat_byte(0xAA);
        let pay_to = Address::repeat_byte(0xBB);
        let amount = U256::from(1_000_000_u64);
        let scheme_payload = sign_auth_capture(&signer, 8453, asset, pay_to, amount, 300, &extra)
            .await
            .expect("sign");

        let mut p = match scheme_payload {
            AuthCapturePayload::Eip3009(p) => p,
            AuthCapturePayload::Permit2(_) => {
                unreachable!("sign_auth_capture with default extra is Eip3009")
            }
        };
        let mut longer = p.signature.to_vec();
        longer.push(0);
        p.signature = alloy_primitives::Bytes::from(longer);
        let forged = AuthCapturePayload::Eip3009(p);

        let requirements = v2::PaymentRequirements::new(
            AuthCaptureScheme,
            "eip155:8453".parse().unwrap(),
            TokenAmount::from(amount),
            ChecksummedAddress(pay_to),
            ChecksummedAddress(asset),
            300,
        )
        .with_extra(extra);
        let payment: v2::PaymentPayload = PaymentPayload::new(requirements.clone(), forged);

        assert!(
            matches!(
                verify_offchain(&payment, &requirements, 8453),
                Err(VerificationError::InvalidSignature(_))
            ),
            "a 65-byte signature with a trailing byte must not verify off-chain",
        );
    }
}