r402-evm 0.20.0

EIP-155 (EVM) chain support for the x402 payment protocol.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
//! Wire types and constants for EVM `auth-capture`.
//!
//! Addresses and field names match
//! `specs/schemes/auth-capture/scheme_auth_capture_evm.md` and official
//! `auth-capture/constants.ts`. Default aliases are commerce-payments **v1.1**.

use std::str::FromStr;

use alloy_primitives::{Address, B256, Bytes, U256, address};
use r402_protocol::error::VerificationError;
use r402_protocol::payment::UnixTimestamp;
pub use r402_protocol::scheme::AuthCaptureScheme;
use serde::{Deserialize, Serialize};

use crate::asset::AssetTransferMethod;
use crate::chain::{ChecksummedAddress, TokenAmount};

/// Official extra-reject reason when `authCaptureEscrow` is not a known escrow.
pub const INVALID_AUTH_CAPTURE_EVM_EXTRA: &str = "invalid_auth_capture_evm_extra";
/// Official payload-reject reason for mixed or deployment-wrong fee fields.
pub const INVALID_AUTH_CAPTURE_EVM_PAYLOAD_FORMAT: &str = "invalid_auth_capture_evm_payload_format";

// commerce-payments v1.0 (tag v1.0.0)
/// v1.0 `AuthCaptureEscrow`.
pub const AUTH_CAPTURE_ESCROW_V1_0_ADDRESS: Address =
    address!("0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff");
/// v1.0 EIP-3009 token collector.
pub const EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS: Address =
    address!("0x0E3dF9510de65469C4518D7843919c0b8C7A7757");
/// v1.0 Permit2 token collector.
pub const PERMIT2_TOKEN_COLLECTOR_V1_0_ADDRESS: Address =
    address!("0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26");
/// v1.0 operator refund collector.
pub const OPERATOR_REFUND_COLLECTOR_V1_0_ADDRESS: Address =
    address!("0x934907bffd0901b6A21e398B9C53A4A38F02fa5d");

// commerce-payments v1.1 (default)
/// v1.1 `AuthCaptureEscrow`.
pub const AUTH_CAPTURE_ESCROW_V1_1_ADDRESS: Address =
    address!("0x13AC3b34322D12FE27D5e192D0c2b2266d4F29CB");
/// v1.1 EIP-3009 token collector.
pub const EIP3009_TOKEN_COLLECTOR_V1_1_ADDRESS: Address =
    address!("0xEA902B37036bcb4944577ec2101ABdEDF56EbD28");
/// v1.1 Permit2 token collector.
pub const PERMIT2_TOKEN_COLLECTOR_V1_1_ADDRESS: Address =
    address!("0x1aacb38b16a1a8709e80746825E53A0C9Cae9b70");
/// v1.1 operator refund collector.
pub const OPERATOR_REFUND_COLLECTOR_V1_1_ADDRESS: Address =
    address!("0x6a1ADdEEb4bD9c5811a613e20c172b6CE61A4aaB");

/// Default deployment aliases (v1.1).
pub const AUTH_CAPTURE_ESCROW_ADDRESS: Address = AUTH_CAPTURE_ESCROW_V1_1_ADDRESS;
/// Default EIP-3009 collector (v1.1).
pub const EIP3009_TOKEN_COLLECTOR_ADDRESS: Address = EIP3009_TOKEN_COLLECTOR_V1_1_ADDRESS;
/// Default Permit2 collector (v1.1).
pub const PERMIT2_TOKEN_COLLECTOR_ADDRESS: Address = PERMIT2_TOKEN_COLLECTOR_V1_1_ADDRESS;
/// Default operator refund collector (v1.1).
pub const OPERATOR_REFUND_COLLECTOR_ADDRESS: Address = OPERATOR_REFUND_COLLECTOR_V1_1_ADDRESS;

/// Clock-skew buffer (seconds) for deadline checks — matches foundation SDKs.
pub const AUTH_CAPTURE_CLOCK_SKEW_SECS: u64 = 6;

