use alloy::primitives::{Address, FixedBytes, U256};
use alloy::signers::local::PrivateKeySigner;
use alloy::signers::SignerSync;
use alloy::sol_types::SolStruct;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use crate::constants::TEMPO_CHAIN_ID;
use crate::PaymentAuthorization;
const EIP712_DOMAIN_NAME: &str = "x402-tempo";
const EIP712_DOMAIN_VERSION: &str = "1";
#[cfg(feature = "demo")]
#[deprecated(note = "Testnet only — never use for real assets")]
pub const DEMO_PRIVATE_KEY: &str =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
pub use crate::constants::TEMPO_CHAIN_ID as CHAIN_ID;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentPayload {
pub x402_version: u32,
pub payload: PaymentData,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentData {
pub from: String,
pub to: String,
pub value: String,
pub token: String,
pub valid_after: u64,
pub valid_before: u64,
pub nonce: String,
pub signature: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PaymentRequirements {
pub scheme: String,
pub network: String,
pub price: String,
pub asset: String,
pub amount: String,
#[serde(rename = "payTo")]
pub pay_to: String,
#[serde(rename = "maxTimeoutSeconds")]
pub max_timeout_seconds: u64,
pub description: Option<String>,
}
fn eip712_domain(token: Address) -> alloy::sol_types::Eip712Domain {
alloy::sol_types::Eip712Domain {
name: Some(Cow::Borrowed(EIP712_DOMAIN_NAME)),
version: Some(Cow::Borrowed(EIP712_DOMAIN_VERSION)),
chain_id: Some(U256::from(TEMPO_CHAIN_ID)),
verifying_contract: Some(token),
salt: None,
}
}
fn random_nonce_bytes() -> [u8; 32] {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("getrandom failed");
bytes
}
fn random_nonce() -> FixedBytes<32> {
alloy::primitives::keccak256(random_nonce_bytes())
}
fn encode_signature_hex(sig: &alloy::primitives::Signature) -> String {
let bytes = sig.as_bytes();
format!(
"0x{}",
bytes
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>()
)
}
pub fn generate_random_key() -> String {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("getrandom failed");
format!(
"0x{}",
bytes
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>()
)
}
pub struct WalletSigner {
inner: PrivateKeySigner,
}
impl WalletSigner {
pub fn new(private_key: &str) -> Result<Self, String> {
let key = private_key.strip_prefix("0x").unwrap_or(private_key);
let signer: PrivateKeySigner = key
.parse()
.map_err(|e| format!("invalid private key: {e}"))?;
Ok(Self { inner: signer })
}
pub fn random() -> Self {
Self {
inner: PrivateKeySigner::random(),
}
}
pub fn address(&self) -> Address {
self.inner.address()
}
pub fn address_string(&self) -> String {
format!("{}", self.inner.address())
}
pub fn sign_message(&self, message: &[u8]) -> Result<String, String> {
let hash = eip191_hash(message);
let sig = self
.inner
.sign_hash_sync(&hash)
.map_err(|e| format!("sign_message failed: {e}"))?;
Ok(encode_signature_hex(&sig))
}
pub fn sign_payment(
&self,
requirements: &PaymentRequirements,
now_secs: u64,
) -> Result<String, String> {
let payload = build_payment_payload(self, requirements, now_secs)?;
let json = serde_json::to_string(&payload).map_err(|e| format!("serialize failed: {e}"))?;
Ok(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
json,
))
}
pub fn sign_payment_payload(
&self,
requirements: &PaymentRequirements,
now_secs: u64,
) -> Result<PaymentPayload, String> {
build_payment_payload(self, requirements, now_secs)
}
}
fn eip191_hash(message: &[u8]) -> FixedBytes<32> {
let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
let mut data = Vec::with_capacity(prefix.len() + message.len());
data.extend_from_slice(prefix.as_bytes());
data.extend_from_slice(message);
alloy::primitives::keccak256(&data)
}
pub fn recover_message_signer(message: &[u8], signature: &[u8]) -> Result<Address, String> {
if signature.len() != 65 {
return Err(format!(
"signature must be 65 bytes, got {}",
signature.len()
));
}
let sig = alloy::primitives::Signature::try_from(signature)
.map_err(|e| format!("invalid signature: {e}"))?;
let hash = eip191_hash(message);
sig.recover_address_from_prehash(&hash)
.map_err(|e| format!("ecrecover failed: {e}"))
}
pub fn build_payment_payload(
signer: &WalletSigner,
requirements: &PaymentRequirements,
now_secs: u64,
) -> Result<PaymentPayload, String> {
let token: Address = requirements
.asset
.parse()
.map_err(|e| format!("invalid asset address: {e}"))?;
let pay_to: Address = requirements
.pay_to
.parse()
.map_err(|e| format!("invalid payTo address: {e}"))?;
let value: U256 = requirements
.amount
.parse()
.map_err(|e| format!("invalid amount: {e}"))?;
let capped_timeout = requirements.max_timeout_seconds.min(600);
let valid_after = now_secs.saturating_sub(60);
let valid_before = now_secs.saturating_add(capped_timeout);
let nonce = random_nonce();
let auth = PaymentAuthorization {
from: signer.address(),
to: pay_to,
value,
token,
validAfter: U256::from(valid_after),
validBefore: U256::from(valid_before),
nonce,
};
let domain = eip712_domain(token);
let signing_hash = auth.eip712_signing_hash(&domain);
let sig = signer
.inner
.sign_hash_sync(&signing_hash)
.map_err(|e| format!("signing failed: {e}"))?;
let sig_hex = encode_signature_hex(&sig);
Ok(PaymentPayload {
x402_version: 1,
payload: PaymentData {
from: format!("{}", signer.address()),
to: format!("{}", pay_to),
value: requirements.amount.clone(),
token: format!("{}", token),
valid_after,
valid_before,
nonce: format!("0x{}", alloy::hex::encode(nonce)),
signature: sig_hex,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::{DEFAULT_TOKEN, SCHEME_NAME, TEMPO_NETWORK};
#[test]
fn test_wallet_signer_random() {
let w = WalletSigner::random();
assert_ne!(w.address(), Address::ZERO);
}
#[test]
#[cfg(feature = "demo")]
#[allow(deprecated)]
fn test_wallet_signer_from_key() {
let w = WalletSigner::new(DEMO_PRIVATE_KEY).unwrap();
assert_ne!(w.address(), Address::ZERO);
assert_eq!(w.address_string().len(), 42); }
#[test]
fn test_wallet_signer_from_random_key() {
let key = generate_random_key();
let w = WalletSigner::new(&key).unwrap();
assert_ne!(w.address(), Address::ZERO);
assert_eq!(w.address_string().len(), 42); }
#[test]
fn test_sign_payment() {
let w = WalletSigner::random();
let req = PaymentRequirements {
scheme: SCHEME_NAME.to_string(),
network: TEMPO_NETWORK.to_string(),
price: "$0.001".to_string(),
asset: format!("{}", DEFAULT_TOKEN),
amount: "1000".to_string(),
pay_to: format!("{}", Address::ZERO),
max_timeout_seconds: 30,
description: None,
};
let b64 = w.sign_payment(&req, 1700000000).unwrap();
assert!(!b64.is_empty());
let json = String::from_utf8(
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64).unwrap(),
)
.unwrap();
let payload: PaymentPayload = serde_json::from_str(&json).unwrap();
assert_eq!(payload.x402_version, 1);
assert_eq!(payload.payload.value, "1000");
assert!(payload.payload.signature.starts_with("0x"));
assert_eq!(payload.payload.signature.len(), 132); }
#[test]
fn test_sign_message_roundtrip() {
let w = WalletSigner::random();
let message = b"hello world";
let sig_hex = w.sign_message(message).unwrap();
assert!(sig_hex.starts_with("0x"));
assert_eq!(sig_hex.len(), 132);
let sig_bytes = alloy::hex::decode(&sig_hex[2..]).unwrap();
let recovered = recover_message_signer(message, &sig_bytes).unwrap();
assert_eq!(recovered, w.address());
}
#[test]
fn test_recover_wrong_message() {
let w = WalletSigner::random();
let sig_hex = w.sign_message(b"correct message").unwrap();
let sig_bytes = alloy::hex::decode(&sig_hex[2..]).unwrap();
let recovered = recover_message_signer(b"wrong message", &sig_bytes).unwrap();
assert_ne!(recovered, w.address());
}
#[test]
fn test_recover_invalid_signature_length() {
let result = recover_message_signer(b"test", &[0u8; 64]);
assert!(result.is_err());
}
#[test]
fn test_build_payment_payload() {
let w = WalletSigner::random();
let req = PaymentRequirements {
scheme: SCHEME_NAME.to_string(),
network: TEMPO_NETWORK.to_string(),
price: "$0.001".to_string(),
asset: format!("{}", DEFAULT_TOKEN),
amount: "1000".to_string(),
pay_to: format!("{}", Address::ZERO),
max_timeout_seconds: 30,
description: None,
};
let payload = build_payment_payload(&w, &req, 1700000000).unwrap();
assert_eq!(payload.payload.from, w.address_string());
assert_eq!(payload.payload.valid_after, 1700000000 - 60);
assert_eq!(payload.payload.valid_before, 1700000000 + 30);
}
}