x402_kit/schemes/
exact_evm_signer.rs

1use alloy_core::{
2    sol,
3    sol_types::{Eip712Domain, SolStruct, eip712_domain},
4};
5use alloy_primitives::{FixedBytes, U256};
6use alloy_signer::{Error as AlloySignerError, Signer as AlloySigner};
7use serde::Deserialize;
8
9use crate::{
10    core::{PaymentSelection, Scheme, SchemeSigner},
11    networks::evm::{EvmAddress, EvmSignature, ExplicitEvmAsset, ExplicitEvmNetwork},
12    schemes::exact_evm::*,
13};
14
15use std::{fmt::Debug, time::SystemTime};
16
17pub trait AuthorizationSigner {
18    type Error: std::error::Error;
19
20    fn sign_authorization(
21        &self,
22        authorization: &Eip3009Authorization,
23        asset_eip712_domain: &Eip712Domain,
24    ) -> impl Future<Output = Result<EvmSignature, Self::Error>>;
25}
26
27sol!(
28    /// Represent EIP-3009 Authorization struct
29    ///
30    /// For generating the EIP-712 signing hash
31    struct Eip3009Authorization {
32        address from;
33        address to;
34        uint256 value;
35        uint256 validAfter;
36        uint256 validBefore;
37        bytes32 nonce;
38    }
39);
40
41impl From<ExactEvmAuthorization> for Eip3009Authorization {
42    fn from(authorization: ExactEvmAuthorization) -> Self {
43        Eip3009Authorization {
44            from: authorization.from.0,
45            to: authorization.to.0,
46            value: U256::from(authorization.value.0),
47            validAfter: U256::from(authorization.valid_after.0),
48            validBefore: U256::from(authorization.valid_before.0),
49            nonce: FixedBytes(authorization.nonce.0),
50        }
51    }
52}
53
54impl<S: AlloySigner> AuthorizationSigner for S {
55    type Error = AlloySignerError;
56
57    async fn sign_authorization(
58        &self,
59        authorization: &Eip3009Authorization,
60        domain: &Eip712Domain,
61    ) -> Result<EvmSignature, Self::Error> {
62        let eip712_hash = authorization.eip712_signing_hash(domain);
63        let signature = self.sign_hash(&eip712_hash).await?;
64
65        Ok(EvmSignature(signature))
66    }
67}
68
69pub struct ExactEvmSigner<S: AuthorizationSigner, A: ExplicitEvmAsset> {
70    pub signer: S,
71    pub asset: A,
72}
73
74#[derive(Debug, thiserror::Error)]
75pub enum ExactEvmSignError<S: AuthorizationSigner> {
76    #[error("Signer error: {0}")]
77    SignerError(S::Error),
78    #[error("System time error: {0}")]
79    SystemTimeError(#[from] std::time::SystemTimeError),
80}
81
82impl<S, A> SchemeSigner<EvmAddress> for ExactEvmSigner<S, A>
83where
84    S: AuthorizationSigner + Debug,
85    A: ExplicitEvmAsset,
86{
87    type Scheme = ExactEvmScheme;
88    type Error = ExactEvmSignError<S>;
89
90    async fn sign(
91        &self,
92        selected: &PaymentSelection<EvmAddress>,
93    ) -> Result<<Self::Scheme as Scheme>::Payload, Self::Error> {
94        let now = SystemTime::now()
95            .duration_since(SystemTime::UNIX_EPOCH)?
96            .as_secs();
97
98        #[derive(Deserialize, Default)]
99        struct Eip712DomainExtra {
100            name: String,
101            version: String,
102        }
103
104        let eip712_domain_info = selected
105            .extra
106            .as_ref()
107            .and_then(|extra| serde_json::from_value::<Eip712DomainExtra>(extra.clone()).ok())
108            // Use empty string if not provided -- This doesn't work in many cases!
109            .unwrap_or_default();
110
111        let authorization = ExactEvmAuthorization {
112            from: selected.pay_to,
113            to: selected.pay_to,
114            value: selected.amount,
115            // Valid after: now - 5mins
116            valid_after: TimestampSeconds(now.saturating_sub(300)),
117            valid_before: TimestampSeconds(now + selected.max_timeout_seconds),
118            nonce: Nonce(rand::random()),
119        };
120
121        let signer = &self.signer;
122        let auth_clone = authorization.clone();
123        let domain = eip712_domain!(
124            name: eip712_domain_info.name,
125            version: eip712_domain_info.version,
126            chain_id: A::Network::NETWORK.chain_id,
127            verifying_contract: A::ASSET.address.0,
128        );
129        let signature = signer
130            .sign_authorization(&auth_clone.into(), &domain)
131            .await
132            .map_err(Self::Error::SignerError)?;
133        Ok(ExactEvmPayload {
134            signature,
135            authorization,
136        })
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use alloy::signers::local::PrivateKeySigner;
143    use alloy_primitives::address;
144    use serde_json::json;
145    use url::Url;
146
147    use crate::{
148        core::Resource,
149        networks::evm::{assets::UsdcBaseSepolia, networks::BaseSepolia},
150        types::{AmountValue, Record},
151    };
152
153    use super::*;
154
155    #[tokio::test]
156    async fn test_signing() {
157        let signer = PrivateKeySigner::random();
158
159        let evm_signer = ExactEvmSigner {
160            signer,
161            asset: UsdcBaseSepolia,
162        };
163
164        let resource = Resource::builder()
165            .url(Url::parse("https://example.com/payment").unwrap())
166            .description("Payment for services".to_string())
167            .mime_type("application/json".to_string())
168            .build();
169
170        let payment = PaymentSelection {
171            amount: 1000u64.into(),
172            resource,
173            pay_to: EvmAddress(address!("0x3CB9B3bBfde8501f411bB69Ad3DC07908ED0dE20")),
174            max_timeout_seconds: 60,
175            asset: UsdcBaseSepolia::ASSET.address,
176            extra: Some(json!({
177                "name": "USD Coin",
178                "version": "2"
179            })),
180            extensions: Record::new(),
181        };
182
183        let payload = evm_signer
184            .sign(&payment)
185            .await
186            .expect("Signing should succeed");
187
188        assert_eq!(payload.authorization.value, AmountValue(1000));
189
190        // Verify the signature
191        let domain = eip712_domain! {
192            name: "USD Coin".to_string(),
193            version: "2".to_string(),
194            chain_id: BaseSepolia::NETWORK.chain_id,
195            verifying_contract: UsdcBaseSepolia::ASSET.address.0,
196        };
197
198        let recovered_address = payload
199            .signature
200            .0
201            .recover_address_from_prehash(
202                &Eip3009Authorization::from(payload.authorization.clone())
203                    .eip712_signing_hash(&domain.into()),
204            )
205            .expect("Recovery should succeed");
206
207        assert_eq!(recovered_address, evm_signer.signer.address());
208    }
209}