/// Shared operator EIP-712 domain `name`.
pub const OPERATOR_EIP712_NAME: &str = "x402 Auth Capture Operator";
/// Shared operator EIP-712 domain `version`.
pub const OPERATOR_EIP712_VERSION: &str = "1";

/// Commerce-payments CREATE2 set version.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthCaptureDeploymentVersion {
    /// commerce-payments v1.0 — charge/capture submit `feeBps`.
    V1_0,
    /// commerce-payments v1.1 — charge/capture submit `feeAmount`.
    V1_1,
}

/// Canonical escrow + collectors for one commerce-payments CREATE2 set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthCaptureDeployment {
    /// Deployment version (selects fee encoding).
    pub version: AuthCaptureDeploymentVersion,
    /// `AuthCaptureEscrow` bound into the signature nonce.
    pub escrow: Address,
    /// EIP-3009 `authorization.to`. Never read from extra.
    pub eip3009_collector: Address,
    /// Permit2 `spender`. Never read from extra.
    pub permit2_collector: Address,
    /// Operator refund collector.
    pub operator_refund_collector: Address,
}

/// commerce-payments v1.0 deployment.
pub const AUTH_CAPTURE_DEPLOYMENT_V1_0: AuthCaptureDeployment = AuthCaptureDeployment {
    version: AuthCaptureDeploymentVersion::V1_0,
    escrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS,
    eip3009_collector: EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS,
    permit2_collector: PERMIT2_TOKEN_COLLECTOR_V1_0_ADDRESS,
    operator_refund_collector: OPERATOR_REFUND_COLLECTOR_V1_0_ADDRESS,
};

/// commerce-payments v1.1 deployment (default).
pub const AUTH_CAPTURE_DEPLOYMENT_V1_1: AuthCaptureDeployment = AuthCaptureDeployment {
    version: AuthCaptureDeploymentVersion::V1_1,
    escrow: AUTH_CAPTURE_ESCROW_V1_1_ADDRESS,
    eip3009_collector: EIP3009_TOKEN_COLLECTOR_V1_1_ADDRESS,
    permit2_collector: PERMIT2_TOKEN_COLLECTOR_V1_1_ADDRESS,
    operator_refund_collector: OPERATOR_REFUND_COLLECTOR_V1_1_ADDRESS,
};

impl AuthCaptureDeployment {
    /// Collector for the payload's transfer method.
    #[must_use]
    pub const fn collector(self, method: AssetTransferMethod) -> Address {
        match method {
            AssetTransferMethod::Eip3009 => self.eip3009_collector,
            AssetTransferMethod::Permit2 => self.permit2_collector,
        }
    }
}

/// Resolve the commerce-payments deployment from optional `extra.authCaptureEscrow`.
///
/// Official `resolveAuthCaptureDeployment`: absent or `""` → v1.1; not an
/// address or unknown address → `None`; v1.1 escrow → v1.1; v1.0 escrow → v1.0.
/// No v1.0-as-default fallback.
#[must_use]
pub fn resolve_auth_capture_deployment(escrow: Option<&str>) -> Option<AuthCaptureDeployment> {
    let Some(escrow) = escrow.filter(|s| !s.is_empty()) else {
        return Some(AUTH_CAPTURE_DEPLOYMENT_V1_1);
    };
    let Ok(addr) = Address::from_str(escrow) else {
        return None;
    };
    if addr == AUTH_CAPTURE_ESCROW_V1_1_ADDRESS || addr == AUTH_CAPTURE_ESCROW_ADDRESS {
        return Some(AUTH_CAPTURE_DEPLOYMENT_V1_1);
    }
    if addr == AUTH_CAPTURE_ESCROW_V1_0_ADDRESS {
        return Some(AUTH_CAPTURE_DEPLOYMENT_V1_0);
    }
    None
}

/// Integer fee from amount and bps (same division the escrow uses).
#[must_use]
pub fn fee_amount_from_bps(amount: U256, fee_bps: u16) -> U256 {
    (amount * U256::from(fee_bps)) / U256::from(10_000u16)
}

