Skip to content
2 changes: 1 addition & 1 deletion contracts/evm/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 45 additions & 1 deletion contracts/evm/script/ComputeAddress.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)" <EXACT_SALT> <UPTO_SALT>
*
* @dev Run BatchSettlement only with custom salt:
* forge script script/ComputeAddress.s.sol --sig "computeBatchAddress(bytes32)" <BATCH_SALT>
*/
contract ComputeAddress is Script {
/// @notice Arachnid's deterministic CREATE2 deployer
Expand All @@ -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;

Expand Down Expand Up @@ -111,6 +119,42 @@ 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,
Expand Down
106 changes: 106 additions & 0 deletions contracts/evm/script/DeployBatchSettlement.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Script, console2} from "forge-std/Script.sol";
import {x402BatchSettlement} from "../src/x402BatchSettlement.sol";

/// @title DeployBatchSettlement
/// @notice Deployment script for x402BatchSettlement 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 = 0x00000000000000000000000000000000000000000000000020000000041a1d56;

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");
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");
}

_deploy(permit2);

console2.log("");
console2.log("Deployment complete!");
console2.log("");
}

function _deploy(address permit2) internal {
console2.log("");
console2.log("------------------------------------------------------------");
console2.log(" Deploying x402BatchSettlement");
console2.log("------------------------------------------------------------");

bytes memory initCode = abi.encodePacked(type(x402BatchSettlement).creationCode, abi.encode(permit2));
bytes32 initCodeHash = keccak256(initCode);
address expectedAddress = _computeCreate2Addr(BATCH_SALT, initCodeHash, CREATE2_DEPLOYER);

console2.log("Salt:", vm.toString(BATCH_SALT));
console2.log("Expected address:", expectedAddress);
console2.log("Init code hash:", vm.toString(initCodeHash));

x402BatchSettlement bs;

if (expectedAddress.code.length > 0) {
console2.log("Contract already deployed at", expectedAddress);
bs = x402BatchSettlement(expectedAddress);
console2.log("PERMIT2:", address(bs.PERMIT2()));
return;
}

vm.startBroadcast();

address deployedAddress;
if (block.chainid == 31_337 || block.chainid == 1337) {
console2.log("(Using regular deployment for local network)");
bs = new x402BatchSettlement(permit2);
deployedAddress = address(bs);
} else {
bytes memory deploymentData = abi.encodePacked(BATCH_SALT, initCode);
(bool success,) = CREATE2_DEPLOYER.call(deploymentData);
require(success, "CREATE2 deployment failed for BatchSettlement");
deployedAddress = expectedAddress;
require(deployedAddress.code.length > 0, "No bytecode at expected address");
bs = x402BatchSettlement(deployedAddress);
}

vm.stopBroadcast();

console2.log("Deployed to:", deployedAddress);
console2.log("Verification - PERMIT2:", address(bs.PERMIT2()));
require(address(bs.PERMIT2()) == permit2, "PERMIT2 mismatch");
}

function _computeCreate2Addr(
bytes32 salt,
bytes32 initCodeHash,
address deployer
) internal pure returns (address) {
return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), deployer, salt, initCodeHash)))));
}
}
20 changes: 20 additions & 0 deletions contracts/evm/src/interfaces/IERC3009.sol
Original file line number Diff line number Diff line change
@@ -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;
}
80 changes: 80 additions & 0 deletions contracts/evm/src/periphery/Authorizable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title Authorizable
/// @notice Abstract base for multi-authorizer access control.
/// Maintains a set of authorized addresses with add/remove operations
/// and a minimum-one invariant (cannot remove the last authorizer).
/// @author x402 Protocol
abstract contract Authorizable {
// =========================================================================
// Storage
// =========================================================================

mapping(address => bool) public authorizers;
uint256 public authorizerCount;

// =========================================================================
// Events
// =========================================================================

event AuthorizerAdded(address indexed authorizer);
event AuthorizerRemoved(address indexed authorizer);

// =========================================================================
// Errors
// =========================================================================

error NotAuthorizer();
error InvalidAddress();
error LastAuthorizer();
error AlreadyAuthorizer();

// =========================================================================
// Modifiers
// =========================================================================

modifier onlyAuthorizer() {
if (!authorizers[msg.sender]) revert NotAuthorizer();
_;
}

// =========================================================================
// Constructor
// =========================================================================

constructor(address[] memory _authorizers) {
if (_authorizers.length == 0) revert InvalidAddress();

for (uint256 i = 0; i < _authorizers.length; ++i) {
if (_authorizers[i] == address(0)) revert InvalidAddress();
if (authorizers[_authorizers[i]]) revert AlreadyAuthorizer();
authorizers[_authorizers[i]] = true;
}
authorizerCount = _authorizers.length;
}

// =========================================================================
// Authorizer Management
// =========================================================================

function addAuthorizer(address account) external onlyAuthorizer {
if (account == address(0)) revert InvalidAddress();
if (authorizers[account]) revert AlreadyAuthorizer();

authorizers[account] = true;
authorizerCount += 1;

emit AuthorizerAdded(account);
}

function removeAuthorizer(address account) external onlyAuthorizer {
if (!authorizers[account]) revert NotAuthorizer();
if (authorizerCount <= 1) revert LastAuthorizer();

authorizers[account] = false;
authorizerCount -= 1;

emit AuthorizerRemoved(account);
}
}
33 changes: 33 additions & 0 deletions contracts/evm/src/periphery/ClaimAuthorizer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

import {Authorizable} from "./Authorizable.sol";

/// @title ClaimAuthorizer
/// @notice ERC-1271 periphery contract used as `ChannelConfig.receiverAuthorizer`.
/// Allows servers to rotate claim-signing keys without opening new channels.
/// Multiple authorizers are supported for redundancy and key rotation.
///
/// @dev Validates that a signature was produced by one of the registered authorizer EOAs.
/// The server deploys this contract once and sets its address as `receiverAuthorizer`
/// in the channel config. To rotate keys, the server adds/removes authorizers on this
/// contract — no channel migration needed.
/// @author x402 Protocol
contract ClaimAuthorizer is Authorizable, IERC1271 {
constructor(address[] memory _authorizers) Authorizable(_authorizers) {}

/// @notice Validates that a signature was produced by one of this contract's authorizers.
/// @param hash The digest to validate
/// @param signature The ECDSA signature to check
/// @return magicValue `0x1626ba7e` if valid, `0xffffffff` otherwise
function isValidSignature(bytes32 hash, bytes calldata signature) external view override returns (bytes4) {
(address recovered, ECDSA.RecoverError err,) = ECDSA.tryRecoverCalldata(hash, signature);
if (err == ECDSA.RecoverError.NoError && authorizers[recovered]) {
return IERC1271.isValidSignature.selector;
}
return bytes4(0xffffffff);
}
}
Loading
Loading