diff --git a/src/protocol/methods/evm/authorization.rs b/src/protocol/methods/evm/authorization.rs new file mode 100644 index 00000000..d0616f81 --- /dev/null +++ b/src/protocol/methods/evm/authorization.rs @@ -0,0 +1,378 @@ +//! EIP-3009 `TransferWithAuthorization` signing and verification for the +//! native `evm/charge` method. +//! +//! The credential is an EIP-712 signature over the token's +//! `TransferWithAuthorization` struct. The EIP-712 domain is bound to the token +//! contract (`verifyingContract = currency`) and chain, and for native MPP +//! challenges the authorization `nonce` is bound to the challenge. + +use alloy::primitives::{keccak256, Address, B256, U256}; +use alloy::sol_types::{eip712_domain, SolStruct}; + +use crate::error::{MppError, Result, ResultExt}; + +alloy::sol! { + #[derive(Debug)] + struct TransferWithAuthorization { + address from; + address to; + uint256 value; + uint256 validAfter; + uint256 validBefore; + bytes32 nonce; + } +} + +/// Build the canonical DID source for an EVM authorization credential: +/// `did:pkh:eip155:{chainId}:{address}`. +pub fn evm_source(address: Address, chain_id: u64) -> String { + format!("did:pkh:eip155:{chain_id}:{address}") +} + +/// Compute the challenge-bound authorization nonce for a native MPP challenge: +/// `keccak256(challengeId ++ realm)`. +pub fn challenge_nonce(challenge_id: &str, realm: &str) -> B256 { + let mut buf = Vec::with_capacity(challenge_id.len() + realm.len()); + buf.extend_from_slice(challenge_id.as_bytes()); + buf.extend_from_slice(realm.as_bytes()); + keccak256(&buf) +} + +/// Compute the EIP-712 signing hash for a `TransferWithAuthorization`. +/// +/// `name` and `version` are the token's EIP-712 domain fields and +/// `verifying_contract` is the token (`currency`) address. +#[allow(clippy::too_many_arguments)] +pub fn signing_hash( + name: &str, + version: &str, + chain_id: u64, + verifying_contract: Address, + from: Address, + to: Address, + value: U256, + valid_after: U256, + valid_before: U256, + nonce: B256, +) -> B256 { + let domain = eip712_domain! { + name: name.to_string(), + version: version.to_string(), + chain_id: chain_id, + verifying_contract: verifying_contract, + }; + + TransferWithAuthorization { + from, + to, + value, + validAfter: valid_after, + validBefore: valid_before, + nonce, + } + .eip712_signing_hash(&domain) +} + +/// Sign a `TransferWithAuthorization` and return the 0x-prefixed signature hex. +#[allow(clippy::too_many_arguments)] +pub async fn sign_authorization( + signer: &impl alloy::signers::Signer, + name: &str, + version: &str, + chain_id: u64, + verifying_contract: Address, + from: Address, + to: Address, + value: U256, + valid_after: U256, + valid_before: U256, + nonce: B256, +) -> Result { + let hash = signing_hash( + name, + version, + chain_id, + verifying_contract, + from, + to, + value, + valid_after, + valid_before, + nonce, + ); + let signature = signer + .sign_hash(&hash) + .await + .mpp_http("failed to sign authorization")?; + Ok(alloy::hex::encode_prefixed(signature.as_bytes())) +} + +/// Recover the signer address from a `TransferWithAuthorization` signature. +#[allow(clippy::too_many_arguments)] +pub fn recover_authorization_signer( + name: &str, + version: &str, + chain_id: u64, + verifying_contract: Address, + from: Address, + to: Address, + value: U256, + valid_after: U256, + valid_before: U256, + nonce: B256, + signature_hex: &str, +) -> Result
{ + let signature_bytes: alloy::primitives::Bytes = signature_hex + .parse() + .map_err(|_| MppError::invalid_payload("invalid authorization signature hex"))?; + // Accept only canonical 65-byte signatures with a typed-data `v` of + // 0/1/27/28. Reject EIP-155 transaction `v` (>= 35), which on-chain + // ecrecover rejects for typed data. + let bytes = signature_bytes.as_ref(); + if bytes.len() != 65 { + return Err(MppError::invalid_payload( + "invalid authorization signature length", + )); + } + if !matches!(bytes[64], 0 | 1 | 27 | 28) { + return Err(MppError::invalid_payload( + "invalid authorization signature v", + )); + } + let signature = alloy::signers::Signature::try_from(bytes) + .map_err(|_| MppError::invalid_payload("invalid authorization signature"))?; + let hash = signing_hash( + name, + version, + chain_id, + verifying_contract, + from, + to, + value, + valid_after, + valid_before, + nonce, + ); + signature + .recover_address_from_prehash(&hash) + .map_err(|_| MppError::invalid_payload("authorization signature recovery failed")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn token() -> Address { + Address::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap() + } + + #[test] + fn test_challenge_nonce_depends_on_inputs() { + let a = challenge_nonce("id-1", "api.example.com"); + let b = challenge_nonce("id-2", "api.example.com"); + let c = challenge_nonce("id-1", "other.example.com"); + assert_ne!(a, b); + assert_ne!(a, c); + } + + #[test] + fn test_challenge_nonce_golden_vector() { + // Golden vector: keccak256(utf8("challenge-123" ++ "api.example.com")), + // independently produced by `cast keccak "challenge-123api.example.com"`. + let nonce = challenge_nonce("challenge-123", "api.example.com"); + assert_eq!( + alloy::hex::encode_prefixed(nonce), + "0x6e75bcb5df1f8022ad4eecac5da5620c2c1db72d885bca54897f41a782c65b3a" + ); + } + + #[test] + fn test_evm_source_format() { + let addr = Address::from_str("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2").unwrap(); + // The address is rendered in EIP-55 checksummed form. + assert_eq!( + evm_source(addr, 84532), + "did:pkh:eip155:84532:0x742D35Cc6634c0532925a3b844bc9e7595F1b0F2" + ); + } + + #[tokio::test] + async fn test_sign_and_recover_roundtrip() { + let signer = alloy::signers::local::PrivateKeySigner::random(); + let from = signer.address(); + let to = Address::from_str("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2").unwrap(); + let nonce = challenge_nonce("challenge-123", "api.example.com"); + + let sig = sign_authorization( + &signer, + "USD Coin", + "2", + 84532, + token(), + from, + to, + U256::from(1_000_000u64), + U256::ZERO, + U256::from(9_999_999_999u64), + nonce, + ) + .await + .unwrap(); + + let recovered = recover_authorization_signer( + "USD Coin", + "2", + 84532, + token(), + from, + to, + U256::from(1_000_000u64), + U256::ZERO, + U256::from(9_999_999_999u64), + nonce, + &sig, + ) + .unwrap(); + assert_eq!(recovered, from); + } + + #[tokio::test] + async fn test_recover_detects_tampered_value() { + let signer = alloy::signers::local::PrivateKeySigner::random(); + let from = signer.address(); + let to = Address::from_str("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2").unwrap(); + let nonce = challenge_nonce("challenge-123", "api.example.com"); + + let sig = sign_authorization( + &signer, + "USD Coin", + "2", + 84532, + token(), + from, + to, + U256::from(1_000_000u64), + U256::ZERO, + U256::from(9_999_999_999u64), + nonce, + ) + .await + .unwrap(); + + // Recover with a different value → recovered signer will not match. + let recovered = recover_authorization_signer( + "USD Coin", + "2", + 84532, + token(), + from, + to, + U256::from(2_000_000u64), + U256::ZERO, + U256::from(9_999_999_999u64), + nonce, + &sig, + ) + .unwrap(); + assert_ne!(recovered, from); + } + + #[tokio::test] + async fn test_recover_rejects_eip155_v() { + let signer = alloy::signers::local::PrivateKeySigner::random(); + let from = signer.address(); + let to = Address::from_str("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2").unwrap(); + let nonce = challenge_nonce("challenge-123", "api.example.com"); + + let sig = sign_authorization( + &signer, + "USD Coin", + "2", + 84532, + token(), + from, + to, + U256::from(1u64), + U256::ZERO, + U256::from(2u64), + nonce, + ) + .await + .unwrap(); + + // Re-encode the signature with an EIP-155 transaction `v` (37), which + // on-chain ecrecover rejects for typed data. + let mut bytes = alloy::hex::decode(&sig).unwrap(); + bytes[64] = 37; + let tampered = alloy::hex::encode_prefixed(&bytes); + + let err = recover_authorization_signer( + "USD Coin", + "2", + 84532, + token(), + from, + to, + U256::from(1u64), + U256::ZERO, + U256::from(2u64), + nonce, + &tampered, + ); + assert!(err.is_err()); + } + + #[test] + fn test_recover_rejects_wrong_length() { + let from = Address::from_str("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2").unwrap(); + let nonce = challenge_nonce("id", "realm"); + let err = recover_authorization_signer( + "USD Coin", + "2", + 84532, + token(), + from, + from, + U256::from(1u64), + U256::ZERO, + U256::from(2u64), + nonce, + "0x1234", + ); + assert!(err.is_err()); + } + + #[test] + fn test_signing_hash_depends_on_domain() { + let from = Address::from_str("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2").unwrap(); + let to = Address::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(); + let nonce = challenge_nonce("id", "realm"); + let base = signing_hash( + "USD Coin", + "2", + 84532, + token(), + from, + to, + U256::from(1u64), + U256::ZERO, + U256::from(2u64), + nonce, + ); + // Different chain id → different hash. + let other_chain = signing_hash( + "USD Coin", + "2", + 1, + token(), + from, + to, + U256::from(1u64), + U256::ZERO, + U256::from(2u64), + nonce, + ); + assert_ne!(base, other_chain); + } +} diff --git a/src/protocol/methods/evm/mod.rs b/src/protocol/methods/evm/mod.rs new file mode 100644 index 00000000..bb4e7c15 --- /dev/null +++ b/src/protocol/methods/evm/mod.rs @@ -0,0 +1,20 @@ +//! Native `evm/charge` payment method. +//! +//! The generic EVM charge method settles an ERC-20 transfer via an EIP-3009 +//! `TransferWithAuthorization` signature, independent of any specific chain. It +//! differs from the Tempo method, which verifies on-chain TIP-20 transfers / +//! Tempo transactions rather than ERC-20 authorizations. +//! +//! This module provides the protocol layer: wire types ([`types`]) and the +//! EIP-712 signing/verification primitives ([`authorization`]). + +pub mod authorization; +pub mod types; + +pub use authorization::{ + challenge_nonce, evm_source, recover_authorization_signer, sign_authorization, signing_hash, +}; +pub use types::{ + AuthorizationPayload, AuthorizationPayloadType, EvmMethodDetails, Split, + CREDENTIAL_TYPE_AUTHORIZATION, METHOD, +}; diff --git a/src/protocol/methods/evm/types.rs b/src/protocol/methods/evm/types.rs new file mode 100644 index 00000000..fd51cb2e --- /dev/null +++ b/src/protocol/methods/evm/types.rs @@ -0,0 +1,200 @@ +//! Wire types for the native `evm/charge` payment method. +//! +//! These mirror the generic EVM charge schemas: an `evm`-method `charge` intent +//! whose `methodDetails` carry the EVM-specific parameters, and an +//! `authorization` credential payload carrying an EIP-3009 +//! `TransferWithAuthorization` signature. + +use serde::{Deserialize, Serialize}; + +/// Payment method identifier for the native EVM charge method. +pub const METHOD: &str = "evm"; + +/// The only credential type currently implemented for `evm/charge`. +pub const CREDENTIAL_TYPE_AUTHORIZATION: &str = "authorization"; + +/// A single split in a split payment. +/// +/// Each split directs a portion of the total charge amount to a different +/// recipient. Note: the `authorization` credential type authorizes a single +/// EIP-3009 transfer and therefore does not support splits. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Split { + /// Amount in atomic units. + pub amount: String, + + /// Recipient address for this split. + pub recipient: String, +} + +/// EVM method-specific details nested under `methodDetails` in the charge request. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EvmMethodDetails { + /// EVM chain ID (CAIP-2 `eip155:`). + #[serde(rename = "chainId")] + pub chain_id: u64, + + /// Credential types the server accepts (e.g. `["authorization"]`). + #[serde(rename = "credentialTypes", skip_serializing_if = "Option::is_none")] + pub credential_types: Option>, + + /// Token decimals for amount conversion. + #[serde(skip_serializing_if = "Option::is_none")] + pub decimals: Option, + + /// Permit2 contract address (reserved; permit2 credentials are not implemented). + #[serde(rename = "permit2Address", skip_serializing_if = "Option::is_none")] + pub permit2_address: Option, + + /// Optional split payouts. + #[serde(skip_serializing_if = "Option::is_none")] + pub splits: Option>, +} + +impl EvmMethodDetails { + /// Whether the challenge explicitly accepts the `authorization` credential + /// type. An absent `credentialTypes` is treated as *not* accepting + /// authorization. + pub fn accepts_authorization(&self) -> bool { + self.credential_types + .as_ref() + .is_some_and(|types| types.iter().any(|t| t == CREDENTIAL_TYPE_AUTHORIZATION)) + } +} + +/// Credential type discriminant. Only `authorization` is implemented; modeling +/// it as a single-variant enum makes any other wire value a parse error. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum AuthorizationPayloadType { + /// EIP-3009 `TransferWithAuthorization` credential. + #[default] + #[serde(rename = "authorization")] + Authorization, +} + +/// Client credential payload for the `authorization` credential type. +/// +/// Carries the fields of an EIP-3009 `TransferWithAuthorization` plus the +/// signature over its EIP-712 hash. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthorizationPayload { + /// Discriminant; always `authorization`. + #[serde(rename = "type")] + pub payload_type: AuthorizationPayloadType, + + /// Authorizing account (payer) address. + pub from: String, + + /// Recipient (payee) address. + pub to: String, + + /// Atomic amount authorized. + pub value: String, + + /// Unix-seconds (as a string) before which the authorization is invalid. + #[serde(rename = "validAfter")] + pub valid_after: String, + + /// Unix-seconds (as a string) after which the authorization is invalid. + #[serde(rename = "validBefore")] + pub valid_before: String, + + /// 32-byte authorization nonce (hex). For native challenges this is the + /// challenge-bound nonce; see [`super::authorization::challenge_nonce`]. + pub nonce: String, + + /// Signature over the EIP-712 `TransferWithAuthorization` hash. + pub signature: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_authorization_payload_serde_camel_case() { + let payload = AuthorizationPayload { + payload_type: AuthorizationPayloadType::Authorization, + from: "0xfrom".to_string(), + to: "0xto".to_string(), + value: "1000000".to_string(), + valid_after: "0".to_string(), + valid_before: "9999999999".to_string(), + nonce: "0xabc".to_string(), + signature: "0xsig".to_string(), + }; + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("\"type\":\"authorization\"")); + assert!(json.contains("\"validAfter\":\"0\"")); + assert!(json.contains("\"validBefore\":\"9999999999\"")); + + let parsed: AuthorizationPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, payload); + } + + #[test] + fn test_authorization_payload_rejects_unknown_type() { + let json = r#"{"type":"permit2","from":"0xf","to":"0xt","value":"1","validAfter":"0","validBefore":"2","nonce":"0xabc","signature":"0xsig"}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn test_method_details_serde_camel_case() { + let details = EvmMethodDetails { + chain_id: 84532, + credential_types: Some(vec!["authorization".to_string()]), + decimals: Some(6), + permit2_address: None, + splits: None, + }; + let json = serde_json::to_string(&details).unwrap(); + assert!(json.contains("\"chainId\":84532")); + assert!(json.contains("\"credentialTypes\":[\"authorization\"]")); + assert!(!json.contains("permit2Address")); + + let parsed: EvmMethodDetails = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.chain_id, 84532); + assert!(parsed.accepts_authorization()); + } + + #[test] + fn test_accepts_authorization_false_when_absent() { + // Absent credentialTypes does not accept authorization. + let details = EvmMethodDetails { + chain_id: 1, + credential_types: None, + ..Default::default() + }; + assert!(!details.accepts_authorization()); + } + + #[test] + fn test_method_details_serde_permit2_and_splits() { + let details = EvmMethodDetails { + chain_id: 1, + credential_types: Some(vec!["authorization".to_string()]), + decimals: None, + permit2_address: Some("0x000000000022D473030F116dDEE9F6B43aC78BA3".to_string()), + splits: Some(vec![Split { + amount: "500".to_string(), + recipient: "0xrecipient".to_string(), + }]), + }; + let json = serde_json::to_string(&details).unwrap(); + assert!(json.contains("\"permit2Address\":\"0x000000000022D473030F116dDEE9F6B43aC78BA3\"")); + assert!(json.contains("\"splits\":[{\"amount\":\"500\",\"recipient\":\"0xrecipient\"}]")); + + let parsed: EvmMethodDetails = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.splits.unwrap().len(), 1); + } + + #[test] + fn test_accepts_authorization_false_when_not_listed() { + let details = EvmMethodDetails { + chain_id: 1, + credential_types: Some(vec!["permit2".to_string()]), + ..Default::default() + }; + assert!(!details.accepts_authorization()); + } +} diff --git a/src/protocol/methods/mod.rs b/src/protocol/methods/mod.rs index 5f3c2253..fa9b8e2c 100644 --- a/src/protocol/methods/mod.rs +++ b/src/protocol/methods/mod.rs @@ -4,6 +4,7 @@ //! //! # Available Methods //! +//! - [`evm`]: Generic EVM charge via EIP-3009 authorization (requires `evm` feature) //! - [`tempo`]: Tempo blockchain (requires `tempo` feature) //! - [`stripe`]: Stripe payments via SPTs (requires `stripe` feature) //! @@ -11,6 +12,9 @@ //! //! ```text //! methods/ +//! ├── evm/ # Generic EVM (ERC-20 EIP-3009 TransferWithAuthorization) +//! │ ├── types.rs # EvmMethodDetails, AuthorizationPayload +//! │ └── authorization.rs # EIP-712 signing/recovery, challenge nonce //! ├── tempo/ # Tempo-specific (chain_id=42431, TIP-20, 2D nonces) //! │ ├── types.rs # TempoMethodDetails //! │ └── charge.rs # TempoChargeExt trait @@ -21,6 +25,9 @@ //! //! Shared EVM utilities (Address, U256, parsing) are in the top-level `evm` module. +#[cfg(feature = "evm")] +pub mod evm; + #[cfg(feature = "tempo")] pub mod tempo; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 82ad0e67..69a69515 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -84,7 +84,7 @@ pub mod core; pub mod intents; -#[cfg(any(feature = "server", feature = "tempo"))] +#[cfg(any(feature = "server", feature = "tempo", feature = "evm"))] pub mod methods; #[cfg(feature = "server")]