/// Submitted `charge` / `capture` fee for a resolved deployment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(
    variant_size_differences,
    reason = "official wire is feeBps (u16) XOR feeAmount (U256)"
)]
pub enum SubmittedFee {
    /// v1.0 pin: `feeBps` (`uint16`).
    Bps(u16),
    /// v1.1: absolute `feeAmount` in atomic units.
    Amount(TokenAmount),
}

/// Default submitted fee: v1.1 `feeAmount = amount * minFeeBps / 10000`; v1.0 pins `feeBps`.
#[must_use]
pub fn default_submitted_fee(
    version: AuthCaptureDeploymentVersion,
    amount: U256,
    min_fee_bps: u16,
) -> SubmittedFee {
    match version {
        AuthCaptureDeploymentVersion::V1_1 => {
            SubmittedFee::Amount(TokenAmount(fee_amount_from_bps(amount, min_fee_bps)))
        }
        AuthCaptureDeploymentVersion::V1_0 => SubmittedFee::Bps(min_fee_bps),
    }
}

/// Decode wire `feeBps` / `feeAmount` against the resolved deployment.
///
/// Neither field → authorize (no submitted fee). Both, or the field that does
/// not belong to the deployment → `invalid_auth_capture_evm_payload_format`.
///
/// # Errors
///
/// Mixed fee fields, `feeBps` on v1.1, or `feeAmount` on a v1.0 pin.
pub fn submitted_fee_from_wire(
    version: AuthCaptureDeploymentVersion,
    fee_bps: Option<u16>,
    fee_amount: Option<TokenAmount>,
) -> Result<Option<SubmittedFee>, VerificationError> {
    match (version, fee_bps, fee_amount) {
        (_, None, None) => Ok(None),
        (AuthCaptureDeploymentVersion::V1_1, None, Some(amount)) => {
            Ok(Some(SubmittedFee::Amount(amount)))
        }
        (AuthCaptureDeploymentVersion::V1_0, Some(bps), None) => Ok(Some(SubmittedFee::Bps(bps))),
        (AuthCaptureDeploymentVersion::V1_1, Some(_), None)
        | (AuthCaptureDeploymentVersion::V1_0, None, Some(_))
        | (_, Some(_), Some(_)) => Err(VerificationError::from_wire(
            INVALID_AUTH_CAPTURE_EVM_PAYLOAD_FORMAT,
        )),
    }
}

/// Server `PaymentRequirements.extra` for auth-capture.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthCaptureExtra {
    /// EIP-712 token domain name.
    pub name: String,
    /// EIP-712 token domain version.
    pub version: String,
    /// On-chain `PaymentInfo.operator` (capture authorizer).
    pub capture_authorizer: ChecksummedAddress,
    /// Absolute Unix seconds — capture must occur before this.
    pub capture_deadline: u64,
    /// Absolute Unix seconds — refunds allowed until this.
    pub refund_deadline: u64,
    /// Fee recipient (`PaymentInfo.feeReceiver`).
    pub fee_recipient: ChecksummedAddress,
    /// Minimum fee in basis points.
    pub min_fee_bps: u16,
    /// Maximum fee in basis points.
    pub max_fee_bps: u16,
    /// `true` is rejected at verify (`autoCapture` is unsupported).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auto_capture: Option<bool>,
    /// Transfer method; default eip3009 when absent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub asset_transfer_method: Option<AssetTransferMethod>,
    /// Selects the commerce-payments deployment. Absent or `""` → v1.1.
    /// Not-an-address and unknown address reject at resolve. Collectors are
    /// never carried here.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_capture_escrow: Option<String>,
}

impl AuthCaptureExtra {
    /// Effective transfer method (default EIP-3009).
    #[must_use]
    pub const fn transfer_method(&self) -> AssetTransferMethod {
        match self.asset_transfer_method {
            Some(m) => m,
            None => AssetTransferMethod::Eip3009,
        }
    }

    /// Whether `extra.autoCapture` is `true` (rejected at verify).
    #[must_use]
    pub const fn auto_capture(&self) -> bool {
        matches!(self.auto_capture, Some(true))
    }

