diff --git a/contracts/evm/foundry.toml b/contracts/evm/foundry.toml index 69e3e75e9b..770334a91c 100644 --- a/contracts/evm/foundry.toml +++ b/contracts/evm/foundry.toml @@ -12,7 +12,7 @@ cbor_metadata = false bytecode_hash = "none" ffi = false fs_permissions = [{ access = "read", path = "./" }] -gas_reports = ["x402ExactPermit2Proxy", "x402UptoPermit2Proxy"] +gas_reports = ["x402ExactPermit2Proxy", "x402UptoPermit2Proxy", "x402BatchSettlement"] [profile.default.fuzz] runs = 256 diff --git a/contracts/evm/script/ComputeAddress.s.sol b/contracts/evm/script/ComputeAddress.s.sol index 602ef3d13b..970524ebcb 100644 --- a/contracts/evm/script/ComputeAddress.s.sol +++ b/contracts/evm/script/ComputeAddress.s.sol @@ -5,6 +5,7 @@ import {Script, console2} from "forge-std/Script.sol"; import {x402ExactPermit2Proxy} from "../src/x402ExactPermit2Proxy.sol"; import {x402UptoPermit2Proxy} from "../src/x402UptoPermit2Proxy.sol"; +import {x402BatchSettlement} from "../src/x402BatchSettlement.sol"; /** * @title ComputeAddress @@ -19,8 +20,11 @@ import {x402UptoPermit2Proxy} from "../src/x402UptoPermit2Proxy.sol"; * @dev Run with default salts: * forge script script/ComputeAddress.s.sol * - * @dev Run with custom salts: + * @dev Run with custom salts (exact + upto): * forge script script/ComputeAddress.s.sol --sig "computeAddresses(bytes32,bytes32)" + * + * @dev Run BatchSettlement only with custom salt: + * forge script script/ComputeAddress.s.sol --sig "computeBatchAddress(bytes32)" */ contract ComputeAddress is Script { /// @notice Arachnid's deterministic CREATE2 deployer @@ -37,6 +41,10 @@ contract ComputeAddress is Script { /// @dev Vanity mined for address 0x4020a4f3b7b90cca423b9fabcc0ce57c6c240002 bytes32 constant DEFAULT_UPTO_SALT = 0x000000000000000000000000000000000000000000000000b000000001db633d; + /// @notice Default salt for x402BatchSettlement + /// @dev Vanity mined for address 0x4020cfaffad9df99f9acc48227c40f80d17a0003 + bytes32 constant DEFAULT_BATCH_SALT = 0x00000000000000000000000000000000000000000000000020000000041a1d56; + /// @notice Expected initCodeHash for x402ExactPermit2Proxy (pre-built, includes CBOR metadata) bytes32 constant EXACT_INIT_CODE_HASH = 0xe774d1d5a07218946ab54efe010b300481478b86861bb17d69c98a57f68a604c; @@ -111,6 +119,43 @@ contract ComputeAddress is Script { } } + /** + * @notice Computes the CREATE2 address for x402BatchSettlement + * @param batchSalt The salt to use for x402BatchSettlement + */ + function computeBatchAddress( + bytes32 batchSalt + ) public view { + console2.log(""); + console2.log("============================================================"); + console2.log(" x402BatchSettlement Address Computation"); + console2.log("============================================================"); + console2.log(""); + + console2.log("Configuration:"); + console2.log(" CREATE2 Deployer: ", CREATE2_DEPLOYER); + console2.log(" Permit2 (ctor arg): ", CANONICAL_PERMIT2); + console2.log(""); + + bytes memory initCode = abi.encodePacked(type(x402BatchSettlement).creationCode, abi.encode(CANONICAL_PERMIT2)); + bytes32 initCodeHash = keccak256(initCode); + address expectedAddress = _computeCreate2Addr(batchSalt, initCodeHash, CREATE2_DEPLOYER); + + console2.log("------------------------------------------------------------"); + console2.log(" x402BatchSettlement (deterministic build)"); + console2.log("------------------------------------------------------------"); + console2.log(" Salt: ", vm.toString(batchSalt)); + console2.log(" Init Code Hash: ", vm.toString(initCodeHash)); + console2.log(" Address: ", expectedAddress); + + if (block.chainid != 0 && expectedAddress.code.length > 0) { + console2.log(" Status: DEPLOYED"); + } else { + console2.log(" Status: NOT DEPLOYED"); + } + console2.log(""); + } + function _computeCreate2Addr( bytes32 salt, bytes32 initCodeHash, diff --git a/contracts/evm/script/DeployBatchSettlement.s.sol b/contracts/evm/script/DeployBatchSettlement.s.sol new file mode 100644 index 0000000000..90261ad685 --- /dev/null +++ b/contracts/evm/script/DeployBatchSettlement.s.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {x402BatchSettlement} from "../src/x402BatchSettlement.sol"; +import {Permit2DepositCollector} from "../src/periphery/Permit2DepositCollector.sol"; +import {Permit2WithERC2612DepositCollector} from "../src/periphery/Permit2WithERC2612DepositCollector.sol"; +import {ERC3009DepositCollector} from "../src/periphery/ERC3009DepositCollector.sol"; + +/// @title DeployBatchSettlement +/// @notice Deployment script for x402BatchSettlement and deposit collectors using CREATE2 +/// @dev Run with: forge script script/DeployBatchSettlement.s.sol --rpc-url $RPC_URL --broadcast --verify +/// +/// Uses deterministic bytecode (cbor_metadata = false in foundry.toml) so +/// any machine compiling at the same git commit produces the same initCode +/// and therefore the same CREATE2 address. +contract DeployBatchSettlement is Script { + /// @notice Canonical Permit2 address (Uniswap's official deployment) + address constant CANONICAL_PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + /// @notice Arachnid's deterministic CREATE2 deployer (same on all EVM chains) + address constant CREATE2_DEPLOYER = 0x4e59b44847b379578588920cA78FbF26c0B4956C; + + bytes32 constant BATCH_SALT = 0x3ae0178c98d9f45c37d2b84ff96b0b02268269ea699dfbcb47374a79ef0156b0; + bytes32 constant ERC3009_SALT = 0xb2ec2379b202aede0729d830e1d0d267773fc2cac72c62f3225e29664737db12; + bytes32 constant PERMIT2_COLLECTOR_SALT = 0x111b85d49160296fc0fb956a78308c07197adb99d9bba5901e94e7c4929ec8e2; + bytes32 constant PERMIT2_ERC2612_SALT = 0x785692bab508580f81a234bd806b3044ef4da86fd9aaeb281f107bd49ed11859; + + function run() public { + address permit2 = vm.envOr("PERMIT2_ADDRESS", CANONICAL_PERMIT2); + + console2.log(""); + console2.log("============================================================"); + console2.log(" x402BatchSettlement Deterministic Deployment (CREATE2)"); + console2.log(" Model: Dual-Authorizer Channel-Config + Deposit Collectors"); + console2.log("============================================================"); + console2.log(""); + + console2.log("Network: chainId", block.chainid); + console2.log("Permit2:", permit2); + console2.log("CREATE2 Deployer:", CREATE2_DEPLOYER); + console2.log(""); + + if (block.chainid != 31_337 && block.chainid != 1337) { + require(permit2.code.length > 0, "Permit2 not found on this network"); + console2.log("Permit2 verified"); + + require(CREATE2_DEPLOYER.code.length > 0, "CREATE2 deployer not found on this network"); + console2.log("CREATE2 deployer verified"); + } + + _deploySettlement(); + _deployCollectors(permit2); + + console2.log(""); + console2.log("Deployment complete!"); + console2.log(""); + } + + function _deploySettlement() internal { + console2.log(""); + console2.log("------------------------------------------------------------"); + console2.log(" Deploying x402BatchSettlement"); + console2.log("------------------------------------------------------------"); + + _deployCreate2("x402BatchSettlement", BATCH_SALT, type(x402BatchSettlement).creationCode); + } + + function _deployCollectors( + address permit2 + ) internal { + console2.log(""); + console2.log("------------------------------------------------------------"); + console2.log(" Deploying Deposit Collectors"); + console2.log("------------------------------------------------------------"); + + _deployCreate2("ERC3009DepositCollector", ERC3009_SALT, type(ERC3009DepositCollector).creationCode); + + _deployCreate2( + "Permit2DepositCollector", + PERMIT2_COLLECTOR_SALT, + abi.encodePacked(type(Permit2DepositCollector).creationCode, abi.encode(permit2)) + ); + + _deployCreate2( + "Permit2WithERC2612DepositCollector", + PERMIT2_ERC2612_SALT, + abi.encodePacked(type(Permit2WithERC2612DepositCollector).creationCode, abi.encode(permit2)) + ); + } + + function _deployCreate2(string memory name, bytes32 salt, bytes memory initCode) internal { + bytes32 initCodeHash = keccak256(initCode); + address expectedAddress = _computeCreate2Addr(salt, initCodeHash, CREATE2_DEPLOYER); + + console2.log(""); + console2.log(string.concat(" ", name)); + console2.log(" Salt:", vm.toString(salt)); + console2.log(" Expected address:", expectedAddress); + + if (expectedAddress.code.length > 0) { + console2.log(" Already deployed, skipping"); + return; + } + + vm.startBroadcast(); + + if (block.chainid == 31_337 || block.chainid == 1337) { + console2.log(" (Local network - using regular CREATE)"); + assembly { + let addr := create(0, add(initCode, 0x20), mload(initCode)) + if iszero(addr) { revert(0, 0) } + } + } else { + bytes memory deploymentData = abi.encodePacked(salt, initCode); + (bool success,) = CREATE2_DEPLOYER.call(deploymentData); + require(success, string.concat("CREATE2 deployment failed for ", name)); + require(expectedAddress.code.length > 0, string.concat("No bytecode at expected address for ", name)); + } + + vm.stopBroadcast(); + + console2.log(" Deployed to:", expectedAddress); + } + + function _computeCreate2Addr( + bytes32 salt, + bytes32 initCodeHash, + address deployer + ) internal pure returns (address) { + return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), deployer, salt, initCodeHash))))); + } +} diff --git a/contracts/evm/src/interfaces/IDepositCollector.sol b/contracts/evm/src/interfaces/IDepositCollector.sol new file mode 100644 index 0000000000..28356bda48 --- /dev/null +++ b/contracts/evm/src/interfaces/IDepositCollector.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title IDepositCollector +/// @notice Interface for pluggable deposit collectors used by x402BatchSettlement. +/// @dev Collectors handle the token transfer mechanics (ERC-3009, Permit2, etc.) +/// while the settlement contract verifies actual token receipt via balance checks. +/// Collectors MUST transfer tokens to msg.sender (the settlement contract). +interface IDepositCollector { + /// @notice Pull tokens from payer to the calling settlement contract. + /// @param payer The address that owns the tokens being deposited + /// @param token The ERC-20 token address + /// @param amount The exact amount of tokens to transfer + /// @param channelId The channel identifier (used by Permit2 collectors for witness binding) + /// @param caller The address that called deposit() on the settlement contract + /// @param collectorData Opaque bytes containing collector-specific parameters (signatures, nonces, etc.) + function collect( + address payer, + address token, + uint256 amount, + bytes32 channelId, + address caller, + bytes calldata collectorData + ) external; +} diff --git a/contracts/evm/src/interfaces/IERC3009.sol b/contracts/evm/src/interfaces/IERC3009.sol new file mode 100644 index 0000000000..891b8118ef --- /dev/null +++ b/contracts/evm/src/interfaces/IERC3009.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title IERC3009 + * @notice Minimal interface for EIP-3009 transferWithAuthorization / receiveWithAuthorization + * @dev Used by tokens like USDC that support gasless transfers via signed authorizations. + * See https://eips.ethereum.org/EIPS/eip-3009 + */ +interface IERC3009 { + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes memory signature + ) external; +} diff --git a/contracts/evm/src/periphery/DepositCollector.sol b/contracts/evm/src/periphery/DepositCollector.sol new file mode 100644 index 0000000000..3bf1cb1e0b --- /dev/null +++ b/contracts/evm/src/periphery/DepositCollector.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IDepositCollector} from "../interfaces/IDepositCollector.sol"; + +/// @title DepositCollector +/// @notice Abstract base for deposit collectors bound to a single x402BatchSettlement instance. +/// @dev All collectors MUST inherit this to ensure only the settlement contract can call collect(), +/// preventing frontrunning and fund theft. +abstract contract DepositCollector is IDepositCollector { + address public immutable settlement; + + error OnlySettlement(); + error InvalidSettlementAddress(); + + constructor( + address _settlement + ) { + if (_settlement == address(0)) revert InvalidSettlementAddress(); + settlement = _settlement; + } + + modifier onlySettlement() { + if (msg.sender != settlement) revert OnlySettlement(); + _; + } +} diff --git a/contracts/evm/src/periphery/ERC3009DepositCollector.sol b/contracts/evm/src/periphery/ERC3009DepositCollector.sol new file mode 100644 index 0000000000..765bcac7a3 --- /dev/null +++ b/contracts/evm/src/periphery/ERC3009DepositCollector.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {IDepositCollector} from "../interfaces/IDepositCollector.sol"; +import {DepositCollector} from "./DepositCollector.sol"; +import {IERC3009} from "../interfaces/IERC3009.sol"; + +/// @title ERC3009DepositCollector +/// @notice Deposit collector that uses ERC-3009 receiveWithAuthorization for gasless token collection. +/// @dev ERC-3009 requires msg.sender == to, so this collector receives tokens first, then forwards +/// them to the settlement contract (msg.sender). This incurs an extra transfer (~30k gas overhead). +contract ERC3009DepositCollector is DepositCollector { + using SafeERC20 for IERC20; + + constructor( + address _settlement + ) DepositCollector(_settlement) {} + + /// @inheritdoc IDepositCollector + function collect( + address payer, + address token, + uint256 amount, + bytes32, + address, + bytes calldata collectorData + ) external override onlySettlement { + (uint256 validAfter, uint256 validBefore, bytes32 nonce, bytes memory signature) = + abi.decode(collectorData, (uint256, uint256, bytes32, bytes)); + + IERC3009(token).receiveWithAuthorization( + payer, address(this), amount, validAfter, validBefore, nonce, signature + ); + + IERC20(token).safeTransfer(msg.sender, amount); + } +} diff --git a/contracts/evm/src/periphery/Permit2DepositCollector.sol b/contracts/evm/src/periphery/Permit2DepositCollector.sol new file mode 100644 index 0000000000..62f3b333f4 --- /dev/null +++ b/contracts/evm/src/periphery/Permit2DepositCollector.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IDepositCollector} from "../interfaces/IDepositCollector.sol"; +import {ISignatureTransfer} from "../interfaces/ISignatureTransfer.sol"; +import {Permit2DepositCollectorBase} from "./Permit2DepositCollectorBase.sol"; + +/// @title Permit2DepositCollector +/// @notice Deposit collector that uses Permit2 permitWitnessTransferFrom with channelId witness binding. +/// @dev Tokens flow directly from payer to settlement via Permit2 (no intermediate hop). +/// The payer's Permit2 signature must name this collector as the spender. +contract Permit2DepositCollector is Permit2DepositCollectorBase { + constructor(address _settlement, address _permit2) Permit2DepositCollectorBase(_settlement, _permit2) {} + + /// @inheritdoc IDepositCollector + function collect( + address payer, + address, + uint256 amount, + bytes32 channelId, + address, + bytes calldata collectorData + ) external override onlySettlement { + (ISignatureTransfer.PermitTransferFrom memory permit, bytes memory signature) = + abi.decode(collectorData, (ISignatureTransfer.PermitTransferFrom, bytes)); + + _executePermit2Transfer(payer, amount, channelId, permit, signature); + } +} diff --git a/contracts/evm/src/periphery/Permit2DepositCollectorBase.sol b/contracts/evm/src/periphery/Permit2DepositCollectorBase.sol new file mode 100644 index 0000000000..45e6c81dfd --- /dev/null +++ b/contracts/evm/src/periphery/Permit2DepositCollectorBase.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ISignatureTransfer} from "../interfaces/ISignatureTransfer.sol"; +import {DepositCollector} from "./DepositCollector.sol"; + +/// @title Permit2DepositCollectorBase +/// @notice Abstract base for deposit collectors that use Permit2 permitWitnessTransferFrom. +/// @dev Shared witness type constants and Permit2 transfer logic for DRY across +/// Permit2DepositCollector and Permit2WithERC2612DepositCollector. +abstract contract Permit2DepositCollectorBase is DepositCollector { + ISignatureTransfer public immutable PERMIT2; + + string public constant DEPOSIT_WITNESS_TYPE_STRING = + "DepositWitness witness)TokenPermissions(address token,uint256 amount)DepositWitness(bytes32 channelId)"; + + bytes32 public constant DEPOSIT_WITNESS_TYPEHASH = keccak256("DepositWitness(bytes32 channelId)"); + + error InvalidPermit2Address(); + + constructor(address _settlement, address _permit2) DepositCollector(_settlement) { + if (_permit2 == address(0)) revert InvalidPermit2Address(); + PERMIT2 = ISignatureTransfer(_permit2); + } + + /// @dev Execute a Permit2 witness transfer from payer directly to the settlement contract (msg.sender). + function _executePermit2Transfer( + address payer, + uint256 amount, + bytes32 channelId, + ISignatureTransfer.PermitTransferFrom memory permit, + bytes memory signature + ) internal { + bytes32 witnessHash = keccak256(abi.encode(DEPOSIT_WITNESS_TYPEHASH, channelId)); + + PERMIT2.permitWitnessTransferFrom( + permit, + ISignatureTransfer.SignatureTransferDetails({to: msg.sender, requestedAmount: amount}), + payer, + witnessHash, + DEPOSIT_WITNESS_TYPE_STRING, + signature + ); + } +} diff --git a/contracts/evm/src/periphery/Permit2WithERC2612DepositCollector.sol b/contracts/evm/src/periphery/Permit2WithERC2612DepositCollector.sol new file mode 100644 index 0000000000..a5613e28d7 --- /dev/null +++ b/contracts/evm/src/periphery/Permit2WithERC2612DepositCollector.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; + +import {IDepositCollector} from "../interfaces/IDepositCollector.sol"; +import {ISignatureTransfer} from "../interfaces/ISignatureTransfer.sol"; +import {Permit2DepositCollectorBase} from "./Permit2DepositCollectorBase.sol"; + +/// @title Permit2WithERC2612DepositCollector +/// @notice Deposit collector combining EIP-2612 permit (to approve Permit2) with Permit2 witness transfer. +/// @dev Enables a single-tx deposit for tokens that implement EIP-2612, without requiring the payer +/// to have previously approved Permit2. The EIP-2612 permit call is soft-fail (try/catch) so that +/// pre-existing approvals or replayed permits don't revert the entire deposit. +contract Permit2WithERC2612DepositCollector is Permit2DepositCollectorBase { + struct EIP2612Permit { + uint256 value; + uint256 deadline; + bytes32 r; + bytes32 s; + uint8 v; + } + + error Permit2612AmountMismatch(); + + event EIP2612PermitFailedWithReason(address indexed token, address indexed owner, string reason); + event EIP2612PermitFailedWithPanic(address indexed token, address indexed owner, uint256 errorCode); + event EIP2612PermitFailedWithData(address indexed token, address indexed owner, bytes data); + + constructor(address _settlement, address _permit2) Permit2DepositCollectorBase(_settlement, _permit2) {} + + /// @inheritdoc IDepositCollector + function collect( + address payer, + address token, + uint256 amount, + bytes32 channelId, + address, + bytes calldata collectorData + ) external override onlySettlement { + (EIP2612Permit memory permit2612, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory signature) = + abi.decode(collectorData, (EIP2612Permit, ISignatureTransfer.PermitTransferFrom, bytes)); + + _executeEIP2612Permit(token, payer, permit2612, permit.permitted.amount); + + _executePermit2Transfer(payer, amount, channelId, permit, signature); + } + + function _executeEIP2612Permit( + address token, + address owner, + EIP2612Permit memory permit2612, + uint256 permittedAmount + ) internal { + if (permit2612.value != permittedAmount) { + revert Permit2612AmountMismatch(); + } + + try IERC20Permit(token).permit( + owner, address(PERMIT2), permit2612.value, permit2612.deadline, permit2612.v, permit2612.r, permit2612.s + ) {} catch Error(string memory reason) { + emit EIP2612PermitFailedWithReason(token, owner, reason); + } catch Panic(uint256 errorCode) { + emit EIP2612PermitFailedWithPanic(token, owner, errorCode); + } catch (bytes memory data) { + emit EIP2612PermitFailedWithData(token, owner, data); + } + } +} diff --git a/contracts/evm/src/x402BatchSettlement.sol b/contracts/evm/src/x402BatchSettlement.sol new file mode 100644 index 0000000000..0c1ecea96c --- /dev/null +++ b/contracts/evm/src/x402BatchSettlement.sol @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +import {IDepositCollector} from "./interfaces/IDepositCollector.sol"; + +/// @title x402BatchSettlement +/// @notice Stateless unidirectional payment channel contract for the x402 `batch-settlement` scheme on EVM. +/// @dev Channel identity is derived from an immutable ChannelConfig struct: +/// `channelId = keccak256(abi.encode(channelConfig))`. +/// Deployed at the same address across all supported EVM chains using CREATE2. +/// @author x402 Protocol +contract x402BatchSettlement is EIP712, Multicall, ReentrancyGuard { + using SafeERC20 for IERC20; + + // ========================================================================= + // Structs + // ========================================================================= + + struct ChannelConfig { + address payer; + address payerAuthorizer; + address receiver; + address receiverAuthorizer; + address token; + uint40 withdrawDelay; + bytes32 salt; + } + + struct ChannelState { + uint128 balance; + uint128 totalClaimed; + } + + struct WithdrawalState { + uint128 amount; + uint40 initiatedAt; + } + + struct ReceiverState { + uint128 totalClaimed; + uint128 totalSettled; + } + + /// @dev The payer-signed data. Does not include claimAmount or signature. + struct Voucher { + ChannelConfig channel; + uint128 maxClaimableAmount; + } + + /// @dev Wraps a Voucher with the payer's signature and the receiverAuthorizer-determined cumulative claim total. + /// Using a cumulative total (rather than a delta) provides natural replay protection: + /// replaying a voucher after it's been applied is a no-op. + struct VoucherClaim { + Voucher voucher; + bytes signature; + uint128 totalClaimed; + } + + // ========================================================================= + // Constants + // ========================================================================= + + uint40 public constant MIN_WITHDRAW_DELAY = 15 minutes; + uint40 public constant MAX_WITHDRAW_DELAY = 30 days; + + // ========================================================================= + // Constants — EIP-712 Type Hashes + // ========================================================================= + + bytes32 public constant VOUCHER_TYPEHASH = keccak256("Voucher(bytes32 channelId,uint128 maxClaimableAmount)"); + + bytes32 public constant REFUND_TYPEHASH = keccak256("Refund(bytes32 channelId)"); + + bytes32 public constant CLAIM_BATCH_TYPEHASH = keccak256("ClaimBatch(bytes32 claimsHash)"); + + // ========================================================================= + // Storage + // ========================================================================= + + mapping(bytes32 channelId => ChannelState) public channels; + mapping(bytes32 channelId => WithdrawalState) public pendingWithdrawals; + mapping(address receiver => mapping(address token => ReceiverState)) public receivers; + + // ========================================================================= + // Events + // ========================================================================= + + event ChannelCreated(bytes32 indexed channelId, ChannelConfig config); + event Deposited(bytes32 indexed channelId, address indexed sender, uint128 amount, uint128 newBalance); + event Claimed(bytes32 indexed channelId, address indexed sender, uint128 claimAmount, uint128 newTotalClaimed); + event Settled(address indexed receiver, address indexed token, address indexed sender, uint128 amount); + event Refunded(bytes32 indexed channelId, address indexed sender, uint128 amount); + event WithdrawInitiated(bytes32 indexed channelId, uint128 amount, uint40 finalizeAfter); + event WithdrawFinalized(bytes32 indexed channelId, uint128 amount, address sender); + + // ========================================================================= + // Errors + // ========================================================================= + + error InvalidChannel(); + error ZeroDeposit(); + error DepositOverflow(); + error InvalidSignature(); + error NotReceiverAuthorizer(); + error ClaimExceedsCeiling(); + error ClaimExceedsBalance(); + error WithdrawalAlreadyPending(); + error WithdrawalNotPending(); + error WithdrawDelayNotElapsed(); + error NothingToWithdraw(); + error WithdrawDelayOutOfRange(); + error EmptyBatch(); + error DepositCollectionFailed(); + error InvalidCollector(); + + // ========================================================================= + // Constructor + // ========================================================================= + + constructor() EIP712("x402 Batch Settlement", "1") {} + + // ========================================================================= + // Deposits + // ========================================================================= + + /// @notice Deposit tokens into a channel using a pluggable collector. + /// @dev The collector handles the token transfer mechanics. This function verifies + /// actual token receipt via balance checks (checks-effects-interactions with post-check). + /// @param config The immutable channel configuration + /// @param amount The exact amount of tokens to deposit + /// @param collector The deposit collector contract address + /// @param collectorData Opaque bytes forwarded to the collector (signatures, nonces, etc.) + function deposit( + ChannelConfig calldata config, + uint128 amount, + address collector, + bytes calldata collectorData + ) external nonReentrant { + _validateConfig(config); + if (amount == 0) revert ZeroDeposit(); + if (collector == address(0)) revert InvalidCollector(); + + bytes32 channelId = getChannelId(config); + ChannelState storage ch = channels[channelId]; + + bool isNew = ch.balance == 0 && ch.totalClaimed == 0; + + if (amount > type(uint128).max - ch.balance) revert DepositOverflow(); + ch.balance += amount; + + _clearPendingWithdrawal(channelId); + + if (isNew) { + emit ChannelCreated(channelId, config); + } + emit Deposited(channelId, msg.sender, amount, ch.balance); + + uint256 balBefore = IERC20(config.token).balanceOf(address(this)); + IDepositCollector(collector).collect(config.payer, config.token, amount, channelId, msg.sender, collectorData); + uint256 balAfter = IERC20(config.token).balanceOf(address(this)); + if (balAfter != balBefore + amount) revert DepositCollectionFailed(); + } + + // ========================================================================= + // Claim & Settle + // ========================================================================= + + /// @notice Batch-validate voucher claims and update channel accounting. + /// Caller must be the receiverAuthorizer for every claim in the batch. + function claim( + VoucherClaim[] calldata voucherClaims + ) external nonReentrant { + if (voucherClaims.length == 0) revert EmptyBatch(); + + for (uint256 i = 0; i < voucherClaims.length; ++i) { + if (msg.sender != voucherClaims[i].voucher.channel.receiverAuthorizer) { + revert NotReceiverAuthorizer(); + } + _processVoucherClaim(voucherClaims[i]); + } + } + + /// @notice Batch-validate voucher claims with an off-chain receiverAuthorizer signature. + /// Anyone can submit. All claims must reference the same receiverAuthorizer. + function claimWithSignature( + VoucherClaim[] calldata voucherClaims, + bytes calldata authorizerSignature + ) external nonReentrant { + if (voucherClaims.length == 0) revert EmptyBatch(); + + address authorizer = voucherClaims[0].voucher.channel.receiverAuthorizer; + + bytes32 claimsHash = _computeClaimsHash(voucherClaims); + bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(CLAIM_BATCH_TYPEHASH, claimsHash))); + if (!SignatureChecker.isValidSignatureNow(authorizer, digest, authorizerSignature)) { + revert InvalidSignature(); + } + + for (uint256 i = 0; i < voucherClaims.length; ++i) { + if (voucherClaims[i].voucher.channel.receiverAuthorizer != authorizer) { + revert NotReceiverAuthorizer(); + } + _processVoucherClaim(voucherClaims[i]); + } + } + + /// @notice Transfer all claimed-but-unsettled funds to the receiver. Permissionless. + function settle(address receiver, address token) external nonReentrant { + ReceiverState storage rs = receivers[receiver][token]; + uint128 amount = rs.totalClaimed - rs.totalSettled; + if (amount == 0) return; + + rs.totalSettled = rs.totalClaimed; + + IERC20(token).safeTransfer(receiver, amount); + + emit Settled(receiver, token, msg.sender, amount); + } + + // ========================================================================= + // Withdrawal Flow + // ========================================================================= + + /// @notice Start the withdrawal countdown. Only the payer can call. + function initiateWithdraw(ChannelConfig calldata config, uint128 amount) external { + if (msg.sender != config.payer) revert InvalidChannel(); + if (amount == 0) revert NothingToWithdraw(); + + bytes32 channelId = getChannelId(config); + + WithdrawalState storage ws = pendingWithdrawals[channelId]; + if (ws.initiatedAt != 0) revert WithdrawalAlreadyPending(); + + ws.amount = amount; + ws.initiatedAt = uint40(block.timestamp); + + uint40 finalizeAfter = uint40(block.timestamp) + config.withdrawDelay; + emit WithdrawInitiated(channelId, amount, finalizeAfter); + } + + /// @notice Finalize withdrawal after delay has elapsed. + /// Anyone can submit. + function finalizeWithdraw( + ChannelConfig calldata config + ) external nonReentrant { + bytes32 channelId = getChannelId(config); + WithdrawalState storage ws = pendingWithdrawals[channelId]; + + if (ws.initiatedAt == 0) revert WithdrawalNotPending(); + if (block.timestamp < uint256(ws.initiatedAt) + uint256(config.withdrawDelay)) { + revert WithdrawDelayNotElapsed(); + } + + ChannelState storage ch = channels[channelId]; + uint128 available = ch.balance - ch.totalClaimed; + uint128 withdrawAmount = ws.amount > available ? available : ws.amount; + + ws.amount = 0; + ws.initiatedAt = 0; + ch.balance -= withdrawAmount; + + emit WithdrawFinalized(channelId, withdrawAmount, msg.sender); + + if (withdrawAmount > 0) { + IERC20(config.token).safeTransfer(config.payer, withdrawAmount); + } + } + + /// @notice Instant refund called by the receiverAuthorizer. + function refund( + ChannelConfig calldata config + ) external nonReentrant { + if (msg.sender != config.receiverAuthorizer) { + revert NotReceiverAuthorizer(); + } + _executeRefund(config); + } + + /// @notice Instant refund. Anyone can submit with a signature authorized by the receiverAuthorizer. + function refundWithSignature( + ChannelConfig calldata config, + bytes calldata receiverAuthorizerSignature + ) external nonReentrant { + bytes32 channelId = getChannelId(config); + bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(REFUND_TYPEHASH, channelId))); + if (!SignatureChecker.isValidSignatureNow(config.receiverAuthorizer, digest, receiverAuthorizerSignature)) { + revert InvalidSignature(); + } + _executeRefund(config); + } + + // ========================================================================= + // View Functions + // ========================================================================= + + function getChannelId( + ChannelConfig calldata config + ) public pure returns (bytes32) { + return keccak256(abi.encode(config)); + } + + function getVoucherDigest(bytes32 channelId, uint128 maxClaimableAmount) external view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, maxClaimableAmount))); + } + + function getRefundDigest( + bytes32 channelId + ) external view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(REFUND_TYPEHASH, channelId))); + } + + function getClaimBatchDigest( + VoucherClaim[] calldata voucherClaims + ) external view returns (bytes32) { + bytes32 claimsHash = _computeClaimsHash(voucherClaims); + return _hashTypedDataV4(keccak256(abi.encode(CLAIM_BATCH_TYPEHASH, claimsHash))); + } + + // ========================================================================= + // Internal Helpers + // ========================================================================= + + function _validateConfig( + ChannelConfig calldata config + ) internal pure { + if (config.payer == address(0)) revert InvalidChannel(); + if (config.receiver == address(0)) revert InvalidChannel(); + if (config.receiverAuthorizer == address(0)) revert InvalidChannel(); + if (config.token == address(0)) revert InvalidChannel(); + if (config.withdrawDelay < MIN_WITHDRAW_DELAY || config.withdrawDelay > MAX_WITHDRAW_DELAY) { + revert WithdrawDelayOutOfRange(); + } + } + + function _processVoucherClaim( + VoucherClaim calldata vc + ) internal { + bytes32 channelId = getChannelId(vc.voucher.channel); + ChannelState storage ch = channels[channelId]; + + if (vc.totalClaimed <= ch.totalClaimed) return; + if (vc.totalClaimed > vc.voucher.maxClaimableAmount) revert ClaimExceedsCeiling(); + if (vc.totalClaimed > ch.balance) revert ClaimExceedsBalance(); + + bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, vc.voucher.maxClaimableAmount)); + bytes32 digest = _hashTypedDataV4(structHash); + + address payerAuth = vc.voucher.channel.payerAuthorizer; + if (payerAuth != address(0)) { + address recovered = ECDSA.recoverCalldata(digest, vc.signature); + if (recovered != payerAuth) revert InvalidSignature(); + } else { + if (!SignatureChecker.isValidSignatureNow(vc.voucher.channel.payer, digest, vc.signature)) { + revert InvalidSignature(); + } + } + + uint128 claimDelta = vc.totalClaimed - ch.totalClaimed; + ch.totalClaimed = vc.totalClaimed; + receivers[vc.voucher.channel.receiver][vc.voucher.channel.token].totalClaimed += claimDelta; + + emit Claimed(channelId, msg.sender, claimDelta, vc.totalClaimed); + } + + function _computeClaimsHash( + VoucherClaim[] calldata voucherClaims + ) internal pure returns (bytes32) { + bytes32[] memory hashes = new bytes32[](voucherClaims.length); + for (uint256 i = 0; i < voucherClaims.length; ++i) { + hashes[i] = keccak256( + abi.encode( + getChannelId(voucherClaims[i].voucher.channel), + voucherClaims[i].voucher.maxClaimableAmount, + voucherClaims[i].totalClaimed + ) + ); + } + return keccak256(abi.encodePacked(hashes)); + } + + function _executeRefund( + ChannelConfig calldata config + ) internal { + bytes32 channelId = getChannelId(config); + ChannelState storage ch = channels[channelId]; + uint128 refundAmount = ch.balance - ch.totalClaimed; + ch.balance = ch.totalClaimed; + + _clearPendingWithdrawal(channelId); + emit Refunded(channelId, msg.sender, refundAmount); + + if (refundAmount > 0) { + IERC20(config.token).safeTransfer(config.payer, refundAmount); + } + } + + function _clearPendingWithdrawal( + bytes32 channelId + ) internal { + WithdrawalState storage ws = pendingWithdrawals[channelId]; + if (ws.initiatedAt != 0) { + ws.amount = 0; + ws.initiatedAt = 0; + } + } +} diff --git a/contracts/evm/test/mocks/MockERC3009Token.sol b/contracts/evm/test/mocks/MockERC3009Token.sol new file mode 100644 index 0000000000..1127fd0695 --- /dev/null +++ b/contracts/evm/test/mocks/MockERC3009Token.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @dev Mock ERC-20 with EIP-3009 receiveWithAuthorization support for testing. + * Does not verify the signature — simply transfers `value` from `from` to `to`. + */ +contract MockERC3009Token is ERC20 { + uint8 private _decimals; + + constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) { + _decimals = decimals_; + } + + function decimals() public view override returns (uint8) { + return _decimals; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256, + uint256, + bytes32, + bytes memory + ) external { + _transfer(from, to, value); + } +} diff --git a/contracts/evm/test/periphery/ERC3009DepositCollector.t.sol b/contracts/evm/test/periphery/ERC3009DepositCollector.t.sol new file mode 100644 index 0000000000..d6e5d2e461 --- /dev/null +++ b/contracts/evm/test/periphery/ERC3009DepositCollector.t.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC3009DepositCollector} from "../../src/periphery/ERC3009DepositCollector.sol"; +import {DepositCollector} from "../../src/periphery/DepositCollector.sol"; +import {MockERC3009Token} from "../mocks/MockERC3009Token.sol"; + +contract ERC3009DepositCollectorTest is Test { + ERC3009DepositCollector public collector; + MockERC3009Token public token; + + address public payer; + + uint256 constant AMOUNT = 1000e6; + + function setUp() public { + collector = new ERC3009DepositCollector(address(this)); + token = new MockERC3009Token("USDC3009", "USDC3009", 6); + + payer = makeAddr("payer"); + + token.mint(payer, 100_000e6); + } + + function test_collect_success() public { + uint256 validAfter = 0; + uint256 validBefore = block.timestamp + 3600; + bytes32 nonce = bytes32(uint256(1)); + bytes memory signature = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)); + + bytes memory collectorData = abi.encode(validAfter, validBefore, nonce, signature); + + collector.collect(payer, address(token), AMOUNT, bytes32(0), address(this), collectorData); + + assertEq(token.balanceOf(address(this)), AMOUNT); + assertEq(token.balanceOf(payer), 100_000e6 - AMOUNT); + } + + function test_collect_transfersViaCollector() public { + bytes memory collectorData = abi.encode( + uint256(0), + uint256(block.timestamp + 3600), + bytes32(uint256(42)), + abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)) + ); + + uint256 collectorBalBefore = token.balanceOf(address(collector)); + collector.collect(payer, address(token), AMOUNT, bytes32(0), address(this), collectorData); + uint256 collectorBalAfter = token.balanceOf(address(collector)); + + assertEq(collectorBalAfter, collectorBalBefore); + assertEq(token.balanceOf(address(this)), AMOUNT); + } + + function test_collect_differentAmounts() public { + uint128 smallAmount = 1e6; + bytes memory collectorData = abi.encode( + uint256(0), + uint256(block.timestamp + 3600), + bytes32(uint256(100)), + abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)) + ); + + collector.collect(payer, address(token), smallAmount, bytes32(0), address(this), collectorData); + assertEq(token.balanceOf(address(this)), smallAmount); + } + + function test_collect_revert_onlySettlement() public { + bytes memory collectorData = + abi.encode(uint256(0), uint256(block.timestamp + 3600), bytes32(uint256(1)), hex"dead"); + + vm.prank(makeAddr("attacker")); + vm.expectRevert(DepositCollector.OnlySettlement.selector); + collector.collect(payer, address(token), AMOUNT, bytes32(0), address(this), collectorData); + } +} diff --git a/contracts/evm/test/periphery/Permit2DepositCollector.t.sol b/contracts/evm/test/periphery/Permit2DepositCollector.t.sol new file mode 100644 index 0000000000..d272041de1 --- /dev/null +++ b/contracts/evm/test/periphery/Permit2DepositCollector.t.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Permit2DepositCollector} from "../../src/periphery/Permit2DepositCollector.sol"; +import {Permit2DepositCollectorBase} from "../../src/periphery/Permit2DepositCollectorBase.sol"; +import {DepositCollector} from "../../src/periphery/DepositCollector.sol"; +import {ISignatureTransfer} from "../../src/interfaces/ISignatureTransfer.sol"; +import {MockPermit2} from "../mocks/MockPermit2.sol"; +import {MockERC20} from "../mocks/MockERC20.sol"; + +contract Permit2DepositCollectorTest is Test { + Permit2DepositCollector public collector; + MockPermit2 public mockPermit2; + MockERC20 public token; + + address public payer; + + uint256 constant AMOUNT = 1000e6; + + function setUp() public { + mockPermit2 = new MockPermit2(); + collector = new Permit2DepositCollector(address(this), address(mockPermit2)); + token = new MockERC20("USDC", "USDC", 6); + + payer = makeAddr("payer"); + + token.mint(payer, 100_000e6); + + vm.prank(payer); + token.approve(address(mockPermit2), type(uint256).max); + mockPermit2.setShouldActuallyTransfer(true); + } + + function test_constructor_setsPermit2() public view { + assertEq(address(collector.PERMIT2()), address(mockPermit2)); + } + + function test_constructor_revert_zeroPermit2() public { + vm.expectRevert(Permit2DepositCollectorBase.InvalidPermit2Address.selector); + new Permit2DepositCollector(address(this), address(0)); + } + + function test_witnessConstants() public view { + assertEq(collector.DEPOSIT_WITNESS_TYPEHASH(), keccak256("DepositWitness(bytes32 channelId)")); + assertEq( + keccak256(bytes(collector.DEPOSIT_WITNESS_TYPE_STRING())), + keccak256( + "DepositWitness witness)TokenPermissions(address token,uint256 amount)DepositWitness(bytes32 channelId)" + ) + ); + } + + function test_collect_success() public { + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(token), amount: AMOUNT}), + nonce: 0, + deadline: block.timestamp + 3600 + }); + bytes memory signature = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)); + bytes memory collectorData = abi.encode(permit, signature); + + bytes32 channelId = keccak256("test-channel"); + collector.collect(payer, address(token), AMOUNT, channelId, address(this), collectorData); + + assertEq(token.balanceOf(address(this)), AMOUNT); + assertEq(token.balanceOf(payer), 100_000e6 - AMOUNT); + } + + function test_collect_directTransfer_noHop() public { + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(token), amount: AMOUNT}), + nonce: 0, + deadline: block.timestamp + 3600 + }); + bytes memory signature = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)); + bytes memory collectorData = abi.encode(permit, signature); + + bytes32 channelId = keccak256("test-channel"); + collector.collect(payer, address(token), AMOUNT, channelId, address(this), collectorData); + + assertEq(token.balanceOf(address(collector)), 0); + } + + function test_collect_consumesNonce() public { + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(token), amount: AMOUNT}), + nonce: 42, + deadline: block.timestamp + 3600 + }); + bytes memory signature = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)); + bytes memory collectorData = abi.encode(permit, signature); + + uint256 bitmapBefore = mockPermit2.nonceBitmap(payer, 0); + assertEq(bitmapBefore, 0); + + collector.collect(payer, address(token), AMOUNT, keccak256("ch"), address(this), collectorData); + + uint256 bitmapAfter = mockPermit2.nonceBitmap(payer, 0); + assertGt(bitmapAfter, 0); + } + + function test_collect_revert_onlySettlement() public { + bytes memory collectorData = abi.encode( + ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(token), amount: AMOUNT}), + nonce: 0, + deadline: block.timestamp + 3600 + }), + hex"dead" + ); + + vm.prank(makeAddr("attacker")); + vm.expectRevert(DepositCollector.OnlySettlement.selector); + collector.collect(payer, address(token), AMOUNT, keccak256("ch"), address(this), collectorData); + } +} diff --git a/contracts/evm/test/periphery/Permit2WithERC2612DepositCollector.t.sol b/contracts/evm/test/periphery/Permit2WithERC2612DepositCollector.t.sol new file mode 100644 index 0000000000..483d5e2919 --- /dev/null +++ b/contracts/evm/test/periphery/Permit2WithERC2612DepositCollector.t.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Permit2WithERC2612DepositCollector} from "../../src/periphery/Permit2WithERC2612DepositCollector.sol"; +import {Permit2DepositCollectorBase} from "../../src/periphery/Permit2DepositCollectorBase.sol"; +import {DepositCollector} from "../../src/periphery/DepositCollector.sol"; +import {ISignatureTransfer} from "../../src/interfaces/ISignatureTransfer.sol"; +import {MockPermit2} from "../mocks/MockPermit2.sol"; +import {MockERC20Permit} from "../mocks/MockERC20Permit.sol"; + +contract Permit2WithERC2612DepositCollectorTest is Test { + Permit2WithERC2612DepositCollector public collector; + MockPermit2 public mockPermit2; + MockERC20Permit public token; + + address public payer; + + uint256 constant AMOUNT = 1000e6; + + event EIP2612PermitFailedWithReason(address indexed token, address indexed owner, string reason); + event EIP2612PermitFailedWithPanic(address indexed token, address indexed owner, uint256 errorCode); + event EIP2612PermitFailedWithData(address indexed token, address indexed owner, bytes data); + + function setUp() public { + mockPermit2 = new MockPermit2(); + collector = new Permit2WithERC2612DepositCollector(address(this), address(mockPermit2)); + token = new MockERC20Permit("PermitUSDC", "pUSDC", 6); + + payer = makeAddr("payer"); + + token.mint(payer, 100_000e6); + + vm.prank(payer); + token.approve(address(mockPermit2), type(uint256).max); + mockPermit2.setShouldActuallyTransfer(true); + } + + function _makeCollectorData( + uint256 amount + ) internal view returns (bytes memory) { + Permit2WithERC2612DepositCollector.EIP2612Permit memory permit2612 = Permit2WithERC2612DepositCollector + .EIP2612Permit({value: amount, deadline: block.timestamp + 3600, r: bytes32(0), s: bytes32(0), v: 27}); + + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(token), amount: amount}), + nonce: 0, + deadline: block.timestamp + 3600 + }); + + bytes memory signature = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)); + return abi.encode(permit2612, permit, signature); + } + + function test_constructor_setsPermit2() public view { + assertEq(address(collector.PERMIT2()), address(mockPermit2)); + } + + function test_constructor_revert_zeroPermit2() public { + vm.expectRevert(Permit2DepositCollectorBase.InvalidPermit2Address.selector); + new Permit2WithERC2612DepositCollector(address(this), address(0)); + } + + function test_collect_success() public { + bytes memory collectorData = _makeCollectorData(AMOUNT); + bytes32 channelId = keccak256("test-channel"); + + collector.collect(payer, address(token), AMOUNT, channelId, address(this), collectorData); + + assertEq(token.balanceOf(address(this)), AMOUNT); + assertEq(token.balanceOf(payer), 100_000e6 - AMOUNT); + } + + function test_collect_softFail_revertWithReason() public { + token.setPermitRevert(true, "ERC20Permit: invalid signature"); + + bytes memory collectorData = _makeCollectorData(AMOUNT); + bytes32 channelId = keccak256("test-channel"); + + vm.expectEmit(true, true, false, true); + emit EIP2612PermitFailedWithReason(address(token), payer, "ERC20Permit: invalid signature"); + + collector.collect(payer, address(token), AMOUNT, channelId, address(this), collectorData); + + assertEq(token.balanceOf(address(this)), AMOUNT); + } + + function test_collect_softFail_panic() public { + token.setRevertMode(MockERC20Permit.RevertMode.Panic); + + bytes memory collectorData = _makeCollectorData(AMOUNT); + bytes32 channelId = keccak256("test-channel"); + + vm.expectEmit(true, true, false, true); + emit EIP2612PermitFailedWithPanic(address(token), payer, 0x12); + + collector.collect(payer, address(token), AMOUNT, channelId, address(this), collectorData); + + assertEq(token.balanceOf(address(this)), AMOUNT); + } + + function test_collect_softFail_customError() public { + token.setRevertMode(MockERC20Permit.RevertMode.CustomError); + + bytes memory collectorData = _makeCollectorData(AMOUNT); + bytes32 channelId = keccak256("test-channel"); + + collector.collect(payer, address(token), AMOUNT, channelId, address(this), collectorData); + + assertEq(token.balanceOf(address(this)), AMOUNT); + } + + function test_collect_revert_amountMismatch() public { + Permit2WithERC2612DepositCollector.EIP2612Permit memory permit2612 = Permit2WithERC2612DepositCollector + .EIP2612Permit({value: AMOUNT + 1, deadline: block.timestamp + 3600, r: bytes32(0), s: bytes32(0), v: 27}); + + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(token), amount: AMOUNT}), + nonce: 0, + deadline: block.timestamp + 3600 + }); + + bytes memory signature = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), uint8(27)); + bytes memory collectorData = abi.encode(permit2612, permit, signature); + + vm.expectRevert(Permit2WithERC2612DepositCollector.Permit2612AmountMismatch.selector); + collector.collect(payer, address(token), AMOUNT, keccak256("ch"), address(this), collectorData); + } + + function test_collect_revert_onlySettlement() public { + bytes memory collectorData = _makeCollectorData(AMOUNT); + + vm.prank(makeAddr("attacker")); + vm.expectRevert(DepositCollector.OnlySettlement.selector); + collector.collect(payer, address(token), AMOUNT, keccak256("ch"), address(this), collectorData); + } +} diff --git a/contracts/evm/test/x402BatchSettlement.fork.t.sol b/contracts/evm/test/x402BatchSettlement.fork.t.sol new file mode 100644 index 0000000000..9e89f89014 --- /dev/null +++ b/contracts/evm/test/x402BatchSettlement.fork.t.sol @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {x402BatchSettlement} from "../src/x402BatchSettlement.sol"; +import {Permit2DepositCollector} from "../src/periphery/Permit2DepositCollector.sol"; +import {ISignatureTransfer} from "../src/interfaces/ISignatureTransfer.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +/// @title X402BatchSettlementForkTest +/// @notice Fork tests against real Permit2 deployment for the dual-authorizer channel model +/// @dev Run with: forge test --match-contract X402BatchSettlementForkTest --fork-url $RPC_URL +contract X402BatchSettlementForkTest is Test { + address constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + bytes32 constant PERMIT2_DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"); + bytes32 constant PERMIT_WITNESS_TYPEHASH = keccak256( + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,DepositWitness witness)TokenPermissions(address token,uint256 amount)DepositWitness(bytes32 channelId)" + ); + bytes32 constant TOKEN_PERMISSIONS_TYPEHASH = keccak256("TokenPermissions(address token,uint256 amount)"); + bytes32 constant DEPOSIT_WITNESS_TYPEHASH = keccak256("DepositWitness(bytes32 channelId)"); + + x402BatchSettlement public settlement; + Permit2DepositCollector public permit2Collector; + MockERC20 public token; + + uint256 public payerKey; + address public payer; + uint256 public payerAuthKey; + address public payerAuthAddr; + uint256 public receiverKey; + address public receiverAddr; + uint256 public receiverAuthKey; + address public receiverAuthAddr; + + uint40 constant WITHDRAW_DELAY = 3600; + uint128 constant DEPOSIT_AMOUNT = 1000e6; + uint128 constant CLAIM_AMOUNT = 100e6; + + function setUp() public { + if (block.chainid == 31_337) return; + require(PERMIT2.code.length > 0, "Permit2 not deployed"); + + payerKey = uint256(keccak256("x402-batch-test-payer")); + payer = vm.addr(payerKey); + payerAuthKey = uint256(keccak256("x402-batch-test-payerAuth")); + payerAuthAddr = vm.addr(payerAuthKey); + receiverKey = uint256(keccak256("x402-batch-test-receiver")); + receiverAddr = vm.addr(receiverKey); + receiverAuthKey = uint256(keccak256("x402-batch-test-receiverAuth")); + receiverAuthAddr = vm.addr(receiverAuthKey); + + settlement = new x402BatchSettlement(); + permit2Collector = new Permit2DepositCollector(address(settlement), PERMIT2); + token = new MockERC20("USDC", "USDC", 6); + token.mint(payer, 100_000e6); + + vm.prank(payer); + token.approve(PERMIT2, type(uint256).max); + } + + modifier onlyFork() { + if (block.chainid == 31_337) return; + _; + } + + // ========================================================================= + // Helpers + // ========================================================================= + + function _makeConfig() internal view returns (x402BatchSettlement.ChannelConfig memory) { + return x402BatchSettlement.ChannelConfig({ + payer: payer, + payerAuthorizer: payerAuthAddr, + receiver: receiverAddr, + receiverAuthorizer: receiverAuthAddr, + token: address(token), + withdrawDelay: WITHDRAW_DELAY, + salt: bytes32(0) + }); + } + + function _channelId( + x402BatchSettlement.ChannelConfig memory config + ) internal pure returns (bytes32) { + return keccak256(abi.encode(config)); + } + + function _getChannel( + bytes32 id + ) internal view returns (x402BatchSettlement.ChannelState memory ch) { + (ch.balance, ch.totalClaimed) = settlement.channels(id); + } + + function _permit2DomainSeparator() internal view returns (bytes32) { + return keccak256(abi.encode(PERMIT2_DOMAIN_TYPEHASH, keccak256("Permit2"), block.chainid, PERMIT2)); + } + + function _nonce( + uint256 salt + ) internal view returns (uint256) { + return uint256(keccak256(abi.encodePacked(block.timestamp, block.number, salt))); + } + + function _signPermit2Deposit( + x402BatchSettlement.ChannelConfig memory config, + uint256 amount, + uint256 nonce, + uint256 deadline + ) internal view returns (bytes memory) { + bytes32 channelId = _channelId(config); + bytes32 witnessHash = keccak256(abi.encode(DEPOSIT_WITNESS_TYPEHASH, channelId)); + bytes32 tokenHash = keccak256(abi.encode(TOKEN_PERMISSIONS_TYPEHASH, address(token), amount)); + bytes32 structHash = keccak256( + abi.encode(PERMIT_WITNESS_TYPEHASH, tokenHash, address(permit2Collector), nonce, deadline, witnessHash) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _permit2DomainSeparator(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(payerKey, digest); + return abi.encodePacked(r, s, v); + } + + function _domainSeparator() internal view returns (bytes32) { + (, string memory name, string memory version, uint256 chainId, address verifyingContract,,) = + settlement.eip712Domain(); + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(name)), + keccak256(bytes(version)), + chainId, + verifyingContract + ) + ); + } + + function _signVoucher(bytes32 channelId, uint128 maxClaimableAmount) internal view returns (bytes memory) { + bytes32 structHash = keccak256(abi.encode(settlement.VOUCHER_TYPEHASH(), channelId, maxClaimableAmount)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(payerAuthKey, digest); + return abi.encodePacked(r, s, v); + } + + function _signRefund( + bytes32 channelId + ) internal view returns (bytes memory) { + bytes32 structHash = keccak256(abi.encode(settlement.REFUND_TYPEHASH(), channelId)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(receiverAuthKey, digest); + return abi.encodePacked(r, s, v); + } + + function _depositViaPermit2( + x402BatchSettlement.ChannelConfig memory config, + uint128 amount, + uint256 nonce, + uint256 deadline + ) internal { + bytes memory depositSig = _signPermit2Deposit(config, amount, nonce, deadline); + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(token), amount: amount}), + nonce: nonce, + deadline: deadline + }); + bytes memory collectorData = abi.encode(permit, depositSig); + settlement.deposit(config, amount, address(permit2Collector), collectorData); + } + + // ========================================================================= + // Fork Tests + // ========================================================================= + + function test_fork_fullLifecycle_permit2() public onlyFork { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + + uint256 nonce = _nonce(0); + uint256 deadline = block.timestamp + 3600; + _depositViaPermit2(config, DEPOSIT_AMOUNT, nonce, deadline); + + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.balance, DEPOSIT_AMOUNT); + + bytes memory voucherSig = _signVoucher(channelId, CLAIM_AMOUNT); + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = x402BatchSettlement.VoucherClaim({ + voucher: x402BatchSettlement.Voucher({channel: config, maxClaimableAmount: CLAIM_AMOUNT}), + signature: voucherSig, + totalClaimed: CLAIM_AMOUNT + }); + vm.prank(receiverAuthAddr); + settlement.claim(claims); + + uint256 balBefore = token.balanceOf(receiverAddr); + settlement.settle(receiverAddr, address(token)); + assertEq(token.balanceOf(receiverAddr), balBefore + CLAIM_AMOUNT); + } + + function test_fork_refund_afterPartialClaim() public onlyFork { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + + uint256 nonce = _nonce(0); + uint256 deadline = block.timestamp + 3600; + _depositViaPermit2(config, DEPOSIT_AMOUNT, nonce, deadline); + + bytes memory voucherSig = _signVoucher(channelId, CLAIM_AMOUNT); + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = x402BatchSettlement.VoucherClaim({ + voucher: x402BatchSettlement.Voucher({channel: config, maxClaimableAmount: CLAIM_AMOUNT}), + signature: voucherSig, + totalClaimed: CLAIM_AMOUNT + }); + vm.prank(receiverAuthAddr); + settlement.claim(claims); + + bytes memory refundSig = _signRefund(channelId); + uint256 payerBalBefore = token.balanceOf(payer); + settlement.refundWithSignature(config, refundSig); + + assertEq(token.balanceOf(payer), payerBalBefore + (DEPOSIT_AMOUNT - CLAIM_AMOUNT)); + } + + function test_fork_tamperedWitness_reverts() public onlyFork { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + + x402BatchSettlement.ChannelConfig memory tamperedConfig = _makeConfig(); + tamperedConfig.salt = bytes32(uint256(999)); + + uint256 nonce = _nonce(0); + uint256 deadline = block.timestamp + 3600; + bytes memory depositSig = _signPermit2Deposit(config, DEPOSIT_AMOUNT, nonce, deadline); + + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(token), amount: DEPOSIT_AMOUNT}), + nonce: nonce, + deadline: deadline + }); + bytes memory collectorData = abi.encode(permit, depositSig); + + vm.expectRevert(); + settlement.deposit(tamperedConfig, DEPOSIT_AMOUNT, address(permit2Collector), collectorData); + } +} diff --git a/contracts/evm/test/x402BatchSettlement.t.sol b/contracts/evm/test/x402BatchSettlement.t.sol new file mode 100644 index 0000000000..ef226fa424 --- /dev/null +++ b/contracts/evm/test/x402BatchSettlement.t.sol @@ -0,0 +1,1250 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {VmSafe} from "forge-std/Vm.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {x402BatchSettlement} from "../src/x402BatchSettlement.sol"; +import {IDepositCollector} from "../src/interfaces/IDepositCollector.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract MockDepositCollector is IDepositCollector { + function collect( + address payer, + address token, + uint256 amount, + bytes32, + address, + bytes calldata + ) external override { + IERC20(token).transferFrom(payer, msg.sender, amount); + } +} + +contract MockShortCollector is IDepositCollector { + function collect( + address payer, + address token, + uint256 amount, + bytes32, + address, + bytes calldata + ) external override { + IERC20(token).transferFrom(payer, msg.sender, amount / 2); + } +} + +contract X402BatchSettlementTest is Test { + x402BatchSettlement public settlement; + MockDepositCollector public mockCollector; + MockShortCollector public shortCollector; + MockERC20 public token; + + VmSafe.Wallet public payerWallet; + VmSafe.Wallet public payerAuthWallet; + VmSafe.Wallet public receiverWallet; + VmSafe.Wallet public receiverAuthWallet; + VmSafe.Wallet public otherWallet; + + uint40 constant WITHDRAW_DELAY = 3600; // 1 hour + uint128 constant DEPOSIT_AMOUNT = 1000e6; + uint128 constant CLAIM_AMOUNT = 100e6; + + event ChannelCreated(bytes32 indexed channelId, x402BatchSettlement.ChannelConfig config); + event Deposited(bytes32 indexed channelId, address indexed sender, uint128 amount, uint128 newBalance); + event Claimed(bytes32 indexed channelId, address indexed sender, uint128 claimAmount, uint128 newTotalClaimed); + event Settled(address indexed receiver, address indexed token, address indexed sender, uint128 amount); + event Refunded(bytes32 indexed channelId, address indexed sender, uint128 amount); + event WithdrawInitiated(bytes32 indexed channelId, uint128 amount, uint40 finalizeAfter); + event WithdrawFinalized(bytes32 indexed channelId, uint128 amount, address sender); + + function setUp() public { + vm.warp(1_000_000); + + payerWallet = vm.createWallet("payer"); + payerAuthWallet = vm.createWallet("payerAuth"); + receiverWallet = vm.createWallet("receiver"); + receiverAuthWallet = vm.createWallet("receiverAuth"); + otherWallet = vm.createWallet("other"); + + settlement = new x402BatchSettlement(); + + mockCollector = new MockDepositCollector(); + shortCollector = new MockShortCollector(); + + token = new MockERC20("USDC", "USDC", 6); + token.mint(payerWallet.addr, 100_000e6); + + vm.prank(payerWallet.addr); + token.approve(address(mockCollector), type(uint256).max); + vm.prank(payerWallet.addr); + token.approve(address(shortCollector), type(uint256).max); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + function _makeConfig() internal view returns (x402BatchSettlement.ChannelConfig memory) { + return x402BatchSettlement.ChannelConfig({ + payer: payerWallet.addr, + payerAuthorizer: payerAuthWallet.addr, + receiver: receiverWallet.addr, + receiverAuthorizer: receiverAuthWallet.addr, + token: address(token), + withdrawDelay: WITHDRAW_DELAY, + salt: bytes32(0) + }); + } + + function _makeStatefulConfig() internal view returns (x402BatchSettlement.ChannelConfig memory) { + return x402BatchSettlement.ChannelConfig({ + payer: payerWallet.addr, + payerAuthorizer: address(0), + receiver: receiverWallet.addr, + receiverAuthorizer: receiverAuthWallet.addr, + token: address(token), + withdrawDelay: WITHDRAW_DELAY, + salt: bytes32(uint256(42)) + }); + } + + function _channelId( + x402BatchSettlement.ChannelConfig memory config + ) internal pure returns (bytes32) { + return keccak256(abi.encode(config)); + } + + function _domainSeparator() internal view returns (bytes32) { + (, string memory name, string memory version, uint256 chainId, address verifyingContract,,) = + settlement.eip712Domain(); + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(name)), + keccak256(bytes(version)), + chainId, + verifyingContract + ) + ); + } + + function _signTypedData(VmSafe.Wallet memory wallet, bytes32 structHash) internal returns (bytes memory) { + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet, digest); + return abi.encodePacked(r, s, v); + } + + function _signVoucher( + VmSafe.Wallet memory wallet, + bytes32 channelId, + uint128 maxClaimableAmount + ) internal returns (bytes memory) { + bytes32 structHash = keccak256(abi.encode(settlement.VOUCHER_TYPEHASH(), channelId, maxClaimableAmount)); + return _signTypedData(wallet, structHash); + } + + function _signRefund(VmSafe.Wallet memory wallet, bytes32 channelId) internal returns (bytes memory) { + bytes32 structHash = keccak256(abi.encode(settlement.REFUND_TYPEHASH(), channelId)); + return _signTypedData(wallet, structHash); + } + + function _signClaimBatch( + VmSafe.Wallet memory wallet, + x402BatchSettlement.VoucherClaim[] memory claims + ) internal returns (bytes memory) { + bytes32 claimsHash = _computeClaimsHashMemory(claims); + bytes32 structHash = keccak256(abi.encode(settlement.CLAIM_BATCH_TYPEHASH(), claimsHash)); + return _signTypedData(wallet, structHash); + } + + function _computeClaimsHashMemory( + x402BatchSettlement.VoucherClaim[] memory claims + ) internal pure returns (bytes32) { + bytes32[] memory hashes = new bytes32[](claims.length); + for (uint256 i = 0; i < claims.length; ++i) { + hashes[i] = keccak256( + abi.encode( + keccak256(abi.encode(claims[i].voucher.channel)), + claims[i].voucher.maxClaimableAmount, + claims[i].totalClaimed + ) + ); + } + return keccak256(abi.encodePacked(hashes)); + } + + function _deposit(x402BatchSettlement.ChannelConfig memory config, uint128 amount) internal { + settlement.deposit(config, amount, address(mockCollector), ""); + } + + function _getChannel( + bytes32 id + ) internal view returns (x402BatchSettlement.ChannelState memory ch) { + (ch.balance, ch.totalClaimed) = settlement.channels(id); + } + + function _getPendingWithdrawal( + bytes32 id + ) internal view returns (x402BatchSettlement.WithdrawalState memory ws) { + (ws.amount, ws.initiatedAt) = settlement.pendingWithdrawals(id); + } + + function _getReceiver( + address receiver, + address tkn + ) internal view returns (x402BatchSettlement.ReceiverState memory rs) { + (rs.totalClaimed, rs.totalSettled) = settlement.receivers(receiver, tkn); + } + + function _makeVoucherClaim( + x402BatchSettlement.ChannelConfig memory config, + uint128 maxClaimableAmount, + uint128 claimAmount + ) internal returns (x402BatchSettlement.VoucherClaim memory) { + bytes32 channelId = _channelId(config); + bytes memory sig = _signVoucher(payerAuthWallet, channelId, maxClaimableAmount); + return x402BatchSettlement.VoucherClaim({ + voucher: x402BatchSettlement.Voucher({channel: config, maxClaimableAmount: maxClaimableAmount}), + signature: sig, + totalClaimed: claimAmount + }); + } + + function _makeVoucherClaimWithSigner( + x402BatchSettlement.ChannelConfig memory config, + uint128 maxClaimableAmount, + uint128 claimAmount, + VmSafe.Wallet memory signer + ) internal returns (x402BatchSettlement.VoucherClaim memory) { + bytes32 channelId = _channelId(config); + bytes memory sig = _signVoucher(signer, channelId, maxClaimableAmount); + return x402BatchSettlement.VoucherClaim({ + voucher: x402BatchSettlement.Voucher({channel: config, maxClaimableAmount: maxClaimableAmount}), + signature: sig, + totalClaimed: claimAmount + }); + } + + // ========================================================================= + // Constructor Tests + // ========================================================================= + + function test_constructor_deploysSuccessfully() public view { + assertTrue(address(settlement) != address(0)); + } + + // ========================================================================= + // Deposit Tests + // ========================================================================= + + function test_deposit_success() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + + vm.expectEmit(true, false, false, true); + emit ChannelCreated(channelId, config); + vm.expectEmit(true, true, false, true); + emit Deposited(channelId, address(this), DEPOSIT_AMOUNT, DEPOSIT_AMOUNT); + + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.balance, DEPOSIT_AMOUNT); + assertEq(ch.totalClaimed, 0); + } + + function test_deposit_topUp() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + + _deposit(config, DEPOSIT_AMOUNT); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.balance, DEPOSIT_AMOUNT * 2); + } + + function test_deposit_revert_zeroAmount() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + vm.expectRevert(x402BatchSettlement.ZeroDeposit.selector); + settlement.deposit(config, 0, address(mockCollector), ""); + } + + function test_deposit_revert_zeroCollector() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + vm.expectRevert(x402BatchSettlement.InvalidCollector.selector); + settlement.deposit(config, DEPOSIT_AMOUNT, address(0), ""); + } + + function test_deposit_revert_collectionFailed() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + vm.expectRevert(x402BatchSettlement.DepositCollectionFailed.selector); + settlement.deposit(config, DEPOSIT_AMOUNT, address(shortCollector), ""); + } + + function test_deposit_revert_withdrawDelayTooLow() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + config.withdrawDelay = 1; + vm.expectRevert(x402BatchSettlement.WithdrawDelayOutOfRange.selector); + settlement.deposit(config, DEPOSIT_AMOUNT, address(mockCollector), ""); + } + + function test_deposit_revert_withdrawDelayTooHigh() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + config.withdrawDelay = uint40(31 days); + vm.expectRevert(x402BatchSettlement.WithdrawDelayOutOfRange.selector); + settlement.deposit(config, DEPOSIT_AMOUNT, address(mockCollector), ""); + } + + function test_deposit_revert_zeroReceiver() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + config.receiver = address(0); + vm.expectRevert(x402BatchSettlement.InvalidChannel.selector); + settlement.deposit(config, DEPOSIT_AMOUNT, address(mockCollector), ""); + } + + function test_deposit_revert_zeroReceiverAuthorizer() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + config.receiverAuthorizer = address(0); + vm.expectRevert(x402BatchSettlement.InvalidChannel.selector); + settlement.deposit(config, DEPOSIT_AMOUNT, address(mockCollector), ""); + } + + function test_deposit_revert_zeroToken() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + config.token = address(0); + vm.expectRevert(x402BatchSettlement.InvalidChannel.selector); + settlement.deposit(config, DEPOSIT_AMOUNT, address(mockCollector), ""); + } + + function test_deposit_revert_zeroPayer() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + config.payer = address(0); + vm.expectRevert(x402BatchSettlement.InvalidChannel.selector); + settlement.deposit(config, DEPOSIT_AMOUNT, address(mockCollector), ""); + } + + function test_deposit_revert_overflow() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + token.mint(payerWallet.addr, type(uint128).max); + _deposit(config, type(uint128).max); + + vm.expectRevert(x402BatchSettlement.DepositOverflow.selector); + settlement.deposit(config, 1, address(mockCollector), ""); + } + + function test_deposit_cancelsPendingWithdrawal() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.WithdrawalState memory ws = _getPendingWithdrawal(channelId); + assertGt(ws.initiatedAt, 0); + + _deposit(config, DEPOSIT_AMOUNT); + + ws = _getPendingWithdrawal(channelId); + assertEq(ws.initiatedAt, 0); + assertEq(ws.amount, 0); + } + + // ========================================================================= + // Claim Tests (direct call) + // ========================================================================= + + function test_claim_single() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT); + + vm.expectEmit(true, true, false, true); + emit Claimed(channelId, receiverAuthWallet.addr, CLAIM_AMOUNT, CLAIM_AMOUNT); + + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.totalClaimed, CLAIM_AMOUNT); + + x402BatchSettlement.ReceiverState memory rs = _getReceiver(receiverWallet.addr, address(token)); + assertEq(rs.totalClaimed, CLAIM_AMOUNT); + } + + function test_claim_batch() public { + x402BatchSettlement.ChannelConfig memory config1 = _makeConfig(); + x402BatchSettlement.ChannelConfig memory config2 = _makeConfig(); + config2.salt = bytes32(uint256(1)); + + _deposit(config1, DEPOSIT_AMOUNT); + _deposit(config2, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](2); + claims[0] = _makeVoucherClaim(config1, CLAIM_AMOUNT, CLAIM_AMOUNT); + claims[1] = _makeVoucherClaim(config2, CLAIM_AMOUNT * 2, CLAIM_AMOUNT * 2); + + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + x402BatchSettlement.ReceiverState memory rs = _getReceiver(receiverWallet.addr, address(token)); + assertEq(rs.totalClaimed, CLAIM_AMOUNT * 3); + } + + function test_claim_cumulative() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, 200e6, 100e6); + + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + claims[0] = _makeVoucherClaim(config, 200e6, 200e6); + + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + bytes32 channelId = _channelId(config); + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.totalClaimed, 200e6); + } + + function test_claim_revert_notReceiverAuthorizer() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT); + + vm.prank(otherWallet.addr); + vm.expectRevert(x402BatchSettlement.NotReceiverAuthorizer.selector); + settlement.claim(claims); + } + + function test_claim_revert_exceedsCeiling() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT + 1); + + vm.prank(receiverAuthWallet.addr); + vm.expectRevert(x402BatchSettlement.ClaimExceedsCeiling.selector); + settlement.claim(claims); + } + + function test_claim_revert_exceedsBalance() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, CLAIM_AMOUNT / 2); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT); + + vm.prank(receiverAuthWallet.addr); + vm.expectRevert(x402BatchSettlement.ClaimExceedsBalance.selector); + settlement.claim(claims); + } + + function test_claim_revert_wrongSigner() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaimWithSigner(config, CLAIM_AMOUNT, CLAIM_AMOUNT, otherWallet); + + vm.prank(receiverAuthWallet.addr); + vm.expectRevert(x402BatchSettlement.InvalidSignature.selector); + settlement.claim(claims); + } + + function test_claim_revert_malformedSignature() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = x402BatchSettlement.VoucherClaim({ + voucher: x402BatchSettlement.Voucher({channel: config, maxClaimableAmount: CLAIM_AMOUNT}), + signature: hex"0000", + totalClaimed: CLAIM_AMOUNT + }); + + vm.prank(receiverAuthWallet.addr); + vm.expectRevert(); + settlement.claim(claims); + } + + // ========================================================================= + // Claim Tests — Stateful (payerAuthorizer == address(0), EIP-1271 path) + // ========================================================================= + + function test_claim_statefulMode_payerSigns() public { + x402BatchSettlement.ChannelConfig memory config = _makeStatefulConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + bytes32 channelId = _channelId(config); + bytes memory sig = _signVoucher(payerWallet, channelId, CLAIM_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = x402BatchSettlement.VoucherClaim({ + voucher: x402BatchSettlement.Voucher({channel: config, maxClaimableAmount: CLAIM_AMOUNT}), + signature: sig, + totalClaimed: CLAIM_AMOUNT + }); + + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.totalClaimed, CLAIM_AMOUNT); + } + + function test_claim_statefulMode_revert_wrongSigner() public { + x402BatchSettlement.ChannelConfig memory config = _makeStatefulConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + bytes32 channelId = _channelId(config); + bytes memory sig = _signVoucher(otherWallet, channelId, CLAIM_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = x402BatchSettlement.VoucherClaim({ + voucher: x402BatchSettlement.Voucher({channel: config, maxClaimableAmount: CLAIM_AMOUNT}), + signature: sig, + totalClaimed: CLAIM_AMOUNT + }); + + vm.prank(receiverAuthWallet.addr); + vm.expectRevert(x402BatchSettlement.InvalidSignature.selector); + settlement.claim(claims); + } + + // ========================================================================= + // claimWithSignature Tests + // ========================================================================= + + function test_claimWithSignature_success() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT); + + bytes memory authSig = _signClaimBatch(receiverAuthWallet, claims); + + vm.prank(otherWallet.addr); + settlement.claimWithSignature(claims, authSig); + + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.totalClaimed, CLAIM_AMOUNT); + } + + function test_claimWithSignature_revert_emptyClaims() public { + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](0); + bytes memory sig = hex"dead"; + + vm.expectRevert(x402BatchSettlement.EmptyBatch.selector); + settlement.claimWithSignature(claims, sig); + } + + function test_claim_revert_emptyBatch() public { + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](0); + + vm.prank(receiverAuthWallet.addr); + vm.expectRevert(x402BatchSettlement.EmptyBatch.selector); + settlement.claim(claims); + } + + function test_claimWithSignature_revert_wrongAuthorizerSignature() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT); + + bytes memory badSig = _signClaimBatch(otherWallet, claims); + + vm.expectRevert(x402BatchSettlement.InvalidSignature.selector); + settlement.claimWithSignature(claims, badSig); + } + + function test_claimWithSignature_revert_mixedReceiverAuthorizers() public { + x402BatchSettlement.ChannelConfig memory config1 = _makeConfig(); + x402BatchSettlement.ChannelConfig memory config2 = _makeConfig(); + config2.receiverAuthorizer = otherWallet.addr; + config2.salt = bytes32(uint256(2)); + + _deposit(config1, DEPOSIT_AMOUNT); + _deposit(config2, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](2); + claims[0] = _makeVoucherClaim(config1, CLAIM_AMOUNT, CLAIM_AMOUNT); + claims[1] = _makeVoucherClaim(config2, CLAIM_AMOUNT, CLAIM_AMOUNT); + + bytes memory authSig = _signClaimBatch(receiverAuthWallet, claims); + + vm.expectRevert(x402BatchSettlement.NotReceiverAuthorizer.selector); + settlement.claimWithSignature(claims, authSig); + } + + // ========================================================================= + // Settle Tests + // ========================================================================= + + function test_settle_success() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT); + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + uint256 balBefore = token.balanceOf(receiverWallet.addr); + + vm.expectEmit(true, true, true, true); + emit Settled(receiverWallet.addr, address(token), address(this), CLAIM_AMOUNT); + settlement.settle(receiverWallet.addr, address(token)); + + assertEq(token.balanceOf(receiverWallet.addr), balBefore + CLAIM_AMOUNT); + + x402BatchSettlement.ReceiverState memory rs = _getReceiver(receiverWallet.addr, address(token)); + assertEq(rs.totalSettled, CLAIM_AMOUNT); + } + + function test_settle_sweepsAcrossChannels() public { + x402BatchSettlement.ChannelConfig memory config1 = _makeConfig(); + x402BatchSettlement.ChannelConfig memory config2 = _makeConfig(); + config2.salt = bytes32(uint256(1)); + + _deposit(config1, DEPOSIT_AMOUNT); + _deposit(config2, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory v1 = new x402BatchSettlement.VoucherClaim[](1); + v1[0] = _makeVoucherClaim(config1, CLAIM_AMOUNT, CLAIM_AMOUNT); + vm.prank(receiverAuthWallet.addr); + settlement.claim(v1); + + x402BatchSettlement.VoucherClaim[] memory v2 = new x402BatchSettlement.VoucherClaim[](1); + v2[0] = _makeVoucherClaim(config2, CLAIM_AMOUNT * 2, CLAIM_AMOUNT * 2); + vm.prank(receiverAuthWallet.addr); + settlement.claim(v2); + + uint256 balBefore = token.balanceOf(receiverWallet.addr); + settlement.settle(receiverWallet.addr, address(token)); + assertEq(token.balanceOf(receiverWallet.addr), balBefore + CLAIM_AMOUNT * 3); + } + + function test_settle_idempotent_nothingToSettle() public { + uint256 balBefore = token.balanceOf(receiverWallet.addr); + settlement.settle(receiverWallet.addr, address(token)); + assertEq(token.balanceOf(receiverWallet.addr), balBefore); + } + + // ========================================================================= + // Timed Withdrawal Tests + // ========================================================================= + + function test_initiateWithdraw_success() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + vm.expectEmit(true, false, false, true); + emit WithdrawInitiated(channelId, DEPOSIT_AMOUNT, uint40(block.timestamp) + WITHDRAW_DELAY); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.WithdrawalState memory ws = _getPendingWithdrawal(channelId); + assertEq(ws.amount, DEPOSIT_AMOUNT); + assertEq(ws.initiatedAt, uint40(block.timestamp)); + } + + function test_initiateWithdraw_revert_notPayer() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(otherWallet.addr); + vm.expectRevert(x402BatchSettlement.InvalidChannel.selector); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + } + + function test_initiateWithdraw_revert_zeroAmount() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + vm.prank(payerWallet.addr); + vm.expectRevert(x402BatchSettlement.NothingToWithdraw.selector); + settlement.initiateWithdraw(config, 0); + } + + function test_initiateWithdraw_revert_alreadyPending() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + vm.expectRevert(x402BatchSettlement.WithdrawalAlreadyPending.selector); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + } + + function test_finalizeWithdraw_success() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + + vm.warp(block.timestamp + WITHDRAW_DELAY + 1); + + uint256 balBefore = token.balanceOf(payerWallet.addr); + + vm.expectEmit(true, false, false, true); + emit WithdrawFinalized(channelId, DEPOSIT_AMOUNT, payerWallet.addr); + + vm.prank(payerWallet.addr); + settlement.finalizeWithdraw(config); + + assertEq(token.balanceOf(payerWallet.addr), balBefore + DEPOSIT_AMOUNT); + } + + function test_finalizeWithdraw_permissionless() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + + vm.warp(block.timestamp + WITHDRAW_DELAY + 1); + + vm.prank(otherWallet.addr); + settlement.finalizeWithdraw(config); + } + + function test_finalizeWithdraw_revert_notPending() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + vm.expectRevert(x402BatchSettlement.WithdrawalNotPending.selector); + settlement.finalizeWithdraw(config); + } + + function test_finalizeWithdraw_revert_delayNotElapsed() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + + vm.expectRevert(x402BatchSettlement.WithdrawDelayNotElapsed.selector); + settlement.finalizeWithdraw(config); + } + + function test_finalizeWithdraw_zeroAmount_afterFullClaim() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, DEPOSIT_AMOUNT, DEPOSIT_AMOUNT); + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + vm.warp(block.timestamp + WITHDRAW_DELAY + 1); + uint256 balBefore = token.balanceOf(payerWallet.addr); + vm.prank(payerWallet.addr); + settlement.finalizeWithdraw(config); + + assertEq(token.balanceOf(payerWallet.addr), balBefore); + } + + function test_finalizeWithdraw_capsIfClaimedDuringDelay() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, 500e6, 500e6); + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + vm.warp(block.timestamp + WITHDRAW_DELAY + 1); + vm.prank(payerWallet.addr); + settlement.finalizeWithdraw(config); + + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.balance, 500e6); + } + + // ========================================================================= + // Cooperative Withdrawal Tests + // ========================================================================= + + function test_refund_directCall() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT); + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + uint256 balBefore = token.balanceOf(payerWallet.addr); + + vm.expectEmit(true, true, false, true); + emit Refunded(channelId, receiverAuthWallet.addr, DEPOSIT_AMOUNT - CLAIM_AMOUNT); + + vm.prank(receiverAuthWallet.addr); + settlement.refund(config); + + assertEq(token.balanceOf(payerWallet.addr), balBefore + DEPOSIT_AMOUNT - CLAIM_AMOUNT); + + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.balance, ch.totalClaimed); + } + + function test_refund_revert_notReceiverAuthorizer() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(otherWallet.addr); + vm.expectRevert(x402BatchSettlement.NotReceiverAuthorizer.selector); + settlement.refund(config); + } + + function test_refundWithSignature_success() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT); + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + bytes memory sig = _signRefund(receiverAuthWallet, channelId); + uint256 balBefore = token.balanceOf(payerWallet.addr); + + vm.prank(otherWallet.addr); + settlement.refundWithSignature(config, sig); + + assertEq(token.balanceOf(payerWallet.addr), balBefore + DEPOSIT_AMOUNT - CLAIM_AMOUNT); + } + + function test_refundWithSignature_clearsPendingWithdrawal() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(payerWallet.addr); + settlement.initiateWithdraw(config, DEPOSIT_AMOUNT); + + bytes memory sig = _signRefund(receiverAuthWallet, channelId); + settlement.refundWithSignature(config, sig); + + x402BatchSettlement.WithdrawalState memory ws = _getPendingWithdrawal(channelId); + assertEq(ws.initiatedAt, 0); + } + + function test_refundWithSignature_zeroRefund() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, DEPOSIT_AMOUNT, DEPOSIT_AMOUNT); + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + bytes memory sig = _signRefund(receiverAuthWallet, channelId); + uint256 balBefore = token.balanceOf(payerWallet.addr); + + settlement.refundWithSignature(config, sig); + + assertEq(token.balanceOf(payerWallet.addr), balBefore); + } + + function test_refundWithSignature_revert_wrongSignature() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + _deposit(config, DEPOSIT_AMOUNT); + + bytes memory badSig = _signRefund(otherWallet, channelId); + vm.expectRevert(x402BatchSettlement.InvalidSignature.selector); + settlement.refundWithSignature(config, badSig); + } + + // ========================================================================= + // View Function Tests + // ========================================================================= + + function test_getChannelId_deterministic() public view { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 id1 = settlement.getChannelId(config); + bytes32 id2 = settlement.getChannelId(config); + assertEq(id1, id2); + assertEq(id1, _channelId(config)); + } + + function test_differentSalt_differentChannelId() public view { + x402BatchSettlement.ChannelConfig memory config1 = _makeConfig(); + x402BatchSettlement.ChannelConfig memory config2 = _makeConfig(); + config2.salt = bytes32(uint256(1)); + + assertNotEq(settlement.getChannelId(config1), settlement.getChannelId(config2)); + } + + function test_getVoucherDigest_matches() public view { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + + bytes32 expected = keccak256( + abi.encodePacked( + "\x19\x01", + _domainSeparator(), + keccak256(abi.encode(settlement.VOUCHER_TYPEHASH(), channelId, uint128(100))) + ) + ); + assertEq(settlement.getVoucherDigest(channelId, 100), expected); + } + + function test_getRefundDigest_matches() public view { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + + bytes32 expected = keccak256( + abi.encodePacked( + "\x19\x01", _domainSeparator(), keccak256(abi.encode(settlement.REFUND_TYPEHASH(), channelId)) + ) + ); + assertEq(settlement.getRefundDigest(channelId), expected); + } + + function test_getClaimBatchDigest_matches() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config, CLAIM_AMOUNT, CLAIM_AMOUNT); + + bytes32 digest = settlement.getClaimBatchDigest(claims); + assertTrue(digest != bytes32(0)); + } + + // ========================================================================= + // Edge Case Tests + // ========================================================================= + + function test_redeposit_afterFullWithdraw() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + bytes32 channelId = _channelId(config); + + _deposit(config, DEPOSIT_AMOUNT); + + vm.prank(receiverAuthWallet.addr); + settlement.refund(config); + + _deposit(config, DEPOSIT_AMOUNT); + + x402BatchSettlement.ChannelState memory ch = _getChannel(channelId); + assertEq(ch.balance, DEPOSIT_AMOUNT); + } + + function test_crossChannel_isolation() public { + x402BatchSettlement.ChannelConfig memory config1 = _makeConfig(); + x402BatchSettlement.ChannelConfig memory config2 = _makeConfig(); + config2.salt = bytes32(uint256(99)); + + _deposit(config1, DEPOSIT_AMOUNT); + _deposit(config2, DEPOSIT_AMOUNT / 2); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(config1, CLAIM_AMOUNT, CLAIM_AMOUNT); + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + bytes32 channelId2 = _channelId(config2); + x402BatchSettlement.ChannelState memory ch2 = _getChannel(channelId2); + assertEq(ch2.totalClaimed, 0); + } + + function test_payerAuthorizer_zeroAllowed_inConfig() public view { + x402BatchSettlement.ChannelConfig memory config = _makeStatefulConfig(); + assertEq(config.payerAuthorizer, address(0)); + bytes32 id = settlement.getChannelId(config); + assertTrue(id != bytes32(0)); + } + + // ========================================================================= + // Multicall Tests + // ========================================================================= + + function test_multicall_migration_refundAndDeposit() public { + x402BatchSettlement.ChannelConfig memory oldConfig = _makeConfig(); + bytes32 oldChannelId = _channelId(oldConfig); + _deposit(oldConfig, DEPOSIT_AMOUNT); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(oldConfig, CLAIM_AMOUNT, CLAIM_AMOUNT); + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + x402BatchSettlement.ChannelConfig memory newConfig = _makeConfig(); + newConfig.salt = bytes32(uint256(77)); + bytes32 newChannelId = _channelId(newConfig); + + bytes memory refundSig = _signRefund(receiverAuthWallet, oldChannelId); + + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeCall(settlement.refundWithSignature, (oldConfig, refundSig)); + calls[1] = + abi.encodeCall(settlement.deposit, (newConfig, DEPOSIT_AMOUNT - CLAIM_AMOUNT, address(mockCollector), "")); + + uint256 payerBalBefore = token.balanceOf(payerWallet.addr); + + settlement.multicall(calls); + + x402BatchSettlement.ChannelState memory oldCh = _getChannel(oldChannelId); + assertEq(oldCh.balance, oldCh.totalClaimed); + + x402BatchSettlement.ChannelState memory newCh = _getChannel(newChannelId); + assertEq(newCh.balance, DEPOSIT_AMOUNT - CLAIM_AMOUNT); + + assertEq(token.balanceOf(payerWallet.addr), payerBalBefore); + } + + function test_multicall_batchDeposits() public { + x402BatchSettlement.ChannelConfig memory config1 = _makeConfig(); + x402BatchSettlement.ChannelConfig memory config2 = _makeConfig(); + config2.salt = bytes32(uint256(1)); + + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeCall(settlement.deposit, (config1, DEPOSIT_AMOUNT, address(mockCollector), "")); + calls[1] = abi.encodeCall(settlement.deposit, (config2, DEPOSIT_AMOUNT / 2, address(mockCollector), "")); + + settlement.multicall(calls); + + assertEq(_getChannel(_channelId(config1)).balance, DEPOSIT_AMOUNT); + assertEq(_getChannel(_channelId(config2)).balance, DEPOSIT_AMOUNT / 2); + } + + function test_multicall_singleCall() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeCall(settlement.deposit, (config, DEPOSIT_AMOUNT, address(mockCollector), "")); + + settlement.multicall(calls); + + assertEq(_getChannel(_channelId(config)).balance, DEPOSIT_AMOUNT); + } + + function test_multicall_revert_propagates() public { + x402BatchSettlement.ChannelConfig memory config = _makeConfig(); + + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeCall(settlement.deposit, (config, DEPOSIT_AMOUNT, address(mockCollector), "")); + calls[1] = abi.encodeCall(settlement.deposit, (config, 0, address(mockCollector), "")); + + vm.expectRevert(); + settlement.multicall(calls); + + assertEq(_getChannel(_channelId(config)).balance, 0); + } + + // ========================================================================= + // Complex Scenario Tests + // ========================================================================= + + /// @dev Full double-migration lifecycle: + /// Round 1: deposit A → claim A → migrate(refund A + deposit B) → settle + /// Round 2: deposit A → claim A → migrate(refund A + deposit B) → settle + /// Asserts channel A is fully drained after each round, channel B accumulates + /// both migrations, and the receiver settles both claims. + function test_scenario_doubleMigrationLifecycle() public { + x402BatchSettlement.ChannelConfig memory configA = _makeConfig(); + configA.salt = bytes32(uint256(0xA)); + + x402BatchSettlement.ChannelConfig memory configB = _makeConfig(); + configB.salt = bytes32(uint256(0xB)); + + uint256 payerStart = token.balanceOf(payerWallet.addr); + + // ── Round 1: deposit A, claim A, migrate A→B, settle ───────────────── + _roundDepositClaimMigrate(configA, configB, 1000e6, 100e6, 100e6); + settlement.settle(receiverWallet.addr, address(token)); + + _assertRound1(configA, configB, payerStart); + + // ── Round 2: deposit A again, claim A, migrate A→B, settle ─────────── + _roundDepositClaimMigrate(configA, configB, 1000e6, 200e6, 200e6); + settlement.settle(receiverWallet.addr, address(token)); + + _assertRound2(configA, configB, payerStart); + } + + function _roundDepositClaimMigrate( + x402BatchSettlement.ChannelConfig memory configA, + x402BatchSettlement.ChannelConfig memory configB, + uint128 depositAmt, + uint128 maxClaimable, + uint128 claimAmt + ) internal { + bytes32 channelA = _channelId(configA); + + _deposit(configA, depositAmt); + + x402BatchSettlement.VoucherClaim[] memory claims = new x402BatchSettlement.VoucherClaim[](1); + claims[0] = _makeVoucherClaim(configA, maxClaimable, claimAmt); + vm.prank(receiverAuthWallet.addr); + settlement.claim(claims); + + x402BatchSettlement.ChannelState memory chA = _getChannel(channelA); + uint128 migrateAmt = chA.balance - chA.totalClaimed; + + bytes memory refundSig = _signRefund(receiverAuthWallet, channelA); + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeCall(settlement.refundWithSignature, (configA, refundSig)); + calls[1] = abi.encodeCall(settlement.deposit, (configB, migrateAmt, address(mockCollector), "")); + settlement.multicall(calls); + } + + function _assertRound1( + x402BatchSettlement.ChannelConfig memory configA, + x402BatchSettlement.ChannelConfig memory configB, + uint256 payerStart + ) internal view { + x402BatchSettlement.ChannelState memory chA = _getChannel(_channelId(configA)); + assertEq(chA.balance, chA.totalClaimed, "R1: A fully drained"); + assertEq(chA.totalClaimed, 100e6); + + x402BatchSettlement.ChannelState memory chB = _getChannel(_channelId(configB)); + assertEq(chB.balance, 900e6, "R1: B holds migrated funds"); + + assertEq(token.balanceOf(receiverWallet.addr), 100e6, "R1: receiver settled"); + assertEq(token.balanceOf(payerWallet.addr), payerStart - 1000e6, "R1: payer spent one deposit"); + } + + function _assertRound2( + x402BatchSettlement.ChannelConfig memory configA, + x402BatchSettlement.ChannelConfig memory configB, + uint256 payerStart + ) internal view { + x402BatchSettlement.ChannelState memory chA = _getChannel(_channelId(configA)); + assertEq(chA.balance, chA.totalClaimed, "R2: A fully drained again"); + assertEq(chA.totalClaimed, 200e6, "R2: A accumulated two claims"); + + x402BatchSettlement.ChannelState memory chB = _getChannel(_channelId(configB)); + assertEq(chB.balance, 1800e6, "R2: B holds both migrations"); + + assertEq(token.balanceOf(receiverWallet.addr), 200e6, "R2: receiver settled both claims"); + + x402BatchSettlement.ReceiverState memory rs = _getReceiver(receiverWallet.addr, address(token)); + assertEq(rs.totalSettled, 200e6, "R2: totalSettled matches"); + assertEq(rs.totalClaimed, rs.totalSettled, "R2: nothing unsettled"); + + assertEq(token.balanceOf(payerWallet.addr), payerStart - 2000e6, "R2: payer funded both deposit rounds"); + } + + /// @dev Multiple individual claims across 3 channels, then a batched + /// claimWithSignature across all 3, followed by a single settle. + function test_scenario_multiChannelClaimsThenBatchedClaimAndSettle() public { + x402BatchSettlement.ChannelConfig memory c1 = _makeConfig(); + c1.salt = bytes32(uint256(1)); + x402BatchSettlement.ChannelConfig memory c2 = _makeConfig(); + c2.salt = bytes32(uint256(2)); + x402BatchSettlement.ChannelConfig memory c3 = _makeConfig(); + c3.salt = bytes32(uint256(3)); + + _deposit(c1, 1000e6); + _deposit(c2, 2000e6); + _deposit(c3, 500e6); + + // ── Individual claims ──────────────────────────────────────────────── + + // C1: claim 50, then claim to total 150 + x402BatchSettlement.VoucherClaim[] memory v = new x402BatchSettlement.VoucherClaim[](1); + v[0] = _makeVoucherClaim(c1, 50e6, 50e6); + vm.prank(receiverAuthWallet.addr); + settlement.claim(v); + + v[0] = _makeVoucherClaim(c1, 150e6, 150e6); + vm.prank(receiverAuthWallet.addr); + settlement.claim(v); + + // C2: claim 200 + v[0] = _makeVoucherClaim(c2, 200e6, 200e6); + vm.prank(receiverAuthWallet.addr); + settlement.claim(v); + + // C3: claim 50 + v[0] = _makeVoucherClaim(c3, 50e6, 50e6); + vm.prank(receiverAuthWallet.addr); + settlement.claim(v); + + // Verify intermediate state + assertEq(_getChannel(_channelId(c1)).totalClaimed, 150e6); + assertEq(_getChannel(_channelId(c2)).totalClaimed, 200e6); + assertEq(_getChannel(_channelId(c3)).totalClaimed, 50e6); + + x402BatchSettlement.ReceiverState memory rsMid = _getReceiver(receiverWallet.addr, address(token)); + assertEq(rsMid.totalClaimed, 400e6, "Mid: total claimed across all channels"); + assertEq(rsMid.totalSettled, 0, "Mid: nothing settled yet"); + + // ── Batched claimWithSignature across all 3 channels ───────────────── + + x402BatchSettlement.VoucherClaim[] memory batchClaims = new x402BatchSettlement.VoucherClaim[](3); + batchClaims[0] = _makeVoucherClaim(c1, 250e6, 250e6); // C1: total 250 (+100 delta) + batchClaims[1] = _makeVoucherClaim(c2, 500e6, 500e6); // C2: total 500 (+300 delta) + batchClaims[2] = _makeVoucherClaim(c3, 200e6, 200e6); // C3: total 200 (+150 delta) + + bytes memory authSig = _signClaimBatch(receiverAuthWallet, batchClaims); + vm.prank(otherWallet.addr); + settlement.claimWithSignature(batchClaims, authSig); + + // Verify post-batch state + assertEq(_getChannel(_channelId(c1)).totalClaimed, 250e6); + assertEq(_getChannel(_channelId(c2)).totalClaimed, 500e6); + assertEq(_getChannel(_channelId(c3)).totalClaimed, 200e6); + + x402BatchSettlement.ReceiverState memory rsPost = _getReceiver(receiverWallet.addr, address(token)); + uint128 expectedTotal = 250e6 + 500e6 + 200e6; + assertEq(rsPost.totalClaimed, expectedTotal, "Post: accumulated across individual + batch"); + assertEq(rsPost.totalSettled, 0, "Post: still nothing settled"); + + // ── Single settle sweeps everything ────────────────────────────────── + + uint256 receiverBefore = token.balanceOf(receiverWallet.addr); + settlement.settle(receiverWallet.addr, address(token)); + assertEq( + token.balanceOf(receiverWallet.addr), + receiverBefore + expectedTotal, + "Settle: receiver received all claimed funds" + ); + + x402BatchSettlement.ReceiverState memory rsFinal = _getReceiver(receiverWallet.addr, address(token)); + assertEq(rsFinal.totalSettled, expectedTotal); + assertEq(rsFinal.totalClaimed, rsFinal.totalSettled, "Final: fully settled"); + + // Channel balances unchanged (claims don't reduce balance) + assertEq(_getChannel(_channelId(c1)).balance, 1000e6); + assertEq(_getChannel(_channelId(c2)).balance, 2000e6); + assertEq(_getChannel(_channelId(c3)).balance, 500e6); + } +} diff --git a/contracts/evm/vanity-miner/src/main.rs b/contracts/evm/vanity-miner/src/main.rs index e9fb5d10bf..f00e0599fa 100644 --- a/contracts/evm/vanity-miner/src/main.rs +++ b/contracts/evm/vanity-miner/src/main.rs @@ -12,13 +12,14 @@ const PERMIT2: [u8; 20] = hex_literal::hex!("000000000022D473030F116dDEE9F6B43aC const PREFIX: [u8; 2] = [0x40, 0x20]; // 0x4020 const EXACT_SUFFIX: [u8; 2] = [0x00, 0x01]; // ...0001 const UPTO_SUFFIX: [u8; 2] = [0x00, 0x02]; // ...0002 +const BATCH_SUFFIX: [u8; 2] = [0x00, 0x03]; // ...0003 // Init code hashes: keccak256(creationCode ++ abi.encode(PERMIT2)) // Run `forge script script/ComputeAddress.s.sol` to verify these match. // // IMPORTANT: The Exact hash is from the ORIGINAL build (with CBOR metadata enabled). // Since that bytecode is already deployed, we preserve it via script/data/exact-proxy-initcode.hex. -// The Upto hash is from the current build (cbor_metadata = false, bytecode_hash = "none"). +// The Upto & Batch hashes are from the current build (cbor_metadata = false, bytecode_hash = "none"). // // x402ExactPermit2Proxy (pre-built initCode, includes CBOR metadata) const EXACT_INIT_CODE_HASH: [u8; 32] = @@ -26,6 +27,10 @@ const EXACT_INIT_CODE_HASH: [u8; 32] = // x402UptoPermit2Proxy (deterministic build, no CBOR metadata) const UPTO_INIT_CODE_HASH: [u8; 32] = hex_literal::hex!("74f7a29cbc3c55f87cdef7f7c551643189e8bb62eed9de67753aebc402b83797"); +// x402BatchSettlement (deterministic build, no CBOR metadata) +// Recompute with: forge script script/ComputeAddress.s.sol --sig "computeBatchAddress(bytes32)" +const BATCH_INIT_CODE_HASH: [u8; 32] = + hex_literal::hex!("9b0fae2e4fbb3a2d487de668b82454033d3becad8a99979a0e2ca4b78f3176d1"); fn compute_create2_address(salt: &[u8; 32], init_code_hash: &[u8; 32]) -> [u8; 20] { let mut hasher = Keccak::v256(); @@ -125,6 +130,7 @@ fn main() { let mine_exact = matches!(filter, None | Some("exact")); let mine_upto = matches!(filter, None | Some("upto")); + let mine_batch = matches!(filter, None | Some("batch")); println!("\n🔍 x402 Vanity Address Miner (Rust)"); println!(" Prefix: 0x{}", hex::encode(PREFIX)); @@ -134,6 +140,9 @@ fn main() { if mine_upto { println!(" Upto suffix: 0x{}", hex::encode(UPTO_SUFFIX)); } + if mine_batch { + println!(" Batch suffix: 0x{}", hex::encode(BATCH_SUFFIX)); + } println!(" CREATE2 Deployer: 0x{}", hex::encode(CREATE2_DEPLOYER)); let num_threads = rayon::current_num_threads(); @@ -151,6 +160,12 @@ fn main() { None }; + let batch_result = if mine_batch { + mine_vanity("x402BatchSettlement", &BATCH_INIT_CODE_HASH, &PREFIX, &BATCH_SUFFIX) + } else { + None + }; + println!("\n{}", "=".repeat(60)); println!("SUMMARY"); println!("{}", "=".repeat(60)); @@ -167,13 +182,23 @@ fn main() { println!(" Address: 0x{}", hex::encode(addr)); } - if let (Some((exact_salt, _)), Some((upto_salt, _))) = (exact_result, upto_result) { - println!("\n// Update Deploy.s.sol with these values:"); - println!("bytes32 constant EXACT_SALT = 0x{};", hex::encode(exact_salt)); - println!("bytes32 constant UPTO_SALT = 0x{};", hex::encode(upto_salt)); - } else if let Some((salt, _)) = exact_result.or(upto_result) { - println!("\n// Update Deploy.s.sol:"); - println!("bytes32 constant SALT = 0x{};", hex::encode(salt)); + if let Some((salt, addr)) = batch_result { + println!("\nx402BatchSettlement:"); + println!(" Salt: 0x{}", hex::encode(salt)); + println!(" Address: 0x{}", hex::encode(addr)); + } + + let results: Vec<(&str, [u8; 32])> = [ + exact_result.map(|(s, _)| ("EXACT_SALT", s)), + upto_result.map(|(s, _)| ("UPTO_SALT", s)), + batch_result.map(|(s, _)| ("BATCH_SALT", s)), + ].iter().filter_map(|x| *x).collect(); + + if !results.is_empty() { + println!("\n// Update deploy scripts with these values:"); + for (name, salt) in &results { + println!("bytes32 constant {} = 0x{};", name, hex::encode(salt)); + } } } diff --git a/specs/schemes/deferred/scheme_batch_settlement_evm.md b/specs/schemes/deferred/scheme_batch_settlement_evm.md new file mode 100644 index 0000000000..5be68ac65f --- /dev/null +++ b/specs/schemes/deferred/scheme_batch_settlement_evm.md @@ -0,0 +1,509 @@ +# Scheme: `batch-settlement` on `EVM` + +## Summary + +The `batch-settlement` scheme on EVM is a **capital-backed** network binding using stateless unidirectional payment channels. Clients deposit funds into onchain channels and sign off-chain cumulative vouchers per request. The server accumulates vouchers and batch-claims them onchain at its discretion; claimed funds are transferred to the receiver via a separate settle operation. + +Channel identity is derived from an immutable `ChannelConfig` struct: `channelId = keccak256(abi.encode(channelConfig))`. There is no onchain registry — all channel parameters are committed at creation and cannot be changed. To modify any parameter (e.g., rotate a signer), the client withdraws from the old channel and deposits into a new one. + +The two-phase **claim/settle** split allows the server to batch-claim vouchers from many clients and batch-settle in separate transactions, minimizing gas costs for high-volume services. + +| AssetTransferMethod | Use Case | Recommendation | +| ------------------- | --------------------------------------------------------------- | -------------------------------------------------------- | +| **`eip3009`** | Tokens with `receiveWithAuthorization` (e.g., USDC) | **Recommended** (simplest, truly gasless) | +| **`permit2`** | Tokens without EIP-3009, payer already has Permit2 approval | **Universal Fallback** (works for any ERC-20) | +| **`direct`** | Payer sends transaction directly with ERC-20 approval | **Simplest** (requires payer gas) | + +Default: `eip3009` if `extra.assetTransferMethod` is omitted. + +--- + +## EVM Core Properties (MUST) + +1. **Stateless Channel Identity**: A channel is identified by `channelId = keccak256(abi.encode(channelConfig))`. All parameters are immutable. +2. **Cumulative Vouchers**: Each voucher carries a `maxClaimableAmount` representing the cumulative ceiling the client authorizes. No nonce — replay protection comes from the cumulative model (`totalClaimed` only increases). +3. **Capital-Backed Escrow**: Clients deposit funds into an onchain channel before consuming resources. The deposit is refundable (unclaimed remainder returns on withdrawal) and can be topped up. +4. **Dual-Mode Payer Authorization**: If `payerAuthorizer != address(0)`, vouchers are verified via ECDSA recovery against the committed EOA (fast, stateless, no RPC needed). If `payerAuthorizer == address(0)`, vouchers are verified via `SignatureChecker` against `payer` (supports EIP-1271 smart wallets, requires RPC). +5. **Receiver Authorizer**: The `receiverAuthorizer` address controls claim authorization and cooperative withdrawals. Can be an EOA or an EIP-1271 contract (e.g., `ClaimAuthorizer` for key rotation). +6. **Cooperative Withdrawal**: Two paths — `cooperativeWithdraw(config)` when the caller IS the `receiverAuthorizer` (direct call), or `cooperativeWithdrawWithSignature(config, sig)` when an off-chain `receiverAuthorizer` signature is provided. Supports both EOA and EIP-1271 (smart contract) authorizers. + +--- + +## ChannelConfig + +All channel parameters are committed in the config struct. The `channelId` is the keccak256 hash of the ABI-encoded struct. + +```solidity +struct ChannelConfig { + address payer; // Client wallet (EOA or smart wallet) + address payerAuthorizer; // EOA for voucher signing, or address(0) for EIP-1271 via payer + address receiver; // Server's payment destination (EOA or routing contract) + address receiverAuthorizer; // Controls claims, cooperative withdraw + address token; // ERC-20 payment token + uint40 withdrawDelay; // Seconds before timed withdrawal completes (15 min – 30 days) + bytes32 salt; // Differentiates channels with identical parameters +} +``` + +| Field | Role | +| -------------------- | ---- | +| `payer` | The client. Deposits funds, initiates withdrawal requests. Can be a smart wallet. | +| `payerAuthorizer` | If non-zero: EOA that signs vouchers, enabling stateless off-chain verification. If `address(0)`: vouchers are verified against `payer` via `SignatureChecker` (supports EIP-1271). | +| `receiver` | Where claimed funds are transferred on `settle()`. Can be an EOA or a routing contract (e.g., `PaymentRouter`, `PaymentSplitter`). | +| `receiverAuthorizer` | The address that authorizes claims (direct call or signature) and cooperative withdrawals. Can be an EOA or EIP-1271 contract (e.g., `ClaimAuthorizer`). Must not be `address(0)`. | +| `token` | The ERC-20 token for this channel. | +| `withdrawDelay` | Grace period for timed withdrawals. Gives the server time to claim outstanding vouchers. Protocol-enforced bounds: 15 minutes minimum, 30 days maximum. | +| `salt` | Allows multiple channels with otherwise identical parameters. | + +--- + +## EIP-712 Types + +All EIP-712 signatures use the contract's domain (`name: "x402 Batch Settlement"`, `version: "1"`, plus `chainId` and `verifyingContract`). + +**Voucher** — signed by `payerAuthorizer` (or `payer` if `payerAuthorizer == address(0)`): + +``` +Voucher(bytes32 channelId, uint128 maxClaimableAmount) +``` + +**CooperativeWithdraw** — signed by `receiverAuthorizer` for `cooperativeWithdrawWithSignature`: + +``` +CooperativeWithdraw(bytes32 channelId) +``` + +**ClaimBatch** — signed by `receiverAuthorizer` for `claimWithSignature`: + +``` +ClaimBatch(bytes32 claimsHash) +``` + +where `claimsHash = keccak256(abi.encodePacked(h_0, h_1, ...))` and each `h_i = keccak256(abi.encode(channelId_i, maxClaimableAmount_i, claimAmount_i))`. + +**Permit2 Deposit Witness:** + +``` +DepositWitness(bytes32 channelId) +``` + +--- + +## 402 Response (PaymentRequirements) + +The 402 response contains pricing terms and the server's channel parameters. The client uses `payTo` as `ChannelConfig.receiver` and fills in `payer`, `payerAuthorizer`, `token`, and `salt` to construct the full `ChannelConfig`. + +```json +{ + "scheme": "batch-settlement", + "network": "eip155:8453", + "amount": "100000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0xServerReceiverAddress", + "maxTimeoutSeconds": 3600, + "extra": { + "receiverAuthorizer": "0xReceiverAuthorizerAddress", + "withdrawDelay": 900, + "name": "USDC", + "version": "2" + } +} +``` + +The `payTo` field serves as `ChannelConfig.receiver` — no separate `extra.receiver` is needed. + +| Field | Type | Required | Description | +| ------------------------------ | -------- | -------- | ----------- | +| `extra.receiverAuthorizer` | `string` | yes | Receiver authorizer address (EOA or EIP-1271 contract) | +| `extra.withdrawDelay` | `number` | yes | Withdrawal delay in seconds (15 min – 30 days) | +| `extra.assetTransferMethod` | `string` | optional | `"eip3009"` (default), `"permit2"`, or `"direct"` | +| `extra.name` | `string` | yes | EIP-712 domain name of the token contract | +| `extra.version` | `string` | yes | EIP-712 domain version of the token contract | + +--- + +## Client: Payment Construction + +The client constructs a `PaymentPayload` whose type depends on channel state: + +- **`deposit`**: No channel exists or balance is exhausted — client signs a token authorization and first voucher +- **`voucher`**: Channel has sufficient balance — client signs a new cumulative voucher + +### Deposit Payload + +The `deposit.authorization` field contains the token transfer authorization — exactly one of `erc3009Authorization`, `permit2Authorization`, or `directDeposit` MUST be present. + +```json +{ + "x402Version": 2, + "accepted": { + "scheme": "batch-settlement", + "network": "eip155:8453", + "amount": "1000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0xServerReceiverAddress", + "maxTimeoutSeconds": 3600, + "extra": { + "receiverAuthorizer": "0xReceiverAuthorizerAddress", + "withdrawDelay": 900, + "name": "USDC", + "version": "2" + } + }, + "payload": { + "type": "deposit", + "deposit": { + "channelConfig": { + "payer": "0xClientAddress", + "payerAuthorizer": "0xClientPayerAuthorizerEOA", + "receiver": "0xServerReceiverAddress", + "receiverAuthorizer": "0xReceiverAuthorizerAddress", + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "withdrawDelay": 900, + "salt": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "amount": "100000", + "authorization": "" + }, + "voucher": { + "channelId": "0xabc123...channelId", + "maxClaimableAmount": "1000", + "signature": "0x...EIP-712 voucher signature" + } + } +} +``` + +### Voucher Payload + +```json +{ + "x402Version": 2, + "accepted": { "..." : "..." }, + "payload": { + "type": "voucher", + "channelConfig": { + "payer": "0xClientAddress", + "payerAuthorizer": "0xClientPayerAuthorizerEOA", + "receiver": "0xServerReceiverAddress", + "receiverAuthorizer": "0xReceiverAuthorizerAddress", + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "withdrawDelay": 900, + "salt": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "channelId": "0xabc123...channelId", + "maxClaimableAmount": "5000", + "signature": "0x...EIP-712 voucher signature", + "withdraw": true + } +} +``` + +The optional `withdraw` flag signals a cooperative withdraw request. + +--- + +## Server: State & Forwarding + +The server is the sole owner of per-channel session state. + +### Per-Channel State + +The server MUST maintain per-channel state, keyed by `channelId`: + +| State Field | Type | Description | +| ------------------------- | --------------- | --------------------------------------------------------------- | +| `channelConfig` | `ChannelConfig` | Full channel configuration object | +| `chargedCumulativeAmount` | `uint128` | Actual accumulated cost for this channel | +| `signedMaxClaimable` | `uint128` | `maxClaimableAmount` from the latest client-signed voucher | +| `signature` | `bytes` | Client's voucher signature for `signedMaxClaimable` | +| `balance` | `uint128` | Current channel balance (mirrored from onchain) | +| `totalClaimed` | `uint128` | Total claimed onchain (mirrored from onchain) | +| `withdrawRequestedAt` | `uint64` | Unix timestamp when timed withdrawal was initiated, or `0` if none (mirrored from onchain) | +| `lastRequestTimestamp` | `uint64` | Timestamp of the last paid request | + +### Request Processing (MUST) + +The server MUST serialize request processing per channel. The server MUST NOT update voucher state until the resource handler has succeeded. + +1. **Verify**: Check increment locally, call facilitator `/verify` +2. **Execute**: Run the resource handler +3. **On success** — commit state: + - `chargedCumulativeAmount += actualPrice` (where `actualPrice <= PaymentRequirements.amount`) + - Mirror `balance`, `totalClaimed`, `withdrawRequestedAt` from the facilitator response +4. **On failure**: State unchanged, client can retry the same voucher. + +### Cooperative Withdraw Settle Flow + +When the server receives a voucher with `withdraw: true`: + +**Path A — Server has `receiverAuthorizerSigner` (signature path):** + +1. Update `chargedCumulativeAmount` as with a normal voucher. +2. Sign a `CooperativeWithdraw(channelId)` digest as the `receiverAuthorizer`. +3. Sign a `ClaimBatch(claimsHash)` digest for the outstanding claims. +4. Build a voucher claim with `claimAmount = chargedCumulativeAmount - totalClaimed`. +5. Submit a `cooperativeWithdrawWithSignature` settle action containing the claim, receiverAuthorizer signature, and claim authorizer signature. +6. On success, reset the session for that channel. + +**Path B — Facilitator IS the `receiverAuthorizer` (direct-call path):** + +1. Update `chargedCumulativeAmount` as with a normal voucher. +2. Build a voucher claim with `claimAmount = chargedCumulativeAmount - totalClaimed`. +3. Submit a `cooperativeWithdraw` settle action containing the claim (no signatures). +4. The facilitator calls `claim(claims)` then `cooperativeWithdraw(config)` as `msg.sender`. +5. On success, reset the session for that channel. + +--- + +## Facilitator Interface + +Uses the standard x402 facilitator interface (`/verify`, `/settle`, `/supported`). The facilitator is an SDK-level convenience — it is not committed in the channel config. + +### POST /verify + +Verifies a payment payload. Returns the onchain channel snapshot: + +```json +{ + "isValid": true, + "payer": "0xPayerAddress", + "extra": { + "channelId": "0xabc123...", + "balance": "1000000", + "totalClaimed": "500000", + "withdrawRequestedAt": 0 + } +} +``` + +### POST /settle + +| `settleAction` | When Used | Onchain Effect | +| ----------------------- | -------------------------------- | ------------------------------------------------------ | +| `"deposit"` | First request or top-up | Deposit tokens into channel | +| `"claim"` | Server batches voucher claims | Validate vouchers, update accounting (no transfer) | +| `"claimWithSignature"` | Claim via receiverAuthorizer sig | Same as claim, anyone can submit with valid signature | +| `"settle"` | Server transfers earned funds | Transfer unsettled amount to receiver | +| `"cooperativeWithdraw"` | Instant refund (msg.sender-gated) | Refund unclaimed deposits to payer (caller is receiverAuthorizer) | +| `"cooperativeWithdrawWithSignature"` | Instant refund (signature-authorized) | Refund unclaimed deposits to payer (off-chain receiverAuthorizer signature) | + +**Response:** + +```json +{ + "success": true, + "transaction": "0x...transactionHash", + "network": "eip155:8453", + "payer": "0xPayerAddress", + "amount": "700", + "extra": { + "channelId": "0xabc123...", + "balance": "100000", + "totalClaimed": "3200", + "withdrawRequestedAt": 0 + } +} +``` + +### GET /supported + +```json +{ + "kinds": [ + { "x402Version": 2, "scheme": "batch-settlement", "network": "eip155:8453" } + ] +} +``` + +### Verification Rules (MUST) + +A facilitator MUST enforce: + +1. **Channel config consistency** (`deposit` and `voucher`): `keccak256(abi.encode(channelConfig)) == channelId`. The client-provided config MUST hash to the claimed channel id. +2. **Token match**: `channelConfig.token` MUST match `paymentRequirements.asset`. +3. **Receiver match**: `channelConfig.receiver` MUST equal `paymentRequirements.payTo`. +4. **Receiver authorizer match**: `channelConfig.receiverAuthorizer` MUST equal `paymentRequirements.extra.receiverAuthorizer`. +5. **Withdraw delay match**: `channelConfig.withdrawDelay` MUST equal `paymentRequirements.extra.withdrawDelay`. +6. **Signature validity**: Recover the signer from the EIP-712 `Voucher` digest. If `payerAuthorizer != address(0)`, the signer MUST equal `payerAuthorizer` (ECDSA only). If `payerAuthorizer == address(0)`, validate via `SignatureChecker` against `payer`. +7. **Channel existence**: The channel MUST have a positive balance (`balance > 0`). +8. **Balance check** (`deposit` only): Client MUST have sufficient token balance. +9. **Deposit sufficiency**: `maxClaimableAmount` MUST be `<= balance` (or `<= balance + depositAmount` for deposit payloads). +10. **Not below claimed**: `maxClaimableAmount` MUST be `> totalClaimed`. + +The facilitator MUST return the channel snapshot (`balance`, `totalClaimed`, `withdrawRequestedAt`) in every `/verify` and `/settle` response `extra` field. If `withdrawRequestedAt != 0`, the server should claim outstanding vouchers promptly before the withdraw delay elapses. + +#### Server Check (off-chain) + +The server MUST additionally verify: + +- `payload.maxClaimableAmount == chargedCumulativeAmount + paymentRequirements.amount` + +If the check fails, reject with `batch_settlement_stale_cumulative_amount` and return a corrective 402. + +--- + +## Claim & Settlement Strategy + +**`claim(VoucherClaim[])`** validates payer voucher signatures and updates accounting across multiple channels in a single transaction. No token transfer occurs. The `claimAmount` is determined by the `receiverAuthorizer`. Caller must be the `receiverAuthorizer`. + +**`claimWithSignature(VoucherClaim[], bytes)`** performs the same accounting but accepts an off-chain `receiverAuthorizer` signature instead of requiring the authorizer to be `msg.sender`. Anyone can submit the transaction. + +**`settle(address, address)`** transfers all claimed-but-unsettled funds for a receiver+token pair to the receiver in one transfer. Permissionless — anyone can call. + +``` +struct Voucher { + ChannelConfig channel; + uint128 maxClaimableAmount; // client-signed cumulative ceiling +} + +struct VoucherClaim { + Voucher voucher; + bytes signature; // EIP-712 Voucher signature from payerAuthorizer (or payer) + uint128 claimAmount; // receiverAuthorizer-determined actual claim +} +``` + +| Strategy | Description | Trade-off | +| ------------------- | ------------------------------------------------------- | -------------------------------- | +| **Periodic** | Claim + settle every N minutes | Predictable gas costs | +| **Threshold** | Claim + settle when unclaimed amount exceeds T | Bounds server's risk exposure | +| **On withdrawal** | Claim + settle when withdrawal is initiated | Minimum gas, maximum risk window | + +The server MUST claim all outstanding vouchers before the withdraw delay elapses. Unclaimed vouchers become unclaimable after `finalizeWithdraw()` reduces the channel balance. + +--- + +## Trust Model + +The `batch-settlement` scheme operates under the following trust assumptions: + +1. **Client trusts server for claim amounts**: The client signs `maxClaimableAmount` (a ceiling). The `receiverAuthorizer` determines the actual `claimAmount` within that bound. Over-claiming is a trust violation, not a protocol violation. The client's risk is bounded by `maxClaimableAmount - totalClaimed`. + +2. **ReceiverAuthorizer is server-controlled**: The `receiverAuthorizer` is committed in the `ChannelConfig` and jointly agreed upon by client and server. It controls claim authorization and cooperative withdrawal. It can be an EOA, a hot wallet, or an EIP-1271 contract like `ClaimAuthorizer` for key rotation. + +3. **Receiver authorizes cooperative withdrawals**: The `receiverAuthorizer` (not the receiver itself) authorizes instant refunds — either as `msg.sender` via `cooperativeWithdraw(config)` or via off-chain signature through `cooperativeWithdrawWithSignature(config, sig)`. This ensures the server's authorized agent decides to forgo revenue. Supports EIP-1271 for smart contract authorizers. + +4. **Incremental signing bounds risk**: The SDK signs `maxClaimableAmount = chargedSoFar + oneRequestMax` for each request. The gap between actual consumption and the authorized ceiling is at most one request's price. + +--- + +## Channel Discovery + +A channel is identified by `channelId = keccak256(abi.encode(channelConfig))`. The client knows all config fields from the 402 response and its own parameters. A single RPC read of `channels[channelId]` retrieves current state. + +For state recovery after client state loss, channels can be discovered via `ChannelCreated` events indexed by payer address. + +--- + +## Client Verification Rules (MUST) + +### In-Session + +Before signing the next voucher, the client MUST verify from `PAYMENT-RESPONSE`: + +1. `amount <= PaymentRequirements.amount` +2. `chargedCumulativeAmount == previous + amount` +3. `balance` is consistent with the client's expectation +4. `channelId` matches + +If any check fails, the client MUST NOT sign further vouchers and SHOULD initiate withdrawal. + +### Recovery After State Loss + +The client reads the channel onchain via `channels[channelId]`. If the server holds unsettled vouchers above the onchain state, it returns a corrective 402 with `chargedCumulativeAmount`, `signedMaxClaimable`, and `signature`. The client MUST verify the returned voucher signature matches its own `payerAuthorizer` (or `payer`) before resuming. + +--- + +## Periphery Contracts + +Three example periphery contracts are provided for common use cases: + +### PaymentRouter + +A mutable `receiver` proxy. Deploy and set as `ChannelConfig.receiver`. Funds from `settle()` arrive here; authorizers call `forward()` to route them to the current destination. Supports `updateDestination()` to change routing without opening a new channel. + +### PaymentSplitter + +Like `PaymentRouter` but distributes to multiple payees by basis-point shares. `distribute()` splits funds per configured shares; `updatePayees()` changes the split. + +### ClaimAuthorizer + +An EIP-1271 contract used as `ChannelConfig.receiverAuthorizer`. Allows servers to rotate claim-signing keys without opening new channels. Multiple authorizer EOAs are supported for redundancy and key rotation. The contract validates signatures from any registered authorizer. + +--- + +## Lifecycle Summary + +- **Channel Creation**: Implicit on first deposit. The `ChannelConfig` is immutable — all parameters are committed at creation. +- **Deposit & Top-Up**: Deposits create or top up a channel. Deposits cancel pending withdrawal requests. +- **Claim & Settle**: `claim()` (or `claimWithSignature()`) validates voucher signatures and updates accounting (no transfer). `settle()` sweeps all claimed-but-unsettled funds for a receiver+token pair in one transfer. +- **Withdrawal**: Two paths — cooperative (instant, via `cooperativeWithdraw` when caller is receiverAuthorizer, or `cooperativeWithdrawWithSignature` with off-chain signature), or timed (`initiateWithdraw` → wait `withdrawDelay` → `finalizeWithdraw`). +- **Parameter Changes**: To rotate `payerAuthorizer`, change `receiverAuthorizer`, or modify other config fields, the client withdraws from the old channel and deposits into a new one. No atomic migration helper — this is a deliberate simplification. +- **Token transfers**: Implementations MUST handle both standard and non-standard ERC-20 return values (e.g., USDT). + +--- + +## Error Codes + +Implementers MUST use the generic `batch-settlement` error codes from [scheme_batch_settlement.md](./scheme_batch_settlement.md#error-codes) when applicable. + +EVM-specific codes: + +| Error Code | Description | +| --------------------------------------------------- | ------------------------------------------------------------------ | +| `batch_settlement_evm_channel_not_found` | No channel with positive balance for the given `channelId` | +| `batch_settlement_evm_withdrawal_pending` | Withdrawal request is pending on this channel | +| `batch_settlement_evm_cumulative_exceeds_balance` | Voucher `maxClaimableAmount` exceeds onchain balance | +| `batch_settlement_evm_withdraw_delay_out_of_range` | `withdrawDelay` is outside the 15 min – 30 day bounds | +| `batch_settlement_stale_cumulative_amount` | Client voucher base doesn't match server state; corrective 402 | +| `cooperative_withdraw_not_supported` | Server has no receiverAuthorizer signing key for cooperative withdraw | +| `batch_settlement_evm_channel_id_mismatch` | `channelConfig` does not hash to the claimed `channelId` | +| `batch_settlement_evm_receiver_mismatch` | `channelConfig.receiver` does not match `paymentRequirements.payTo` | +| `batch_settlement_evm_receiver_authorizer_mismatch` | `channelConfig.receiverAuthorizer` does not match `extra.receiverAuthorizer` | +| `batch_settlement_evm_withdraw_delay_mismatch` | `channelConfig.withdrawDelay` does not match `extra.withdrawDelay` | + +--- + +## Security Considerations + +1. **Capital risk**: Clients bear risk up to their `maxClaimableAmount` ceiling. Servers bear risk of unclaimed vouchers during the withdrawal delay. +2. **Withdrawal delay**: Bounds (15 min – 30 day) prevent unreasonable delays that trap funds. Cooperative withdraw provides an instant exit when the server cooperates. +3. **Dual-mode payer verification**: When `payerAuthorizer` is set, vouchers are verified statelessly via ECDSA — no RPC required. When `payerAuthorizer == address(0)`, verification falls back to `SignatureChecker` against `payer`, supporting EIP-1271 smart wallets at the cost of an RPC call. +4. **ReceiverAuthorizer commitment**: The `receiverAuthorizer` is committed in `ChannelConfig` and gated by `msg.sender` (for `claim` and `cooperativeWithdraw`) or signature verification (for `claimWithSignature` and `cooperativeWithdrawWithSignature`). This prevents unauthorized claim or withdrawal operations. +5. **Cumulative replay protection**: Without nonces, the cumulative model ensures `totalClaimed` only increases. Old vouchers with lower ceilings are naturally superseded. The client's risk gap is bounded by incremental signing. +6. **Cross-function replay prevention**: `CooperativeWithdraw` and `ClaimBatch` use distinct EIP-712 type hashes to prevent signature reuse across operations. + +--- + +## Annex + +### Reference Implementation: `x402BatchSettlement` + +The reference implementation is deployed via CREATE2 (same address on all EVM chains). Source: [`contracts/evm/src/x402BatchSettlement.sol`](../../../contracts/evm/src/x402BatchSettlement.sol). + +### Periphery Contracts + +- [`PaymentRouter`](../../../contracts/evm/src/periphery/PaymentRouter.sol) — Mutable receiver routing +- [`PaymentSplitter`](../../../contracts/evm/src/periphery/PaymentSplitter.sol) — Multi-payee distribution +- [`ClaimAuthorizer`](../../../contracts/evm/src/periphery/ClaimAuthorizer.sol) — EIP-1271 authorizer rotation + +### Canonical Permit2 + +The Canonical Permit2 contract address can be found at [https://docs.uniswap.org/contracts/v4/deployments](https://docs.uniswap.org/contracts/v4/deployments). + +--- + +## Version History + +| Version | Date | Changes | Author | +| ------- | ---------- | -------------------------------------------------------------------- | -------------- | +| v1.2 | 2026-04-09 | `finalizeWithdraw` permissionless after delay; removed `finalizeWithdrawWithSignature`, `FinalizeWithdraw` EIP-712 type and `getFinalizeWithdrawDigest`; Dual-path cooperative withdraw: `cooperativeWithdraw(config)` msg.sender-gated + `cooperativeWithdrawWithSignature(config, sig)` signature-based; Voucher payload now includes `channelConfig` | @phdargen | +| v1.1 | 2026-04-08 | Dual-authorizer model: `payerAuthorizer` (EOA or address(0) for EIP-1271), `receiverAuthorizer` replaces `facilitator`, `claimWithSignature` and `finalizeWithdrawWithSignature`, removed migration helper, added `ClaimAuthorizer` periphery, removed EIP-1271 from PaymentRouter/PaymentSplitter | @CarsonRoscoe | +| v1.0 | 2026-04-08 | Stateless channel-config model: immutable ChannelConfig, 2 typehashes, nonce-less cumulative vouchers, committed facilitator, cooperative withdraw via receiver signature, channel migration, EIP-1271 for non-voucher ops | @CarsonRoscoe | +| v0.6 | 2026-04-07 | Multi-token subchannels, client signer delegation, withdrawWindow bounds, replay-protected requestWithdrawalFor, renamed to `batch-settlement` | @CarsonRoscoe | +| v0.5 | 2026-04-02 | Add cooperativeWithdraw | @phdargen | +| v0.4 | 2026-03-31 | Service registry + subchannel architecture | @CarsonRoscoe | +| v0.3 | 2026-03-31 | Add voucherId for concurrency | @phdargen | +| v0.2 | 2025-03-30 | Add dynamic price | @phdargen | +| v0.1 | 2025-03-21 | Initial draft | @phdargen | \ No newline at end of file