Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/calm-hawks-build.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 68 additions & 11 deletions src/client/tempo/charge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -232,6 +233,26 @@ impl TempoCharge {
signer: &(impl alloy::signers::Signer + Clone),
options: SignOptions,
) -> Result<SignedTempoCharge, MppError> {
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());

Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -385,21 +408,21 @@ pub struct SignOptions {
/// A signed Tempo charge, ready to be converted into a [`PaymentCredential`].
#[derive(Debug)]
pub struct SignedTempoCharge {
challenge: PaymentChallenge,
tx_bytes: Vec<u8>,
credential: PaymentCredential,
tx_bytes: Option<Vec<u8>>,
chain_id: u64,
from: Address,
}

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.
Expand Down Expand Up @@ -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,
};
Expand All @@ -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,
};
Expand Down Expand Up @@ -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()));
}
}
48 changes: 48 additions & 0 deletions src/client/tempo/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -127,6 +128,18 @@ impl PaymentProvider for TempoProvider {
async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
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() {
Expand Down Expand Up @@ -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();
Expand Down
68 changes: 62 additions & 6 deletions src/protocol/core/challenge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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)?,
}

Expand All @@ -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
Expand Down Expand Up @@ -612,6 +619,14 @@ impl PaymentPayload {
}
}

/// Create a new proof payload.
pub fn proof(signature: impl Into<String>) -> Self {
Self {
payload_type: PayloadType::Proof,
data: signature.into(),
}
}

/// Get the payload type.
pub fn payload_type(&self) -> PayloadType {
self.payload_type.clone()
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -935,6 +986,11 @@ mod tests {
let result: Result<PaymentPayload, _> = 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<PaymentPayload, _> = serde_json::from_str(bad_proof);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("signature"));
}

#[test]
Expand Down
Loading
Loading