    /// Resolved deployment from `authCaptureEscrow`. `None` when empty is not
    /// the case and the value is not a known escrow.
    #[must_use]
    pub fn deployment(&self) -> Option<AuthCaptureDeployment> {
        resolve_auth_capture_deployment(self.auth_capture_escrow.as_deref())
    }

    /// Resolved deployment, or the official extra-reject reason.
    ///
    /// # Errors
    ///
    /// Unknown `authCaptureEscrow`.
    pub fn require_deployment(&self) -> Result<AuthCaptureDeployment, VerificationError> {
        self.deployment()
            .ok_or_else(|| VerificationError::from_wire(INVALID_AUTH_CAPTURE_EVM_EXTRA))
    }
}

/// EIP-3009 `ReceiveWithAuthorization` body inside the payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthCaptureEip3009Authorization {
    /// Payer.
    pub from: Address,
    /// Must be the resolved deployment's EIP-3009 collector.
    pub to: Address,
    /// Amount (== requirements.amount).
    pub value: TokenAmount,
    /// Always `0` for this scheme.
    pub valid_after: UnixTimestamp,
    /// `preApprovalExpiry` (= now + maxTimeoutSeconds at sign time).
    pub valid_before: UnixTimestamp,
    /// Payer-agnostic `PaymentInfo` hash.
    pub nonce: B256,
}

/// EIP-3009-shaped auth-capture payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthCaptureEip3009Payload {
    /// Authorization that was signed.
    pub authorization: AuthCaptureEip3009Authorization,
    /// 65-byte ECDSA signature (r, s, v).
    pub signature: Bytes,
    /// Fresh client salt (bytes32).
    pub salt: B256,
    /// v1.0 charge/capture `feeBps`. Mutually exclusive with [`Self::fee_amount`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fee_bps: Option<u16>,
    /// v1.1 charge/capture `feeAmount`. Mutually exclusive with [`Self::fee_bps`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fee_amount: Option<TokenAmount>,
}

/// Permit2 token permissions for auth-capture.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthCaptureTokenPermissions {
    /// Token contract.
    pub token: Address,
    /// Amount.
    pub amount: TokenAmount,
}

/// Permit2 authorization (no witness — nonce binds `PaymentInfo`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthCapturePermit2Authorization {
    /// Payer.
    pub from: Address,
    /// Token + amount.
    pub permitted: AuthCaptureTokenPermissions,
    /// Must be the resolved deployment's Permit2 collector.
    pub spender: Address,
    /// `uint256` form of payer-agnostic `PaymentInfo` hash.
    pub nonce: TokenAmount,
    /// `preApprovalExpiry`.
    pub deadline: TokenAmount,
}

/// Permit2-shaped auth-capture payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthCapturePermit2Payload {
    /// Permit2 fields that were signed.
    pub permit2_authorization: AuthCapturePermit2Authorization,
    /// 65-byte ECDSA signature (r, s, v).
    pub signature: Bytes,
    /// Fresh client salt.
    pub salt: B256,
    /// v1.0 charge/capture `feeBps`. Mutually exclusive with [`Self::fee_amount`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fee_bps: Option<u16>,
    /// v1.1 charge/capture `feeAmount`. Mutually exclusive with [`Self::fee_bps`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fee_amount: Option<TokenAmount>,
}

/// Unified auth-capture payload (Permit2 tried first — unique field name).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AuthCapturePayload {
    /// Permit2 collector path.
    Permit2(AuthCapturePermit2Payload),
    /// EIP-3009 collector path.
    Eip3009(AuthCaptureEip3009Payload),
}

impl AuthCapturePayload {
    /// Payer address.
    #[must_use]
    pub const fn payer(&self) -> Address {
        match self {
            Self::Eip3009(p) => p.authorization.from,
            Self::Permit2(p) => p.permit2_authorization.from,
        }
    }

    /// Client salt.
    #[must_use]
    pub const fn salt(&self) -> B256 {
        match self {
            Self::Eip3009(p) => p.salt,
            Self::Permit2(p) => p.salt,
        }
    }

