diff --git a/.changelog/calm-hawks-build.md b/.changelog/calm-hawks-build.md new file mode 100644 index 00000000..41bb6565 --- /dev/null +++ b/.changelog/calm-hawks-build.md @@ -0,0 +1,5 @@ +--- +mpp: minor +--- + +Added zero-amount proof credential support for identity flows. Introduced a new `PayloadType::Proof` variant with EIP-712 signing via a new `proof` module, enabling clients to authenticate without sending a blockchain transaction. Updated `TempoCharge`, `TempoProvider`, and server-side verification to handle zero-amount challenges with signed proofs. diff --git a/src/client/tempo/charge/mod.rs b/src/client/tempo/charge/mod.rs index 1efe8913..595b30f4 100644 --- a/src/client/tempo/charge/mod.rs +++ b/src/client/tempo/charge/mod.rs @@ -38,10 +38,11 @@ use crate::client::tempo::signing::{ sign_and_encode_async, sign_and_encode_fee_payer_envelope_async, TempoSigningMode, }; use crate::error::{MppError, ResultExt}; -use crate::protocol::core::{PaymentChallenge, PaymentCredential}; +use crate::protocol::core::{PaymentChallenge, PaymentCredential, PaymentPayload}; use crate::protocol::intents::ChargeRequest; use crate::protocol::methods::tempo::charge::{parse_memo_bytes_checked, TempoChargeExt}; use crate::protocol::methods::tempo::network::TempoNetwork; +use crate::protocol::methods::tempo::proof; use crate::protocol::methods::tempo::transfers::get_transfers; use crate::protocol::methods::tempo::types::Split; use crate::protocol::methods::tempo::CHAIN_ID; @@ -232,6 +233,26 @@ impl TempoCharge { signer: &(impl alloy::signers::Signer + Clone), options: SignOptions, ) -> Result { + if self.amount.is_zero() { + // Proof credentials are raw typed-data signatures, so they bind to + // the actual signing key rather than a keychain-wrapped wallet address. + let from = signer.address(); + let credential = PaymentCredential::with_source( + self.challenge.to_echo(), + proof::proof_source(from, self.chain_id), + PaymentPayload::proof( + proof::sign_proof(signer, self.chain_id, &self.challenge.id).await?, + ), + ); + + return Ok(SignedTempoCharge { + credential, + tx_bytes: None, + chain_id: self.chain_id, + from, + }); + } + let signing_mode = options.signing_mode.unwrap_or_default(); let from = signing_mode.from_address(signer.address()); @@ -344,9 +365,11 @@ impl TempoCharge { sign_and_encode_async(tx, signer, &signing_mode).await? }; + let credential = build_charge_credential(&self.challenge, &tx_bytes, self.chain_id, from); + Ok(SignedTempoCharge { - challenge: self.challenge, - tx_bytes, + credential, + tx_bytes: Some(tx_bytes), chain_id: self.chain_id, from, }) @@ -385,8 +408,8 @@ pub struct SignOptions { /// A signed Tempo charge, ready to be converted into a [`PaymentCredential`]. #[derive(Debug)] pub struct SignedTempoCharge { - challenge: PaymentChallenge, - tx_bytes: Vec, + credential: PaymentCredential, + tx_bytes: Option>, chain_id: u64, from: Address, } @@ -394,12 +417,12 @@ pub struct SignedTempoCharge { impl SignedTempoCharge { /// Convert the signed charge into a [`PaymentCredential`]. pub fn into_credential(self) -> PaymentCredential { - build_charge_credential(&self.challenge, &self.tx_bytes, self.chain_id, self.from) + self.credential } /// Get the raw signed transaction bytes. pub fn tx_bytes(&self) -> &[u8] { - &self.tx_bytes + self.tx_bytes.as_deref().unwrap_or(&[]) } /// Get the chain ID. @@ -541,9 +564,10 @@ mod tests { let from: Address = "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2" .parse() .unwrap(); + let credential = build_charge_credential(&challenge, &[0x76, 0xab, 0xcd], 42431, from); let signed = SignedTempoCharge { - challenge, - tx_bytes: vec![0x76, 0xab, 0xcd], + credential, + tx_bytes: Some(vec![0x76, 0xab, 0xcd]), chain_id: 42431, from, }; @@ -564,9 +588,10 @@ mod tests { fn test_signed_charge_accessors() { let challenge = test_challenge(); let from = Address::repeat_byte(0x11); + let credential = build_charge_credential(&challenge, &[0x76], 4217, from); let signed = SignedTempoCharge { - challenge, - tx_bytes: vec![0x76], + credential, + tx_bytes: Some(vec![0x76]), chain_id: 4217, from, }; @@ -640,4 +665,36 @@ mod tests { let calls = charge.build_transfer_calls().unwrap(); assert_eq!(calls.len(), 1); } + + #[tokio::test] + async fn test_zero_amount_sign_returns_proof_credential() { + let request_json = serde_json::json!({ + "amount": "0", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2", + "methodDetails": { + "chainId": 42431 + } + }); + let request = Base64UrlJson::from_value(&request_json).unwrap(); + let challenge = + PaymentChallenge::new("proof-id", "api.example.com", "tempo", "charge", request); + let signer: alloy::signers::local::PrivateKeySigner = + "0x1234567890123456789012345678901234567890123456789012345678901234" + .parse() + .unwrap(); + + let charge = TempoCharge::from_challenge(&challenge).unwrap(); + let signed = charge + .sign_with_options(&signer, SignOptions::default()) + .await + .unwrap(); + let credential = signed.into_credential(); + let payload: PaymentPayload = credential.payload_as().unwrap(); + let expected_did = PaymentCredential::evm_did(42431, &signer.address().to_string()); + + assert!(payload.is_proof()); + assert!(payload.proof_signature().unwrap().starts_with("0x")); + assert_eq!(credential.source.as_deref(), Some(expected_did.as_str())); + } } diff --git a/src/client/tempo/provider.rs b/src/client/tempo/provider.rs index d6d461a9..216a9df9 100644 --- a/src/client/tempo/provider.rs +++ b/src/client/tempo/provider.rs @@ -2,6 +2,7 @@ use crate::error::{MppError, ResultExt}; use crate::protocol::core::{PaymentChallenge, PaymentCredential}; +use crate::protocol::methods::tempo::proof::sign_proof; use super::autoswap::AutoswapConfig; use super::charge::SignOptions; @@ -127,6 +128,18 @@ impl PaymentProvider for TempoProvider { async fn pay(&self, challenge: &PaymentChallenge) -> Result { let mut charge = super::charge::TempoCharge::from_challenge(challenge)?; + if charge.amount().is_zero() { + let signature = sign_proof(&self.signer, charge.chain_id(), &challenge.id).await?; + let source = + PaymentCredential::evm_did(charge.chain_id(), &self.signer.address().to_string()); + + return Ok(PaymentCredential::with_source( + challenge.to_echo(), + source, + crate::protocol::core::PaymentPayload::proof(signature), + )); + } + // Auto-generate an attribution memo when the server doesn't provide one, // so MPP transactions are identifiable on-chain via `TransferWithMemo` events. if charge.memo().is_none() { @@ -244,6 +257,41 @@ mod tests { assert!(crate::tempo::attribution::is_mpp_memo(&memo)); } + #[tokio::test] + async fn test_zero_amount_challenge_returns_proof_credential() { + use crate::protocol::core::Base64UrlJson; + + let signer = alloy::signers::local::PrivateKeySigner::random(); + let provider = TempoProvider::new(signer.clone(), "https://rpc.example.com").unwrap(); + let request = Base64UrlJson::from_value(&serde_json::json!({ + "amount": "0", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2", + "methodDetails": { "chainId": 42431 } + })) + .unwrap(); + let challenge = PaymentChallenge::new( + "challenge-123", + "api.example.com", + "tempo", + "charge", + request, + ); + + let credential = provider.pay(&challenge).await.unwrap(); + let payload = credential.charge_payload().unwrap(); + + assert!(payload.is_proof()); + assert!(payload.proof_signature().is_some()); + assert_eq!( + credential.source, + Some(PaymentCredential::evm_did( + 42431, + &signer.address().to_string() + )) + ); + } + #[test] fn test_tempo_provider_supports_only_tempo_charge() { let signer = alloy::signers::local::PrivateKeySigner::random(); diff --git a/src/protocol/core/challenge.rs b/src/protocol/core/challenge.rs index 6777d935..f6427a86 100644 --- a/src/protocol/core/challenge.rs +++ b/src/protocol/core/challenge.rs @@ -528,19 +528,21 @@ pub struct ChallengeEcho { /// Payment payload in credential. /// -/// Contains the signed transaction or transaction hash. +/// Contains the signed transaction, typed proof, or transaction hash. /// /// Per IETF spec (Tempo §5.1-5.2): /// - `type="transaction"` uses field `signature` containing the signed transaction +/// - `type="proof"` uses field `signature` containing the signed typed proof /// - `type="hash"` uses field `hash` containing the transaction hash #[derive(Debug, Clone)] pub struct PaymentPayload { - /// Payload type: "transaction" or "hash" + /// Payload type: "transaction", "proof", or "hash" pub payload_type: PayloadType, /// Hex-encoded signed data. /// /// For `type="transaction"`: the RLP-encoded signed transaction to broadcast. + /// For `type="proof"`: the EIP-712 signature for the challenge proof. /// For `type="hash"`: the transaction hash (0x-prefixed) of an already-broadcast tx. data: String, } @@ -556,7 +558,9 @@ impl serde::Serialize for PaymentPayload { state.serialize_field("type", &self.payload_type)?; match self.payload_type { - PayloadType::Transaction => state.serialize_field("signature", &self.data)?, + PayloadType::Transaction | PayloadType::Proof => { + state.serialize_field("signature", &self.data)? + } PayloadType::Hash => state.serialize_field("hash", &self.data)?, } @@ -580,8 +584,11 @@ impl<'de> serde::Deserialize<'de> for PaymentPayload { let raw = RawPayload::deserialize(deserializer)?; let data = match raw.payload_type { - PayloadType::Transaction => raw.signature.ok_or_else(|| { - serde::de::Error::custom("transaction payload requires 'signature' field") + PayloadType::Transaction | PayloadType::Proof => raw.signature.ok_or_else(|| { + serde::de::Error::custom(format!( + "{} payload requires 'signature' field", + raw.payload_type + )) })?, PayloadType::Hash => raw .hash @@ -612,6 +619,14 @@ impl PaymentPayload { } } + /// Create a new proof payload. + pub fn proof(signature: impl Into) -> Self { + Self { + payload_type: PayloadType::Proof, + data: signature.into(), + } + } + /// Get the payload type. pub fn payload_type(&self) -> PayloadType { self.payload_type.clone() @@ -620,9 +635,10 @@ impl PaymentPayload { /// Get the underlying data (works for both transaction and hash payloads). /// /// For transaction payloads, this is the signed transaction bytes. + /// For proof payloads, this is the proof signature. /// For hash payloads, this is the transaction hash. /// - /// Prefer using `tx_hash()` or `signed_tx()` for type-safe access. + /// Prefer using `tx_hash()`, `signed_tx()`, or `proof_signature()` for type-safe access. pub fn data(&self) -> &str { &self.data } @@ -649,6 +665,17 @@ impl PaymentPayload { } } + /// Get the proof signature (for proof payloads). + /// + /// Returns the proof signature if this is a proof payload, None otherwise. + pub fn proof_signature(&self) -> Option<&str> { + if self.payload_type == PayloadType::Proof { + Some(&self.data) + } else { + None + } + } + /// Check if this is a transaction payload. pub fn is_transaction(&self) -> bool { self.payload_type == PayloadType::Transaction @@ -659,6 +686,11 @@ impl PaymentPayload { self.payload_type == PayloadType::Hash } + /// Check if this is a proof payload. + pub fn is_proof(&self) -> bool { + self.payload_type == PayloadType::Proof + } + /// Get the transaction reference (hash or signature data). /// /// Returns the underlying data, which contains either: @@ -888,6 +920,14 @@ mod tests { assert_eq!(hash.tx_hash(), Some("0xdef")); assert_eq!(hash.data(), "0xdef"); assert_eq!(hash.signed_tx(), None); + + let proof = PaymentPayload::proof("0x123"); + assert_eq!(proof.payload_type(), PayloadType::Proof); + assert!(proof.is_proof()); + assert_eq!(proof.proof_signature(), Some("0x123")); + assert_eq!(proof.data(), "0x123"); + assert_eq!(proof.signed_tx(), None); + assert_eq!(proof.tx_hash(), None); } #[test] @@ -905,6 +945,12 @@ mod tests { assert!(json.contains("\"hash\":\"0xdef\"")); assert!(json.contains("\"type\":\"hash\"")); assert!(!json.contains("\"signature\"")); + + let proof = PaymentPayload::proof("0x123"); + let json = serde_json::to_string(&proof).unwrap(); + assert!(json.contains("\"signature\":\"0x123\"")); + assert!(json.contains("\"type\":\"proof\"")); + assert!(!json.contains("\"hash\"")); } #[test] @@ -920,6 +966,11 @@ mod tests { let payload: PaymentPayload = serde_json::from_str(tx_json).unwrap(); assert!(payload.is_transaction()); assert_eq!(payload.signed_tx(), Some("0xabc456")); + + let proof_json = r#"{"type":"proof","signature":"0x123456"}"#; + let payload: PaymentPayload = serde_json::from_str(proof_json).unwrap(); + assert!(payload.is_proof()); + assert_eq!(payload.proof_signature(), Some("0x123456")); } #[test] @@ -935,6 +986,11 @@ mod tests { let result: Result = serde_json::from_str(bad_tx); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("signature")); + + let bad_proof = r#"{"type":"proof","hash":"0x123456"}"#; + let result: Result = serde_json::from_str(bad_proof); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("signature")); } #[test] diff --git a/src/protocol/core/types.rs b/src/protocol/core/types.rs index bf9f2e54..400b23c0 100644 --- a/src/protocol/core/types.rs +++ b/src/protocol/core/types.rs @@ -333,6 +333,7 @@ impl fmt::Display for PaymentProtocol { /// Indicates what kind of data is in the payload. Per spec: /// - `transaction`: Signed blockchain transaction (to be broadcast by server) /// - `hash`: Transaction hash (already broadcast by client) +/// - `proof`: Signed typed proof for zero-amount identity flows #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PayloadType { @@ -340,6 +341,8 @@ pub enum PayloadType { Transaction, /// Transaction hash (already broadcast by client) Hash, + /// Signed typed proof for zero-amount identity flows + Proof, } impl fmt::Display for PayloadType { @@ -347,6 +350,7 @@ impl fmt::Display for PayloadType { match self { Self::Transaction => write!(f, "transaction"), Self::Hash => write!(f, "hash"), + Self::Proof => write!(f, "proof"), } } } @@ -529,6 +533,10 @@ mod tests { serde_json::to_string(&PayloadType::Hash).unwrap(), "\"hash\"" ); + assert_eq!( + serde_json::to_string(&PayloadType::Proof).unwrap(), + "\"proof\"" + ); } #[test] diff --git a/src/protocol/methods/tempo/method.rs b/src/protocol/methods/tempo/method.rs index aa46971c..c84b9bc1 100644 --- a/src/protocol/methods/tempo/method.rs +++ b/src/protocol/methods/tempo/method.rs @@ -39,7 +39,7 @@ use crate::store::Store; use crate::tempo::attribution; use super::transfers::{get_request_transfers, Transfer}; -use super::{TempoChargeExt, CHAIN_ID, INTENT_CHARGE, METHOD_NAME}; +use super::{proof, TempoChargeExt, CHAIN_ID, INTENT_CHARGE, METHOD_NAME}; const MAX_FEE_PAYER_GAS_LIMIT: u64 = 1_000_000; @@ -1017,6 +1017,17 @@ where ) })?; + let is_zero_amount = request + .amount_u256() + .map_err(|e| VerificationError::new(format!("Invalid amount in request: {}", e)))? + .is_zero(); + + if is_zero_amount && !charge_payload.is_proof() { + return Err(VerificationError::new( + "Zero-amount challenges require a proof credential.", + )); + } + if charge_payload.is_hash() { // Client already broadcast the transaction, verify by hash this.verify_hash( @@ -1026,6 +1037,37 @@ where &credential.challenge.realm, ) .await + } else if charge_payload.is_proof() { + if !is_zero_amount { + return Err(VerificationError::new( + "Proof credentials are only valid for zero-amount challenges.", + )); + } + + let source = credential.source.as_deref().ok_or_else(|| { + VerificationError::new("Proof credential must include a source.") + })?; + let parsed_source = proof::parse_proof_source(source) + .map_err(|_| VerificationError::new("Proof credential source is invalid."))?; + + if parsed_source.chain_id != expected_chain_id { + return Err(VerificationError::new( + "Proof credential source is invalid.", + )); + } + + if !proof::verify_proof( + expected_chain_id, + &credential.challenge.id, + charge_payload.proof_signature().unwrap(), + parsed_source.address, + ) { + return Err(VerificationError::new( + "Proof signature does not match source.", + )); + } + + Ok(Receipt::success(METHOD_NAME, &credential.challenge.id)) } else { // Client sent signed transaction, validate and broadcast it. // broadcast_transaction already does pre-broadcast dedup and @@ -1049,6 +1091,27 @@ mod tests { use alloy::primitives::hex; use super::{super::MODERATO_CHAIN_ID, *}; + use crate::protocol::core::{Base64UrlJson, PaymentChallenge}; + + fn test_charge_request_with_amount(amount: &str) -> ChargeRequest { + ChargeRequest { + amount: amount.to_string(), + currency: "0x20c0000000000000000000000000000000000000".to_string(), + recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".to_string()), + method_details: Some(serde_json::json!({ "chainId": 42431 })), + ..Default::default() + } + } + + fn test_proof_challenge(request: &ChargeRequest) -> PaymentChallenge { + PaymentChallenge::new( + "proof-challenge-id", + "api.example.com", + "tempo", + "charge", + Base64UrlJson::from_typed(request).unwrap(), + ) + } #[test] fn test_transfer_selector() { @@ -1149,6 +1212,68 @@ mod tests { .contains("fee sponsorship is not configured")); } + #[tokio::test] + async fn test_zero_amount_proof_accepted() { + let signer = alloy::signers::local::PrivateKeySigner::random(); + let request = test_charge_request_with_amount("0"); + let challenge = test_proof_challenge(&request); + let signature = proof::sign_proof(&signer, 42431, &challenge.id) + .await + .unwrap(); + let credential = PaymentCredential::with_source( + challenge.to_echo(), + proof::proof_source(signer.address(), 42431), + crate::protocol::core::PaymentPayload::proof(signature), + ); + + let provider = + alloy::providers::ProviderBuilder::new_with_network::() + .connect_http("http://127.0.0.1:1".parse().unwrap()); + let method = ChargeMethod::new(provider); + + let receipt = method.verify(&credential, &request).await.unwrap_err(); + assert!(receipt.to_string().contains("Failed to fetch chain ID") || receipt.retryable); + } + + #[tokio::test] + async fn test_verify_proof_rejects_wrong_signer() { + let signer = alloy::signers::local::PrivateKeySigner::random(); + let other = alloy::signers::local::PrivateKeySigner::random(); + let request = test_charge_request_with_amount("0"); + let challenge = test_proof_challenge(&request); + let signature = proof::sign_proof(&other, 42431, &challenge.id) + .await + .unwrap(); + let payload = crate::protocol::core::PaymentPayload::proof(signature); + let credential = PaymentCredential::with_source( + challenge.to_echo(), + proof::proof_source(signer.address(), 42431), + payload.clone(), + ); + + let source = credential.source.as_deref().unwrap(); + let parsed = proof::parse_proof_source(source).unwrap(); + assert!(!proof::verify_proof( + 42431, + &credential.challenge.id, + payload.proof_signature().unwrap(), + parsed.address, + )); + } + + #[test] + fn test_verify_zero_amount_requires_proof_payload() { + let request = test_charge_request_with_amount("0"); + let challenge = test_proof_challenge(&request); + let credential = PaymentCredential::new( + challenge.to_echo(), + crate::protocol::core::PaymentPayload::transaction("0xdeadbeef"), + ); + let payload = credential.charge_payload().unwrap(); + assert!(!payload.is_proof()); + assert!(request.amount_u256().unwrap().is_zero()); + } + // ==================== Fee payer co-sign unit tests ==================== /// Helper: build a valid TempoTransaction for fee payer tests. diff --git a/src/protocol/methods/tempo/mod.rs b/src/protocol/methods/tempo/mod.rs index 0a37d862..8cf0deca 100644 --- a/src/protocol/methods/tempo/mod.rs +++ b/src/protocol/methods/tempo/mod.rs @@ -99,6 +99,7 @@ pub mod charge; pub mod fee_payer_envelope; pub mod network; +pub(crate) mod proof; pub mod session; pub mod session_receipt; pub mod transaction; diff --git a/src/protocol/methods/tempo/proof.rs b/src/protocol/methods/tempo/proof.rs new file mode 100644 index 00000000..fed5bba4 --- /dev/null +++ b/src/protocol/methods/tempo/proof.rs @@ -0,0 +1,211 @@ +//! EIP-712 proof signing for zero-amount Tempo charge flows. + +use alloy::primitives::{Address, B256}; +use alloy::sol_types::{eip712_domain, SolStruct}; + +use crate::error::{MppError, ResultExt}; + +/// EIP-712 domain name for zero-amount proof credentials. +pub const DOMAIN_NAME: &str = "MPP"; + +/// EIP-712 domain version for zero-amount proof credentials. +pub const DOMAIN_VERSION: &str = "1"; + +alloy::sol! { + #[derive(Debug)] + struct Proof { + string challengeId; + } +} + +/// Build the canonical DID source for a proof credential. +pub fn proof_source(address: Address, chain_id: u64) -> String { + format!("did:pkh:eip155:{chain_id}:{address}") +} + +/// Parsed proof credential source DID. +pub struct ProofSource { + pub address: Address, + pub chain_id: u64, +} + +/// Extract the signer address and chain ID from a proof credential source DID. +/// +/// Enforces canonical DID format matching mppx: `did:pkh:eip155:{chainId}:{address}` +/// where chain ID has no leading zeros (except literal `0`) and the address is +/// a valid EIP-55 hex address with no extra colon segments. +pub fn parse_proof_source(source: &str) -> crate::error::Result { + let rest = source + .strip_prefix("did:pkh:eip155:") + .ok_or_else(|| MppError::invalid_payload("proof source must be a did:pkh:eip155 DID"))?; + // Use split_once (not rsplit_once) so extra colons in the address segment are rejected. + let (chain_id_str, address_str) = rest + .split_once(':') + .ok_or_else(|| MppError::invalid_payload("proof source is missing an address"))?; + // Reject leading zeros (e.g. "01") — only "0" itself is valid for zero. + if chain_id_str.len() > 1 && chain_id_str.starts_with('0') { + return Err(MppError::invalid_payload( + "proof source chain id has leading zeros", + )); + } + let chain_id: u64 = chain_id_str + .parse() + .map_err(|e| MppError::invalid_payload(format!("invalid proof source chain id: {e}")))?; + // Reject addresses containing extra colons. + if address_str.contains(':') { + return Err(MppError::invalid_payload( + "proof source address contains invalid characters", + )); + } + let address: Address = address_str + .parse() + .map_err(|e| MppError::invalid_payload(format!("invalid proof source address: {e}")))?; + Ok(ProofSource { address, chain_id }) +} + +/// Compute the EIP-712 signing hash for a proof credential. +pub fn signing_hash(chain_id: u64, challenge_id: &str) -> B256 { + let domain = eip712_domain! { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chain_id: chain_id, + }; + + Proof { + challengeId: challenge_id.to_string(), + } + .eip712_signing_hash(&domain) +} + +/// Sign a zero-amount charge proof for the given challenge ID. +#[cfg(feature = "evm")] +pub async fn sign_proof( + signer: &impl alloy::signers::Signer, + chain_id: u64, + challenge_id: &str, +) -> crate::error::Result { + let signature = signer + .sign_hash(&signing_hash(chain_id, challenge_id)) + .await + .mpp_http("failed to sign proof")?; + + Ok(alloy::hex::encode_prefixed(signature.as_bytes())) +} + +/// Verify a zero-amount charge proof against the expected signer. +#[cfg(feature = "evm")] +pub fn verify_proof( + chain_id: u64, + challenge_id: &str, + signature_hex: &str, + expected_signer: Address, +) -> bool { + let signature_bytes = match signature_hex.parse::() { + Ok(bytes) => bytes, + Err(_) => return false, + }; + + let signature = match alloy::signers::Signature::try_from(signature_bytes.as_ref()) { + Ok(sig) => sig, + Err(_) => return false, + }; + + match signature.recover_address_from_prehash(&signing_hash(chain_id, challenge_id)) { + Ok(recovered) => recovered == expected_signer, + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_proof_source_roundtrip() { + let signer = alloy::signers::local::PrivateKeySigner::random(); + let source = proof_source(signer.address(), 42431); + let parsed = parse_proof_source(&source).unwrap(); + assert_eq!(parsed.address, signer.address()); + assert_eq!(parsed.chain_id, 42431); + } + + #[test] + fn test_parse_proof_source_rejects_leading_zero_chain_id() { + assert!(parse_proof_source( + "did:pkh:eip155:042431:0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2" + ) + .is_err()); + } + + #[test] + fn test_parse_proof_source_rejects_extra_colons() { + assert!(parse_proof_source( + "did:pkh:eip155:42431:extra:0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2" + ) + .is_err()); + } + + #[test] + fn test_parse_proof_source_rejects_missing_prefix() { + assert!( + parse_proof_source("did:pkh:eip155:0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2") + .is_err() + ); + } + + #[test] + fn test_parse_proof_source_accepts_chain_id_zero() { + let parsed = + parse_proof_source("did:pkh:eip155:0:0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2") + .unwrap(); + assert_eq!(parsed.chain_id, 0); + } + + #[tokio::test] + async fn test_sign_and_verify_proof_roundtrip() { + let signer = alloy::signers::local::PrivateKeySigner::random(); + let signature = sign_proof(&signer, 42431, "challenge-123").await.unwrap(); + + assert!(verify_proof( + 42431, + "challenge-123", + &signature, + signer.address(), + )); + } + + #[tokio::test] + async fn test_verify_proof_rejects_wrong_challenge_id() { + let signer = alloy::signers::local::PrivateKeySigner::random(); + let signature = sign_proof(&signer, 42431, "challenge-123").await.unwrap(); + + assert!(!verify_proof( + 42431, + "challenge-456", + &signature, + signer.address(), + )); + } + + #[tokio::test] + async fn test_verify_proof_rejects_wrong_signer() { + let signer = alloy::signers::local::PrivateKeySigner::random(); + let other = alloy::signers::local::PrivateKeySigner::random(); + let signature = sign_proof(&signer, 42431, "challenge-123").await.unwrap(); + + assert!(!verify_proof( + 42431, + "challenge-123", + &signature, + other.address(), + )); + } + + #[test] + fn test_signing_hash_depends_on_chain_id() { + assert_ne!( + signing_hash(1, "challenge-123"), + signing_hash(42431, "challenge-123") + ); + } +} diff --git a/src/server/amount.rs b/src/server/amount.rs index b6926920..48d493d4 100644 --- a/src/server/amount.rs +++ b/src/server/amount.rs @@ -135,9 +135,8 @@ pub fn parse_dollar_amount( .and_then(|v| v.checked_add(frac_val.checked_mul(frac_scale)?)) .ok_or(AmountError::Overflow)?; - if base_units == 0 { - return Err(AmountError::ZeroOrNegative); - } + // Zero-amount charges are valid for identity/proof flows. + // Negative amounts are caught by the leading '-' check above. Ok(base_units.to_string()) } @@ -177,12 +176,9 @@ mod tests { } #[test] - fn test_zero_rejected() { - let err = parse_dollar_amount("0", 6).unwrap_err(); - assert!(matches!(err, AmountError::ZeroOrNegative)); - - let err = parse_dollar_amount("0.000000", 6).unwrap_err(); - assert!(matches!(err, AmountError::ZeroOrNegative)); + fn test_zero_allowed() { + assert_eq!(parse_dollar_amount("0", 6).unwrap(), "0"); + assert_eq!(parse_dollar_amount("0.000000", 6).unwrap(), "0"); } #[test] diff --git a/tests/integration_charge.rs b/tests/integration_charge.rs index efea0cd3..bc2f8d15 100644 --- a/tests/integration_charge.rs +++ b/tests/integration_charge.rs @@ -23,7 +23,7 @@ use alloy::signers::local::PrivateKeySigner; use alloy::signers::SignerSync; use alloy::sol_types::SolCall; use axum::{routing::get, Json, Router}; -use mpp::client::{Fetch, TempoProvider}; +use mpp::client::{Fetch, PaymentProvider, TempoProvider}; use mpp::server::axum::{ChargeChallenger, ChargeConfig, MppCharge, WithReceipt}; use mpp::server::{tempo, Mpp, TempoConfig}; use reqwest::Client; @@ -289,6 +289,13 @@ impl ChargeConfig for OneDollar { } } +struct ZeroDollar; +impl ChargeConfig for ZeroDollar { + fn amount() -> &'static str { + "0" + } +} + // ==================== Server helpers ==================== /// Start an axum server on port 0 and return (url, JoinHandle). @@ -299,6 +306,7 @@ async fn start_server( let app = Router::new() .route("/health", get(health)) + .route("/identity", get(identity)) .route("/paid", get(paid)) .route("/premium", get(premium)) .with_state(state); @@ -334,6 +342,13 @@ async fn premium(charge: MppCharge) -> WithReceipt) -> WithReceipt> { + WithReceipt { + receipt: charge.receipt, + body: Json(serde_json::json!({ "message": "identity verified" })), + } +} + // ==================== Tests ==================== /// Verify the health endpoint works (no payment required). @@ -479,6 +494,83 @@ async fn test_e2e_charge_round_trip() { let _ = handle.await; } +/// Zero-amount auth flows should use a signed proof instead of a transaction. +#[tokio::test] +async fn test_zero_amount_identity_flow_uses_proof_credential() { + let rpc = rpc_url(); + let chain_id = get_chain_id(&rpc).await; + + let server_signer = PrivateKeySigner::random(); + let client_signer = PrivateKeySigner::random(); + + let mpp = Mpp::create( + tempo(TempoConfig { + recipient: &format!("{}", server_signer.address()), + }) + .rpc_url(&rpc) + .chain_id(chain_id) + .secret_key("identity-test-secret"), + ) + .expect("failed to create Mpp"); + + let (url, handle) = start_server(Arc::new(mpp) as Arc).await; + + let provider = TempoProvider::new(client_signer, &rpc).expect("failed to create TempoProvider"); + + let first = Client::new() + .get(format!("{url}/identity")) + .send() + .await + .expect("identity request failed"); + assert_eq!(first.status(), 402); + + let www_auth = first + .headers() + .get("www-authenticate") + .expect("missing WWW-Authenticate header") + .to_str() + .unwrap(); + let challenge = mpp::parse_www_authenticate(www_auth).expect("failed to parse challenge"); + + let credential = provider + .pay(&challenge) + .await + .expect("failed to create proof credential"); + let payload = credential + .charge_payload() + .expect("expected charge payload"); + assert!( + payload.is_proof(), + "zero-amount flow should use proof payloads" + ); + + let auth_header = mpp::format_authorization(&credential).expect("failed to format credential"); + let response = Client::new() + .get(format!("{url}/identity")) + .header("authorization", auth_header) + .send() + .await + .expect("identity auth request failed"); + + assert_eq!(response.status(), 200); + + let receipt_hdr = response + .headers() + .get("payment-receipt") + .expect("missing Payment-Receipt header") + .to_str() + .unwrap(); + let receipt = mpp::parse_receipt(receipt_hdr).expect("failed to parse receipt"); + assert_eq!(receipt.status, mpp::ReceiptStatus::Success); + assert_eq!(receipt.reference, challenge.id); + + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["message"], "identity verified"); + + handle.abort(); + let _ = handle.await; +} + /// E2E charge round-trip for a higher amount with description. #[tokio::test] async fn test_e2e_premium_charge() {