1use base64::{engine::general_purpose, Engine as _};
11use chrono::Utc;
12use k256::ecdsa::SigningKey;
13#[cfg(test)]
14use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
15use k256::elliptic_curve::rand_core::{OsRng, RngCore as _};
16use serde::{Deserialize, Serialize};
17use sha3::{Digest as _, Keccak256};
18use thiserror::Error;
19
20const X402_VERSION_V2: i64 = 2;
21const DEFAULT_VALIDITY_BUFFER_SECONDS: i64 = 30;
22
23const EIP712_DOMAIN_TYPE: &str =
25 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
26const TRANSFER_WITH_AUTHORIZATION_TYPE: &str = "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)";
27
28#[derive(Debug, Error)]
29pub enum X402Error {
30 #[error("missing payment requirements")]
31 MissingRequirements,
32
33 #[error("unsupported payment scheme: {0}")]
34 UnsupportedScheme(String),
35
36 #[error("invalid base64 header: {0}")]
37 Base64(#[from] base64::DecodeError),
38
39 #[error("invalid json: {0}")]
40 Json(#[from] serde_json::Error),
41
42 #[error("invalid private key")]
43 InvalidPrivateKey,
44
45 #[error("invalid address: {0}")]
46 InvalidAddress(String),
47
48 #[error("invalid u256 decimal: {0}")]
49 InvalidU256(String),
50
51 #[error("signing failed")]
52 SignFailure,
53
54 #[error("malformed payment payload: {0}")]
55 MalformedPayload(String),
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ResourceInfo {
60 pub url: String,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub description: Option<String>,
63 #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")]
64 pub mime_type: Option<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PaymentRequirements {
69 pub scheme: String,
70 pub network: String,
71 pub asset: String,
72 pub amount: String,
73 #[serde(rename = "payTo")]
74 pub pay_to: String,
75 #[serde(rename = "maxTimeoutSeconds")]
76 pub max_timeout_seconds: i64,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub extra: Option<serde_json::Value>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct PaymentRequired {
83 #[serde(rename = "x402Version")]
84 pub x402_version: i64,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub error: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub resource: Option<ResourceInfo>,
89 pub accepts: Vec<PaymentRequirements>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub extensions: Option<serde_json::Value>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct PaymentPayload {
96 #[serde(rename = "x402Version")]
97 pub x402_version: i64,
98 pub payload: serde_json::Value,
99 pub accepted: PaymentRequirements,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub resource: Option<ResourceInfo>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub extensions: Option<serde_json::Value>,
104}
105
106#[derive(Debug, Clone)]
107struct Eip3009Authorization {
108 from: [u8; 20],
109 to: [u8; 20],
110 value: [u8; 32],
111 valid_after: u64,
112 valid_before: u64,
113 nonce: [u8; 32],
114}
115
116#[derive(Clone)]
117pub struct X402Payer {
118 signing_key: SigningKey,
119 address: String,
120}
121
122impl X402Payer {
123 pub fn from_env() -> Option<Self> {
130 let raw = std::env::var("SYNTH_X402_PRIVATE_KEY")
131 .ok()
132 .filter(|v| !v.trim().is_empty())
133 .or_else(|| {
134 std::env::var("SYNTH_X402_EVM_PRIVATE_KEY")
135 .ok()
136 .filter(|v| !v.trim().is_empty())
137 })
138 .or_else(|| {
139 std::env::var("X402_PRIVATE_KEY")
140 .ok()
141 .filter(|v| !v.trim().is_empty())
142 })?;
143
144 let signing_key = parse_signing_key(&raw).ok()?;
145 let address = checksum_address(address_from_signing_key(&signing_key));
146 Some(Self {
147 signing_key,
148 address,
149 })
150 }
151
152 pub fn address(&self) -> &str {
153 &self.address
154 }
155
156 pub fn build_payment_signature_header(
158 &self,
159 payment_required_header: &str,
160 ) -> Result<String, X402Error> {
161 let payment_required = decode_payment_required_header(payment_required_header)?;
162 let accepted = select_accepted_requirements(&payment_required)?;
163 let payment_payload = self.create_payment_payload(&payment_required, accepted)?;
164 encode_payment_signature_header(&payment_payload)
165 }
166
167 fn create_payment_payload(
168 &self,
169 payment_required: &PaymentRequired,
170 accepted: &PaymentRequirements,
171 ) -> Result<PaymentPayload, X402Error> {
172 if accepted.scheme != "exact" {
173 return Err(X402Error::UnsupportedScheme(accepted.scheme.clone()));
174 }
175
176 let chain_id = evm_chain_id_from_network(&accepted.network)?;
177 let verifying_contract = parse_address(&accepted.asset)?;
178 let token_name = accepted
179 .extra
180 .as_ref()
181 .and_then(|v| v.get("name"))
182 .and_then(|v| v.as_str())
183 .unwrap_or("USDC");
184 let token_version = accepted
185 .extra
186 .as_ref()
187 .and_then(|v| v.get("version"))
188 .and_then(|v| v.as_str())
189 .unwrap_or("1");
190
191 let mut nonce_bytes = [0u8; 32];
192 OsRng.fill_bytes(&mut nonce_bytes);
193 let nonce_hex = format!("0x{}", hex::encode(nonce_bytes));
194
195 let now = Utc::now().timestamp();
196 let valid_after = (now - DEFAULT_VALIDITY_BUFFER_SECONDS).max(0) as u64;
197 let valid_before = (now + accepted.max_timeout_seconds.max(1)).max(0) as u64;
198
199 let from_addr = address_from_signing_key(&self.signing_key);
200 let to_addr = parse_address(&accepted.pay_to)?;
201 let value_u256 = u256_from_dec_str(&accepted.amount)?;
202
203 let auth = Eip3009Authorization {
204 from: from_addr,
205 to: to_addr,
206 value: value_u256,
207 valid_after,
208 valid_before,
209 nonce: nonce_bytes,
210 };
211
212 let signature_hex = sign_eip3009_authorization(
213 &self.signing_key,
214 chain_id,
215 verifying_contract,
216 token_name,
217 token_version,
218 &auth,
219 )?;
220
221 let payload = serde_json::json!({
222 "authorization": {
223 "from": self.address,
224 "to": accepted.pay_to,
225 "value": accepted.amount,
226 "validAfter": valid_after.to_string(),
227 "validBefore": valid_before.to_string(),
228 "nonce": nonce_hex,
229 },
230 "signature": signature_hex,
231 });
232
233 Ok(PaymentPayload {
234 x402_version: X402_VERSION_V2,
235 payload,
236 accepted: accepted.clone(),
237 resource: payment_required.resource.clone(),
238 extensions: payment_required.extensions.clone(),
239 })
240 }
241}
242
243pub fn decode_payment_signature_header(value: &str) -> Result<PaymentPayload, X402Error> {
245 let decoded = general_purpose::STANDARD.decode(value.trim())?;
246 Ok(serde_json::from_slice(&decoded)?)
247}
248
249pub fn decode_payment_required_header(value: &str) -> Result<PaymentRequired, X402Error> {
250 let decoded = general_purpose::STANDARD.decode(value.trim())?;
251 Ok(serde_json::from_slice(&decoded)?)
252}
253
254pub fn encode_payment_signature_header(payload: &PaymentPayload) -> Result<String, X402Error> {
255 let json = serde_json::to_vec(payload)?;
256 Ok(general_purpose::STANDARD.encode(json))
257}
258
259#[cfg(test)]
264pub(crate) fn recover_payer_address_from_payment_payload(
265 payload: &PaymentPayload,
266) -> Result<String, X402Error> {
267 if payload.accepted.scheme != "exact" {
268 return Err(X402Error::UnsupportedScheme(
269 payload.accepted.scheme.clone(),
270 ));
271 }
272
273 let chain_id = evm_chain_id_from_network(&payload.accepted.network)?;
274 let verifying_contract = parse_address(&payload.accepted.asset)?;
275 let token_name = payload
276 .accepted
277 .extra
278 .as_ref()
279 .and_then(|v| v.get("name"))
280 .and_then(|v| v.as_str())
281 .unwrap_or("USDC");
282 let token_version = payload
283 .accepted
284 .extra
285 .as_ref()
286 .and_then(|v| v.get("version"))
287 .and_then(|v| v.as_str())
288 .unwrap_or("1");
289
290 let auth_obj = payload
291 .payload
292 .get("authorization")
293 .and_then(|v| v.as_object())
294 .ok_or_else(|| X402Error::MalformedPayload("missing authorization".to_string()))?;
295
296 let from_str = auth_obj
297 .get("from")
298 .and_then(|v| v.as_str())
299 .ok_or_else(|| X402Error::MalformedPayload("missing from".to_string()))?;
300 let to_str = auth_obj
301 .get("to")
302 .and_then(|v| v.as_str())
303 .ok_or_else(|| X402Error::MalformedPayload("missing to".to_string()))?;
304 let value_str = auth_obj
305 .get("value")
306 .and_then(|v| v.as_str())
307 .ok_or_else(|| X402Error::MalformedPayload("missing value".to_string()))?;
308 let valid_after_str = auth_obj
309 .get("validAfter")
310 .and_then(|v| v.as_str())
311 .ok_or_else(|| X402Error::MalformedPayload("missing validAfter".to_string()))?;
312 let valid_before_str = auth_obj
313 .get("validBefore")
314 .and_then(|v| v.as_str())
315 .ok_or_else(|| X402Error::MalformedPayload("missing validBefore".to_string()))?;
316 let nonce_str = auth_obj
317 .get("nonce")
318 .and_then(|v| v.as_str())
319 .ok_or_else(|| X402Error::MalformedPayload("missing nonce".to_string()))?;
320
321 let sig_str = payload
322 .payload
323 .get("signature")
324 .and_then(|v| v.as_str())
325 .ok_or_else(|| X402Error::MalformedPayload("missing signature".to_string()))?;
326
327 let nonce_hex = nonce_str
328 .trim()
329 .strip_prefix("0x")
330 .unwrap_or(nonce_str.trim());
331 let nonce_bytes =
332 hex::decode(nonce_hex).map_err(|_| X402Error::InvalidU256(nonce_str.to_string()))?;
333 if nonce_bytes.len() != 32 {
334 return Err(X402Error::InvalidU256(nonce_str.to_string()));
335 }
336 let mut nonce = [0u8; 32];
337 nonce.copy_from_slice(&nonce_bytes);
338
339 let auth = Eip3009Authorization {
340 from: parse_address(from_str)?,
341 to: parse_address(to_str)?,
342 value: u256_from_dec_str(value_str)?,
343 valid_after: valid_after_str
344 .parse::<u64>()
345 .map_err(|_| X402Error::InvalidU256(valid_after_str.to_string()))?,
346 valid_before: valid_before_str
347 .parse::<u64>()
348 .map_err(|_| X402Error::InvalidU256(valid_before_str.to_string()))?,
349 nonce,
350 };
351
352 let digest = eip712_signing_hash_transfer_with_authorization(
353 chain_id,
354 verifying_contract,
355 token_name,
356 token_version,
357 &auth,
358 );
359
360 let sig_hex = sig_str.trim().strip_prefix("0x").unwrap_or(sig_str.trim());
361 let sig_bytes = hex::decode(sig_hex).map_err(|_| X402Error::SignFailure)?;
362 if sig_bytes.len() != 65 {
363 return Err(X402Error::SignFailure);
364 }
365 let v = sig_bytes[64];
366 if v < 27 {
367 return Err(X402Error::SignFailure);
368 }
369 let recid = RecoveryId::try_from(v - 27).map_err(|_| X402Error::SignFailure)?;
370 let sig = Signature::from_slice(&sig_bytes[..64]).map_err(|_| X402Error::SignFailure)?;
371
372 let recovered = VerifyingKey::recover_from_prehash(&digest, &sig, recid)
373 .map_err(|_| X402Error::SignFailure)?;
374 Ok(checksum_address(address_from_verifying_key(&recovered)))
375}
376
377fn select_accepted_requirements<'a>(
378 payment_required: &'a PaymentRequired,
379) -> Result<&'a PaymentRequirements, X402Error> {
380 if payment_required.accepts.is_empty() {
381 return Err(X402Error::MissingRequirements);
382 }
383
384 if let Some(req) = payment_required
386 .accepts
387 .iter()
388 .find(|req| req.scheme == "exact")
389 {
390 return Ok(req);
391 }
392
393 Ok(&payment_required.accepts[0])
394}
395
396fn parse_signing_key(raw: &str) -> Result<SigningKey, X402Error> {
397 let s = raw.trim();
398 let s = s.strip_prefix("0x").unwrap_or(s);
399 let bytes = hex::decode(s).map_err(|_| X402Error::InvalidPrivateKey)?;
400 if bytes.len() != 32 {
401 return Err(X402Error::InvalidPrivateKey);
402 }
403 let mut buf = [0u8; 32];
404 buf.copy_from_slice(&bytes);
405 SigningKey::from_bytes(&buf.into()).map_err(|_| X402Error::InvalidPrivateKey)
406}
407
408fn address_from_signing_key(signing_key: &SigningKey) -> [u8; 20] {
409 let verify_key = signing_key.verifying_key();
411 let encoded = verify_key.to_encoded_point(false);
412 let bytes = encoded.as_bytes();
413 let hash = keccak256(&bytes[1..]);
415 let mut addr = [0u8; 20];
416 addr.copy_from_slice(&hash[12..]);
417 addr
418}
419
420#[cfg(test)]
421fn address_from_verifying_key(vk: &VerifyingKey) -> [u8; 20] {
422 let encoded = vk.to_encoded_point(false);
423 let bytes = encoded.as_bytes();
424 let hash = keccak256(&bytes[1..]);
425 let mut addr = [0u8; 20];
426 addr.copy_from_slice(&hash[12..]);
427 addr
428}
429
430fn checksum_address(address: [u8; 20]) -> String {
431 let hex_lower = hex::encode(address);
433 let hash = keccak256(hex_lower.as_bytes());
434 let mut out = String::with_capacity(2 + 40);
435 out.push_str("0x");
436
437 for (i, ch) in hex_lower.chars().enumerate() {
438 let nibble = if i % 2 == 0 {
439 (hash[i / 2] >> 4) & 0x0f
440 } else {
441 hash[i / 2] & 0x0f
442 };
443 if ch.is_ascii_hexdigit() && ch.is_ascii_alphabetic() && nibble >= 8 {
444 out.push(ch.to_ascii_uppercase());
445 } else {
446 out.push(ch);
447 }
448 }
449
450 out
451}
452
453fn parse_address(raw: &str) -> Result<[u8; 20], X402Error> {
454 let s = raw.trim();
455 let s = s.strip_prefix("0x").unwrap_or(s);
456 if s.len() != 40 {
457 return Err(X402Error::InvalidAddress(raw.to_string()));
458 }
459 let bytes = hex::decode(s).map_err(|_| X402Error::InvalidAddress(raw.to_string()))?;
460 let mut out = [0u8; 20];
461 out.copy_from_slice(&bytes);
462 Ok(out)
463}
464
465fn evm_chain_id_from_network(network: &str) -> Result<u64, X402Error> {
466 let network = network.trim();
468 let Some(rest) = network.strip_prefix("eip155:") else {
469 return Err(X402Error::InvalidU256(network.to_string()));
470 };
471 rest.parse::<u64>()
472 .map_err(|_| X402Error::InvalidU256(network.to_string()))
473}
474
475fn keccak256(data: &[u8]) -> [u8; 32] {
476 let mut hasher = Keccak256::new();
477 hasher.update(data);
478 let digest = hasher.finalize();
479 let mut out = [0u8; 32];
480 out.copy_from_slice(&digest);
481 out
482}
483
484fn encode_address(addr: [u8; 20]) -> [u8; 32] {
485 let mut out = [0u8; 32];
486 out[12..].copy_from_slice(&addr);
487 out
488}
489
490fn encode_u64(value: u64) -> [u8; 32] {
491 let mut out = [0u8; 32];
492 out[24..].copy_from_slice(&value.to_be_bytes());
493 out
494}
495
496fn u256_from_dec_str(raw: &str) -> Result<[u8; 32], X402Error> {
497 let s = raw.trim();
498 if s.is_empty() {
499 return Err(X402Error::InvalidU256(raw.to_string()));
500 }
501 let mut out = [0u8; 32];
502 for ch in s.chars() {
503 if !ch.is_ascii_digit() {
504 return Err(X402Error::InvalidU256(raw.to_string()));
505 }
506 let digit = (ch as u8 - b'0') as u16;
507
508 let mut carry: u16 = digit;
510 for i in (0..32).rev() {
511 let val: u16 = (out[i] as u16) * 10 + carry;
512 out[i] = (val & 0xff) as u8;
513 carry = val >> 8;
514 }
515 if carry != 0 {
516 return Err(X402Error::InvalidU256(raw.to_string()));
517 }
518 }
519 Ok(out)
520}
521
522fn eip712_domain_separator(
523 name: &str,
524 version: &str,
525 chain_id: u64,
526 verifying_contract: [u8; 20],
527) -> [u8; 32] {
528 let type_hash = keccak256(EIP712_DOMAIN_TYPE.as_bytes());
529 let name_hash = keccak256(name.as_bytes());
530 let version_hash = keccak256(version.as_bytes());
531 let chain_id_enc = encode_u64(chain_id);
532 let verifying_contract_enc = encode_address(verifying_contract);
533
534 let mut encoded = Vec::with_capacity(32 * 5);
535 encoded.extend_from_slice(&type_hash);
536 encoded.extend_from_slice(&name_hash);
537 encoded.extend_from_slice(&version_hash);
538 encoded.extend_from_slice(&chain_id_enc);
539 encoded.extend_from_slice(&verifying_contract_enc);
540 keccak256(&encoded)
541}
542
543fn eip712_transfer_with_authorization_struct_hash(auth: &Eip3009Authorization) -> [u8; 32] {
544 let type_hash = keccak256(TRANSFER_WITH_AUTHORIZATION_TYPE.as_bytes());
545
546 let mut encoded = Vec::with_capacity(32 * 7);
547 encoded.extend_from_slice(&type_hash);
548 encoded.extend_from_slice(&encode_address(auth.from));
549 encoded.extend_from_slice(&encode_address(auth.to));
550 encoded.extend_from_slice(&auth.value);
551 encoded.extend_from_slice(&encode_u64(auth.valid_after));
552 encoded.extend_from_slice(&encode_u64(auth.valid_before));
553 encoded.extend_from_slice(&auth.nonce);
554 keccak256(&encoded)
555}
556
557fn eip712_signing_hash_transfer_with_authorization(
558 chain_id: u64,
559 verifying_contract: [u8; 20],
560 token_name: &str,
561 token_version: &str,
562 auth: &Eip3009Authorization,
563) -> [u8; 32] {
564 let domain_sep =
565 eip712_domain_separator(token_name, token_version, chain_id, verifying_contract);
566 let struct_hash = eip712_transfer_with_authorization_struct_hash(auth);
567
568 let mut encoded = Vec::with_capacity(2 + 32 + 32);
569 encoded.extend_from_slice(&[0x19, 0x01]);
570 encoded.extend_from_slice(&domain_sep);
571 encoded.extend_from_slice(&struct_hash);
572 keccak256(&encoded)
573}
574
575fn sign_eip3009_authorization(
576 signing_key: &SigningKey,
577 chain_id: u64,
578 verifying_contract: [u8; 20],
579 token_name: &str,
580 token_version: &str,
581 auth: &Eip3009Authorization,
582) -> Result<String, X402Error> {
583 let digest = eip712_signing_hash_transfer_with_authorization(
584 chain_id,
585 verifying_contract,
586 token_name,
587 token_version,
588 auth,
589 );
590
591 let (signature, recid) = signing_key
592 .sign_prehash_recoverable(&digest)
593 .map_err(|_| X402Error::SignFailure)?;
594
595 let sig64 = signature.to_bytes();
596 let recid_u8: u8 = recid.into();
597 let v = recid_u8.saturating_add(27);
598
599 let mut sig65 = [0u8; 65];
600 sig65[..64].copy_from_slice(sig64.as_slice());
601 sig65[64] = v;
602
603 Ok(format!("0x{}", hex::encode(sig65)))
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn test_eip712_digest_matches_python_x402() {
612 let signing_key =
622 parse_signing_key("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
623 .unwrap();
624 let from = address_from_signing_key(&signing_key);
625 assert_eq!(
626 checksum_address(from),
627 "0xFCAd0B19bB29D4674531d6f115237E16AfCE377c"
628 );
629
630 let auth = Eip3009Authorization {
631 from,
632 to: parse_address("0x1111111111111111111111111111111111111111").unwrap(),
633 value: u256_from_dec_str("250000").unwrap(),
634 valid_after: 1_700_000_000u64,
635 valid_before: 1_700_000_300u64,
636 nonce: [0x11u8; 32],
637 };
638
639 let digest = eip712_signing_hash_transfer_with_authorization(
640 84532,
641 parse_address("0x036CbD53842c5426634e7929541eC2318f3dCF7e").unwrap(),
642 "USDC",
643 "2",
644 &auth,
645 );
646
647 assert_eq!(
648 format!("0x{}", hex::encode(digest)),
649 "0x798f516cfe5a9cc10934b46623d65b0facb181da516e4dc7cfea11a16cc44a81"
650 );
651
652 let sig = sign_eip3009_authorization(
653 &signing_key,
654 84532,
655 parse_address("0x036CbD53842c5426634e7929541eC2318f3dCF7e").unwrap(),
656 "USDC",
657 "2",
658 &auth,
659 )
660 .unwrap();
661
662 assert_eq!(sig, "0xdbed925a525095c7d6933ab969b8421521c160de32d28e4628fa01913908382745a0b499c5562376e4d275e0626b30355fa03342bb7168dfe3dfae277eab4eb41c");
663 }
664}