    /// Signature bytes.
    #[must_use]
    pub const fn signature(&self) -> &Bytes {
        match self {
            Self::Eip3009(p) => &p.signature,
            Self::Permit2(p) => &p.signature,
        }
    }

    /// Transfer method implied by payload shape.
    #[must_use]
    pub const fn transfer_method(&self) -> AssetTransferMethod {
        match self {
            Self::Eip3009(_) => AssetTransferMethod::Eip3009,
            Self::Permit2(_) => AssetTransferMethod::Permit2,
        }
    }

    /// Wire `feeBps`, if present.
    #[must_use]
    pub const fn fee_bps(&self) -> Option<u16> {
        match self {
            Self::Eip3009(p) => p.fee_bps,
            Self::Permit2(p) => p.fee_bps,
        }
    }

    /// Wire `feeAmount`, if present.
    #[must_use]
    pub const fn fee_amount(&self) -> Option<TokenAmount> {
        match self {
            Self::Eip3009(p) => p.fee_amount,
            Self::Permit2(p) => p.fee_amount,
        }
    }
}

/// On-chain `PaymentInfo` (Solidity field names — typehash must match escrow).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaymentInfo {
    /// `extra.captureAuthorizer`.
    pub operator: Address,
    /// Payer (zeroed for nonce hash).
    pub payer: Address,
    /// `requirements.payTo`.
    pub receiver: Address,
    /// `requirements.asset`.
    pub token: Address,
    /// `requirements.amount` as uint120 (stored as U256).
    pub max_amount: U256,
    /// `now + maxTimeoutSeconds` at sign time.
    pub pre_approval_expiry: u64,
    /// `extra.captureDeadline`.
    pub authorization_expiry: u64,
    /// `extra.refundDeadline`.
    pub refund_expiry: u64,
    /// `extra.minFeeBps`.
    pub min_fee_bps: u16,
    /// `extra.maxFeeBps`.
    pub max_fee_bps: u16,
    /// `extra.feeRecipient`.
    pub fee_receiver: Address,
    /// Client salt as uint256.
    pub salt: U256,
}

/// EIP-712 structs shared by client signing and off-chain verify.
#[cfg(any(feature = "client", feature = "facilitator"))]
use alloy_sol_types::sol;

#[cfg(any(feature = "client", feature = "facilitator"))]
sol! {
    struct ReceiveWithAuthorization {
        address from;
        address to;
        uint256 value;
        uint256 validAfter;
        uint256 validBefore;
        bytes32 nonce;
    }

    struct TokenPermissions {
        address token;
        uint256 amount;
    }

    struct PermitTransferFrom {
        TokenPermissions permitted;
        address spender;
        uint256 nonce;
        uint256 deadline;
    }
}

/// v1.0 operator Charge/Capture EIP-712 types (`feeBps`). Primary type names
/// are `Charge` / `Capture`.
#[cfg(any(feature = "client", feature = "facilitator"))]
pub mod v1_0 {
    use alloy_sol_types::sol;

    sol! {
        struct Charge {
            bytes32 paymentInfoHash;
            uint256 amount;
            address tokenCollector;
            bytes32 collectorDataHash;
            uint16 feeBps;
            address feeReceiver;
        }

        struct Capture {
            bytes32 paymentInfoHash;
            uint256 amount;
            uint16 feeBps;
            address feeReceiver;
            uint256 expectedCapturableAmount;
            uint256 expectedRefundableAmount;
        }
    }
}

/// v1.1 operator Charge/Capture EIP-712 types (`feeAmount`). Primary type
/// names are `Charge` / `Capture`.
#[cfg(any(feature = "client", feature = "facilitator"))]
pub mod v1_1 {
    use alloy_sol_types::sol;

    sol! {
        struct Charge {
            bytes32 paymentInfoHash;
            uint256 amount;
            address tokenCollector;
            bytes32 collectorDataHash;
            uint256 feeAmount;
            address feeReceiver;
        }

        struct Capture {
            bytes32 paymentInfoHash;
            uint256 amount;
            uint256 feeAmount;
            address feeReceiver;
            uint256 expectedCapturableAmount;
            uint256 expectedRefundableAmount;
        }
    }
}

