Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
47 changes: 46 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,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,
Expand Down
133 changes: 133 additions & 0 deletions contracts/evm/script/DeployBatchSettlement.s.sol
Original file line number Diff line number Diff line change
@@ -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)))));
}
}
25 changes: 25 additions & 0 deletions contracts/evm/src/interfaces/IDepositCollector.sol
Original file line number Diff line number Diff line change
@@ -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;
}
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;
}
27 changes: 27 additions & 0 deletions contracts/evm/src/periphery/DepositCollector.sol
Original file line number Diff line number Diff line change
@@ -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();
_;
}
}
40 changes: 40 additions & 0 deletions contracts/evm/src/periphery/ERC3009DepositCollector.sol
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: This function is permissionless and lets anyone choose the recipient, letting anyone frontrun transactions and steal funds. This needs to be bound to the BatchSettlement singleton and only forward funds there and also lock down the caller to the singleton too. Same for all other collectors and would recommend making an abstract DepositCollector for this purpose.

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);
}
}
29 changes: 29 additions & 0 deletions contracts/evm/src/periphery/Permit2DepositCollector.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading