r402-evm 0.20.0

EIP-155 (EVM) chain support for the x402 payment protocol.
Documentation
//! Off-chain verification for EVM `auth-capture`.

use alloy_primitives::{Address, B256, U256};
use alloy_sol_types::{SolStruct, eip712_domain};
use r402_protocol::error::VerificationError;
use r402_protocol::payment::UnixTimestamp;
use r402_protocol::scheme::AuthCaptureScheme;

use super::nonce::{compute_payer_agnostic_payment_info_hash, hash_as_uint256};
use super::payload::{
    AUTH_CAPTURE_CLOCK_SKEW_SECS, AuthCaptureDeployment, AuthCaptureEip3009Payload,
    AuthCaptureExtra, AuthCapturePayload, AuthCapturePermit2Payload, PaymentInfo,
    PermitTransferFrom, ReceiveWithAuthorization, TokenPermissions, submitted_fee_from_wire, v2,
};
use crate::permit2::PERMIT2_ADDRESS;

/// Reconstructs on-chain `PaymentInfo` from requirements + payload salt/payer.
#[must_use]
pub const fn reconstruct_payment_info(
    requirements: &v2::PaymentRequirements,
    extra: &AuthCaptureExtra,
    payer: Address,
    salt: B256,
    pre_approval_expiry: u64,
) -> PaymentInfo {
    PaymentInfo {
        operator: extra.capture_authorizer.0,
        payer,
        receiver: requirements.pay_to.0,
        token: requirements.asset.0,
        max_amount: requirements.amount.0,
        pre_approval_expiry,
        authorization_expiry: extra.capture_deadline,
        refund_expiry: extra.refund_deadline,
        min_fee_bps: extra.min_fee_bps,
        max_fee_bps: extra.max_fee_bps,
        fee_receiver: extra.fee_recipient.0,
        salt: U256::from_be_bytes(salt.0),
    }
}

/// Runs off-chain verification steps 1–12 from the auth-capture EVM spec.
///
/// Returns the recovered payer address on success.
///
/// # Errors
///
/// Structured [`VerificationError`] for each failed check.
pub fn verify_offchain(
    payload: &v2::PaymentPayload,
    requirements: &v2::PaymentRequirements,
    chain_id: u64,
) -> Result<Address, VerificationError> {
    if requirements.scheme != AuthCaptureScheme {
        return Err(VerificationError::UnsupportedScheme);
    }
    if payload.accepted.scheme != AuthCaptureScheme {
        return Err(VerificationError::AcceptedRequirementsMismatch);
    }
    if payload.accepted.network != requirements.network {
        return Err(VerificationError::ChainIdMismatch);
    }

    let extra = requirements
        .extra
        .as_ref()
        .ok_or_else(|| VerificationError::InvalidFormat("missing auth-capture extra".into()))?;

    validate_extra(extra)?;
    let deployment = extra.require_deployment()?;
    submitted_fee_from_wire(
        deployment.version,
        payload.payload.fee_bps(),
        payload.payload.fee_amount(),
    )?;

    if payload.payload.transfer_method() != extra.transfer_method() {
        return Err(VerificationError::InvalidFormat(
            "payload assetTransferMethod mismatch".into(),
        ));
    }

    let now = UnixTimestamp::now().as_secs();
    if extra.refund_deadline < extra.capture_deadline {
        return Err(VerificationError::InvalidFormat(
            "refundDeadline must be >= captureDeadline".into(),
        ));
    }
    if extra.capture_deadline <= now.saturating_add(AUTH_CAPTURE_CLOCK_SKEW_SECS) {
        return Err(VerificationError::Expired);
    }

    match &payload.payload {
        AuthCapturePayload::Eip3009(p) => {
            verify_eip3009(p, requirements, extra, deployment, chain_id, now)
        }
        AuthCapturePayload::Permit2(p) => {
            verify_permit2(p, requirements, extra, deployment, chain_id, now)
        }
    }
}

fn validate_extra(extra: &AuthCaptureExtra) -> Result<(), VerificationError> {
    if extra.name.is_empty() || extra.version.is_empty() {
        return Err(VerificationError::InvalidFormat(
            "extra.name and extra.version required".into(),
        ));
    }
    if extra.max_fee_bps < extra.min_fee_bps {
        return Err(VerificationError::InvalidFormat(
            "maxFeeBps must be >= minFeeBps".into(),
        ));
    }
    if extra.auto_capture() {
        return Err(VerificationError::InvalidFormat(
            "autoCapture is not supported".into(),
        ));
    }
    Ok(())
}