/// Default v1.1 Capture EIP-712 type (`NAME` is `"Capture"`).
#[cfg(any(feature = "client", feature = "facilitator"))]
pub use v1_1::Capture;
/// Default v1.1 Charge EIP-712 type (`NAME` is `"Charge"`).
#[cfg(any(feature = "client", feature = "facilitator"))]
pub use v1_1::Charge;

/// Wire aliases for typed verify/settle.
pub mod v2 {
    use r402_protocol::payment::{
        PaymentPayload as WirePayload, PaymentRequirements as WireReqs, TypedVerifyRequest,
    };

    use super::{AuthCaptureExtra, AuthCapturePayload, AuthCaptureScheme};
    use crate::chain::{ChecksummedAddress, TokenAmount};

    /// Typed verify request.
    pub type VerifyRequest = TypedVerifyRequest<2, PaymentPayload, PaymentRequirements>;
    /// Typed settle request.
    pub type SettleRequest = VerifyRequest;
    /// Payment payload.
    pub type PaymentPayload = WirePayload<PaymentRequirements, AuthCapturePayload>;
    /// Payment requirements.
    pub type PaymentRequirements =
        WireReqs<AuthCaptureScheme, TokenAmount, ChecksummedAddress, AuthCaptureExtra>;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn canonical_addresses_checksum() {
        assert_eq!(
            AUTH_CAPTURE_ESCROW_ADDRESS.to_checksum(None),
            "0x13AC3b34322D12FE27D5e192D0c2b2266d4F29CB"
        );
        assert_eq!(
            EIP3009_TOKEN_COLLECTOR_ADDRESS.to_checksum(None),
            "0xEA902B37036bcb4944577ec2101ABdEDF56EbD28"
        );
        assert_eq!(
            PERMIT2_TOKEN_COLLECTOR_ADDRESS.to_checksum(None),
            "0x1aacb38b16a1a8709e80746825E53A0C9Cae9b70"
        );
        assert_eq!(
            OPERATOR_REFUND_COLLECTOR_ADDRESS.to_checksum(None),
            "0x6a1ADdEEb4bD9c5811a613e20c172b6CE61A4aaB"
        );
    }

    #[test]
    fn v1_0_named_constants_checksum() {
        assert_eq!(
            AUTH_CAPTURE_ESCROW_V1_0_ADDRESS.to_checksum(None),
            "0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff"
        );
        assert_eq!(
            EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS.to_checksum(None),
            "0x0E3dF9510de65469C4518D7843919c0b8C7A7757"
        );
        assert_eq!(
            PERMIT2_TOKEN_COLLECTOR_V1_0_ADDRESS.to_checksum(None),
            "0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26"
        );
        assert_eq!(
            OPERATOR_REFUND_COLLECTOR_V1_0_ADDRESS.to_checksum(None),
            "0x934907bffd0901b6A21e398B9C53A4A38F02fa5d"
        );
    }

    #[test]
    fn aliases_are_v1_1_not_v1_0() {
        assert_eq!(
            AUTH_CAPTURE_ESCROW_ADDRESS,
            AUTH_CAPTURE_ESCROW_V1_1_ADDRESS
        );
        assert_ne!(
            AUTH_CAPTURE_ESCROW_ADDRESS,
            AUTH_CAPTURE_ESCROW_V1_0_ADDRESS
        );
        assert_eq!(
            EIP3009_TOKEN_COLLECTOR_ADDRESS,
            EIP3009_TOKEN_COLLECTOR_V1_1_ADDRESS
        );
        assert_eq!(
            PERMIT2_TOKEN_COLLECTOR_ADDRESS,
            PERMIT2_TOKEN_COLLECTOR_V1_1_ADDRESS
        );
    }

    #[test]
    fn resolve_absent_is_v1_1() {
        let d = resolve_auth_capture_deployment(None).expect("default");
        assert_eq!(d, AUTH_CAPTURE_DEPLOYMENT_V1_1);
        assert_eq!(d.version, AuthCaptureDeploymentVersion::V1_1);
    }

    #[test]
    fn resolve_v1_1_escrow_is_v1_1() {
        let s = AUTH_CAPTURE_ESCROW_V1_1_ADDRESS.to_checksum(None);
        let d = resolve_auth_capture_deployment(Some(s.as_str())).expect("v1.1 escrow");
        assert_eq!(d, AUTH_CAPTURE_DEPLOYMENT_V1_1);
    }

    #[test]
    fn resolve_v1_0_escrow_is_v1_0() {
        let s = AUTH_CAPTURE_ESCROW_V1_0_ADDRESS.to_checksum(None);
        let d = resolve_auth_capture_deployment(Some(s.as_str())).expect("v1.0 escrow");
        assert_eq!(d, AUTH_CAPTURE_DEPLOYMENT_V1_0);
        assert_eq!(d.eip3009_collector, EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS);
        assert_eq!(d.permit2_collector, PERMIT2_TOKEN_COLLECTOR_V1_0_ADDRESS);
    }

    #[test]
    fn resolve_empty_string_is_v1_1() {
        assert_eq!(
            resolve_auth_capture_deployment(Some("")),
            Some(AUTH_CAPTURE_DEPLOYMENT_V1_1)
        );
    }

    #[test]
    fn resolve_not_an_address_is_none() {
        assert_eq!(
            resolve_auth_capture_deployment(Some("not-an-address")),
            None,
            "not-an-address must not fall back to v1.0 or v1.1"
        );
    }

    #[test]
    fn resolve_unknown_escrow_is_none() {
        let s = Address::repeat_byte(0x99).to_checksum(None);
        assert_eq!(
            resolve_auth_capture_deployment(Some(s.as_str())),
            None,
            "unknown escrow must not fall back to v1.0 or v1.1"
        );
    }

    #[test]
    fn extra_empty_escrow_deserializes_as_v1_1() {
        let extra: AuthCaptureExtra = serde_json::from_value(serde_json::json!({
            "name": "USD Coin",
            "version": "2",
            "captureAuthorizer": "0x1111111111111111111111111111111111111111",
            "captureDeadline": 1,
            "refundDeadline": 2,
            "feeRecipient": "0x2222222222222222222222222222222222222222",
            "minFeeBps": 0,
            "maxFeeBps": 0,
            "authCaptureEscrow": ""
        }))
        .expect("empty escrow is valid extra");
        assert_eq!(extra.auth_capture_escrow.as_deref(), Some(""));
        assert_eq!(
            extra.deployment(),
            Some(AUTH_CAPTURE_DEPLOYMENT_V1_1),
            "empty string selects v1.1"
        );
    }

    #[test]
    fn extra_not_an_address_deserializes_and_rejects_at_resolve() {
        let extra: AuthCaptureExtra = serde_json::from_value(serde_json::json!({
            "name": "USD Coin",
            "version": "2",
            "captureAuthorizer": "0x1111111111111111111111111111111111111111",
            "captureDeadline": 1,
            "refundDeadline": 2,
            "feeRecipient": "0x2222222222222222222222222222222222222222",
            "minFeeBps": 0,
            "maxFeeBps": 0,
            "authCaptureEscrow": "not-an-address"
        }))
        .expect("not-an-address must not fail extra deserialize");
        assert_eq!(
            extra.require_deployment().expect_err("reject").to_string(),
            "invalid_auth_capture_evm_extra"
        );
    }

    #[test]
    fn fee_amount_from_bps_matches_escrow_integer_div() {
        assert_eq!(
            fee_amount_from_bps(U256::from(1_000_000_u64), 100),
            U256::from(10_000_u64)
        );
        assert_eq!(
            fee_amount_from_bps(U256::from(1_u64), 1),
            U256::ZERO,
            "escrow integer division truncates"
        );
        assert_eq!(
            default_submitted_fee(
                AuthCaptureDeploymentVersion::V1_1,
                U256::from(1_000_000_u64),
                100
            ),
            SubmittedFee::Amount(TokenAmount(U256::from(10_000_u64)))
        );
        assert_eq!(
            default_submitted_fee(
                AuthCaptureDeploymentVersion::V1_0,
                U256::from(1_000_000_u64),
                100
            ),
            SubmittedFee::Bps(100)
        );
    }