fn verify_eip3009(
    p: &AuthCaptureEip3009Payload,
    requirements: &v2::PaymentRequirements,
    extra: &AuthCaptureExtra,
    deployment: AuthCaptureDeployment,
    chain_id: u64,
    now: u64,
) -> Result<Address, VerificationError> {
    let auth = &p.authorization;
    if auth.to != deployment.eip3009_collector {
        return Err(VerificationError::from_wire(
            "invalid_auth_capture_evm_token_collector_mismatch",
        ));
    }
    if auth.value.0 != requirements.amount.0 {
        return Err(VerificationError::InvalidPaymentAmount);
    }
    let valid_before = auth.valid_before.as_secs();
    if valid_before <= now.saturating_add(AUTH_CAPTURE_CLOCK_SKEW_SECS) {
        return Err(VerificationError::Expired);
    }
    if valid_before > extra.capture_deadline {
        return Err(VerificationError::InvalidFormat(
            "validBefore must be <= captureDeadline".into(),
        ));
    }
    if auth.valid_after.as_secs() > now {
        return Err(VerificationError::Early);
    }

    let info = reconstruct_payment_info(requirements, extra, auth.from, p.salt, valid_before);
    let expected_nonce =
        compute_payer_agnostic_payment_info_hash(chain_id, &info, deployment.escrow);
    if auth.nonce != expected_nonce {
        return Err(VerificationError::InvalidFormat(
            "authorization.nonce does not match PaymentInfo hash".into(),
        ));
    }

    let domain = eip712_domain! {
        name: extra.name.clone(),
        version: extra.version.clone(),
        chain_id: chain_id,
        verifying_contract: requirements.asset.0,
    };
    let typed = ReceiveWithAuthorization {
        from: auth.from,
        to: auth.to,
        value: auth.value.0,
        validAfter: U256::from(auth.valid_after.as_secs()),
        validBefore: U256::from(valid_before),
        nonce: auth.nonce,
    };
    let hash = typed.eip712_signing_hash(&domain);
    recover_payer(hash, &p.signature, auth.from)?;
    Ok(auth.from)
}

fn verify_permit2(
    p: &AuthCapturePermit2Payload,
    requirements: &v2::PaymentRequirements,
    extra: &AuthCaptureExtra,
    deployment: AuthCaptureDeployment,
    chain_id: u64,
    now: u64,
) -> Result<Address, VerificationError> {
    let a = &p.permit2_authorization;
    if a.spender != deployment.permit2_collector {
        return Err(VerificationError::from_wire(
            "invalid_auth_capture_evm_token_collector_mismatch",
        ));
    }
    if a.permitted.token != requirements.asset.0 {
        return Err(VerificationError::AssetMismatch);
    }
    if a.permitted.amount.0 != requirements.amount.0 {
        return Err(VerificationError::InvalidPaymentAmount);
    }
    let deadline = u64::try_from(a.deadline.0)
        .map_err(|_| VerificationError::InvalidFormat("permit2 deadline exceeds u64".into()))?;
    if deadline <= now.saturating_add(AUTH_CAPTURE_CLOCK_SKEW_SECS) {
        return Err(VerificationError::Expired);
    }
    if deadline > extra.capture_deadline {
        return Err(VerificationError::InvalidFormat(
            "deadline must be <= captureDeadline".into(),
        ));
    }

    let info = reconstruct_payment_info(requirements, extra, a.from, p.salt, deadline);
    let expected = compute_payer_agnostic_payment_info_hash(chain_id, &info, deployment.escrow);
    if a.nonce.0 != hash_as_uint256(expected) {
        return Err(VerificationError::InvalidFormat(
            "permit2 nonce does not match PaymentInfo hash".into(),
        ));
    }

    let domain = eip712_domain! {
        name: "Permit2",
        chain_id: chain_id,
        verifying_contract: PERMIT2_ADDRESS,
    };
    let typed = PermitTransferFrom {
        permitted: TokenPermissions {
            token: a.permitted.token,
            amount: a.permitted.amount.0,
        },
        spender: a.spender,
        nonce: a.nonce.0,
        deadline: a.deadline.0,
    };
    let hash = typed.eip712_signing_hash(&domain);
    recover_payer(hash, &p.signature, a.from)?;
    Ok(a.from)
}

fn recover_payer(
    hash: B256,
    signature: &alloy_primitives::Bytes,
    expected: Address,
) -> Result<(), VerificationError> {
    // Off-chain /verify is the access gate. Non-65-byte blobs are not recovered,
    // so accepting them would grant access without a checked signer.
    if signature.len() != 65 {
        return Err(VerificationError::InvalidSignature(
            "signature must be 65 bytes".into(),
        ));
    }
    let sig = alloy_primitives::Signature::from_raw(signature.as_ref())
        .map_err(|e| VerificationError::InvalidSignature(format!("parse signature: {e}")))?;
    let recovered = sig
        .recover_address_from_prehash(&hash)
        .map_err(|e| VerificationError::InvalidSignature(format!("recover: {e}")))?;
    if recovered != expected {
        return Err(VerificationError::InvalidSignature(
            "recovered signer mismatch".into(),
        ));
    }
    Ok(())
}