    #[test]
    fn submitted_fee_rejects_mixed_and_wrong_deployment_field() {
        let amount = TokenAmount(U256::from(10_u64));
        assert!(
            submitted_fee_from_wire(AuthCaptureDeploymentVersion::V1_1, Some(1), Some(amount))
                .is_err()
        );
        assert!(
            submitted_fee_from_wire(AuthCaptureDeploymentVersion::V1_1, Some(1), None).is_err(),
            "v1.1 rejects feeBps"
        );
        assert!(
            submitted_fee_from_wire(AuthCaptureDeploymentVersion::V1_0, None, Some(amount))
                .is_err(),
            "v1.0 pin rejects feeAmount"
        );
        assert_eq!(
            submitted_fee_from_wire(AuthCaptureDeploymentVersion::V1_1, None, Some(amount))
                .expect("v1.1 feeAmount"),
            Some(SubmittedFee::Amount(amount))
        );
        assert_eq!(
            submitted_fee_from_wire(AuthCaptureDeploymentVersion::V1_0, Some(50), None)
                .expect("v1.0 feeBps"),
            Some(SubmittedFee::Bps(50))
        );
        assert_eq!(
            submitted_fee_from_wire(AuthCaptureDeploymentVersion::V1_1, None, None)
                .expect("authorize"),
            None
        );
    }

    #[test]
    fn collectors_are_not_read_from_extra() {
        let extra: AuthCaptureExtra = serde_json::from_value(serde_json::json!({
            "name": "USD Coin",
            "version": "2",
            "captureAuthorizer": "0x1111111111111111111111111111111111111111",
            "captureDeadline": 1,
            "refundDeadline": 2,
            "feeRecipient": "0x2222222222222222222222222222222222222222",
            "minFeeBps": 0,
            "maxFeeBps": 0,
            "eip3009Collector": "0x0E3dF9510de65469C4518D7843919c0b8C7A7757",
            "permit2Collector": "0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26"
        }))
        .expect("unknown collector keys ignored");
        let d = extra.deployment().expect("omitted escrow is v1.1");
        assert_eq!(d.eip3009_collector, EIP3009_TOKEN_COLLECTOR_V1_1_ADDRESS);
        assert_eq!(d.permit2_collector, PERMIT2_TOKEN_COLLECTOR_V1_1_ADDRESS);
        assert_eq!(d.escrow, AUTH_CAPTURE_ESCROW_V1_1_ADDRESS);
    }

    #[cfg(any(feature = "client", feature = "facilitator"))]
    #[test]
    fn charge_capture_eip712_encode_type_is_official_primary_type() {
        use alloy_sol_types::SolStruct;

        assert_eq!(
            Charge::eip712_encode_type(),
            "Charge(bytes32 paymentInfoHash,uint256 amount,address tokenCollector,bytes32 collectorDataHash,uint256 feeAmount,address feeReceiver)"
        );
        assert_eq!(
            Capture::eip712_encode_type(),
            "Capture(bytes32 paymentInfoHash,uint256 amount,uint256 feeAmount,address feeReceiver,uint256 expectedCapturableAmount,uint256 expectedRefundableAmount)"
        );
        assert_eq!(
            v1_0::Charge::eip712_encode_type(),
            "Charge(bytes32 paymentInfoHash,uint256 amount,address tokenCollector,bytes32 collectorDataHash,uint16 feeBps,address feeReceiver)"
        );
        assert_eq!(
            v1_0::Capture::eip712_encode_type(),
            "Capture(bytes32 paymentInfoHash,uint256 amount,uint16 feeBps,address feeReceiver,uint256 expectedCapturableAmount,uint256 expectedRefundableAmount)"
        );
        assert_eq!(Charge::NAME, "Charge");
        assert_eq!(v1_0::Charge::NAME, "Charge");
        assert_eq!(Capture::NAME, "Capture");
        assert_eq!(v1_0::Capture::NAME, "Capture");
    }
}