From 34180c02ae9559aba9ba24c680d0a8fdd839bf5d Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 12 May 2026 16:01:31 -0400 Subject: [PATCH 01/31] feat: vendor Peanut Protocol V4.4 under OpenZeppelin v5 Imports peanutprotocol/peanut-contracts V4.4 (vault + batcher + router) plus EIP-3009 mocks, sample SCW, and Squid mock into src/peanut/, with the squirrel-labs test suite under test/peanut/. OZ v5 patches applied during vendoring: - ReentrancyGuard moved from security/ to utils/ - ECDSA.toEthSignedMessageHash -> MessageHashUtils - SafeERC20.safeApprove -> forceApprove - Ownable constructor takes initial owner explicitly - EIP3009Implementation marks interface fns override 60/60 peanut tests pass. Open follow-ups: MFA_AUTHORIZER hardcoded to upstream key, no deploy script yet, IL2ECO branches kept (unused on Nodle). --- src/peanut/V4/PeanutBatcherV4.4.sol | 265 ++++++ src/peanut/V4/PeanutRouter.sol | 98 ++ src/peanut/V4/PeanutV4.4.sol | 883 ++++++++++++++++++ src/peanut/util/ECRecover.sol | 47 + src/peanut/util/EIP3009Implementation.sol | 42 + src/peanut/util/EIP3009Internals.sol | 101 ++ src/peanut/util/EIP712.sol | 37 + src/peanut/util/EIP712Domain.sol | 15 + src/peanut/util/ERC1155Mock.sol | 14 + src/peanut/util/ERC20Mock.sol | 17 + src/peanut/util/ERC721Mock.sol | 14 + src/peanut/util/IEIP3009.sol | 65 ++ src/peanut/util/IL2ECO.sol | 8 + src/peanut/util/SampleSCW.sol | 13 + src/peanut/util/SquidMock.sol | 25 + test/peanut/Batch/testBatchDeposit.sol | 111 +++ test/peanut/Batch/testBatchDepositEther.sol | 161 ++++ .../Batch/testBatchDepositEtherOptimized.sol | 160 ++++ test/peanut/PeanutBatcher.t.sol | 230 +++++ test/peanut/PeanutRouter.t.sol | 240 +++++ test/peanut/PeanutV4.t.sol | 137 +++ test/peanut/PeanutV4Gasless.t.sol | 214 +++++ test/peanut/RecipeintBound.t.sol | 82 ++ test/peanut/hardhat/PeanutV4.1.spec.ts | 178 ++++ test/peanut/testBatch.sol | 111 +++ test/peanut/testDeposit.sol | 74 ++ test/peanut/testIntegration.sol | 137 +++ test/peanut/testMFA.sol | 56 ++ test/peanut/testSenderWithdraw.sol | 132 +++ test/peanut/testSigWithdraw.sol | 58 ++ 30 files changed, 3725 insertions(+) create mode 100644 src/peanut/V4/PeanutBatcherV4.4.sol create mode 100644 src/peanut/V4/PeanutRouter.sol create mode 100644 src/peanut/V4/PeanutV4.4.sol create mode 100644 src/peanut/util/ECRecover.sol create mode 100644 src/peanut/util/EIP3009Implementation.sol create mode 100644 src/peanut/util/EIP3009Internals.sol create mode 100644 src/peanut/util/EIP712.sol create mode 100644 src/peanut/util/EIP712Domain.sol create mode 100644 src/peanut/util/ERC1155Mock.sol create mode 100644 src/peanut/util/ERC20Mock.sol create mode 100644 src/peanut/util/ERC721Mock.sol create mode 100644 src/peanut/util/IEIP3009.sol create mode 100644 src/peanut/util/IL2ECO.sol create mode 100644 src/peanut/util/SampleSCW.sol create mode 100644 src/peanut/util/SquidMock.sol create mode 100644 test/peanut/Batch/testBatchDeposit.sol create mode 100644 test/peanut/Batch/testBatchDepositEther.sol create mode 100644 test/peanut/Batch/testBatchDepositEtherOptimized.sol create mode 100644 test/peanut/PeanutBatcher.t.sol create mode 100644 test/peanut/PeanutRouter.t.sol create mode 100644 test/peanut/PeanutV4.t.sol create mode 100644 test/peanut/PeanutV4Gasless.t.sol create mode 100644 test/peanut/RecipeintBound.t.sol create mode 100644 test/peanut/hardhat/PeanutV4.1.spec.ts create mode 100644 test/peanut/testBatch.sol create mode 100644 test/peanut/testDeposit.sol create mode 100644 test/peanut/testIntegration.sol create mode 100644 test/peanut/testMFA.sol create mode 100644 test/peanut/testSenderWithdraw.sol create mode 100644 test/peanut/testSigWithdraw.sol diff --git a/src/peanut/V4/PeanutBatcherV4.4.sol b/src/peanut/V4/PeanutBatcherV4.4.sol new file mode 100644 index 00000000..614a091a --- /dev/null +++ b/src/peanut/V4/PeanutBatcherV4.4.sol @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "./PeanutV4.4.sol"; + +contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { + using SafeERC20 for IERC20; + + PeanutV4 public peanut; + + function _setAllowanceIfZero(address tokenAddress, address spender) internal { + uint256 currentAllowance = IERC20(tokenAddress).allowance(address(this), spender); + if (currentAllowance == 0) { + IERC20(tokenAddress).forceApprove(spender, type(uint256).max); + } + } + + /** + * @notice supportsInterface function + * @dev ERC165 interface detection + * @param _interfaceId bytes4 the interface identifier, as specified in ERC-165 + * @return bool true if the contract implements the interface specified in _interfaceId + */ + function supportsInterface(bytes4 _interfaceId) external pure override returns (bool) { + return _interfaceId == type(IERC165).interfaceId || _interfaceId == type(IERC721Receiver).interfaceId + || _interfaceId == type(IERC1155Receiver).interfaceId; + } + + /** + * @notice Erc721 token receiver function + * @dev These functions are called by the token contracts when a token is sent to this contract + */ + function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) + external + override + returns (bytes4) + { + if (_operator == address(this)) { + return this.onERC721Received.selector; + } + } + + /** + * @notice Erc1155 token receiver function + * @dev These functions are called by the token contracts when a token is sent to this contract + */ + function onERC1155Received(address _operator, address _from, uint256 _tokenId, uint256 _value, bytes calldata _data) + external + override + returns (bytes4) + { + if (_operator == address(this)) { + return this.onERC1155Received.selector; + } + } + + /** + * @notice Erc1155 token receiver function + * @dev These functions are called by the token contracts when a set of tokens is sent to this contract + */ + function onERC1155BatchReceived( + address _operator, + address _from, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external override returns (bytes4) { + if (_operator == address(this)) { + return this.onERC1155BatchReceived.selector; + } + } + + function batchMakeDeposit( + address _peanutAddress, + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address[] calldata _pubKeys20 + ) external payable returns (uint256[] memory) { + peanut = PeanutV4(_peanutAddress); + uint256 totalAmount = _amount * _pubKeys20.length; + uint256 etherAmount; + + if (_contractType == 0) { + require(msg.value == totalAmount, "INVALID TOTAL ETHER SENT"); + etherAmount = _amount; + } else if (_contractType == 1) { + IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); + _setAllowanceIfZero(_tokenAddress, address(peanut)); + etherAmount = 0; + } else if (_contractType == 2) { + // revert not implemented + revert("ERC721 batch not implemented"); + } else if (_contractType == 3) { + IERC1155(_tokenAddress).safeTransferFrom(msg.sender, address(this), _tokenId, totalAmount, ""); + IERC1155(_tokenAddress).setApprovalForAll(address(peanut), true); + etherAmount = 0; + } + + uint256[] memory depositIndexes = new uint256[](_pubKeys20.length); + + for (uint256 i = 0; i < _pubKeys20.length; i++) { + depositIndexes[i] = + peanut.makeSelflessDeposit{value: etherAmount}(_tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender); + } + + return depositIndexes; + } + + // Arbitrary but samesy deposit. Assumes all deposits are the same. Gas efficient + function batchMakeDepositNoReturn( + address _peanutAddress, + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address[] calldata _pubKeys20 + ) external payable { + peanut = PeanutV4(_peanutAddress); + + for (uint256 i = 0; i < _pubKeys20.length; i++) { + peanut.makeSelflessDeposit{value: msg.value}(_tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender); + } + } + + // arbitrary deposits + function batchMakeDepositArbitrary( + address _peanutAddress, + address[] memory _tokenAddresses, + uint8[] memory _contractTypes, + uint256[] memory _amounts, + uint256[] memory _tokenIds, + address[] memory _pubKeys20, + bool[] memory _withMFAs + ) external payable returns (uint256[] memory) { + require( + _tokenAddresses.length == _pubKeys20.length && _contractTypes.length == _pubKeys20.length + && _amounts.length == _pubKeys20.length && _tokenIds.length == _pubKeys20.length, + "PARAMETERS LENGTH MISMATCH" + ); + peanut = PeanutV4(_peanutAddress); + + uint256[] memory depositIndexes = new uint256[](_amounts.length); + + for (uint256 i = 0; i < _amounts.length; i++) { + uint256 etherAmount; + + if (_contractTypes[i] == 0) { + etherAmount = _amounts[i]; + } else if (_contractTypes[i] == 1) { + IERC20(_tokenAddresses[i]).safeTransferFrom(msg.sender, address(this), _amounts[i]); + _setAllowanceIfZero(_tokenAddresses[i], _peanutAddress); + etherAmount = 0; + } else if (_contractTypes[i] == 2) { + // revert not implemented + revert("ERC721 batch not implemented"); + } else if (_contractTypes[i] == 3) { + IERC1155(_tokenAddresses[i]).safeTransferFrom(msg.sender, address(this), _tokenIds[i], _amounts[i], ""); + IERC1155(_tokenAddresses[i]).setApprovalForAll(_peanutAddress, true); + etherAmount = 0; + } + + depositIndexes[i] = peanut.makeCustomDeposit{value: etherAmount}( + _tokenAddresses[i], + _contractTypes[i], + _amounts[i], + _tokenIds[i], + _pubKeys20[i], + msg.sender, // deposit ownerm + _withMFAs[i], + address(0), // not recipient-bound + uint40(0), // not recipient-bound + false, // not a EIP-3009 deposit + "" // not a EIP-3009 deposit + ); + } + + return depositIndexes; + } + + function batchMakeDepositRaffle( + address _peanutAddress, + address _tokenAddress, + uint8 _contractType, + uint256[] calldata _amounts, + address _pubKey20 + ) external payable returns (uint256[] memory) { + require( + _contractType == 0 || _contractType == 1, + "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED" + ); + + peanut = PeanutV4(_peanutAddress); + if (_contractType == 1) { + _setAllowanceIfZero(_tokenAddress, _peanutAddress); + uint256 totalAmount; + for(uint256 i = 0; i < _amounts.length; i++) { + totalAmount += _amounts[i]; + } + IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); + } + + uint256[] memory depositIndexes = new uint256[](_amounts.length); + + for (uint256 i = 0; i < _amounts.length; i++) { + uint256 etherAmount; + + if (_contractType == 0) { + etherAmount = _amounts[i]; + } + + depositIndexes[i] = peanut.makeSelflessDeposit{value: etherAmount}( + _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender + ); + } + + return depositIndexes; + } + + function batchMakeDepositRaffleMFA( + address _peanutAddress, + address _tokenAddress, + uint8 _contractType, + uint256[] calldata _amounts, + address _pubKey20 + ) external payable returns (uint256[] memory) { + require( + _contractType == 0 || _contractType == 1, + "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED" + ); + + peanut = PeanutV4(_peanutAddress); + if (_contractType == 1) { + _setAllowanceIfZero(_tokenAddress, _peanutAddress); + uint256 totalAmount; + for(uint256 i = 0; i < _amounts.length; i++) { + totalAmount += _amounts[i]; + } + IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); + } + + uint256[] memory depositIndexes = new uint256[](_amounts.length); + + for (uint256 i = 0; i < _amounts.length; i++) { + uint256 etherAmount; + + if (_contractType == 0) { + etherAmount = _amounts[i]; + } + + depositIndexes[i] = peanut.makeSelflessMFADeposit{value: etherAmount}( + _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender + ); + } + + return depositIndexes; + } +} diff --git a/src/peanut/V4/PeanutRouter.sol b/src/peanut/V4/PeanutRouter.sol new file mode 100644 index 00000000..b9d0c355 --- /dev/null +++ b/src/peanut/V4/PeanutRouter.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.23; + +////////////////////////////////////////////////////////////////////////////////////// +// @title Peanut Router +// @notice This contract is used on top of Peanut V4 to add cross-chain functionality to links. +// more at: https://peanut.to +// @version 0.1.0 +// @author Squirrel Labs +////////////////////////////////////////////////////////////////////////////////////// + +import {PeanutV4} from "./PeanutV4.4.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract PeanutV4Router is Ownable { + using SafeERC20 for IERC20; + + address public squidAddress; + + constructor(address _squidAddress) Ownable(msg.sender) { + squidAddress = _squidAddress; + } + + /** + * @notice Function to withdraw a peanut deposit to a different chain. + * @param _peanutAddress peanut vault to withdraw the deposit from. + * @param _depositIndex index of the deposit in the peanut vault. + * @param _withdrawalSignature signature to withdraw from peanut. + * @param _squidFee squid router fee. + * @param _peanutFee fee amount taken by peanut (this contract) for routing. + * @param _squidData calldata for the squid router + * @param _routingSignature signed _squidFee, _peanutFee and _squidData + */ + function withdrawAndBridge( + address _peanutAddress, + uint256 _depositIndex, + bytes calldata _withdrawalSignature, + uint256 _squidFee, + uint256 _peanutFee, + bytes calldata _squidData, + bytes calldata _routingSignature + ) public payable { + PeanutV4 peanut = PeanutV4(_peanutAddress); + PeanutV4.Deposit memory deposit = peanut.getDeposit(_depositIndex); + + // We must first validate _routingSignature to prevent front-running + // The signature structure follows version 0x00 from EIP-191 + bytes32 digest = keccak256( + abi.encodePacked( + bytes2(0x1900), + address(this), + block.chainid, + _peanutAddress, + _depositIndex, + squidAddress, + _squidFee, + _peanutFee, + _squidData + ) + ); + address routingSigner = ECDSA.recover(digest, _routingSignature); + require(routingSigner == deposit.pubKey20, "WRONG ROUTING SIGNER"); + + require(_squidFee == msg.value, "msg.value MUST BE THE SQUID FEE"); + require(deposit.contractType == 0 || deposit.contractType == 1, "X-CHAIN CLAIMS WORK ONLY FOR ETH AND ERC20 TOKENS"); + require(_peanutFee < deposit.amount, "TOO HIGH FEE"); + + peanut.withdrawDepositAsRecipient(_depositIndex, address(this), _withdrawalSignature); + + uint256 amountToBridge = deposit.amount - _peanutFee; + uint256 ethAmountToSquid = msg.value; + if (deposit.contractType == 0) { // ETH deposit + ethAmountToSquid += amountToBridge; + } else if (deposit.contractType == 1) { // ERC20 deposit + IERC20(deposit.tokenAddress).safeIncreaseAllowance(address(squidAddress), amountToBridge); + } else { + revert("UNSUPPORTED contractType"); + } + + // initiate the cross-chain transfer + (bool success,) = payable(squidAddress).call{value: ethAmountToSquid}(_squidData); + require(success, "FAILED TO INITIATE SQUID TRANSFER"); + } + + function withdrawFees(address token, address to, uint256 amount) public onlyOwner { + if (token == address(0)) { + (bool success,) = payable(to).call{value: amount}(""); + require(success, "FAILED TO WITHDRAW ETH"); + } else { + IERC20(token).transfer(to, amount); + } + } + + receive() external payable {} // allow ETH transfers from peanut vault +} diff --git a/src/peanut/V4/PeanutV4.4.sol b/src/peanut/V4/PeanutV4.4.sol new file mode 100644 index 00000000..6c3ad656 --- /dev/null +++ b/src/peanut/V4/PeanutV4.4.sol @@ -0,0 +1,883 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.23; + +////////////////////////////////////////////////////////////////////////////////////// +// @title Peanut Protocol +// @notice This contract is used to send non front-runnable link payments. These can +// be erc20, erc721, erc1155 or just plain eth. The recipient address is arbitrary. +// Links use asymmetric ECDSA encryption by default to be secure & enable trustless, +// gasless claiming. +// more at: https://peanut.to +// @version 0.4.4 +// @author Squirrel Labs +////////////////////////////////////////////////////////////////////////////////////// +//⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⢀⣀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣶⣦⣌⠙⠋⢡⣴⣶⡄⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠀⣿⣿⣿⡿⢋⣠⣶⣶⡌⠻⣿⠟⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⡆⠸⠟⢁⣴⣿⣿⣿⣿⣿⡦⠉⣴⡇⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣾⣿⠟⠀⠰⣿⣿⣿⣿⣿⣿⠟⣠⡄⠹⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⢸⡿⢋⣤⣿⣄⠙⣿⣿⡿⠟⣡⣾⣿⣿⠀⠀⠀ +// ⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣾⠿⠀⢠⣾⣿⣿⣿⣦⠈⠉⢠⣾⣿⣿⣿⠏⠀⠀⠀ +// ⠀⠀⠀⠀⣀⣤⣦⣄⠙⠋⣠⣴⣿⣿⣿⣿⠿⠛⢁⣴⣦⡄⠙⠛⠋⠁⠀⠀⠀⠀ +// ⠀⠀⢀⣾⣿⣿⠟⢁⣴⣦⡈⠻⣿⣿⡿⠁⡀⠚⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠘⣿⠟⢁⣴⣿⣿⣿⣿⣦⡈⠛⢁⣼⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⢰⡦⠀⢴⣿⣿⣿⣿⣿⣿⣿⠟⢀⠘⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠘⢀⣶⡀⠻⣿⣿⣿⣿⡿⠋⣠⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⢿⣿⣿⣦⡈⠻⣿⠟⢁⣼⣿⣿⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠈⠻⣿⣿⣿⠖⢀⠐⠿⠟⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +// ⠀⠀⠀⠀⠈⠉⠁⠀⠀⠀⠀⠀ +// +////////////////////////////////////////////////////////////////////////////////////// + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {IL2ECO} from "../util/IL2ECO.sol"; +import {IEIP3009} from "../util/IEIP3009.sol"; + +contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { + using SafeERC20 for IERC20; + + struct Deposit { + address pubKey20; // (20 bytes) last 20 bytes of the hash of the public key for the deposit + uint256 amount; // (32 bytes) amount of the asset being sent + ///// tokenAddress, contractType, tokenId, claimed & timestamp are stored in a single 32 byte word + address tokenAddress; // (20 bytes) address of the asset being sent. 0x0 for eth + uint8 contractType; // (1 byte) 0 for eth, 1 for erc20, 2 for erc721, 3 for erc1155 4 for ECO-like rebasing erc20 + bool claimed; // (1 byte) has this deposit been claimed + bool requiresMFA; // (1 byte) is additional auth (MFA) required? + uint40 timestamp; // ( 5 bytes) timestamp of the deposit + ///// + uint256 tokenId; // (32 bytes) id of the token being sent (if erc721 or erc1155) + address senderAddress; // (20 bytes) address of the sender + ///// slot for address-bound links data + address recipient; // unless it's 0x00, only this address can claim the link + uint40 reclaimableAfter; // for address-bound links, the sender is able to re-claim only after this timestamp + } // 6 storage slots (32 byte each) + + // We may include this hash in peanut-specific signatures to make sure + // that the message signed by the user has effects only in peanut contracts. + bytes32 public constant PEANUT_SALT = 0x70adbbeba9d4f0c82e28dd574f15466f75df0543b65f24460fc445813b5d94e0; // keccak256("Konrad makes tokens go woosh tadam"); + + bytes32 public constant ANYONE_WITHDRAWAL_MODE = 0x0000000000000000000000000000000000000000000000000000000000000000; // default. Any address can trigger the withdrawal function + bytes32 public constant RECIPIENT_WITHDRAWAL_MODE = 0x2bb5bef2b248d3edba501ad918c3ab524cce2aea54d4c914414e1c4401dc4ff4; // keccak256("only recipient") - only the signed recipient can trigger the withdrawal function + + bytes32 public DOMAIN_SEPARATOR; // initialized in the constructor + + bytes32 public constant EIP712DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + address public constant MFA_AUTHORIZER = 0x3B14D43Bf521EF7FD9600533bEB73B6e9178DE7C; + + struct EIP712Domain { + string name; + string version; + uint256 chainId; + address verifyingContract; + } + + bytes32 public constant GASLESS_RECLAIM_TYPEHASH = keccak256("GaslessReclaim(uint256 depositIndex)"); + + struct GaslessReclaim { + uint256 depositIndex; + } + + Deposit[] public deposits; // array of deposits + address public ecoAddress; // address of the ECO token + + // events + event DepositEvent( + uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _senderAddress + ); + event WithdrawEvent( + uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _recipientAddress + ); + event MessageEvent(string message); + + // constructor. Accepts ECO token address to prohibit ECO usage in normal + // ERC20 deposits. + // Initializes DOMAIN_SEPARATOR. + // Wishes you a nutty day. + constructor(address _ecoAddress) { + emit MessageEvent("Hello World, have a nutty day!"); + ecoAddress = _ecoAddress; + DOMAIN_SEPARATOR = hash( + EIP712Domain({name: "Peanut", version: "4.4", chainId: block.chainid, verifyingContract: address(this)}) + ); + } + + function hash(EIP712Domain memory eip712Domain) internal pure returns (bytes32) { + return keccak256( + abi.encode( + EIP712DOMAIN_TYPEHASH, + keccak256(bytes(eip712Domain.name)), + keccak256(bytes(eip712Domain.version)), + eip712Domain.chainId, + eip712Domain.verifyingContract + ) + ); + } + + function hash(GaslessReclaim memory reclaim) internal pure returns (bytes32) { + return keccak256(abi.encode(GASLESS_RECLAIM_TYPEHASH, reclaim.depositIndex)); + } + + /** + * @notice Recover a EIP-712 signed gasless reclaim message + * @param reclaim the reclaim request + * @param signer the expected signer of the reclaim request + * @param signature r-s-v if the signer is an EOA or any random bytes if the signer is a smart contract + */ + function verifyGaslessReclaim(GaslessReclaim memory reclaim, address signer, bytes memory signature) + internal + view + { + // Note: we need to use `encodePacked` here instead of `encode`. + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, hash(reclaim))); + // By using SignatureChecker we support both EOAs and smart contract wallets + bool valid = SignatureChecker.isValidSignatureNow(signer, digest, signature); + require(valid, "INVALID SIGNATURE"); + } + + /** + * @notice supportsInterface function + * @dev ERC165 interface detection + * @param _interfaceId bytes4 the interface identifier, as specified in ERC-165 + * @return bool true if the contract implements the interface specified in _interfaceId + */ + function supportsInterface(bytes4 _interfaceId) external pure override returns (bool) { + return _interfaceId == type(IERC165).interfaceId || _interfaceId == type(IERC721Receiver).interfaceId + || _interfaceId == type(IERC1155Receiver).interfaceId; + } + + /* + * A minimalistic function to make a deposit. + * @deprecated makeCustomDeposit should be used for everything + */ + function makeDeposit( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address _pubKey20 + ) public payable nonReentrant returns (uint256) { + _amount = _pullTokensViaApproval( + _tokenAddress, + _contractType, + _amount, + _tokenId + ); + return _storeDeposit( + _tokenAddress, + _contractType, + _amount, + _tokenId, + _pubKey20, + msg.sender, // the sender is the onBehalfOf here + false, // no MFA + address(0), // no restrictions on the recipient + 0 // no restrictions on the recipient + ); + } + + /* + * Makes a minimalistic with MFA (requires an external authorisation to withdraw). + * @deprecated makeCustomDeposit should be used for everything + */ + function makeMFADeposit( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address _pubKey20 + ) public payable nonReentrant returns (uint256) { + _amount = _pullTokensViaApproval( + _tokenAddress, + _contractType, + _amount, + _tokenId + ); + return _storeDeposit( + _tokenAddress, + _contractType, + _amount, + _tokenId, + _pubKey20, + msg.sender, // the sender is the onBehalfOf here + true, // with MFA + address(0), // no restrictions on the recipient + 0 // no restrictions on the recipient + ); + } + + /* + * Minimalistic function to make an MFA deposit and delegate ownership of the deposit. + * @deprecated makeCustomDeposit should be used for everything + */ + function makeSelflessMFADeposit( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address _pubKey20, + address _onBehalfOf + ) public payable nonReentrant returns (uint256) { + _amount = _pullTokensViaApproval( + _tokenAddress, + _contractType, + _amount, + _tokenId + ); + return _storeDeposit( + _tokenAddress, + _contractType, + _amount, + _tokenId, + _pubKey20, + _onBehalfOf, + true, // with MFA + address(0), // no restrictions on the recipient + 0 // no restrictions on the recipient + ); + } + + /* + * Minimalistic function to make a deposit and delegate ownership. + * @deprecated makeCustomDeposit should be used for everything + */ + function makeSelflessDeposit( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address _pubKey20, + address _onBehalfOf + ) public payable nonReentrant returns (uint256) { + _amount = _pullTokensViaApproval( + _tokenAddress, + _contractType, + _amount, + _tokenId + ); + return _storeDeposit( + _tokenAddress, + _contractType, + _amount, + _tokenId, + _pubKey20, + _onBehalfOf, + false, // no MFA + address(0), // no restrictions on the recipient + 0 // no restrictions on the recipient + ); + } + + /** + * The big main function that supports ALL possible scenarios of depositing. + * @dev For token deposits, allowance must be set before calling this function + * @param _tokenAddress address of the token being sent. 0x0 for eth + * @param _contractType uint8 for the type of contract being sent. 0 for eth, 1 for erc20, 2 for erc721, 3 for erc1155, 4 for ECO-like rebasing erc20 + * @param _amount uint256 of the amount of tokens being sent (if erc20) + * @param _tokenId uint256 of the id of the token being sent if erc721 or erc1155 + * @param _pubKey20 last 20 bytes of the public key of the deposit signer + * @param _onBehalfOf who will be able to reclaim the link if the private key is lost + * @param _withMFA whether an external auhorisation is required for withdrawal + * @param _recipient if not 0x00.00, only _recipient will be able to withdraw + * @param _reclaimableAfter if _recipient is set, the sender will be able to reclaim only after this timestamp + * @param _isGasless3009 if true, the deposit will be made via eip-3009, see makeDepositWithAuthorization funfction for more info + * @param _args3009 all the arguments for an EIP-3009 deposit, used if _isGasless3009 is true. Encoded with abi.encode, this is: address (from), bytes32 (_nonce), uint256 (_validAfter), uint256 (_validBefore), uint8 (_v), bytes32 (_r), bytes32 (_s). Unfortunately we have to encode it this way, because else we get a stack too deep error (EVM supports max 16 variables on the stack). + * @return uint256 index of the deposit + */ + function makeCustomDeposit( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address _pubKey20, + address _onBehalfOf, + bool _withMFA, + // arguments for address-bound deposits + address _recipient, + uint40 _reclaimableAfter, + // arguments for 3009 + bool _isGasless3009, + bytes calldata _args3009 + ) public payable nonReentrant returns (uint256) { + if (_isGasless3009) { + require(_contractType == 1, "_contractType HAS TO BE 1 FOR 3009"); + _amount = _pullTokensVia3009Encoded( + _tokenAddress, + _amount, + _pubKey20, + _onBehalfOf, + _args3009 + ); + } else { + _amount = _pullTokensViaApproval( + _tokenAddress, + _contractType, + _amount, + _tokenId + ); + } + + return _storeDeposit( + _tokenAddress, + _contractType, + _amount, + _tokenId, + _pubKey20, + _onBehalfOf, + _withMFA, + _recipient, + _reclaimableAfter + ); + } + + function _storeDeposit( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address _pubKey20, + address _onBehalfOf, + bool _requiresMFA, + address _recipient, + uint40 _reclaimableAfter + ) internal returns (uint256) { + // create deposit + deposits.push( + Deposit({ + tokenAddress: _tokenAddress, + contractType: _contractType, + amount: _amount, + tokenId: _tokenId, + claimed: false, + pubKey20: _pubKey20, + senderAddress: _onBehalfOf, + timestamp: uint40(block.timestamp), + requiresMFA: _requiresMFA, + recipient: _recipient, + reclaimableAfter: _reclaimableAfter + }) + ); + + // emit the deposit event + emit DepositEvent(deposits.length - 1, _contractType, _amount, _onBehalfOf); + + // return id of new deposit + return deposits.length - 1; + } + + /** + * Pulls tokens from msg.sender via a standard approval. + * @return IMPORTANT: returns the amount that has been actually deposited. MUST be used by the caller. + */ + function _pullTokensViaApproval( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId + ) internal returns (uint256) { + // check that the contract type is valid + require(_contractType < 5, "INVALID CONTRACT TYPE"); + + // handle deposit types + if (_contractType == 0) { + require(_amount == msg.value, "WRONG ETH AMOUNT"); + } else if (_contractType == 1) { + // REMINDER: User must approve this contract to spend the tokens before calling this function + // Unfortunately there's no way of doing this in just one transaction. + // Wallet abstraction pls + + // If ECO is deposited as a normal ERC20 and then inflation is increased, + // the recipient would get more tokens than what was deposited. + require(_tokenAddress != ecoAddress, "ECO DEPOSITS MUST USE _contractType 4"); + + IERC20 token = IERC20(_tokenAddress); + + // transfer the tokens to the contract + token.safeTransferFrom(msg.sender, address(this), _amount); + } else if (_contractType == 2) { + // REMINDER: User must approve this contract to spend the tokens before calling this function. + require(_amount == 1, "AMOUNT MUST BE 1 FOR ERC721"); + + IERC721 token = IERC721(_tokenAddress); + // require(token.ownerOf(_tokenId) == msg.sender, "Invalid token id"); + token.safeTransferFrom(msg.sender, address(this), _tokenId, "Internal transfer"); + } else if (_contractType == 3) { + // REMINDER: User must approve this contract to spend the tokens before calling this function. + + IERC1155 token = IERC1155(_tokenAddress); + token.safeTransferFrom(msg.sender, address(this), _tokenId, _amount, "Internal transfer"); + } else if (_contractType == 4) { + // REMINDER: User must approve this contract to spend the tokens before calling this function + IL2ECO token = IL2ECO(_tokenAddress); + + // transfer the tokens to the contract + require( + token.transferFrom(msg.sender, address(this), _amount), "TRANSFER FAILED. CHECK ALLOWANCE & BALANCE" + ); + + // calculate the rebase invariant amount to store in the deposits array + _amount *= token.linearInflationMultiplier(); + } + + return _amount; + } + + /** + * Pulls the tokens via EIP-3009 according to the encoded data + * Also validates that _onBehalfOf is the unpacked _from. + */ + function _pullTokensVia3009Encoded( + address _tokenAddress, + uint256 _amount, + address _pubKey20, + address _onBehalfOf, + bytes calldata _encodedArgs + ) internal returns (uint256) { + address _from; + bytes32 _nonce; + uint256 _validAfter; + uint256 _validBefore; + uint8 _v; + bytes32 _r; + bytes32 _s; + + (_from, _nonce, _validAfter, _validBefore, _v, _r, _s) = + abi.decode(_encodedArgs, (address, bytes32, uint256, uint256, uint8, bytes32, bytes32)); + + require(_from == _onBehalfOf, "WRONG _onBehalfOf FOR EIP-3009"); + return _pullTokensVia3009(_tokenAddress, _from, _amount, _pubKey20, _nonce, _validAfter, _validBefore, _v, _r, _s); + } + + /** + * Performs a EIP-3009 transfer for tokens like USDC. + * Reverts if the transfer failed. + * Returns the amount of actually deposited tokens. + */ + function _pullTokensVia3009( + address _tokenAddress, + address _from, + uint256 _amount, + address _pubKey20, + bytes32 _nonce, + uint256 _validAfter, + uint256 _validBefore, + uint8 _v, + bytes32 _r, + bytes32 _s + ) internal returns(uint256) { + // Recalculate the nonce. + // If we don't include pubKey20 in the nonce, the link will be front-runnable + bytes32 nonce = keccak256(abi.encodePacked(_pubKey20, _nonce)); + + IEIP3009 token = IEIP3009(_tokenAddress); + token.receiveWithAuthorization( + _from, + address(this), // to + _amount, + _validAfter, + _validBefore, + nonce, + _v, + _r, + _s + ); + + return _amount; + } + + /** + * @notice Function to make a deposit with EIP-3009 authorization + * @dev No need to pre-approve tokens! + * @param _tokenAddress address of the token being sent + * @param _from the depositor of the tokens + * @param _amount uint256 of the amount of tokens being sent + * @param _pubKey20 last 20 bytes of the public key of the deposit signer + * @param _nonce a unique value + * @param _validAfter deposit is valid only after this timestamp (in seconds) + * @param _validBefore deposit is valid only before this timestamp (in seconds) + * @param _v v of the signature + * @param _r r of the signature + * @param _s s of the signature + * @return uint256 index of the deposit + */ + function makeDepositWithAuthorization( + address _tokenAddress, + address _from, + uint256 _amount, + address _pubKey20, + bytes32 _nonce, + uint256 _validAfter, + uint256 _validBefore, + uint8 _v, + bytes32 _r, + bytes32 _s + ) public nonReentrant returns (uint256) { + // If ECO is deposited as a normal ERC20 and then inflation is increased, + // the recipient would get more tokens than what was deposited. + require(_tokenAddress != ecoAddress, "ECO must be be deposited via makeDeposit with tokenType 4"); + + _pullTokensVia3009( + _tokenAddress, + _from, + _amount, + _pubKey20, + _nonce, + _validAfter, + _validBefore, + _v, + _r, + _s + ); + + return _storeDeposit( + _tokenAddress, + 1, // contractType is always 1 here (ERC20) + _amount, + 0, // it's alwasy ERC20, so tokenId doesn't matter + _pubKey20, + _from, + false, // no MFA + address(0), // no restrictions on the recipient + 0 // no restrictions on the recipient + ); + } + + /** + * @notice Erc721 token receiver function + * @dev These functions are called by the token contracts when a token is sent to this contract + */ + function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) + external + override + returns (bytes4) + { + if (_operator == address(this)) { + return this.onERC721Received.selector; + } + } + + /** + * @notice Erc1155 token receiver function + * @dev These functions are called by the token contracts when a token is sent to this contract + */ + function onERC1155Received(address _operator, address _from, uint256 _tokenId, uint256 _value, bytes calldata _data) + external + override + returns (bytes4) + { + if (_operator == address(this)) { + return this.onERC1155Received.selector; + } + } + + /** + * @notice Erc1155 token receiver function + * @dev These functions are called by the token contracts when a set of tokens is sent to this contract + */ + function onERC1155BatchReceived( + address _operator, + address _from, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external override returns (bytes4) { + if (_operator == address(this)) { + return this.onERC1155BatchReceived.selector; + } + } + + /** + * @notice Function to withdraw tokens. Can be called by anyone. + * @return bool true if successful + */ + function withdrawDeposit( + uint256 _index, + address _recipientAddress, + bytes memory _signature + ) external nonReentrant returns (bool) { + return _withdrawDeposit( + _index, + _recipientAddress, + ANYONE_WITHDRAWAL_MODE, + _signature, + false + ); + } + + /** + * @notice Function to withdraw tokens with MFA. + * @return bool true if successful + */ + function withdrawMFADeposit( + uint256 _index, + address _recipientAddress, + bytes memory _signature, + bytes memory _MFASignature + ) external nonReentrant returns (bool) { + // Verify the MFA signature + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + PEANUT_SALT, + block.chainid, + address(this), + _index, + _recipientAddress + ) + ) + ); + address authorizationSigner = getSigner(digest, _MFASignature); + require(authorizationSigner == MFA_AUTHORIZER, "WRONG MFA SIGNATURE"); + + return _withdrawDeposit( + _index, + _recipientAddress, + ANYONE_WITHDRAWAL_MODE, + _signature, + true + ); + } + + /** + * @notice Function to withdraw tokens. Must be called by the recipient. + * This is useful for + * @return bool true if successful + */ + function withdrawDepositAsRecipient( + uint256 _index, + address _recipientAddress, + bytes memory _signature + ) external nonReentrant returns (bool) { + require(_recipientAddress == msg.sender, "NOT THE RECIPIENT"); + + return _withdrawDeposit( + _index, + _recipientAddress, + RECIPIENT_WITHDRAWAL_MODE, + _signature, + false + ); + } + + /** + * @notice Function to withdraw a deposit. Withdraws the deposit to the recipient address. + * @dev _recipientAddressHash is hash("\x19Ethereum Signed Message:\n32" + hash(_recipientAddress)) + * @dev The signature should be signed with the private key corresponding to the public key stored in the deposit + * @dev We don't check the unhashed address for security reasons. It's preferable to sign a hash of the address. + * @param _index uint256 index of the deposit + * @param _recipientAddress address of the recipient + * @param _extraData extra data that has to be signed by the user + * @param _signature bytes signature of the recipient address (65 bytes) + * @return bool true if successful + */ + function _withdrawDeposit( + uint256 _index, + address _recipientAddress, + bytes32 _extraData, + bytes memory _signature, + bool _authorized + ) internal returns (bool) { + // check that the deposit exists and that it isn't already withdrawn + require(_index < deposits.length, "DEPOSIT INDEX DOES NOT EXIST"); + Deposit memory _deposit = deposits[_index]; + require(_deposit.claimed == false, "DEPOSIT ALREADY WITHDRAWN"); + + // check that the signer is the same as the one stored in the deposit. + // Signature may be empty for address-bound deposits. + address depositSigner; + if (_signature.length > 0) { + // Compute the hash of the withdrawal message + bytes32 _recipientAddressHash = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + PEANUT_SALT, + block.chainid, + address(this), + _index, + _recipientAddress, + _extraData + ) + ) + ); + depositSigner = getSigner(_recipientAddressHash, _signature); + } + require(!_deposit.requiresMFA || _authorized, "REQUIRES AUTHORIZATION"); + require(_deposit.pubKey20 == address(0) || depositSigner == _deposit.pubKey20, "WRONG SIGNATURE"); + require(_deposit.recipient == address(0) || _recipientAddress == _deposit.recipient, "WRONG RECIPIENT"); + + // emit the withdraw event + emit WithdrawEvent(_index, _deposit.contractType, _deposit.amount, _recipientAddress); + + // mark as claimed + deposits[_index].claimed = true; + + // Deposit request is valid. Withdraw the deposit to the recipient address. + if (_deposit.contractType == 0) { + /// handle eth deposits + (bool success,) = _recipientAddress.call{value: _deposit.amount}(""); + require(success, "Transfer failed"); + } else if (_deposit.contractType == 1) { + /// handle erc20 deposits + IERC20 token = IERC20(_deposit.tokenAddress); + token.safeTransfer(_recipientAddress, _deposit.amount); + } else if (_deposit.contractType == 2) { + /// handle erc721 deposits + IERC721 token = IERC721(_deposit.tokenAddress); + token.safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId); + } else if (_deposit.contractType == 3) { + /// handle erc1155 deposits + IERC1155 token = IERC1155(_deposit.tokenAddress); + token.safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId, _deposit.amount, ""); + } else if (_deposit.contractType == 4) { + /// handle rebasing erc20 deposits on l2 + IL2ECO token = IL2ECO(_deposit.tokenAddress); + uint256 scaledAmount = _deposit.amount / token.linearInflationMultiplier(); + require(token.transfer(_deposit.senderAddress, scaledAmount), "TRANSFER FAILED"); + } + + return true; + } + + /** + * @notice Function to allow a sender to withdraw their deposit after 24 hours + * @param _index uint256 index of the deposit + * @param _senderAddress the address of the depositor + * @return bool true if successful + */ + function _withdrawDepositSender(uint256 _index, address _senderAddress) internal returns (bool) { + // check that the deposit exists + require(_index < deposits.length, "DEPOSIT INDEX DOES NOT EXIST"); + Deposit memory _deposit = deposits[_index]; + require(_deposit.claimed == false, "DEPOSIT ALREADY WITHDRAWN"); + // check that the sender is the one who made the deposit + require(_deposit.senderAddress == _senderAddress, "NOT THE SENDER"); + // check timestamp for address-bound links + if (_deposit.recipient != address(0)) { + require(block.timestamp > _deposit.reclaimableAfter, "TOO EARLY TO RECLAIM"); + } + + // emit the withdraw event + emit WithdrawEvent(_index, _deposit.contractType, _deposit.amount, _deposit.senderAddress); + + // Delete the deposit + deposits[_index].claimed = true; + + if (_deposit.contractType == 0) { + /// handle eth deposits + (bool success,) = payable(_deposit.senderAddress).call{value: _deposit.amount}(""); + require(success, "FAILED TO WITHDRAW ETH TO SENDER"); + } else if (_deposit.contractType == 1) { + /// handle erc20 deposits + IERC20 token = IERC20(_deposit.tokenAddress); + token.safeTransfer(_deposit.senderAddress, _deposit.amount); + } else if (_deposit.contractType == 2) { + /// handle erc721 deposits + IERC721 token = IERC721(_deposit.tokenAddress); + token.safeTransferFrom(address(this), _deposit.senderAddress, _deposit.tokenId); + } else if (_deposit.contractType == 3) { + /// handle erc1155 deposits + IERC1155 token = IERC1155(_deposit.tokenAddress); + token.safeTransferFrom(address(this), _deposit.senderAddress, _deposit.tokenId, _deposit.amount, ""); + } else if (_deposit.contractType == 4) { + /// handle rebasing erc20 deposits on l2 + IL2ECO token = IL2ECO(_deposit.tokenAddress); + uint256 scaledAmount = _deposit.amount / token.linearInflationMultiplier(); + require(token.transfer(_deposit.senderAddress, scaledAmount), "TRANSFER FAILED"); + } + + return true; + } + + function withdrawDepositSender(uint256 _index) external nonReentrant returns (bool) { + return _withdrawDepositSender(_index, msg.sender); + } + + function withdrawDepositSenderGasless(GaslessReclaim calldata reclaim, address signer, bytes calldata signature) + external + nonReentrant + returns (bool) + { + verifyGaslessReclaim(reclaim, signer, signature); + return _withdrawDepositSender(reclaim.depositIndex, signer); + } + + //// Some utility functions //// + + /** + * @notice Gets the signer of a messageHash. Used for signature verification. + * @dev Uses ECDSA.recover. On Frontend, use secp256k1 to sign the messageHash + * @dev also remember to prepend the messageHash with "\x19Ethereum Signed Message:\n32" + * @param messageHash bytes32 hash of the message + * @param signature bytes signature of the message + * @return address of the signer + */ + function getSigner(bytes32 messageHash, bytes memory signature) public pure returns (address) { + address signer = ECDSA.recover(messageHash, signature); + return signer; + } + + /** + * @notice Simple way to get the total number of deposits + * @return uint256 number of deposits + */ + function getDepositCount() external view returns (uint256) { + return deposits.length; + } + + /** + * @notice Simple way to get single deposit + * @param _index uint256 index of the deposit + * @return Deposit struct + */ + function getDeposit(uint256 _index) external view returns (Deposit memory) { + return deposits[_index]; + } + + /** + * @notice Get all deposits in contract + * @return Deposit[] array of deposits + */ + function getAllDeposits() external view returns (Deposit[] memory) { + return deposits; + } + + /** + * @notice Get all deposits for a given address + * @param _address address of the deposits + * @return Deposit[] array of deposits + */ + function getAllDepositsForAddress(address _address) external view returns (Deposit[] memory) { + uint256 count = 0; + for (uint256 i = 0; i < deposits.length; i++) { + if (deposits[i].senderAddress == _address) { + count++; + } + } + + Deposit[] memory _deposits = new Deposit[](count); + + count = 0; + // Second loop to populate the array + for (uint256 i = 0; i < deposits.length; i++) { + if (deposits[i].senderAddress == _address) { + _deposits[count] = deposits[i]; + count++; + } + } + return _deposits; + } + + // and that's all! Have a nutty day! +} diff --git a/src/peanut/util/ECRecover.sol b/src/peanut/util/ECRecover.sol new file mode 100644 index 00000000..876f88b0 --- /dev/null +++ b/src/peanut/util/ECRecover.sol @@ -0,0 +1,47 @@ +/** + * SPDX-License-Identifier: MIT + * + * Copyright (c) 2016-2019 zOS Global Limited + * Copyright (c) 2018-2020 CENTRE SECZ + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +pragma solidity ^0.8.23; + +/** + * @title ECRecover + * @notice A library that provides a safe ECDSA recovery function + */ +library ECRecover { + function recover(bytes32 digest, uint8 v, bytes32 r, bytes32 s) internal pure returns (address) { + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { + revert("ECRecover: invalid signature 's' value"); + } + + if (v != 27 && v != 28) { + revert("ECRecover: invalid signature 'v' value"); + } + + address signer = ecrecover(digest, v, r, s); + require(signer != address(0), "ECRecover: invalid signature"); + + return signer; + } +} diff --git a/src/peanut/util/EIP3009Implementation.sol b/src/peanut/util/EIP3009Implementation.sol new file mode 100644 index 00000000..278e7c40 --- /dev/null +++ b/src/peanut/util/EIP3009Implementation.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {EIP3009Internals} from "./EIP3009Internals.sol"; +import {IEIP3009} from "./IEIP3009.sol"; + +// Basic implementation of EIP3009 for testing purposes ONLY. +abstract contract EIP3009Implementation is EIP3009Internals, IEIP3009 { + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external override { + _transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); + } + + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external override { + _receiveWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s); + } + + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external override { + _cancelAuthorization(authorizer, nonce, v, r, s); + } +} diff --git a/src/peanut/util/EIP3009Internals.sol b/src/peanut/util/EIP3009Internals.sol new file mode 100644 index 00000000..034bedf8 --- /dev/null +++ b/src/peanut/util/EIP3009Internals.sol @@ -0,0 +1,101 @@ +/** + * SPDX-License-Identifier: MIT + * + * Copyright (c) 2018-2020 CENTRE SECZ + */ + +pragma solidity ^0.8.23; + +import {EIP712Domain} from "./EIP712Domain.sol"; +import {EIP712} from "./EIP712.sol"; +import {IEIP3009} from "./IEIP3009.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +abstract contract EIP3009Internals is EIP712Domain, ERC20 { + bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = + 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267; + bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8; + bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH = + 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429; + + mapping(address => mapping(bytes32 => bool)) private _authorizationStates; + + event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); + event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce); + + function authorizationState(address authorizer, bytes32 nonce) external view returns (bool) { + return _authorizationStates[authorizer][nonce]; + } + + function _transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) internal { + _requireValidAuthorization(from, nonce, validAfter, validBefore); + + bytes memory data = + abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce); + require(EIP712.recover(DOMAIN_SEPARATOR, v, r, s, data) == from, "FiatTokenV2: invalid signature"); + + _markAuthorizationAsUsed(from, nonce); + _transfer(from, to, value); + } + + function _receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) internal { + require(to == msg.sender, "FiatTokenV2: caller must be the payee"); + _requireValidAuthorization(from, nonce, validAfter, validBefore); + + bytes memory data = + abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce); + require(EIP712.recover(DOMAIN_SEPARATOR, v, r, s, data) == from, "FiatTokenV2: invalid signature"); + + _markAuthorizationAsUsed(from, nonce); + _transfer(from, to, value); + } + + function _cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) internal { + _requireUnusedAuthorization(authorizer, nonce); + + bytes memory data = abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce); + require(EIP712.recover(DOMAIN_SEPARATOR, v, r, s, data) == authorizer, "FiatTokenV2: invalid signature"); + + _authorizationStates[authorizer][nonce] = true; + emit AuthorizationCanceled(authorizer, nonce); + } + + function _requireUnusedAuthorization(address authorizer, bytes32 nonce) private view { + require(!_authorizationStates[authorizer][nonce], "FiatTokenV2: authorization is used or canceled"); + } + + function _requireValidAuthorization(address authorizer, bytes32 nonce, uint256 validAfter, uint256 validBefore) + private + view + { + require(block.timestamp > validAfter, "FiatTokenV2: authorization is not yet valid"); + require(block.timestamp < validBefore, "FiatTokenV2: authorization is expired"); + _requireUnusedAuthorization(authorizer, nonce); + } + + function _markAuthorizationAsUsed(address authorizer, bytes32 nonce) private { + _authorizationStates[authorizer][nonce] = true; + emit AuthorizationUsed(authorizer, nonce); + } +} diff --git a/src/peanut/util/EIP712.sol b/src/peanut/util/EIP712.sol new file mode 100644 index 00000000..516a88eb --- /dev/null +++ b/src/peanut/util/EIP712.sol @@ -0,0 +1,37 @@ +/** + * SPDX-License-Identifier: MIT + * + * Copyright (c) 2018-2020 CENTRE SECZ + */ + +pragma solidity ^0.8.23; + +import {ECRecover} from "./ECRecover.sol"; + +library EIP712 { + function makeDomainSeparator(string memory name, string memory version) internal view returns (bytes32) { + uint256 chainId; + assembly { + chainId := chainid() + } + return keccak256( + abi.encode( + // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") + 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, + keccak256(bytes(name)), + keccak256(bytes(version)), + chainId, + address(this) + ) + ); + } + + function recover(bytes32 domainSeparator, uint8 v, bytes32 r, bytes32 s, bytes memory typeHashAndData) + internal + pure + returns (address) + { + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, keccak256(typeHashAndData))); + return ECRecover.recover(digest, v, r, s); + } +} diff --git a/src/peanut/util/EIP712Domain.sol b/src/peanut/util/EIP712Domain.sol new file mode 100644 index 00000000..d5f6de5e --- /dev/null +++ b/src/peanut/util/EIP712Domain.sol @@ -0,0 +1,15 @@ +/** + * SPDX-License-Identifier: MIT + * + * Copyright (c) 2018-2020 CENTRE SECZ + */ + +pragma solidity ^0.8.23; + +contract EIP712Domain { + /** + * @dev EIP712 Domain Separator + * @dev The value is the current DOMAIN_SEPARATOR of USDC on Polygon (used by tests as a fixed value) + */ + bytes32 public DOMAIN_SEPARATOR = 0xcaa2ce1a5703ccbe253a34eb3166df60a705c561b44b192061e28f2a985be2ca; +} diff --git a/src/peanut/util/ERC1155Mock.sol b/src/peanut/util/ERC1155Mock.sol new file mode 100644 index 00000000..425c4ede --- /dev/null +++ b/src/peanut/util/ERC1155Mock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; + +contract ERC1155Mock is ERC1155 { + constructor() ERC1155("https://example.com/{id}.json") { + _mint(0x6B3751c5b04Aa818EA90115AA06a4D9A36A16f02, 1, 100000, ""); + } + + function mint(address account, uint256 id, uint256 amount, bytes memory data) external { + _mint(account, id, amount, data); + } +} diff --git a/src/peanut/util/ERC20Mock.sol b/src/peanut/util/ERC20Mock.sol new file mode 100644 index 00000000..13f4a6b3 --- /dev/null +++ b/src/peanut/util/ERC20Mock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {EIP3009Implementation} from "./EIP3009Implementation.sol"; + +// A simple ERC20 mock that also implements EIP-3009 and allows gasless transfers +contract ERC20Mock is EIP3009Implementation { + constructor() ERC20("ERC20Mock", "20MOCK") { + this; + } + + function mint(address account, uint256 amount) external { + _mint(account, amount); + } +} diff --git a/src/peanut/util/ERC721Mock.sol b/src/peanut/util/ERC721Mock.sol new file mode 100644 index 00000000..dcca4d16 --- /dev/null +++ b/src/peanut/util/ERC721Mock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +contract ERC721Mock is ERC721 { + constructor() ERC721("Name", "MOCK") { + this; + } + + function mint(address account, uint256 tokenId) external { + _mint(account, tokenId); + } +} diff --git a/src/peanut/util/IEIP3009.sol b/src/peanut/util/IEIP3009.sol new file mode 100644 index 00000000..e7aee542 --- /dev/null +++ b/src/peanut/util/IEIP3009.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +interface IEIP3009 { + /** + * @notice Execute a transfer with a signed authorization + * @param from Payer's address (Authorizer) + * @param to Payee's address + * @param value Amount to be transferred + * @param validAfter The time after which this is valid (unix time) + * @param validBefore The time before which this is valid (unix time) + * @param nonce Unique nonce + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + /** + * @notice Receive a transfer with a signed authorization from the payer + * @dev This has an additional check to ensure that the payee's address + * matches the caller of this function to prevent front-running attacks. + * @param from Payer's address (Authorizer) + * @param to Payee's address + * @param value Amount to be transferred + * @param validAfter The time after which this is valid (unix time) + * @param validBefore The time before which this is valid (unix time) + * @param nonce Unique nonce + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + /** + * @notice Attempt to cancel an authorization + * @dev Works only if the authorization is not yet used. + * @param authorizer Authorizer's address + * @param nonce Nonce of the authorization + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external; +} diff --git a/src/peanut/util/IL2ECO.sol b/src/peanut/util/IL2ECO.sol new file mode 100644 index 00000000..2885df39 --- /dev/null +++ b/src/peanut/util/IL2ECO.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IL2ECO is IERC20 { + function linearInflationMultiplier() external view returns (uint256); +} diff --git a/src/peanut/util/SampleSCW.sol b/src/peanut/util/SampleSCW.sol new file mode 100644 index 00000000..44dccf0a --- /dev/null +++ b/src/peanut/util/SampleSCW.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +// Super simple smart contract wallet that implements EIP-1271 +// Code taken from https://eips.ethereum.org/EIPS/eip-1271 +contract SampleWallet { + bytes4 internal constant MAGICVALUE = 0x1626ba7e; + + function isValidSignature(bytes32 _hash, bytes memory _signature) public pure returns (bytes4 magicValue) { + if (bytes32(_signature) == _hash) return MAGICVALUE; + return bytes4(0); + } +} diff --git a/src/peanut/util/SquidMock.sol b/src/peanut/util/SquidMock.sol new file mode 100644 index 00000000..49fbb898 --- /dev/null +++ b/src/peanut/util/SquidMock.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +// Suuuuper dumb squid mock. +// We call squid router with just a blob of calldata and don't care about the details +// (e.g. which function was called, with what particular arguments, etc.), +// so here we just have a simple function that we encode into a calldata blob in tests. +contract SquidMock { + using SafeERC20 for IERC20; + + event SquidMockBridged(); + + function superPowerfulBridge(address bridgedToken, uint256 bridgedAmount) public payable { + if (bridgedToken == address(0)) { + require(msg.value == bridgedAmount, "msg.value DOESNT MATCH bridgedAmount"); + } else { + IERC20(bridgedToken).safeTransferFrom(msg.sender, address(this), bridgedAmount); + } + + emit SquidMockBridged(); + } +} diff --git a/test/peanut/Batch/testBatchDeposit.sol b/test/peanut/Batch/testBatchDeposit.sol new file mode 100644 index 00000000..f4a836aa --- /dev/null +++ b/test/peanut/Batch/testBatchDeposit.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +// import "forge-std/Test.sol"; +// import "../src/V4/PeanutV4.2.sol"; +// import "../src/util/ERC20Mock.sol"; +// import "../src/util/ERC721Mock.sol"; +// import "../src/util/ERC1155Mock.sol"; + +// contract test is Test { +// PeanutV4 public peanutV4; +// ERC20Mock public testToken; +// ERC721Mock public testToken721; +// ERC1155Mock public testToken1155; + +// // a dummy private/public keypair to test withdrawals +// address public constant PUBKEY20 = +// address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); +// bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + +// function setUp() public { +// console.log("Setting up test"); +// peanutV4 = new PeanutV4(address(0)); +// testToken = new ERC20Mock(); +// testToken721 = new ERC721Mock(); +// // testToken1155 = new ERC1155Mock(); + +// // Mint tokens for test accounts +// testToken.mint(address(this), 10000000); +// testToken721.mint(address(this), 1); +// // testToken1155.mint(address(this), 1, 1000, ""); + +// // Approve PeanutV4 to spend tokens +// testToken.approve(address(peanutV4), 100000000); +// testToken721.setApprovalForAll(address(peanutV4), true); +// // testToken1155.setApprovalForAll(address(peanutV4), true); +// } + +// function testBatchMakeDeposit() public { +// address[] memory tokenAddresses = new address[](3); +// uint8[] memory contractTypes = new uint8[](3); +// uint256[] memory amounts = new uint256[](3); +// uint256[] memory tokenIds = new uint256[](3); +// address[] memory pubKeys20 = new address[](3); + +// // Deposit 1: ERC20 +// tokenAddresses[0] = address(testToken); +// contractTypes[0] = 1; +// amounts[0] = 100; +// tokenIds[0] = 0; +// pubKeys20[0] = PUBKEY20; + +// // Deposit 2: ERC721 +// tokenAddresses[1] = address(testToken721); +// contractTypes[1] = 2; +// amounts[1] = 1; +// tokenIds[1] = 1; +// pubKeys20[1] = PUBKEY20; + +// // Deposit 3: Ether +// tokenAddresses[2] = address(0); +// contractTypes[2] = 0; +// amounts[2] = 1 ether; +// tokenIds[2] = 0; +// pubKeys20[2] = PUBKEY20; + +// // Moved minting and approval to the setup function +// uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( +// tokenAddresses, +// contractTypes, +// amounts, +// tokenIds, +// pubKeys20 +// ); + +// assertEq(depositIndexes.length, 3, "Batch deposit failed"); +// assertEq(peanutV4.getDepositCount(), 3, "Deposit count mismatch"); +// } + +// // fuzzy testing of batchMakeDeposit with varying length of input arrays +// function testFuzz_BatchMakeDeposit_number( +// uint8 arrayLength +// ) public { +// address[] memory tokenAddresses = new address[](arrayLength); +// uint8[] memory contractTypes = new uint8[](arrayLength); +// uint256[] memory amounts = new uint256[](arrayLength); +// uint256[] memory tokenIds = new uint256[](arrayLength); +// address[] memory pubKeys20 = new address[](arrayLength); + +// // fill in dummy values for the arrays +// for (uint256 i = 0; i < arrayLength; i++) { +// tokenAddresses[i] = address(testToken); +// contractTypes[i] = 1; +// amounts[i] = 100; +// tokenIds[i] = 0; +// pubKeys20[i] = PUBKEY20; +// } + +// uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( +// tokenAddresses, +// contractTypes, +// amounts, +// tokenIds, +// pubKeys20 +// ); + +// assertEq(depositIndexes.length, arrayLength, "Batch deposit failed"); +// assertEq(peanutV4.getDepositCount(), arrayLength, "Deposit count mismatch"); +// } + +// } diff --git a/test/peanut/Batch/testBatchDepositEther.sol b/test/peanut/Batch/testBatchDepositEther.sol new file mode 100644 index 00000000..3bac30b3 --- /dev/null +++ b/test/peanut/Batch/testBatchDepositEther.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +// import "forge-std/Test.sol"; +// import "../src/V4/PeanutV4.2.sol"; +// import "../src/util/ERC20Mock.sol"; +// import "../src/util/ERC721Mock.sol"; +// import "../src/util/ERC1155Mock.sol"; + +// contract test is Test { +// PeanutV4 public peanutV4; +// ERC20Mock public testToken; +// ERC721Mock public testToken721; +// ERC1155Mock public testToken1155; + +// // a dummy private/public keypair to test withdrawals +// address public constant PUBKEY20 = +// address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); +// bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + +// function setUp() public { +// console.log("Setting up test"); +// peanutV4 = new PeanutV4(address(0)); +// testToken = new ERC20Mock(); +// testToken721 = new ERC721Mock(); +// // testToken1155 = new ERC1155Mock(); + +// // Mint tokens for test accounts +// testToken.mint(address(this), 10000000); +// testToken721.mint(address(this), 1); +// // testToken1155.mint(address(this), 1, 1000, ""); + +// // Approve PeanutV4 to spend tokens +// testToken.approve(address(peanutV4), 100000000); +// testToken721.setApprovalForAll(address(peanutV4), true); +// // testToken1155.setApprovalForAll(address(peanutV4), true); +// } + +// // /** +// // * @notice Batch ERC20 token deposit +// // * @param _tokenAddress address of the token being sent +// // * @param _amounts uint256 array of the amounts of tokens being sent +// // * @param _pubKeys20 array of the last 20 bytes of the public keys of the deposit signers +// // * @return uint256[] array of indices of the deposits +// // */ +// // function batchMakeDepositERC20( +// // address _tokenAddress, +// // uint256[] calldata _amounts, +// // address[] calldata _pubKeys20 +// // ) external returns (uint256[] memory) { +// // require( +// // _amounts.length == _pubKeys20.length, +// // "PARAMETERS LENGTH MISMATCH" +// // ); + +// // uint256[] memory depositIndexes = new uint256[](_amounts.length); + +// // for (uint256 i = 0; i < _amounts.length; i++) { +// // depositIndexes[i] = makeDeposit( +// // _tokenAddress, +// // 1, +// // _amounts[i], +// // 0, +// // _pubKeys20[i] +// // ); +// // } + +// // return depositIndexes; +// // } +// function testBatchMakeDepositEther() public { +// uint256[] memory amounts = new uint256[](3); +// address[] memory pubKeys20 = new address[](3); +// amounts[0] = 100; +// amounts[1] = 200; +// amounts[2] = 300; +// pubKeys20[0] = PUBKEY20; +// pubKeys20[1] = PUBKEY20; +// pubKeys20[2] = PUBKEY20; + +// // value should be sum of amounts +// uint256[] memory depositIndexes = peanutV4.batchMakeDepositEther{value: 600}( +// amounts, +// pubKeys20 +// ); + +// assertEq(depositIndexes.length, 3, "Batch deposit failed"); +// assertEq(peanutV4.getDepositCount(), 3, "Deposit count mismatch"); + +// // console log the deposit indexes +// for (uint256 i = 0; i < depositIndexes.length; i++) { +// console.log("Deposit index: %s", depositIndexes[i]); +// } +// // console log the deposits themselves +// for (uint256 i = 0; i < depositIndexes.length; i++) { +// // print deposit index +// console.log(" Deposit index: %s", depositIndexes[i]); +// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).pubKey20); +// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).amount); +// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).tokenAddress); +// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).contractType); +// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).tokenId); +// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).senderAddress); +// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).timestamp); +// } + +// } + +// function testBatchMakeDepositEther100() public { +// uint256[] memory amounts = new uint256[](100); +// address[] memory pubKeys20 = new address[](100); +// uint256 totalValue = 0; + +// // fill the arrays +// for (uint256 i = 0; i < 100; i++) { +// amounts[i] = 100; // or any other amount +// pubKeys20[i] = PUBKEY20; // or any other public key +// totalValue += amounts[i]; +// } + +// // value should be sum of amounts +// uint256[] memory depositIndexes = peanutV4.batchMakeDepositEther{value: totalValue}( +// amounts, +// pubKeys20 +// ); + +// assertEq(depositIndexes.length, 100, "Batch deposit failed"); +// assertEq(peanutV4.getDepositCount(), 100, "Deposit count mismatch"); +// } + +// // // fuzzy testing of batchMakeDeposit with varying length of input arrays +// // function testFuzz_BatchMakeDeposit_number( +// // uint8 arrayLength +// // ) public { +// // address[] memory tokenAddresses = new address[](arrayLength); +// // uint8[] memory contractTypes = new uint8[](arrayLength); +// // uint256[] memory amounts = new uint256[](arrayLength); +// // uint256[] memory tokenIds = new uint256[](arrayLength); +// // address[] memory pubKeys20 = new address[](arrayLength); + +// // // fill in dummy values for the arrays +// // for (uint256 i = 0; i < arrayLength; i++) { +// // tokenAddresses[i] = address(testToken); +// // contractTypes[i] = 1; +// // amounts[i] = 100; +// // tokenIds[i] = 0; +// // pubKeys20[i] = PUBKEY20; +// // } + +// // uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( +// // tokenAddresses, +// // contractTypes, +// // amounts, +// // tokenIds, +// // pubKeys20 +// // ); + +// // assertEq(depositIndexes.length, arrayLength, "Batch deposit failed"); +// // assertEq(peanutV4.getDepositCount(), arrayLength, "Deposit count mismatch"); +// // } + +// } diff --git a/test/peanut/Batch/testBatchDepositEtherOptimized.sol b/test/peanut/Batch/testBatchDepositEtherOptimized.sol new file mode 100644 index 00000000..40dc429e --- /dev/null +++ b/test/peanut/Batch/testBatchDepositEtherOptimized.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +// import "forge-std/Test.sol"; +// import "../src/V4/PeanutV4.2.sol"; +// import "../src/util/ERC20Mock.sol"; +// import "../src/util/ERC721Mock.sol"; +// import "../src/util/ERC1155Mock.sol"; + +// contract test is Test { +// PeanutV4 public peanutV4; +// ERC20Mock public testToken; +// ERC721Mock public testToken721; +// ERC1155Mock public testToken1155; + +// // a dummy private/public keypair to test withdrawals +// address public constant PUBKEY20 = +// address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); +// bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + +// function setUp() public { +// console.log("Setting up test"); +// peanutV4 = new PeanutV4(address(0)); +// testToken = new ERC20Mock(); +// testToken721 = new ERC721Mock(); +// // testToken1155 = new ERC1155Mock(); + +// // Mint tokens for test accounts +// testToken.mint(address(this), 10000000); +// testToken721.mint(address(this), 1); +// // testToken1155.mint(address(this), 1, 1000, ""); + +// // Approve PeanutV4 to spend tokens +// testToken.approve(address(peanutV4), 100000000); +// testToken721.setApprovalForAll(address(peanutV4), true); +// // testToken1155.setApprovalForAll(address(peanutV4), true); +// } + +// // /** +// // * @notice Batch ERC20 token deposit +// // * @param _tokenAddress address of the token being sent +// // * @param _amounts uint256 array of the amounts of tokens being sent +// // * @param _pubKeys20 array of the last 20 bytes of the public keys of the deposit signers +// // * @return uint256[] array of indices of the deposits +// // */ +// // function batchMakeDepositERC20( +// // address _tokenAddress, +// // uint256[] calldata _amounts, +// // address[] calldata _pubKeys20 +// // ) external returns (uint256[] memory) { +// // require( +// // _amounts.length == _pubKeys20.length, +// // "PARAMETERS LENGTH MISMATCH" +// // ); + +// // uint256[] memory depositIndexes = new uint256[](_amounts.length); + +// // for (uint256 i = 0; i < _amounts.length; i++) { +// // depositIndexes[i] = makeDeposit( +// // _tokenAddress, +// // 1, +// // _amounts[i], +// // 0, +// // _pubKeys20[i] +// // ); +// // } + +// // return depositIndexes; +// // } +// function testBatchMakeDepositEtherOptimized() public { +// uint256[] memory amounts = new uint256[](3); +// address[] memory pubKeys20 = new address[](3); +// amounts[0] = 100; +// amounts[1] = 200; +// amounts[2] = 300; +// pubKeys20[0] = PUBKEY20; +// pubKeys20[1] = PUBKEY20; +// pubKeys20[2] = PUBKEY20; + +// // value should be sum of amounts +// uint256[] memory depositIndexes = peanutV4.batchMakeDepositEtherOptimized{value: 600}( +// amounts, +// pubKeys20 +// ); + +// assertEq(depositIndexes.length, 3, "Batch deposit failed"); +// assertEq(peanutV4.getDepositCount(), 3, "Deposit count mismatch"); + +// // // console log the deposit indexes +// // for (uint256 i = 0; i < depositIndexes.length; i++) { +// // console.log("Deposit index: %s", depositIndexes[i]); +// // } +// // // console log the deposits themselves +// // for (uint256 i = 0; i < depositIndexes.length; i++) { +// // // print deposit index +// // console.log(" Deposit index: %s", depositIndexes[i]); +// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).pubKey20); +// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).amount); +// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).tokenAddress); +// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).contractType); +// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).tokenId); +// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).senderAddress); +// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).timestamp); +// // } +// } + +// function testBatchMakeDepositEtherOptimized100() public { +// uint256[] memory amounts = new uint256[](100); +// address[] memory pubKeys20 = new address[](100); +// uint256 totalValue = 0; + +// // fill the arrays +// for (uint256 i = 0; i < 100; i++) { +// amounts[i] = 100; // or any other amount +// pubKeys20[i] = PUBKEY20; // or any other public key +// totalValue += amounts[i]; +// } + +// // value should be sum of amounts +// uint256[] memory depositIndexes = peanutV4.batchMakeDepositEtherOptimized{value: totalValue}( +// amounts, +// pubKeys20 +// ); + +// assertEq(depositIndexes.length, 100, "Batch deposit failed"); +// assertEq(peanutV4.getDepositCount(), 100, "Deposit count mismatch"); +// } + +// // // fuzzy testing of batchMakeDeposit with varying length of input arrays +// // function testFuzz_BatchMakeDeposit_number( +// // uint8 arrayLength +// // ) public { +// // address[] memory tokenAddresses = new address[](arrayLength); +// // uint8[] memory contractTypes = new uint8[](arrayLength); +// // uint256[] memory amounts = new uint256[](arrayLength); +// // uint256[] memory tokenIds = new uint256[](arrayLength); +// // address[] memory pubKeys20 = new address[](arrayLength); + +// // // fill in dummy values for the arrays +// // for (uint256 i = 0; i < arrayLength; i++) { +// // tokenAddresses[i] = address(testToken); +// // contractTypes[i] = 1; +// // amounts[i] = 100; +// // tokenIds[i] = 0; +// // pubKeys20[i] = PUBKEY20; +// // } + +// // uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( +// // tokenAddresses, +// // contractTypes, +// // amounts, +// // tokenIds, +// // pubKeys20 +// // ); + +// // assertEq(depositIndexes.length, arrayLength, "Batch deposit failed"); +// // assertEq(peanutV4.getDepositCount(), arrayLength, "Deposit count mismatch"); +// // } + +// } diff --git a/test/peanut/PeanutBatcher.t.sol b/test/peanut/PeanutBatcher.t.sol new file mode 100644 index 00000000..fbc44c09 --- /dev/null +++ b/test/peanut/PeanutBatcher.t.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutBatcherV4.4.sol"; +import "../../src/peanut/util/ERC20Mock.sol"; +import "../../src/peanut/util/ERC721Mock.sol"; +import "../../src/peanut/util/ERC1155Mock.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; + +contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { + PeanutBatcherV4 public batcher; + PeanutV4 public peanutV4; + ERC20Mock public testToken; + ERC721Mock public testToken721; + ERC1155Mock public testToken1155; + address public PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + + function setUp() public { + batcher = new PeanutBatcherV4(); + peanutV4 = new PeanutV4(address(0)); + testToken = new ERC20Mock(); + testToken721 = new ERC721Mock(); + testToken1155 = new ERC1155Mock(); + } + + // make contract payable + receive() external payable {} + + // Test making a batch deposit of ERC20 tokens + function testBaseEtherDeposit() public { + uint64 amount = 100; + uint64 numDeposits = 10; + address[] memory pubKeys20 = new address[](numDeposits); + for (uint256 i = 0; i < numDeposits; i++) { + pubKeys20[i] = PUBKEY20; + } + + uint256 totalAmount = amount * numDeposits; + // make the batch deposit + uint256[] memory depositIndexes = + batcher.batchMakeDeposit{value: totalAmount}(address(peanutV4), address(0), 0, amount, 0, pubKeys20); + // check that the correct number of deposits were made + assertEq(depositIndexes.length, numDeposits); + } + + // Test making a batch deposit of ERC20 tokens + function testBatchERC20Deposit() public { + uint64 amount = 100; + uint64 numDeposits = 10; + address[] memory pubKeys20 = new address[](numDeposits); + for (uint256 i = 0; i < numDeposits; i++) { + pubKeys20[i] = PUBKEY20; + } + // mint tokens to the caller + testToken.mint(address(this), amount * numDeposits); + testToken.approve(address(batcher), amount * numDeposits); + + // make the batch deposit + uint256[] memory depositIndexes = + batcher.batchMakeDeposit(address(peanutV4), address(testToken), 1, amount, 0, pubKeys20); + // check that the correct number of deposits were made + assertEq(depositIndexes.length, numDeposits); + } + + // Test making a batch deposit of ERC721 tokens + // The batcher intentionally does not support ERC721 batches (each NFT has a unique + // tokenId, which doesn't fit the same-args-per-deposit shape of batchMakeDeposit). + // The contract reverts with "ERC721 batch not implemented" for _contractType == 2. + function test_RevertWhen_BatchERC721NotImplemented() public { + uint64 numDeposits = 10; + address[] memory pubKeys20 = new address[](numDeposits); + for (uint256 i = 0; i < numDeposits; i++) { + uint64 tokenId = uint64(i); + pubKeys20[i] = PUBKEY20; + testToken721.mint(address(this), tokenId); + testToken721.approve(address(batcher), tokenId); + } + vm.expectRevert("ERC721 batch not implemented"); + batcher.batchMakeDeposit(address(peanutV4), address(testToken721), 2, 1, 1, pubKeys20); + } + + // Test making a batch deposit of ERC1155 tokens + function testBatchERC1155Deposit() public { + uint64 numDeposits = 10; + address[] memory pubKeys20 = new address[](numDeposits); + + for (uint256 i = 0; i < numDeposits; i++) { + pubKeys20[i] = PUBKEY20; + // mint a token to the caller + testToken1155.mint(address(this), 1, 100, ""); + // approve the PeanutV4 contract to spend the tokens + testToken1155.setApprovalForAll(address(batcher), true); + } + // make the batch deposit + uint256[] memory depositIndexes = + batcher.batchMakeDeposit(address(peanutV4), address(testToken1155), 3, 1, 1, pubKeys20); + // check that the correct number of deposits were made + assertEq(depositIndexes.length, numDeposits); + } + + // Test failure case where PeanutV4 contract is not approved to spend ERC20 tokens + function test_RevertWhen_BatchERC20DepositNotApproved() public { + uint64 amount = 100; + uint64 numDeposits = 10; + address[] memory pubKeys20 = new address[](numDeposits); + for (uint256 i = 0; i < numDeposits; i++) { + pubKeys20[i] = PUBKEY20; + } + testToken.mint(address(this), amount * numDeposits); + // Do NOT approve the batcher to spend the tokens + vm.expectRevert(); + batcher.batchMakeDeposit(address(peanutV4), address(testToken), 1, amount, 0, pubKeys20); + } + + // Test failure case where PeanutV4 contract is not approved to spend ERC721 tokens + function test_RevertWhen_BatchERC721DepositNotApproved() public { + uint64 numDeposits = 10; + address[] memory pubKeys20 = new address[](numDeposits); + for (uint256 i = 0; i < numDeposits; i++) { + uint64 tokenId = uint64(i); + pubKeys20[i] = PUBKEY20; + testToken721.mint(address(this), tokenId); + // Do NOT approve the batcher to spend the tokens + } + vm.expectRevert(); + batcher.batchMakeDeposit(address(peanutV4), address(testToken721), 2, 1, numDeposits, pubKeys20); + } + + // Test failure case where PeanutV4 contract is not approved to spend ERC1155 tokens + function test_RevertWhen_BatchERC1155DepositNotApproved() public { + uint64 numDeposits = 10; + address[] memory pubKeys20 = new address[](numDeposits); + for (uint256 i = 0; i < numDeposits; i++) { + uint64 tokenId = uint64(i); + pubKeys20[i] = PUBKEY20; + testToken1155.mint(address(this), tokenId, 1, ""); + // Do NOT approve the batcher to transfer the tokens + } + vm.expectRevert(); + batcher.batchMakeDeposit(address(peanutV4), address(testToken1155), 3, 1, numDeposits, pubKeys20); + } + + // Test making multiple batch deposits of ERC20 tokens in a row + function testMultipleBatchERC20DepositsInRow() public { + uint64 amount = 100; + uint64 numDeposits = 10; + uint64 numberOfBatches = 3; // number of times you want to batch deposit in a row + address[] memory pubKeys20 = new address[](numDeposits); + + // Set up the pubKeys20 array + for (uint256 i = 0; i < numDeposits; i++) { + pubKeys20[i] = PUBKEY20; + } + + // Iterate over the number of batches you want to create + for (uint256 batch = 0; batch < numberOfBatches; batch++) { + // Mint tokens to the caller for this batch + testToken.mint(address(this), amount * numDeposits); + testToken.approve(address(batcher), amount * numDeposits); + + // Make the batch deposit + uint256[] memory depositIndexes = + batcher.batchMakeDeposit(address(peanutV4), address(testToken), 1, amount, 0, pubKeys20); + + // Check that the correct number of deposits were made + assertEq(depositIndexes.length, numDeposits); + } + } + + function testRaffleETHDeposit() public { + uint256[] memory amounts = new uint256[](4); + + amounts[0] = 10; + amounts[1] = 20; + amounts[2] = 30; + amounts[3] = 40; + + uint256[] memory depositIndices = batcher.batchMakeDepositRaffle{value: 100}( + address(peanutV4), + address(testToken), + 0, + amounts, + PUBKEY20 + ); + + for(uint256 i = 0; i < amounts.length; i++) { + PeanutV4.Deposit memory deposit = peanutV4.getDeposit(depositIndices[i]); + assert(deposit.amount == amounts[i]); // main assertion + + // a few sanity checks + assert(deposit.contractType == 0); + assert(deposit.pubKey20 == PUBKEY20); + // check that the sender is this contract and not the address of the batcher + assert(deposit.senderAddress == address(this)); + } + } + + function testRaffleERC20Deposit() public { + uint256[] memory amounts = new uint256[](4); + + amounts[0] = 10; + amounts[1] = 20; + amounts[2] = 30; + amounts[3] = 40; + + testToken.mint(address(this), 100); + testToken.approve(address(batcher), 100); + + uint256[] memory depositIndices = batcher.batchMakeDepositRaffle( + address(peanutV4), + address(testToken), + 1, + amounts, + PUBKEY20 + ); + + for(uint256 i = 0; i < amounts.length; i++) { + PeanutV4.Deposit memory deposit = peanutV4.getDeposit(depositIndices[i]); + assert(deposit.amount == amounts[i]); // main assertion + + // a few sanity checks + assert(deposit.contractType == 1); + assert(deposit.pubKey20 == PUBKEY20); + // check that the sender is this contract and not the address of the batcher + assert(deposit.senderAddress == address(this)); + } + } +} diff --git a/test/peanut/PeanutRouter.t.sol b/test/peanut/PeanutRouter.t.sol new file mode 100644 index 00000000..9df32d07 --- /dev/null +++ b/test/peanut/PeanutRouter.t.sol @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutRouter.sol"; +import "../../src/peanut/util/SquidMock.sol"; +import "../../src/peanut/util/ERC20Mock.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + + +contract PeanutV4RouterTest is Test { + PeanutV4 public peanutV4; + SquidMock public squidMock; + PeanutV4Router public peanutV4Router; + ERC20Mock public testToken; + + address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); + bytes32 public constant SAMPLE_PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + bytes4 SQUID_MOCK_FUNCTION_SIGNATURE = bytes4(keccak256("superPowerfulBridge(address,uint256)")); + + function setUp() public { + testToken = new ERC20Mock(); + peanutV4 = new PeanutV4(address(0)); + squidMock = new SquidMock(); + peanutV4Router = new PeanutV4Router(address(squidMock)); + } + + function _signPeanutWithdrawal(uint256 depositIndex, address recipientAddress, bytes32 privateKey) internal view returns (bytes memory signature) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + peanutV4.PEANUT_SALT(), + block.chainid, + address(peanutV4), + depositIndex, + recipientAddress, + peanutV4.RECIPIENT_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(privateKey), digest); + signature = abi.encodePacked(r, s, v); + } + + function _signPeanutRouting(uint256 depositIndex, uint256 squidFee, uint256 peanutFee, bytes memory squidData, bytes32 privateKey) internal view returns (bytes memory signature) { + bytes32 digest = keccak256( + abi.encodePacked( + bytes2(0x1900), + address(peanutV4Router), + block.chainid, + address(peanutV4), + depositIndex, + address(squidMock), + squidFee, + peanutFee, + squidData + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(privateKey), digest); + signature = abi.encodePacked(r, s, v); + } + + function testWithdrawERC20AndBridge( + uint128 amountDeposited, // uint128 to prevent total supply overflow + uint96 requiredSquidFee, // uint96 to not run out of the default fuceted ETH amount + uint256 requiredPeanutFee + ) public { + vm.assume(requiredPeanutFee < amountDeposited); + + testToken.mint(address(this), amountDeposited); + testToken.approve(address(peanutV4), amountDeposited); + uint256 depositIndex = peanutV4.makeDeposit(address(testToken), 1, amountDeposited, 0, SAMPLE_ADDRESS); + + bytes memory withdrawalSignature = _signPeanutWithdrawal( + depositIndex, + address(peanutV4Router), + SAMPLE_PRIVKEY + ); + + bytes memory squidData = abi.encodePacked( + SQUID_MOCK_FUNCTION_SIGNATURE, + abi.encode( // args have to be 32-bytes padded + address(testToken), + amountDeposited - requiredPeanutFee // testToken amount to be transferred to the squid mock + ) + ); + + bytes memory routingSignature = _signPeanutRouting( + depositIndex, + requiredSquidFee, + requiredPeanutFee, + squidData, + SAMPLE_PRIVKEY + ); + + // Relayer attempts to charge a higher peanut fee + vm.expectRevert("WRONG ROUTING SIGNER"); + peanutV4Router.withdrawAndBridge{value: requiredSquidFee}( + address(peanutV4), + depositIndex, + withdrawalSignature, + requiredSquidFee, + requiredPeanutFee + 10, + squidData, + routingSignature + ); + + if (requiredSquidFee > 0) { + // Relayer attempts to pay a lower squid fee + vm.expectRevert("msg.value MUST BE THE SQUID FEE"); + peanutV4Router.withdrawAndBridge{value: requiredSquidFee - 1}( + address(peanutV4), + depositIndex, + withdrawalSignature, + requiredSquidFee, + requiredPeanutFee, + squidData, + routingSignature + ); + + // Relayer attempts to pay a lower squid fee and also modifies the arguments + vm.expectRevert("WRONG ROUTING SIGNER"); + peanutV4Router.withdrawAndBridge{value: requiredSquidFee - 1}( + address(peanutV4), + depositIndex, + withdrawalSignature, + requiredSquidFee - 1, + requiredPeanutFee, + squidData, + routingSignature + ); + } + + // Someone tries to front-run with malicious squidData + vm.expectRevert("WRONG ROUTING SIGNER"); + peanutV4Router.withdrawAndBridge{value: requiredSquidFee}( + address(peanutV4), + depositIndex, + withdrawalSignature, + requiredSquidFee, + requiredPeanutFee, + bytes("BAD BAD BAD BAD"), + routingSignature + ); + + // Withdraw and bridge! Withdraw and bridge! Withdraw and bridge! + peanutV4Router.withdrawAndBridge{value: requiredSquidFee}( + address(peanutV4), + depositIndex, + withdrawalSignature, + requiredSquidFee, + requiredPeanutFee, + squidData, + routingSignature + ); + + require(testToken.balanceOf(address(squidMock)) == amountDeposited - requiredPeanutFee, "TOKENS WERE NOT TRANSFERRED TO SQUID"); + require(testToken.balanceOf(address(peanutV4Router)) == requiredPeanutFee, "PEANUT FEE WAS NOT COLLECTED"); + require(address(squidMock).balance == requiredSquidFee, "FEE WAS NOT PAID TO SQUID"); + } + + function testWithdrawETHAndBridge( + uint96 amountDeposited, + uint96 requiredSquidFee, + uint96 requiredPeanutFee + ) public { + // prevent out of funds problems + vm.assume(uint256(amountDeposited) + uint256(requiredSquidFee) + uint256(requiredPeanutFee) < 2 ** 96); + vm.assume(amountDeposited > requiredPeanutFee); + + uint256 depositIndex = peanutV4.makeDeposit{value: amountDeposited}(address(0), 0, amountDeposited, 0, SAMPLE_ADDRESS); + + bytes memory withdrawalSignature = _signPeanutWithdrawal( + depositIndex, + address(peanutV4Router), + SAMPLE_PRIVKEY + ); + + // uint256 requiredSquidFee = 100; // 100 wei + // uint256 requiredPeanutFee = 130; // 130 wei + + bytes memory squidData = abi.encodePacked( + SQUID_MOCK_FUNCTION_SIGNATURE, + abi.encode( // args have to be 32-bytes padded + address(0), + amountDeposited + requiredSquidFee - requiredPeanutFee // ETH amount to be transferred to the squid mock + ) + ); + + bytes memory routingSignature = _signPeanutRouting( + depositIndex, + requiredSquidFee, + requiredPeanutFee, + squidData, + SAMPLE_PRIVKEY + ); + + // Withdraw and bridge! Withdraw and bridge! Withdraw and bridge! + peanutV4Router.withdrawAndBridge{value: requiredSquidFee}( + address(peanutV4), + depositIndex, + withdrawalSignature, + requiredSquidFee, + requiredPeanutFee, + squidData, + routingSignature + ); + + require(address(squidMock).balance == amountDeposited + requiredSquidFee - requiredPeanutFee, "AMOUNT OR FEE WAS NOT PAID TO SQUID"); + require(address(peanutV4Router).balance == requiredPeanutFee, "PEANUT FEE WAS NOT COLLECTED"); + } + + function testWithdrawFee( + uint96 collectedEth, + uint128 collectedTokens, + uint96 ethToWithdraw, + uint128 tokensToWithdraw + ) public { + vm.assume(ethToWithdraw <= collectedEth); + vm.assume(tokensToWithdraw <= collectedTokens); + + // Pretend that there were some transfers and some fee was collected in the peanut router + testToken.mint(address(this), collectedTokens); + testToken.transfer(address(peanutV4Router), collectedTokens); + payable(address(peanutV4Router)).transfer(collectedEth); + + // Non-owner can't withdraw + vm.prank(SAMPLE_ADDRESS); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, SAMPLE_ADDRESS)); + peanutV4Router.withdrawFees(address(0), SAMPLE_ADDRESS, ethToWithdraw); + + peanutV4Router.withdrawFees(address(0), SAMPLE_ADDRESS, ethToWithdraw); + require(address(SAMPLE_ADDRESS).balance == ethToWithdraw, "RECEIVED WRONG AMOUNT OF ETH"); + + peanutV4Router.withdrawFees(address(testToken), SAMPLE_ADDRESS, tokensToWithdraw); + require(testToken.balanceOf(SAMPLE_ADDRESS) == tokensToWithdraw, "RECEIVED WRONG AMOUNT OF testToken"); + } +} diff --git a/test/peanut/PeanutV4.t.sol b/test/peanut/PeanutV4.t.sol new file mode 100644 index 00000000..068763f0 --- /dev/null +++ b/test/peanut/PeanutV4.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/peanut/util/ERC20Mock.sol"; +import "../../src/peanut/util/ERC721Mock.sol"; +import "../../src/peanut/util/ERC1155Mock.sol"; + +contract PeanutV4Test is Test { + PeanutV4 public peanutV4; + ERC20Mock public testToken; + ERC721Mock public testToken721; + ERC1155Mock public testToken1155; + + // a dummy private/public keypair to test withdrawals + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + + address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); + bytes32 public constant SAMPLE_PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + // For EIP-3009 testing + // keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8; + bytes32 public DOMAIN_SEPARATOR = 0xcaa2ce1a5703ccbe253a34eb3166df60a705c561b44b192061e28f2a985be2ca; + + function setUp() public { + console.log("Setting up test"); + testToken = new ERC20Mock(); + testToken721 = new ERC721Mock(); + testToken1155 = new ERC1155Mock(); + peanutV4 = new PeanutV4(address(0)); + + // Mint tokens for test accounts + testToken.mint(address(this), 1000); + testToken721.mint(address(this), 1); + // testToken1155.mint(address(this), 1, 1000, ""); + + // Approve PeanutV4 to spend tokens + testToken.approve(address(peanutV4), 1000); + testToken721.setApprovalForAll(address(peanutV4), true); + // testToken1155.setApprovalForAll(address(peanutV4), true); + } + + function testContractCreation() public { + assertTrue(address(peanutV4) != address(0), "Contract creation failed"); + } + + function testMakeDepositERC20() public { + uint256 amount = 100; + + // Moved minting and approval to the setup function + uint256 depositIndex = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + + assertEq(depositIndex, 0, "Deposit failed"); + assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + } + + function testMakeSelflessDepositERC20() public { + uint256 amount = 100; + + // Make a deposit on behalf of SAMPLE_ADDRESS + uint256 depositIndex = peanutV4.makeSelflessDeposit(address(testToken), 1, amount, 0, PUBKEY20, SAMPLE_ADDRESS); + + // Deposit was made on behalf of other address, so we can't withdraw :((( + vm.expectRevert("NOT THE SENDER"); + peanutV4.withdrawDepositSender(depositIndex); + + vm.prank(SAMPLE_ADDRESS); // Now we talkin'! + peanutV4.withdrawDepositSender(depositIndex); + } + + // If we attempt to deposit ECO tokens as pure ERC20s (i.e. with _contractType = 1), + // makeDeposit function must revert. + function testECOMaliciousDeposit() public { + // pretent that testToken is ECO + PeanutV4 peanutV4ECO = new PeanutV4(address(testToken)); + + // approve tokens to be spent by the new peanut instance + testToken.approve(address(peanutV4), 1000); + + // Test!!!!!!!! + vm.expectRevert("ECO DEPOSITS MUST USE _contractType 4"); + peanutV4ECO.makeDeposit(address(testToken), 1, 100, 0, address(0)); + } + + function testMakeDepositERC721() public { + uint256 tokenId = 1; + + // Moved minting and approval to the setup function + uint256 depositIndex = peanutV4.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); + + assertEq(depositIndex, 0, "Deposit failed"); + assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + } + + // function testMakeDepositERC1155() public { + // uint256 tokenId = 1; + // uint256 amount = 100; + + // // Moved minting and approval to the setup function + // uint256 depositIndex = peanutV4.makeDeposit( + // address(testToken1155), + // 3, + // amount, + // tokenId, + // PUBKEY20 + // ); + + // assertEq(depositIndex, 0, "Deposit failed"); + // assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + // } + + // test sender withdrawal + function testSenderTimeWithdraw() public { + uint256 amount = 1000; + + assertEq(testToken.balanceOf(address(peanutV4)), 0, "Contract balance mismatch"); + // Moved minting and approval to the setup function + uint256 depositIndex = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + + assertEq(depositIndex, 0, "Deposit failed"); + assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + assertEq(testToken.balanceOf(address(peanutV4)), 1000, "Contract balance mismatch"); + + // wait 25 hours + vm.warp(block.timestamp + 25 hours); + + // Withdraw the deposit + peanutV4.withdrawDepositSender(depositIndex); + + // Check that the contract has the correct balance + assertEq(testToken.balanceOf(address(peanutV4)), 0, "Contract balance mismatch"); + assertEq(testToken.balanceOf(address(this)), 1000, "Sender balance mismatch"); + } +} diff --git a/test/peanut/PeanutV4Gasless.t.sol b/test/peanut/PeanutV4Gasless.t.sol new file mode 100644 index 00000000..34e10d6d --- /dev/null +++ b/test/peanut/PeanutV4Gasless.t.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/peanut/util/ERC20Mock.sol"; +import "../../src/peanut/util/SampleSCW.sol"; + +contract PeanutV4GaslessTest is Test { + PeanutV4 public peanutV4; + ERC20Mock public testToken; + + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + + address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); + bytes32 public constant SAMPLE_PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + address public constant SAMPLE_ADDRESS_2 = address(0x88f9B82462f6C4bf4a0Fb15e5c3971559a316e7f); + bytes32 public constant SAMPLE_PRIVKEY_2 = 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb; + + // For EIP-3009 testing + // keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") + bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8; + bytes32 public DOMAIN_SEPARATOR = 0xcaa2ce1a5703ccbe253a34eb3166df60a705c561b44b192061e28f2a985be2ca; + + function setUp() public { + console.log("Setting up test"); + testToken = new ERC20Mock(); + peanutV4 = new PeanutV4(address(0)); + } + + function testMakeDepostERC20WithAuthorization() public { + testToken.mint(SAMPLE_ADDRESS, 1000); + + uint256 amount = 1000; + bytes32 _nonce = bytes32(0); // any random value + bytes32 authorizationNonce = keccak256(abi.encodePacked(PUBKEY20, _nonce)); + + bytes memory typeHashAndData = abi.encode( + RECEIVE_WITH_AUTHORIZATION_TYPEHASH, + SAMPLE_ADDRESS, // the spender & peanut depositor address + address(peanutV4), // receiver of the tokens + amount, + block.timestamp - 1, // validUntil + block.timestamp + 1, // validBefore + authorizationNonce + ); + + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, keccak256(typeHashAndData))); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), digest); + + uint256 depositIndex = peanutV4.makeDepositWithAuthorization( + address(testToken), + SAMPLE_ADDRESS, // who makes the deposit + amount, + PUBKEY20, + _nonce, + block.timestamp - 1, // validUntil + block.timestamp + 1, // validBefore + v, + r, + s + ); + + assertEq(depositIndex, 0, "Deposit failed"); + assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + } + + function _makeDeposit(address depositor) internal returns (uint256 depositIndex) { + // Make a deposit + testToken.mint(depositor, 1000); + uint256 amount = 100; + vm.prank(depositor); + testToken.approve(address(peanutV4), amount); + vm.prank(depositor); + depositIndex = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + } + + function _calculateDigest(uint256 depositIndex) internal view returns (bytes32 digest) { + bytes32 hashedReclaimRequest = keccak256(abi.encode(peanutV4.GASLESS_RECLAIM_TYPEHASH(), depositIndex)); + // Prepare data for the withdrawal + digest = keccak256(abi.encodePacked("\x19\x01", peanutV4.DOMAIN_SEPARATOR(), hashedReclaimRequest)); + } + + function _withdrawDepositSenderGaslessEOA( + uint256 depositIndex, + address depositorAddress, + bytes32 privateKey, + string memory expectRevert + ) internal { + bytes32 digest = _calculateDigest(depositIndex); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(privateKey), digest); + bytes memory signature = abi.encodePacked(r, s, v); + + PeanutV4.GaslessReclaim memory reclaimRequest = PeanutV4.GaslessReclaim(depositIndex); + + if (bytes(expectRevert).length > 0) { + vm.expectRevert(bytes(expectRevert)); + } + + peanutV4.withdrawDepositSenderGasless(reclaimRequest, depositorAddress, signature); + } + + function testWithdrawDepositSenderGaslessEOA() public { + // Make 2 deposits + uint256 depositIndex1 = _makeDeposit(SAMPLE_ADDRESS); + uint256 depositIndex2 = _makeDeposit(SAMPLE_ADDRESS); + + // Test a successful withdrawal of the second deposit + _withdrawDepositSenderGaslessEOA(depositIndex2, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, ""); + + // depositIndex2 has already been withdrawn + _withdrawDepositSenderGaslessEOA(depositIndex2, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, "DEPOSIT ALREADY WITHDRAWN"); + + // Correct depositor address, but wrong private key. + // Private key and the provied address don't match. + _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS, SAMPLE_PRIVKEY_2, "INVALID SIGNATURE"); + + // Provided address and private key do match, but they are wrong. + _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS_2, SAMPLE_PRIVKEY_2, "NOT THE SENDER"); + + // Make one more from another address + uint256 depositIndex3 = _makeDeposit(SAMPLE_ADDRESS_2); + + // Make sure that we can't withdraw it with the keys from another deposit + _withdrawDepositSenderGaslessEOA(depositIndex3, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, "NOT THE SENDER"); + + // Withdraw both + _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, ""); + _withdrawDepositSenderGaslessEOA(depositIndex3, SAMPLE_ADDRESS_2, SAMPLE_PRIVKEY_2, ""); + } + + // Test that smart contract wallets are able to withdraw gsalessly too + function testWithdrawDepositSenderGaslessSCW() public { + // Make a deposit + SampleWallet scwallet = new SampleWallet(); + uint256 depositIndex = _makeDeposit(address(scwallet)); + + bytes32 digest = _calculateDigest(depositIndex); + + PeanutV4.GaslessReclaim memory reclaimRequest = PeanutV4.GaslessReclaim(depositIndex); + + // Submit a wrong signature + vm.expectRevert("INVALID SIGNATURE"); + peanutV4.withdrawDepositSenderGasless( + reclaimRequest, address(scwallet), bytes("LOL THIS IS DEFINITELY NOT THE SIGNATURE") + ); + + // Try to withdraw with an EOA + _withdrawDepositSenderGaslessEOA(depositIndex, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, "NOT THE SENDER"); + + // Withdraw! + peanutV4.withdrawDepositSenderGasless( + reclaimRequest, + address(scwallet), + // In our sample SCW the digest will be the right signature + abi.encodePacked(digest) + ); + } + + /** + * Test that we can use makeCustomisableDeposit to deposit gaslessly + */ + function testGaslessViaMakeCustomisableDeposit() public { + testToken.mint(SAMPLE_ADDRESS, 1000); + + uint256 amount = 1000; + bytes32 _nonce = bytes32(0); // any random value + bytes32 authorizationNonce = keccak256(abi.encodePacked(PUBKEY20, _nonce)); + + bytes memory typeHashAndData = abi.encode( + RECEIVE_WITH_AUTHORIZATION_TYPEHASH, + SAMPLE_ADDRESS, // the spender & peanut depositor address + address(peanutV4), // receiver of the tokens + amount, + block.timestamp - 1, // validUntil + block.timestamp + 1, // validBefore + authorizationNonce + ); + + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, keccak256(typeHashAndData))); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), digest); + + bytes memory packed3009args = abi.encode( + SAMPLE_ADDRESS, // from + _nonce, + block.timestamp - 1, // validAfter + block.timestamp + 1, // validBefore + v, + r, + s + ); + + uint256 depositIndex = peanutV4.makeCustomDeposit( + address(testToken), + 1, // contract type - erc 20 + amount, + 0, // tokenId. Not used for 3009 deposits. + PUBKEY20, + SAMPLE_ADDRESS, // the depositor + false, // no MFA + address(0), // not recipient bound + 0, // not recipient bound + true, // yes, it is a 3009 deposit! + packed3009args + ); + + assertEq(depositIndex, 0, "Deposit failed"); + assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + } +} diff --git a/test/peanut/RecipeintBound.t.sol b/test/peanut/RecipeintBound.t.sol new file mode 100644 index 00000000..e3c0714d --- /dev/null +++ b/test/peanut/RecipeintBound.t.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/peanut/util/ERC20Mock.sol"; +import "../../src/peanut/util/ERC721Mock.sol"; +import "../../src/peanut/util/ERC1155Mock.sol"; + +contract RecipientBoundTest is Test { + PeanutV4 public peanutV4; + ERC20Mock public testToken; + ERC721Mock public testToken721; + ERC1155Mock public testToken1155; + + // a dummy private/public keypair to test withdrawals + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + + address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); + bytes32 public constant SAMPLE_PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + function setUp() public { + console.log("Setting up test"); + testToken = new ERC20Mock(); + peanutV4 = new PeanutV4(address(0)); + testToken.mint(address(this), 1000); + testToken.approve(address(peanutV4), 1000); + } + + function testRecipientBoundDeposit() public { + uint256 depositIndex = peanutV4.makeCustomDeposit( + address(testToken), + 1, // contract type - erc 20 + 1000, // amount + 0, // tokenId. Not used for erc20 deposits. + address(0), // pubKey20. Not used for recipient-bound deposits. + address(this), // the depositor + false, // no MFA + SAMPLE_ADDRESS, // recipient + 0, // no timelock for reclaiming + false, // not a 3009 deposit + bytes("") // not a 3009 deposit + ); + require(testToken.balanceOf(address(this)) == 0, "TOKEN WAS NOT CHARGED!"); + require(testToken.balanceOf(SAMPLE_ADDRESS) == 0, "SAMPLE_ADDRESS MUST NOT HAVE TOKENS AT START!"); + + // Should not be able to withdraw to anybody except SAMPLE_ADDRESS + vm.expectRevert("WRONG RECIPIENT"); + peanutV4.withdrawDeposit(depositIndex, address(this), bytes("")); + + peanutV4.withdrawDeposit(depositIndex, SAMPLE_ADDRESS, bytes("")); + require(testToken.balanceOf(SAMPLE_ADDRESS) == 1000, "SAMPLE_ADDRESS SHOULD HAVE RECEIVED TOKENS!"); + } + + /* + * Reclaim an address-bound deposit. + */ + function testRecipientBoundReclaim() public { + uint256 depositIndex = peanutV4.makeCustomDeposit( + address(testToken), + 1, // contract type - erc 20 + 1000, // amount + 0, // tokenId. Not used for erc20 deposits. + address(0), // pubKey20. Not used for recipient-bound deposits. + address(this), // the depositor + false, // no MFA + SAMPLE_ADDRESS, // recipient + uint40(block.timestamp + 10), // the sender will be able to reclaim in 10 seconds + false, // not a 3009 deposit + bytes("") // not a 3009 deposit + ); + require(testToken.balanceOf(address(this)) == 0, "TOKEN WAS NOT CHARGED!"); + + // Try to reclaim, but it's too early + vm.expectRevert("TOO EARLY TO RECLAIM"); + peanutV4.withdrawDepositSender(depositIndex); + + vm.warp(block.timestamp + 11); // wooooooosh! Controlling the time :) + peanutV4.withdrawDepositSender(depositIndex); // reclaim! + require(testToken.balanceOf(address(this)) == 1000, "WAS NOT REFUNDED!"); + } +} diff --git a/test/peanut/hardhat/PeanutV4.1.spec.ts b/test/peanut/hardhat/PeanutV4.1.spec.ts new file mode 100644 index 00000000..f740a5c1 --- /dev/null +++ b/test/peanut/hardhat/PeanutV4.1.spec.ts @@ -0,0 +1,178 @@ +/* eslint-disable camelcase */ +import { ethers } from 'hardhat' +import { Signer, Contract, constants, BigNumber } from 'ethers' +import { smock, FakeContract, MockContract } from '@defi-wonderland/smock' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { expect } from 'chai' + +export const REGISTRY_DEPLOY_TX = + '0xf90a388085174876e800830c35008080b909e5608060405234801561001057600080fd5b506109c5806100206000396000f3fe608060405234801561001057600080fd5b50600436106100a5576000357c010000000000000000000000000000000000000000000000000000000090048063a41e7d5111610078578063a41e7d51146101d4578063aabbb8ca1461020a578063b705676514610236578063f712f3e814610280576100a5565b806329965a1d146100aa5780633d584063146100e25780635df8122f1461012457806365ba36c114610152575b600080fd5b6100e0600480360360608110156100c057600080fd5b50600160a060020a038135811691602081013591604090910135166102b6565b005b610108600480360360208110156100f857600080fd5b5035600160a060020a0316610570565b60408051600160a060020a039092168252519081900360200190f35b6100e06004803603604081101561013a57600080fd5b50600160a060020a03813581169160200135166105bc565b6101c26004803603602081101561016857600080fd5b81019060208101813564010000000081111561018357600080fd5b82018360208201111561019557600080fd5b803590602001918460018302840111640100000000831117156101b757600080fd5b5090925090506106b3565b60408051918252519081900360200190f35b6100e0600480360360408110156101ea57600080fd5b508035600160a060020a03169060200135600160e060020a0319166106ee565b6101086004803603604081101561022057600080fd5b50600160a060020a038135169060200135610778565b61026c6004803603604081101561024c57600080fd5b508035600160a060020a03169060200135600160e060020a0319166107ef565b604080519115158252519081900360200190f35b61026c6004803603604081101561029657600080fd5b508035600160a060020a03169060200135600160e060020a0319166108aa565b6000600160a060020a038416156102cd57836102cf565b335b9050336102db82610570565b600160a060020a031614610339576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b6103428361092a565b15610397576040805160e560020a62461bcd02815260206004820152601a60248201527f4d757374206e6f7420626520616e204552433136352068617368000000000000604482015290519081900360640190fd5b600160a060020a038216158015906103b85750600160a060020a0382163314155b156104ff5760405160200180807f455243313832305f4143434550545f4d4147494300000000000000000000000081525060140190506040516020818303038152906040528051906020012082600160a060020a031663249cb3fa85846040518363ffffffff167c01000000000000000000000000000000000000000000000000000000000281526004018083815260200182600160a060020a0316600160a060020a031681526020019250505060206040518083038186803b15801561047e57600080fd5b505afa158015610492573d6000803e3d6000fd5b505050506040513d60208110156104a857600080fd5b5051146104ff576040805160e560020a62461bcd02815260206004820181905260248201527f446f6573206e6f7420696d706c656d656e742074686520696e74657266616365604482015290519081900360640190fd5b600160a060020a03818116600081815260208181526040808320888452909152808220805473ffffffffffffffffffffffffffffffffffffffff19169487169485179055518692917f93baa6efbd2244243bfee6ce4cfdd1d04fc4c0e9a786abd3a41313bd352db15391a450505050565b600160a060020a03818116600090815260016020526040812054909116151561059a5750806105b7565b50600160a060020a03808216600090815260016020526040902054165b919050565b336105c683610570565b600160a060020a031614610624576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b81600160a060020a031681600160a060020a0316146106435780610646565b60005b600160a060020a03838116600081815260016020526040808220805473ffffffffffffffffffffffffffffffffffffffff19169585169590951790945592519184169290917f605c2dbf762e5f7d60a546d42e7205dcb1b011ebc62a61736a57c9089d3a43509190a35050565b600082826040516020018083838082843780830192505050925050506040516020818303038152906040528051906020012090505b92915050565b6106f882826107ef565b610703576000610705565b815b600160a060020a03928316600081815260208181526040808320600160e060020a031996909616808452958252808320805473ffffffffffffffffffffffffffffffffffffffff19169590971694909417909555908152600284528181209281529190925220805460ff19166001179055565b600080600160a060020a038416156107905783610792565b335b905061079d8361092a565b156107c357826107ad82826108aa565b6107b85760006107ba565b815b925050506106e8565b600160a060020a0390811660009081526020818152604080832086845290915290205416905092915050565b6000808061081d857f01ffc9a70000000000000000000000000000000000000000000000000000000061094c565b909250905081158061082d575080155b1561083d576000925050506106e8565b61084f85600160e060020a031961094c565b909250905081158061086057508015155b15610870576000925050506106e8565b61087a858561094c565b909250905060018214801561088f5750806001145b1561089f576001925050506106e8565b506000949350505050565b600160a060020a0382166000908152600260209081526040808320600160e060020a03198516845290915281205460ff1615156108f2576108eb83836107ef565b90506106e8565b50600160a060020a03808316600081815260208181526040808320600160e060020a0319871684529091529020549091161492915050565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff161590565b6040517f01ffc9a7000000000000000000000000000000000000000000000000000000008082526004820183905260009182919060208160248189617530fa90519096909550935050505056fea165627a7a72305820377f4a2d4301ede9949f163f319021a6e9c687c292a5e2b2c4734c126b524e6c00291ba01820182018201820182018201820182018201820182018201820182018201820a01820182018201820182018201820182018201820182018201820182018201820' +export const REGISTRY_DEPLOYER_ADDRESS = + '0xa990077c3205cbDf861e17Fa532eeB069cE9fF96' + +const UNUSED_ADDRESS = '0x1111111111111111111111111111111111111111' +const TOTAL_SUPPLY = ethers.utils.parseUnits('5', 'ether') // 5 ECO +const INITIAL_INFLATION_MULTIPLIER = ethers.utils.parseUnits('1', 'ether') // 1e18 + +describe('PeanutV3.1', () => { + let alice: SignerWithAddress + let bob: SignerWithAddress + let charlie: SignerWithAddress + + before(async () => { + ;[alice, bob, charlie] = await ethers.getSigners() + await ( + await alice.sendTransaction({ + to: REGISTRY_DEPLOYER_ADDRESS, + value: ethers.utils.parseEther('0.08'), + }) + ).wait() + if (alice.provider) { + await (await alice.provider.sendTransaction(REGISTRY_DEPLOY_TX)).wait() + } + }) + + let Peanut: MockContract + let ECO: MockContract + beforeEach(async () => { + Peanut = await (await smock.mock('PeanutV3')).deploy() + + // deploy an ECO mock to test against + ECO = await (await smock.mock( + 'PeanutECO') + ).deploy( + UNUSED_ADDRESS, // none of the constructor arguments are used + UNUSED_ADDRESS, + 0, + UNUSED_ADDRESS, + ) + + await ECO.connect(alice).freeMint(TOTAL_SUPPLY) + }) + + describe('makeDeposit', () => { + const depositAmount = ethers.utils.parseUnits('1', 'ether') + beforeEach(async () => { + await ECO.connect(alice).approve(Peanut.address, depositAmount) + }) + + it('can deposit', async () => { + await Peanut.connect(alice).makeDeposit( + ECO.address, + 4, + depositAmount, + 0, + bob.address, + ) + }) + + it('deposit emits the correct event', async () => { + await expect( + Peanut.connect(alice).makeDeposit( + ECO.address, + 4, + depositAmount, + 0, + bob.address, + ) + ).to.emit(Peanut, 'DepositEvent') + .withArgs( + 0, + 4, + depositAmount.mul(INITIAL_INFLATION_MULTIPLIER), + alice.address, + ) + }) + + it('stores the correct data', async () => { + await Peanut.connect(alice).makeDeposit( + ECO.address, + 4, + depositAmount, + 1, + bob.address, + ) + + const deposit = await Peanut.deposits(0) + expect(deposit.pubKey20 === bob.address).to.be.true + expect(deposit.amount.eq(depositAmount.mul(INITIAL_INFLATION_MULTIPLIER))).to.be.true + expect(deposit.tokenAddress === ECO.address).to.be.true + expect(deposit.contractType === 4).to.be.true + expect(deposit.tokenId.eq('1')).to.be.true + }) + }) + + describe('makeWithdrawal', () => { + const depositAmount = ethers.utils.parseUnits('1', 'ether') + let signature + let presignedAddrHash + + beforeEach(async () => { + await ECO.connect(alice).approve(Peanut.address, depositAmount) + await Peanut.connect(alice).makeDeposit( + ECO.address, + 4, + depositAmount, + 0, + bob.address, + ) + + const addrHash = ethers.utils.solidityKeccak256(['address'], [charlie.address.toLocaleLowerCase()]) + const addrHashbinary = ethers.utils.arrayify(addrHash) + presignedAddrHash = ethers.utils.hashMessage(addrHashbinary) + signature = await bob.signMessage(addrHashbinary); + }) + + it('can withdraw', async () => { + await Peanut.withdrawDeposit( + 0, + charlie.address, + presignedAddrHash, + signature + ) + }) + + it('withdraw emits the right event', async () => { + await expect(Peanut.withdrawDeposit( + 0, + charlie.address, + presignedAddrHash, + signature + )).to.emit(Peanut,'WithdrawEvent') + .withArgs( + 0, + 4, + depositAmount.mul(INITIAL_INFLATION_MULTIPLIER), + charlie.address, + ) + }) + + it('sends tokens', async () => { + expect((await ECO.balanceOf(charlie.address)).eq(0)).to.be.true + await Peanut.withdrawDeposit( + 0, + charlie.address, + presignedAddrHash, + signature + ) + expect((await ECO.balanceOf(charlie.address)).eq(depositAmount)).to.be.true + }) + + it('is rebase safe', async () => { + expect((await ECO.balanceOf(charlie.address)).eq(0)).to.be.true + await ECO.setVariable('_linearInflationCheckpoints', [ + { + fromBlock: (await alice.provider?.getBlock('latest'))?.number, + value: INITIAL_INFLATION_MULTIPLIER.div(2), + }, + ]) + await Peanut.withdrawDeposit( + 0, + charlie.address, + presignedAddrHash, + signature + ) + expect((await ECO.balanceOf(charlie.address)).eq(depositAmount.mul(2))).to.be.true + }) + }) +}) \ No newline at end of file diff --git a/test/peanut/testBatch.sol b/test/peanut/testBatch.sol new file mode 100644 index 00000000..f9ac7b29 --- /dev/null +++ b/test/peanut/testBatch.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +// import "forge-std/Test.sol"; +// import "../../src/V4/PeanutV4.2.sol"; +// import "../../src/util/ERC20Mock.sol"; +// import "../../src/util/ERC721Mock.sol"; +// import "../../src/util/ERC1155Mock.sol"; + +// contract test is Test { +// PeanutV4 public peanutV4; +// ERC20Mock public testToken; +// ERC721Mock public testToken721; +// ERC1155Mock public testToken1155; + +// // a dummy private/public keypair to test withdrawals +// address public constant PUBKEY20 = +// address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); +// bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + +// function setUp() public { +// console.log("Setting up test"); +// peanutV4 = new PeanutV4(address(0)); +// testToken = new ERC20Mock(); +// testToken721 = new ERC721Mock(); +// // testToken1155 = new ERC1155Mock(); + +// // Mint tokens for test accounts +// testToken.mint(address(this), 10000000); +// testToken721.mint(address(this), 1); +// // testToken1155.mint(address(this), 1, 1000, ""); + +// // Approve PeanutV4 to spend tokens +// testToken.approve(address(peanutV4), 100000000); +// testToken721.setApprovalForAll(address(peanutV4), true); +// // testToken1155.setApprovalForAll(address(peanutV4), true); +// } + +// function testBatchMakeDeposit() public { +// address[] memory tokenAddresses = new address[](3); +// uint8[] memory contractTypes = new uint8[](3); +// uint256[] memory amounts = new uint256[](3); +// uint256[] memory tokenIds = new uint256[](3); +// address[] memory pubKeys20 = new address[](3); + +// // Deposit 1: ERC20 +// tokenAddresses[0] = address(testToken); +// contractTypes[0] = 1; +// amounts[0] = 100; +// tokenIds[0] = 0; +// pubKeys20[0] = PUBKEY20; + +// // Deposit 2: ERC721 +// tokenAddresses[1] = address(testToken721); +// contractTypes[1] = 2; +// amounts[1] = 1; +// tokenIds[1] = 1; +// pubKeys20[1] = PUBKEY20; + +// // Deposit 3: Ether +// tokenAddresses[2] = address(0); +// contractTypes[2] = 0; +// amounts[2] = 1 ether; +// tokenIds[2] = 0; +// pubKeys20[2] = PUBKEY20; + +// // Moved minting and approval to the setup function +// uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( +// tokenAddresses, +// contractTypes, +// amounts, +// tokenIds, +// pubKeys20 +// ); + +// assertEq(depositIndexes.length, 3, "Batch deposit failed"); +// assertEq(peanutV4.getDepositCount(), 3, "Deposit count mismatch"); +// } + +// fuzzy testing of batchMakeDeposit with varying length of input arrays +// function testFuzz_BatchMakeDeposit_number( +// uint8 arrayLength +// ) public { +// address[] memory tokenAddresses = new address[](arrayLength); +// uint8[] memory contractTypes = new uint8[](arrayLength); +// uint256[] memory amounts = new uint256[](arrayLength); +// uint256[] memory tokenIds = new uint256[](arrayLength); +// address[] memory pubKeys20 = new address[](arrayLength); + +// // fill in dummy values for the arrays +// for (uint256 i = 0; i < arrayLength; i++) { +// tokenAddresses[i] = address(testToken); +// contractTypes[i] = 1; +// amounts[i] = 100; +// tokenIds[i] = 0; +// pubKeys20[i] = PUBKEY20; +// } + +// uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( +// tokenAddresses, +// contractTypes, +// amounts, +// tokenIds, +// pubKeys20 +// ); + +// assertEq(depositIndexes.length, arrayLength, "Batch deposit failed"); +// assertEq(peanutV4.getDepositCount(), arrayLength, "Deposit count mismatch"); +// } + +// } diff --git a/test/peanut/testDeposit.sol b/test/peanut/testDeposit.sol new file mode 100644 index 00000000..332c093c --- /dev/null +++ b/test/peanut/testDeposit.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +////////////////////////////// +// A few integration tests for the PeanutV4 contract +////////////////////////////// + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/peanut/util/ERC20Mock.sol"; +import "../../src/peanut/util/ERC721Mock.sol"; +import "../../src/peanut/util/ERC1155Mock.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; + +contract PeanutV4DepositTest is Test, ERC1155Holder, ERC721Holder { + PeanutV4 public peanutV4; + ERC20Mock public testToken; + ERC721Mock public testToken721; + ERC1155Mock public testToken1155; + + // a dummy private/public keypair to test withdrawals + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + function setUp() public { + console.log("Setting up test"); + peanutV4 = new PeanutV4(address(0)); + testToken = new ERC20Mock(); + testToken721 = new ERC721Mock(); + testToken1155 = new ERC1155Mock(); + } + + // make contract payable + receive() external payable {} + + // Make a deposit, withdraw the deposit. + // check invariants + function testDepositEther(uint64 amount, address randomAddress) public { + vm.assume(amount > 0); + peanutV4.makeDeposit{value: amount}(randomAddress, 0, amount, 0, PUBKEY20); + } + + function testDepositERC20(uint64 amount) public { + vm.assume(amount > 0); + // mint tokens to the contract + testToken.mint(address(this), amount); + // approve the contract to spend the tokens + testToken.approve(address(peanutV4), amount); + // console log allowance and amount + console.log("Allowance: ", testToken.allowance(address(this), address(peanutV4))); + console.log("Amount: ", amount); + peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + } + + // Test for ERC721 Token + function testDepositERC721(uint64 tokenId) public { + // mint a token to the contract + testToken721.mint(address(this), tokenId); + // approve the contract to spend the tokens + testToken721.approve(address(peanutV4), tokenId); + peanutV4.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); + } + + // Test for ERC1155 Token + function testDepositERC1155(uint64 tokenId, uint64 amount) public { + vm.assume(amount > 0); + // mint tokens to the contract + testToken1155.mint(address(this), tokenId, amount, ""); + // approve the contract to spend the tokens + testToken1155.setApprovalForAll(address(peanutV4), true); + peanutV4.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); + } +} diff --git a/test/peanut/testIntegration.sol b/test/peanut/testIntegration.sol new file mode 100644 index 00000000..7c8b40d8 --- /dev/null +++ b/test/peanut/testIntegration.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +////////////////////////////// +// A few integration tests for the PeanutV4 contract +////////////////////////////// + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/peanut/util/ERC20Mock.sol"; +import "../../src/peanut/util/ERC721Mock.sol"; +import "../../src/peanut/util/ERC1155Mock.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; + +contract PeanutV4IntegrationTest is Test, ERC1155Holder, ERC721Holder { + PeanutV4 public peanutV4; + ERC20Mock public testToken; + ERC721Mock public testToken721; + ERC1155Mock public testToken1155; + + // a dummy private/public keypair to test withdrawals + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + function setUp() public { + console.log("Setting up test"); + peanutV4 = new PeanutV4(address(0)); + testToken = new ERC20Mock(); + testToken721 = new ERC721Mock(); + testToken1155 = new ERC1155Mock(); + } + + receive() external payable {} + + // Make a deposit, withdraw the deposit. + // check invariants + function testIntegrationEtherSenderWithdraw(uint64 amount) public { + vm.assume(amount > 0); + assertEq(peanutV4.getDepositCount(), 0); // deposit count invariant + assertEq(address(peanutV4).balance, 0); // contract balance invariant + uint256 senderBalance = address(this).balance; // sender balance invariant + uint256 depositIdx = peanutV4.makeDeposit{value: amount}(address(0), 0, amount, 0, PUBKEY20); + assertEq(depositIdx, 0); // deposit index invariant + assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant + assertEq(address(peanutV4).balance, amount); // contract balance invariant + assertEq(address(this).balance, senderBalance - amount); // sender balance invariant + + // wait 25 hours + vm.warp(block.timestamp + 25 hours); + + // Withdraw the deposit + peanutV4.withdrawDepositSender(depositIdx); + assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant + assertEq(address(peanutV4).balance, 0); // contract balance invariant + assertEq(address(this).balance, senderBalance); // sender balance invariant + } + + function testIntegrationERC20SenderWithdraw(uint64 amount) public { + vm.assume(amount > 0); + // mint tokens to the contract + testToken.mint(address(this), amount); + // approve the contract to spend the tokens + testToken.approve(address(peanutV4), amount); + assertEq(testToken.balanceOf(address(this)), amount); // contract token balance invariant + uint256 depositIdx = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + assertEq(depositIdx, 0); // deposit index invariant + assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant + assertEq(testToken.balanceOf(address(peanutV4)), amount); // contract token balance invariant + assertEq(testToken.balanceOf(address(this)), 0); // sender token balance invariant + + // wait 25 hours + vm.warp(block.timestamp + 25 hours); + + // Withdraw the deposit + peanutV4.withdrawDepositSender(depositIdx); + assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant + assertEq(testToken.balanceOf(address(peanutV4)), 0); // contract token balance invariant + assertEq(testToken.balanceOf(address(this)), amount); // sender token balance invariant + } + + // Test for ERC721 Token + function testIntegrationERC721SenderWithdraw(uint64 tokenId) public { + // setup + testToken721.mint(address(this), tokenId); + testToken721.approve(address(peanutV4), tokenId); + + // invariant checks + assertEq(peanutV4.getDepositCount(), 0); + assertEq(testToken721.ownerOf(tokenId), address(this)); + assertEq(testToken721.balanceOf(address(peanutV4)), 0); + assertEq(testToken721.balanceOf(address(this)), 1); + uint256 depositIdx = peanutV4.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); + + // invariant checks + assertEq(depositIdx, 0); + assertEq(peanutV4.getDepositCount(), 1); + assertEq(testToken721.ownerOf(tokenId), address(peanutV4)); + assertEq(testToken721.balanceOf(address(peanutV4)), 1); + assertEq(testToken721.balanceOf(address(this)), 0); + + // wait 25 hours + vm.warp(block.timestamp + 25 hours); + + // Withdraw the deposit + peanutV4.withdrawDepositSender(depositIdx); + + // invariant checks + assertEq(peanutV4.getDepositCount(), 1); + assertEq(testToken721.ownerOf(tokenId), address(this)); + assertEq(testToken721.balanceOf(address(peanutV4)), 0); + assertEq(testToken721.balanceOf(address(this)), 1); + } + + // Test for ERC1155 Token + function testIntegrationERC1155SenderWithdraw(uint64 tokenId, uint64 amount) public { + vm.assume(amount > 0); + // mint tokens to the contract + testToken1155.mint(address(this), tokenId, amount, ""); + testToken1155.setApprovalForAll(address(peanutV4), true); + assertEq(testToken1155.balanceOf(address(this), tokenId), amount); // contract token balance invariant + uint256 depositIdx = peanutV4.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); + assertEq(depositIdx, 0); // deposit index invariant + assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant + assertEq(testToken1155.balanceOf(address(peanutV4), tokenId), amount); // contract token balance invariant + assertEq(testToken1155.balanceOf(address(this), tokenId), 0); // sender token balance invariant + + // wait 25 hours + vm.warp(block.timestamp + 25 hours); + + // Withdraw the deposit + peanutV4.withdrawDepositSender(depositIdx); + assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant + assertEq(testToken1155.balanceOf(address(peanutV4), tokenId), 0); // contract token balance invariant + assertEq(testToken1155.balanceOf(address(this), tokenId), amount); // sender token balance invariant + } +} diff --git a/test/peanut/testMFA.sol b/test/peanut/testMFA.sol new file mode 100644 index 00000000..2bbeb4f2 --- /dev/null +++ b/test/peanut/testMFA.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutV4.4.sol"; + +contract PeanutV4MFATest is Test { + PeanutV4 public peanutV4; + + // a dummy private/public keypair to test withdrawals + address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); + bytes32 public constant SAMPLE_PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + function setUp() public { + peanutV4 = new PeanutV4(address(0)); + } + + function testMFADeposit() public { + uint256 depositIndex = peanutV4.makeSelflessMFADeposit{value: 1}( + 0x0000000000000000000000000000000000000000, + 0, + 1, + 0, + SAMPLE_ADDRESS, + 0x0000000000000000000000000000000000001234); + + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + peanutV4.PEANUT_SALT(), + block.chainid, + address(peanutV4), + depositIndex, + address(this), // recipient + peanutV4.ANYONE_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), digest); + bytes memory signature = abi.encodePacked(r, s, v); + + // Withdrawing without authorization, so should fail + vm.expectRevert("REQUIRES AUTHORIZATION"); + peanutV4.withdrawDeposit(depositIndex, address(this), signature); + + // Withdrawing with incorrect authorizattion signature + vm.expectRevert("WRONG MFA SIGNATURE"); + peanutV4.withdrawMFADeposit(depositIndex, address(this), signature, signature); + + // Authorization is correct! Withdrawal has to be successful! + bytes memory authorization = hex"41caae599d693a31ea45aab95c8d166e9709cb450f1c76a2b06306ee61cb28b37ed0cad0d47d055580ce204ac9973b671a0970d02f9ee6572a9234f3130707321c"; + peanutV4.withdrawMFADeposit(depositIndex, address(this), signature, authorization); + } + + receive () payable external {} +} \ No newline at end of file diff --git a/test/peanut/testSenderWithdraw.sol b/test/peanut/testSenderWithdraw.sol new file mode 100644 index 00000000..d9b09094 --- /dev/null +++ b/test/peanut/testSenderWithdraw.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/peanut/util/ERC20Mock.sol"; +import "../../src/peanut/util/ERC721Mock.sol"; +import "../../src/peanut/util/ERC1155Mock.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; + +contract TestSenderWithdrawEther is Test { + PeanutV4 public peanutV4; + // a dummy private/public keypair to test withdrawals + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + receive() external payable {} // necessary to receive ether + + function setUp() public { + console.log("Setting up test"); + peanutV4 = new PeanutV4(address(0)); + } + + function testSenderWithdrawEther(uint64 amount) public { + vm.assume(amount > 0); + uint256 depositIdx = peanutV4.makeDeposit{value: amount}(address(0), 0, amount, 0, PUBKEY20); + + // Withdraw the deposit + peanutV4.withdrawDepositSender(depositIdx); + } +} + +contract TestSenderWithdrawErc20 is Test { + PeanutV4 public peanutV4; + ERC20Mock public testToken; + + // a dummy private/public keypair to test withdrawals + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + uint256 _depositIdx; + + // apparently not possible to fuzz test in setUp() function? + function setUp() public { + console.log("Setting up test"); + peanutV4 = new PeanutV4(address(0)); + testToken = new ERC20Mock(); // contractype 1 + + // Mint tokens for test accounts (larger than uint128) + testToken.mint(address(this), 2 ** 130); + + // Approve the contract to spend the tokens + testToken.approve(address(peanutV4), 2 ** 130); + + // Make a deposit + uint256 amount = 2 ** 128; + _depositIdx = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + } + + function testSenderWithdrawErc20() public { + // Withdraw the deposit + peanutV4.withdrawDepositSender(_depositIdx); + } +} + +contract TestSenderWithdrawErc721 is Test, ERC721Holder { + PeanutV4 public peanutV4; + ERC721Mock public testToken; + + // a dummy private/public keypair to test withdrawals + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + uint256 _depositIdx; + uint256 _tokenId = 1; // tokenId used for ERC721 + + // apparently not possible to fuzz test in setUp() function? + function setUp() public { + console.log("Setting up test"); + peanutV4 = new PeanutV4(address(0)); + testToken = new ERC721Mock(); // contractype 2 + + // Mint token for test + testToken.mint(address(this), _tokenId); + + // Approve the contract to spend the tokens + testToken.approve(address(peanutV4), _tokenId); + + // Make a deposit + _depositIdx = peanutV4.makeDeposit(address(testToken), 2, 1, _tokenId, PUBKEY20); + } + + function testSenderWithdrawErc721() public { + // Withdraw the deposit + peanutV4.withdrawDepositSender(_depositIdx); + } +} + +contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { + PeanutV4 public peanutV4; + ERC1155Mock public testToken; + + // a dummy private/public keypair to test withdrawals + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + + uint256 _depositIdx; + uint256 _tokenId = 1; // tokenId used for ERC1155 + uint256 _tokenAmount = 100; // amount of ERC1155 tokens + + // apparently not possible to fuzz test in setUp() function? + function setUp() public { + console.log("Setting up test"); + peanutV4 = new PeanutV4(address(0)); + testToken = new ERC1155Mock(); // contractype 3 + + // Mint tokens for test + testToken.mint(address(this), _tokenId, _tokenAmount, ""); + + // Approve the contract to spend the tokens + testToken.setApprovalForAll(address(peanutV4), true); + + // Make a deposit + _depositIdx = peanutV4.makeDeposit(address(testToken), 3, _tokenAmount, _tokenId, PUBKEY20); + } + + function testSenderWithdrawErc1155() public { + // Withdraw the deposit + peanutV4.withdrawDepositSender(_depositIdx); + } +} diff --git a/test/peanut/testSigWithdraw.sol b/test/peanut/testSigWithdraw.sol new file mode 100644 index 00000000..242cd12c --- /dev/null +++ b/test/peanut/testSigWithdraw.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/peanut/util/ERC20Mock.sol"; +import "../../src/peanut/util/ERC721Mock.sol"; +import "../../src/peanut/util/ERC1155Mock.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; + +contract TestSigWithdrawEther is Test { + PeanutV4 public peanutV4; + + // sample inputs + address _pubkey20 = 0x8fd379246834eac74B8419FfdA202CF8051F7A03; + address _recipientAddress = 0x6B3751c5b04Aa818EA90115AA06a4D9A36A16f02; + bytes public signatureAnybody = + hex"02a37d0548c14c6b07eba4ef1438eb946cdada4f481164755129eb3725f7e8c13d7c052308e73314338f4d484a5f4aef20c7519a1dbc283e4826253b742817241c"; + bytes public signatureRecipient = hex"364c17bca8823977b29b7646c954353996f363549f08ce3943969171c050f0d74006eabb597df680e9e4229631f473bfbedf995336a03d2fd3be7f1fff22d2511b"; + + receive() external payable {} // necessary to receive ether + + function setUp() public { + console.log("Setting up test"); + peanutV4 = new PeanutV4(address(0)); + } + + // test sender withdrawal of ETH + function testSigWithdrawEther(uint64 amount) public { + vm.assume(amount > 0); + uint256 depositIdx = peanutV4.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); + + // Can't use withdrawDepositAsRecipient + vm.expectRevert("NOT THE RECIPIENT"); + peanutV4.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureAnybody); + + // Anybody can withdraw + peanutV4.withdrawDeposit(depositIdx, _recipientAddress, signatureAnybody); + } + + function testWithdrawDepositAsRecipient(uint64 amount) public { + vm.assume(amount > 0); + uint256 depositIdx = peanutV4.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); + + // Can't use pure withdrawDeposit + vm.expectRevert("WRONG SIGNATURE"); + peanutV4.withdrawDeposit(depositIdx, _recipientAddress, signatureRecipient); + + // Only the recipient is able to withdraw via withdrawDepositAsRecipient + vm.expectRevert("NOT THE RECIPIENT"); + peanutV4.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); + + vm.prank(_recipientAddress); // Withdraw! + peanutV4.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); + } +} From 1f677da5626ff5a0cebc2f2e29c424ebf6feac9f Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 12 May 2026 16:33:52 -0400 Subject: [PATCH 02/31] fix(peanut): use call instead of transfer for ETH seeding in router test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZkSync rejects
.transfer() under the sendtransfer error policy because the 2300 gas stipend isn't safe under EraVM pubdata costs. This was the only native .transfer() in the peanut suite — IERC20.transfer calls elsewhere are fine. --- test/peanut/PeanutRouter.t.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/peanut/PeanutRouter.t.sol b/test/peanut/PeanutRouter.t.sol index 9df32d07..c449e3ec 100644 --- a/test/peanut/PeanutRouter.t.sol +++ b/test/peanut/PeanutRouter.t.sol @@ -224,7 +224,8 @@ contract PeanutV4RouterTest is Test { // Pretend that there were some transfers and some fee was collected in the peanut router testToken.mint(address(this), collectedTokens); testToken.transfer(address(peanutV4Router), collectedTokens); - payable(address(peanutV4Router)).transfer(collectedEth); + (bool ok,) = payable(address(peanutV4Router)).call{value: collectedEth}(""); + require(ok, "ETH seed transfer failed"); // Non-owner can't withdraw vm.prank(SAMPLE_ADDRESS); From 12a77ce1262647bd4214920ed69dfa6d0ee49076 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 12 May 2026 18:37:20 -0400 Subject: [PATCH 03/31] refactor(peanut): security hardening + ZkSync-aligned modernization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes: - ERC721/1155 receivers now revert on direct (non-self) transfers instead of silently dropping (was: implicit return bytes4(0); some tokens accepted it and the assets got stuck with no recovery path) - PeanutRouter.withdrawFees uses SafeERC20.safeTransfer (works with USDT and other non-bool-returning ERC20s) - MFA_AUTHORIZER promoted from hardcoded constant to immutable constructor arg, so each deploy can pick its own signer (or address(0) to disable) - _storeDeposit rejects deposits with both pubKey20 == 0 and recipient == 0 (would otherwise be claimable by anyone) - Fixed upstream bug: _withdrawDeposit's L2ECO branch was sending tokens to senderAddress instead of recipientAddress; now correct - PeanutRouter switched to Ownable2Step (safer ownership handoff) ZkSync-aligned patterns: - Pragma pinned to 0.8.26 (matches repo, aligns with zksolc) - Batcher dropped public PeanutV4 storage var; uses local in each call so EraVM doesn't charge pubdata for every batch invocation - Explicit override(IERC165) on supportsInterface for stricter solc/zksolc - All raw IL2ECO transfer/transferFrom calls replaced with SafeERC20 Modernization: - Named imports throughout - Cleaner NatSpec on constructors and public methods - Removed unused parameter names from receiver hooks (silences zksolc warns) Tests: - Updated all `new PeanutV4(address(0))` call sites to the 2-arg constructor - testMFA pins LEGACY_MFA_AUTHORIZER (the upstream Squirrel address) so its pre-baked authorization signature still verifies - New PeanutHardening.t.sol with 11 tests covering each fix above 71/71 peanut tests pass (60 vendored + 11 hardening). 849/849 rest-of-repo tests still pass — no regressions. zksolc compiles peanut clean (only cosmetic warnings; pre-existing repo-level zksync errors in SwarmRegistryL1Upgradeable / FleetIdentity.t.sol / TestUpgradeOnAnvil.s.sol are unrelated). --- src/peanut/V4/PeanutBatcherV4.4.sol | 152 ++++++-------- src/peanut/V4/PeanutRouter.sol | 64 +++--- src/peanut/V4/PeanutV4.4.sol | 121 ++++++----- src/peanut/util/ECRecover.sol | 2 +- src/peanut/util/EIP3009Implementation.sol | 2 +- src/peanut/util/EIP3009Internals.sol | 2 +- src/peanut/util/EIP712.sol | 2 +- src/peanut/util/EIP712Domain.sol | 2 +- src/peanut/util/ERC1155Mock.sol | 2 +- src/peanut/util/ERC20Mock.sol | 2 +- src/peanut/util/ERC721Mock.sol | 2 +- src/peanut/util/IEIP3009.sol | 2 +- src/peanut/util/IL2ECO.sol | 2 +- src/peanut/util/SampleSCW.sol | 2 +- src/peanut/util/SquidMock.sol | 2 +- test/peanut/PeanutBatcher.t.sol | 2 +- test/peanut/PeanutHardening.t.sol | 236 ++++++++++++++++++++++ test/peanut/PeanutRouter.t.sol | 2 +- test/peanut/PeanutV4.t.sol | 4 +- test/peanut/PeanutV4Gasless.t.sol | 2 +- test/peanut/RecipeintBound.t.sol | 2 +- test/peanut/testBatch.sol | 2 +- test/peanut/testDeposit.sol | 2 +- test/peanut/testIntegration.sol | 2 +- test/peanut/testMFA.sol | 6 +- test/peanut/testSenderWithdraw.sol | 8 +- test/peanut/testSigWithdraw.sol | 2 +- 27 files changed, 420 insertions(+), 211 deletions(-) create mode 100644 test/peanut/PeanutHardening.t.sol diff --git a/src/peanut/V4/PeanutBatcherV4.4.sol b/src/peanut/V4/PeanutBatcherV4.4.sol index 614a091a..3408c1ce 100644 --- a/src/peanut/V4/PeanutBatcherV4.4.sol +++ b/src/peanut/V4/PeanutBatcherV4.4.sol @@ -1,19 +1,23 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.23; - -import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; -import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; -import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; -import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; -import "./PeanutV4.4.sol"; - +pragma solidity 0.8.26; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {PeanutV4} from "./PeanutV4.4.sol"; + +/// @title Peanut Batcher V4.4 +/// @notice Stateless helper that pulls tokens from msg.sender then forwards N deposits +/// to a target PeanutV4 vault. +/// @dev Holds no persistent state — the PeanutV4 reference is taken per call so the +/// contract can fan out to multiple vaults and so EraVM doesn't charge pubdata +/// for storage writes on the hot path. contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { using SafeERC20 for IERC20; - PeanutV4 public peanut; - function _setAllowanceIfZero(address tokenAddress, address spender) internal { uint256 currentAllowance = IERC20(tokenAddress).allowance(address(this), spender); if (currentAllowance == 0) { @@ -21,60 +25,44 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { } } - /** - * @notice supportsInterface function - * @dev ERC165 interface detection - * @param _interfaceId bytes4 the interface identifier, as specified in ERC-165 - * @return bool true if the contract implements the interface specified in _interfaceId - */ - function supportsInterface(bytes4 _interfaceId) external pure override returns (bool) { + function supportsInterface(bytes4 _interfaceId) external pure override(IERC165) returns (bool) { return _interfaceId == type(IERC165).interfaceId || _interfaceId == type(IERC721Receiver).interfaceId || _interfaceId == type(IERC1155Receiver).interfaceId; } - /** - * @notice Erc721 token receiver function - * @dev These functions are called by the token contracts when a token is sent to this contract - */ - function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) + /// @notice ERC-721 receiver hook. Self-only — unsolicited transfers revert (S1). + function onERC721Received(address _operator, address, uint256, bytes calldata) external + view override returns (bytes4) { - if (_operator == address(this)) { - return this.onERC721Received.selector; - } + require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + return this.onERC721Received.selector; } - /** - * @notice Erc1155 token receiver function - * @dev These functions are called by the token contracts when a token is sent to this contract - */ - function onERC1155Received(address _operator, address _from, uint256 _tokenId, uint256 _value, bytes calldata _data) + /// @notice ERC-1155 receiver hook. Self-only — unsolicited transfers revert (S1). + function onERC1155Received(address _operator, address, uint256, uint256, bytes calldata) external + view override returns (bytes4) { - if (_operator == address(this)) { - return this.onERC1155Received.selector; - } + require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + return this.onERC1155Received.selector; } - /** - * @notice Erc1155 token receiver function - * @dev These functions are called by the token contracts when a set of tokens is sent to this contract - */ + /// @notice ERC-1155 batch receiver hook. Self-only — unsolicited transfers revert (S1). function onERC1155BatchReceived( address _operator, - address _from, - uint256[] calldata _ids, - uint256[] calldata _values, - bytes calldata _data - ) external override returns (bytes4) { - if (_operator == address(this)) { - return this.onERC1155BatchReceived.selector; - } - } + address, + uint256[] calldata, + uint256[] calldata, + bytes calldata + ) external view override returns (bytes4) { + require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + return this.onERC1155BatchReceived.selector; + } function batchMakeDeposit( address _peanutAddress, @@ -84,7 +72,7 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { uint256 _tokenId, address[] calldata _pubKeys20 ) external payable returns (uint256[] memory) { - peanut = PeanutV4(_peanutAddress); + PeanutV4 peanut = PeanutV4(_peanutAddress); uint256 totalAmount = _amount * _pubKeys20.length; uint256 etherAmount; @@ -94,27 +82,25 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { } else if (_contractType == 1) { IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); _setAllowanceIfZero(_tokenAddress, address(peanut)); - etherAmount = 0; } else if (_contractType == 2) { - // revert not implemented revert("ERC721 batch not implemented"); } else if (_contractType == 3) { IERC1155(_tokenAddress).safeTransferFrom(msg.sender, address(this), _tokenId, totalAmount, ""); IERC1155(_tokenAddress).setApprovalForAll(address(peanut), true); - etherAmount = 0; } uint256[] memory depositIndexes = new uint256[](_pubKeys20.length); - for (uint256 i = 0; i < _pubKeys20.length; i++) { - depositIndexes[i] = - peanut.makeSelflessDeposit{value: etherAmount}(_tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender); + depositIndexes[i] = peanut.makeSelflessDeposit{value: etherAmount}( + _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender + ); } - return depositIndexes; } - // Arbitrary but samesy deposit. Assumes all deposits are the same. Gas efficient + /// @notice Variant of batchMakeDeposit that does not allocate the return array. + /// @dev Assumes all deposits are the same; uses msg.value as etherAmount per call + /// (only meaningful when called with a single deposit, or when sending only ETH dust). function batchMakeDepositNoReturn( address _peanutAddress, address _tokenAddress, @@ -123,14 +109,14 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { uint256 _tokenId, address[] calldata _pubKeys20 ) external payable { - peanut = PeanutV4(_peanutAddress); - + PeanutV4 peanut = PeanutV4(_peanutAddress); for (uint256 i = 0; i < _pubKeys20.length; i++) { - peanut.makeSelflessDeposit{value: msg.value}(_tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender); + peanut.makeSelflessDeposit{value: msg.value}( + _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender + ); } } - // arbitrary deposits function batchMakeDepositArbitrary( address _peanutAddress, address[] memory _tokenAddresses, @@ -142,13 +128,13 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { ) external payable returns (uint256[] memory) { require( _tokenAddresses.length == _pubKeys20.length && _contractTypes.length == _pubKeys20.length - && _amounts.length == _pubKeys20.length && _tokenIds.length == _pubKeys20.length, + && _amounts.length == _pubKeys20.length && _tokenIds.length == _pubKeys20.length + && _withMFAs.length == _pubKeys20.length, "PARAMETERS LENGTH MISMATCH" ); - peanut = PeanutV4(_peanutAddress); + PeanutV4 peanut = PeanutV4(_peanutAddress); uint256[] memory depositIndexes = new uint256[](_amounts.length); - for (uint256 i = 0; i < _amounts.length; i++) { uint256 etherAmount; @@ -157,14 +143,11 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { } else if (_contractTypes[i] == 1) { IERC20(_tokenAddresses[i]).safeTransferFrom(msg.sender, address(this), _amounts[i]); _setAllowanceIfZero(_tokenAddresses[i], _peanutAddress); - etherAmount = 0; } else if (_contractTypes[i] == 2) { - // revert not implemented revert("ERC721 batch not implemented"); } else if (_contractTypes[i] == 3) { IERC1155(_tokenAddresses[i]).safeTransferFrom(msg.sender, address(this), _tokenIds[i], _amounts[i], ""); IERC1155(_tokenAddresses[i]).setApprovalForAll(_peanutAddress, true); - etherAmount = 0; } depositIndexes[i] = peanut.makeCustomDeposit{value: etherAmount}( @@ -173,15 +156,14 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { _amounts[i], _tokenIds[i], _pubKeys20[i], - msg.sender, // deposit ownerm + msg.sender, // deposit owner _withMFAs[i], address(0), // not recipient-bound - uint40(0), // not recipient-bound - false, // not a EIP-3009 deposit - "" // not a EIP-3009 deposit + uint40(0), + false, // not EIP-3009 + "" // not EIP-3009 ); } - return depositIndexes; } @@ -192,35 +174,28 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { uint256[] calldata _amounts, address _pubKey20 ) external payable returns (uint256[] memory) { - require( - _contractType == 0 || _contractType == 1, - "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED" - ); + require(_contractType == 0 || _contractType == 1, "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED"); + PeanutV4 peanut = PeanutV4(_peanutAddress); - peanut = PeanutV4(_peanutAddress); if (_contractType == 1) { _setAllowanceIfZero(_tokenAddress, _peanutAddress); uint256 totalAmount; - for(uint256 i = 0; i < _amounts.length; i++) { + for (uint256 i = 0; i < _amounts.length; i++) { totalAmount += _amounts[i]; } IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); } uint256[] memory depositIndexes = new uint256[](_amounts.length); - for (uint256 i = 0; i < _amounts.length; i++) { uint256 etherAmount; - if (_contractType == 0) { etherAmount = _amounts[i]; } - depositIndexes[i] = peanut.makeSelflessDeposit{value: etherAmount}( _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender ); } - return depositIndexes; } @@ -231,35 +206,28 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { uint256[] calldata _amounts, address _pubKey20 ) external payable returns (uint256[] memory) { - require( - _contractType == 0 || _contractType == 1, - "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED" - ); + require(_contractType == 0 || _contractType == 1, "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED"); + PeanutV4 peanut = PeanutV4(_peanutAddress); - peanut = PeanutV4(_peanutAddress); if (_contractType == 1) { _setAllowanceIfZero(_tokenAddress, _peanutAddress); uint256 totalAmount; - for(uint256 i = 0; i < _amounts.length; i++) { + for (uint256 i = 0; i < _amounts.length; i++) { totalAmount += _amounts[i]; } IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); } uint256[] memory depositIndexes = new uint256[](_amounts.length); - for (uint256 i = 0; i < _amounts.length; i++) { uint256 etherAmount; - if (_contractType == 0) { etherAmount = _amounts[i]; } - depositIndexes[i] = peanut.makeSelflessMFADeposit{value: etherAmount}( _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender ); } - return depositIndexes; } } diff --git a/src/peanut/V4/PeanutRouter.sol b/src/peanut/V4/PeanutRouter.sol index b9d0c355..65b85cf7 100644 --- a/src/peanut/V4/PeanutRouter.sol +++ b/src/peanut/V4/PeanutRouter.sol @@ -1,39 +1,41 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.23; +pragma solidity 0.8.26; ////////////////////////////////////////////////////////////////////////////////////// // @title Peanut Router -// @notice This contract is used on top of Peanut V4 to add cross-chain functionality to links. -// more at: https://peanut.to -// @version 0.1.0 -// @author Squirrel Labs +// @notice Bridges a Peanut V4 deposit to another chain via the Squid router. +// @version 0.2.0 +// @author Squirrel Labs (vendored + modernized for nodle/rollup) ////////////////////////////////////////////////////////////////////////////////////// import {PeanutV4} from "./PeanutV4.4.sol"; -import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; -contract PeanutV4Router is Ownable { +contract PeanutV4Router is Ownable2Step { using SafeERC20 for IERC20; address public squidAddress; + /// @param _squidAddress target Squid router address to forward bridged value to. constructor(address _squidAddress) Ownable(msg.sender) { squidAddress = _squidAddress; } - /** - * @notice Function to withdraw a peanut deposit to a different chain. - * @param _peanutAddress peanut vault to withdraw the deposit from. - * @param _depositIndex index of the deposit in the peanut vault. - * @param _withdrawalSignature signature to withdraw from peanut. - * @param _squidFee squid router fee. - * @param _peanutFee fee amount taken by peanut (this contract) for routing. - * @param _squidData calldata for the squid router - * @param _routingSignature signed _squidFee, _peanutFee and _squidData - */ + /// @notice Withdraw a Peanut deposit and bridge it cross-chain via Squid. + /// @dev Validates the EIP-191 v0x00 routing signature first to prevent front-running: + /// the relayer is constrained to exactly the squidFee/peanutFee/squidData the + /// deposit owner signed off-chain. + /// @param _peanutAddress peanut vault to withdraw the deposit from. + /// @param _depositIndex index of the deposit in the peanut vault. + /// @param _withdrawalSignature signature authorizing the peanut withdrawal. + /// @param _squidFee squid router fee (must equal msg.value). + /// @param _peanutFee fee retained by this router (must be < deposit.amount). + /// @param _squidData calldata blob forwarded to the squid router. + /// @param _routingSignature signature over (squidFee, peanutFee, squidData), signed by deposit.pubKey20. function withdrawAndBridge( address _peanutAddress, uint256 _depositIndex, @@ -46,8 +48,7 @@ contract PeanutV4Router is Ownable { PeanutV4 peanut = PeanutV4(_peanutAddress); PeanutV4.Deposit memory deposit = peanut.getDeposit(_depositIndex); - // We must first validate _routingSignature to prevent front-running - // The signature structure follows version 0x00 from EIP-191 + // Validate routingSignature (EIP-191 v0x00). bytes32 digest = keccak256( abi.encodePacked( bytes2(0x1900), @@ -65,32 +66,39 @@ contract PeanutV4Router is Ownable { require(routingSigner == deposit.pubKey20, "WRONG ROUTING SIGNER"); require(_squidFee == msg.value, "msg.value MUST BE THE SQUID FEE"); - require(deposit.contractType == 0 || deposit.contractType == 1, "X-CHAIN CLAIMS WORK ONLY FOR ETH AND ERC20 TOKENS"); + require( + deposit.contractType == 0 || deposit.contractType == 1, "X-CHAIN CLAIMS WORK ONLY FOR ETH AND ERC20 TOKENS" + ); require(_peanutFee < deposit.amount, "TOO HIGH FEE"); peanut.withdrawDepositAsRecipient(_depositIndex, address(this), _withdrawalSignature); uint256 amountToBridge = deposit.amount - _peanutFee; uint256 ethAmountToSquid = msg.value; - if (deposit.contractType == 0) { // ETH deposit + if (deposit.contractType == 0) { + // ETH deposit ethAmountToSquid += amountToBridge; - } else if (deposit.contractType == 1) { // ERC20 deposit - IERC20(deposit.tokenAddress).safeIncreaseAllowance(address(squidAddress), amountToBridge); + } else if (deposit.contractType == 1) { + // ERC20 deposit + IERC20(deposit.tokenAddress).safeIncreaseAllowance(squidAddress, amountToBridge); } else { revert("UNSUPPORTED contractType"); } - // initiate the cross-chain transfer (bool success,) = payable(squidAddress).call{value: ethAmountToSquid}(_squidData); require(success, "FAILED TO INITIATE SQUID TRANSFER"); } + /// @notice Withdraw collected fees. Owner-gated (Ownable2Step — handoff requires acceptance). + /// @param token address(0) for ETH, ERC20 contract otherwise. + /// @param to recipient of the fees. + /// @param amount amount to withdraw. function withdrawFees(address token, address to, uint256 amount) public onlyOwner { if (token == address(0)) { (bool success,) = payable(to).call{value: amount}(""); require(success, "FAILED TO WITHDRAW ETH"); } else { - IERC20(token).transfer(to, amount); + IERC20(token).safeTransfer(to, amount); } } diff --git a/src/peanut/V4/PeanutV4.4.sol b/src/peanut/V4/PeanutV4.4.sol index 6c3ad656..629e8028 100644 --- a/src/peanut/V4/PeanutV4.4.sol +++ b/src/peanut/V4/PeanutV4.4.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.23; +pragma solidity 0.8.26; ////////////////////////////////////////////////////////////////////////////////////// // @title Peanut Protocol @@ -30,16 +30,17 @@ pragma solidity ^0.8.23; // ////////////////////////////////////////////////////////////////////////////////////// -import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; -import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; -import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; -import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; -import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; -import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; -import "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; import {IL2ECO} from "../util/IL2ECO.sol"; import {IEIP3009} from "../util/IEIP3009.sol"; @@ -75,7 +76,9 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { bytes32 public constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); - address public constant MFA_AUTHORIZER = 0x3B14D43Bf521EF7FD9600533bEB73B6e9178DE7C; + /// @notice Address authorized to issue MFA signatures gating withdrawMFADeposit calls. + /// @dev Configurable per deployment. Address(0) disables MFA — withdrawMFADeposit will revert. + address public immutable MFA_AUTHORIZER; struct EIP712Domain { string name; @@ -102,13 +105,12 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { ); event MessageEvent(string message); - // constructor. Accepts ECO token address to prohibit ECO usage in normal - // ERC20 deposits. - // Initializes DOMAIN_SEPARATOR. - // Wishes you a nutty day. - constructor(address _ecoAddress) { + /// @param _ecoAddress address of the ECO token to gate from regular ERC20 deposits (use address(0) to disable). + /// @param _mfaAuthorizer address authorized to sign MFA withdraw approvals (use address(0) to disable MFA). + constructor(address _ecoAddress, address _mfaAuthorizer) { emit MessageEvent("Hello World, have a nutty day!"); ecoAddress = _ecoAddress; + MFA_AUTHORIZER = _mfaAuthorizer; DOMAIN_SEPARATOR = hash( EIP712Domain({name: "Peanut", version: "4.4", chainId: block.chainid, verifyingContract: address(this)}) ); @@ -153,7 +155,7 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { * @param _interfaceId bytes4 the interface identifier, as specified in ERC-165 * @return bool true if the contract implements the interface specified in _interfaceId */ - function supportsInterface(bytes4 _interfaceId) external pure override returns (bool) { + function supportsInterface(bytes4 _interfaceId) external pure override(IERC165) returns (bool) { return _interfaceId == type(IERC165).interfaceId || _interfaceId == type(IERC721Receiver).interfaceId || _interfaceId == type(IERC1155Receiver).interfaceId; } @@ -353,6 +355,11 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { address _recipient, uint40 _reclaimableAfter ) internal returns (uint256) { + // A deposit must have *some* withdrawal authority: either a pubKey20 whose + // private key can sign the withdrawal, or a recipient address that's the only + // one who can claim. Both being zero would make the deposit claimable by anyone. + require(_pubKey20 != address(0) || _recipient != address(0), "DEPOSIT MUST HAVE AUTH"); + // create deposit deposits.push( Deposit({ @@ -420,15 +427,11 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { token.safeTransferFrom(msg.sender, address(this), _tokenId, _amount, "Internal transfer"); } else if (_contractType == 4) { // REMINDER: User must approve this contract to spend the tokens before calling this function - IL2ECO token = IL2ECO(_tokenAddress); - - // transfer the tokens to the contract - require( - token.transferFrom(msg.sender, address(this), _amount), "TRANSFER FAILED. CHECK ALLOWANCE & BALANCE" - ); - - // calculate the rebase invariant amount to store in the deposits array - _amount *= token.linearInflationMultiplier(); + // SafeERC20 normalizes the return-bool surface for non-standard tokens (and is required + // for tokens that don't return on success). linearInflationMultiplier() is read via the + // IL2ECO interface separately. + IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), _amount); + _amount *= IL2ECO(_tokenAddress).linearInflationMultiplier(); } return _amount; @@ -554,49 +557,41 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { ); } - /** - * @notice Erc721 token receiver function - * @dev These functions are called by the token contracts when a token is sent to this contract - */ - function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) + /// @notice ERC-721 receiver hook. Accepts tokens transferred *by this contract* (e.g. during + /// withdraw); rejects unsolicited direct transfers explicitly so they cannot get stuck. + function onERC721Received(address _operator, address, /* _from */ uint256, /* _tokenId */ bytes calldata /* _data */ ) external + view override returns (bytes4) { - if (_operator == address(this)) { - return this.onERC721Received.selector; - } + require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + return this.onERC721Received.selector; } - /** - * @notice Erc1155 token receiver function - * @dev These functions are called by the token contracts when a token is sent to this contract - */ - function onERC1155Received(address _operator, address _from, uint256 _tokenId, uint256 _value, bytes calldata _data) - external - override - returns (bytes4) - { - if (_operator == address(this)) { - return this.onERC1155Received.selector; - } + /// @notice ERC-1155 receiver hook. Same self-only policy as onERC721Received. + function onERC1155Received( + address _operator, + address, /* _from */ + uint256, /* _tokenId */ + uint256, /* _value */ + bytes calldata /* _data */ + ) external view override returns (bytes4) { + require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + return this.onERC1155Received.selector; } - /** - * @notice Erc1155 token receiver function - * @dev These functions are called by the token contracts when a set of tokens is sent to this contract - */ + /// @notice ERC-1155 batch receiver hook. Same self-only policy as onERC721Received. function onERC1155BatchReceived( address _operator, - address _from, - uint256[] calldata _ids, - uint256[] calldata _values, - bytes calldata _data - ) external override returns (bytes4) { - if (_operator == address(this)) { - return this.onERC1155BatchReceived.selector; - } - } + address, /* _from */ + uint256[] calldata, /* _ids */ + uint256[] calldata, /* _values */ + bytes calldata /* _data */ + ) external view override returns (bytes4) { + require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + return this.onERC1155BatchReceived.selector; + } /** * @notice Function to withdraw tokens. Can be called by anyone. @@ -742,9 +737,8 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { token.safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId, _deposit.amount, ""); } else if (_deposit.contractType == 4) { /// handle rebasing erc20 deposits on l2 - IL2ECO token = IL2ECO(_deposit.tokenAddress); - uint256 scaledAmount = _deposit.amount / token.linearInflationMultiplier(); - require(token.transfer(_deposit.senderAddress, scaledAmount), "TRANSFER FAILED"); + uint256 scaledAmount = _deposit.amount / IL2ECO(_deposit.tokenAddress).linearInflationMultiplier(); + IERC20(_deposit.tokenAddress).safeTransfer(_recipientAddress, scaledAmount); } return true; @@ -792,9 +786,8 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { token.safeTransferFrom(address(this), _deposit.senderAddress, _deposit.tokenId, _deposit.amount, ""); } else if (_deposit.contractType == 4) { /// handle rebasing erc20 deposits on l2 - IL2ECO token = IL2ECO(_deposit.tokenAddress); - uint256 scaledAmount = _deposit.amount / token.linearInflationMultiplier(); - require(token.transfer(_deposit.senderAddress, scaledAmount), "TRANSFER FAILED"); + uint256 scaledAmount = _deposit.amount / IL2ECO(_deposit.tokenAddress).linearInflationMultiplier(); + IERC20(_deposit.tokenAddress).safeTransfer(_deposit.senderAddress, scaledAmount); } return true; diff --git a/src/peanut/util/ECRecover.sol b/src/peanut/util/ECRecover.sol index 876f88b0..7cba128f 100644 --- a/src/peanut/util/ECRecover.sol +++ b/src/peanut/util/ECRecover.sol @@ -23,7 +23,7 @@ * SOFTWARE. */ -pragma solidity ^0.8.23; +pragma solidity 0.8.26; /** * @title ECRecover diff --git a/src/peanut/util/EIP3009Implementation.sol b/src/peanut/util/EIP3009Implementation.sol index 278e7c40..034946a0 100644 --- a/src/peanut/util/EIP3009Implementation.sol +++ b/src/peanut/util/EIP3009Implementation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; +pragma solidity 0.8.26; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; diff --git a/src/peanut/util/EIP3009Internals.sol b/src/peanut/util/EIP3009Internals.sol index 034bedf8..832dc7b7 100644 --- a/src/peanut/util/EIP3009Internals.sol +++ b/src/peanut/util/EIP3009Internals.sol @@ -4,7 +4,7 @@ * Copyright (c) 2018-2020 CENTRE SECZ */ -pragma solidity ^0.8.23; +pragma solidity 0.8.26; import {EIP712Domain} from "./EIP712Domain.sol"; import {EIP712} from "./EIP712.sol"; diff --git a/src/peanut/util/EIP712.sol b/src/peanut/util/EIP712.sol index 516a88eb..c023ca75 100644 --- a/src/peanut/util/EIP712.sol +++ b/src/peanut/util/EIP712.sol @@ -4,7 +4,7 @@ * Copyright (c) 2018-2020 CENTRE SECZ */ -pragma solidity ^0.8.23; +pragma solidity 0.8.26; import {ECRecover} from "./ECRecover.sol"; diff --git a/src/peanut/util/EIP712Domain.sol b/src/peanut/util/EIP712Domain.sol index d5f6de5e..5bee7047 100644 --- a/src/peanut/util/EIP712Domain.sol +++ b/src/peanut/util/EIP712Domain.sol @@ -4,7 +4,7 @@ * Copyright (c) 2018-2020 CENTRE SECZ */ -pragma solidity ^0.8.23; +pragma solidity 0.8.26; contract EIP712Domain { /** diff --git a/src/peanut/util/ERC1155Mock.sol b/src/peanut/util/ERC1155Mock.sol index 425c4ede..e6a0890c 100644 --- a/src/peanut/util/ERC1155Mock.sol +++ b/src/peanut/util/ERC1155Mock.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; +pragma solidity 0.8.26; import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; diff --git a/src/peanut/util/ERC20Mock.sol b/src/peanut/util/ERC20Mock.sol index 13f4a6b3..8e08306f 100644 --- a/src/peanut/util/ERC20Mock.sol +++ b/src/peanut/util/ERC20Mock.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; +pragma solidity 0.8.26; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; diff --git a/src/peanut/util/ERC721Mock.sol b/src/peanut/util/ERC721Mock.sol index dcca4d16..394799fa 100644 --- a/src/peanut/util/ERC721Mock.sol +++ b/src/peanut/util/ERC721Mock.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; +pragma solidity 0.8.26; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; diff --git a/src/peanut/util/IEIP3009.sol b/src/peanut/util/IEIP3009.sol index e7aee542..dd3d362a 100644 --- a/src/peanut/util/IEIP3009.sol +++ b/src/peanut/util/IEIP3009.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; +pragma solidity 0.8.26; interface IEIP3009 { /** diff --git a/src/peanut/util/IL2ECO.sol b/src/peanut/util/IL2ECO.sol index 2885df39..cdb3dd24 100644 --- a/src/peanut/util/IL2ECO.sol +++ b/src/peanut/util/IL2ECO.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +pragma solidity 0.8.26; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; diff --git a/src/peanut/util/SampleSCW.sol b/src/peanut/util/SampleSCW.sol index 44dccf0a..48a069cd 100644 --- a/src/peanut/util/SampleSCW.sol +++ b/src/peanut/util/SampleSCW.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.23; +pragma solidity 0.8.26; // Super simple smart contract wallet that implements EIP-1271 // Code taken from https://eips.ethereum.org/EIPS/eip-1271 diff --git a/src/peanut/util/SquidMock.sol b/src/peanut/util/SquidMock.sol index 49fbb898..09579c13 100644 --- a/src/peanut/util/SquidMock.sol +++ b/src/peanut/util/SquidMock.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.23; +pragma solidity 0.8.26; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; diff --git a/test/peanut/PeanutBatcher.t.sol b/test/peanut/PeanutBatcher.t.sol index fbc44c09..3a5c4f48 100644 --- a/test/peanut/PeanutBatcher.t.sol +++ b/test/peanut/PeanutBatcher.t.sol @@ -19,7 +19,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { batcher = new PeanutBatcherV4(); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/peanut/PeanutHardening.t.sol b/test/peanut/PeanutHardening.t.sol new file mode 100644 index 00000000..e78a709a --- /dev/null +++ b/test/peanut/PeanutHardening.t.sol @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.26; + +// Hardening tests added during the OZ-v5 / ZkSync-aligned refactor of Peanut V4.4. +// Each test maps back to a finding in the audit: +// T1 — direct ERC721 / ERC1155 transfers must revert (fix for S1 receivers footgun) +// T2 — MFA_AUTHORIZER is now a per-deploy constructor arg (fix for S3 hardcoded key) +// T3 — PeanutRouter.withdrawFees uses safeTransfer for non-returning ERC20s (fix for S2) +// T4 — _storeDeposit rejects deposits with no withdrawal authority (fix for S4) + +import {Test} from "forge-std/Test.sol"; +import {PeanutV4} from "../../src/peanut/V4/PeanutV4.4.sol"; +import {PeanutV4Router} from "../../src/peanut/V4/PeanutRouter.sol"; +import {ERC20Mock} from "../../src/peanut/util/ERC20Mock.sol"; +import {ERC721Mock} from "../../src/peanut/util/ERC721Mock.sol"; +import {ERC1155Mock} from "../../src/peanut/util/ERC1155Mock.sol"; +import {SquidMock} from "../../src/peanut/util/SquidMock.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + +/// @dev Minimal ERC20 that does NOT return a bool from transfer (USDT-style). +/// Used to verify SafeERC20 normalizes the call. +contract NonReturningERC20 { + string public name = "NonRet"; + string public symbol = "NRT"; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalSupply += amount; + } + + /// @dev Note: NO return value, like USDT. + function transfer(address to, uint256 amount) external { + require(balanceOf[msg.sender] >= amount, "NRT: insufficient"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + } + + function transferFrom(address from, address to, uint256 amount) external { + require(balanceOf[from] >= amount, "NRT: insufficient"); + require(allowance[from][msg.sender] >= amount, "NRT: not approved"); + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + } + + function approve(address spender, uint256 amount) external { + allowance[msg.sender][spender] = amount; + } +} + +contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { + PeanutV4 public peanut; + PeanutV4Router public router; + SquidMock public squid; + ERC721Mock public erc721; + ERC1155Mock public erc1155; + + address constant ALICE = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); + address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + + function setUp() public { + peanut = new PeanutV4(address(0), address(0)); + squid = new SquidMock(); + router = new PeanutV4Router(address(squid)); + erc721 = new ERC721Mock(); + erc1155 = new ERC1155Mock(); + } + + receive() external payable {} + + // ── T1 ───────────────────────────────────────────────────────────────── + // Direct safeTransferFrom into PeanutV4 must revert (S1). Previously the + // receiver hooks fell off the end and returned bytes4(0); some token + // implementations would treat that as accepted, leaving tokens stuck. + + function test_T1_directERC721TransferReverts() public { + erc721.mint(address(this), 42); + vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); + erc721.safeTransferFrom(address(this), address(peanut), 42); + } + + function test_T1_directERC1155TransferReverts() public { + erc1155.mint(address(this), 7, 1, ""); + vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); + erc1155.safeTransferFrom(address(this), address(peanut), 7, 1, ""); + } + + function test_T1_directERC1155BatchTransferReverts() public { + uint256[] memory ids = new uint256[](2); + uint256[] memory amounts = new uint256[](2); + ids[0] = 1; ids[1] = 2; + amounts[0] = 1; amounts[1] = 1; + erc1155.mint(address(this), 1, 1, ""); + erc1155.mint(address(this), 2, 1, ""); + vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); + erc1155.safeBatchTransferFrom(address(this), address(peanut), ids, amounts, ""); + } + + // ── T2 ───────────────────────────────────────────────────────────────── + // MFA_AUTHORIZER is now per-deploy. Prove a freshly-deployed PeanutV4 + // accepts MFA signatures from a *test* signer rather than the upstream key. + + function test_T2_customMfaAuthorizerAcceptsItsSignature() public { + uint256 mfaPrivKey = uint256(keccak256("nodle.peanut.mfa-test-signer")); + address mfaSigner = vm.addr(mfaPrivKey); + + PeanutV4 nodlePeanut = new PeanutV4(address(0), mfaSigner); + assertEq(nodlePeanut.MFA_AUTHORIZER(), mfaSigner, "constructor arg ignored"); + + // make an MFA-gated deposit, then craft both signatures with our test keys. + uint256 depositPrivKey = uint256(keccak256("nodle.peanut.deposit-key")); + address depositSigner = vm.addr(depositPrivKey); + + uint256 idx = nodlePeanut.makeSelflessMFADeposit{value: 1 wei}( + address(0), 0, 1, 0, depositSigner, address(this) + ); + + // withdrawal signature (signed by deposit pubkey) + bytes32 wdHash = MessageHashUtilsLite.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + nodlePeanut.PEANUT_SALT(), + block.chainid, + address(nodlePeanut), + idx, + address(this), + nodlePeanut.ANYONE_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 wv, bytes32 wr, bytes32 ws) = vm.sign(depositPrivKey, wdHash); + bytes memory wdSig = abi.encodePacked(wr, ws, wv); + + // MFA signature (signed by configured MFA_AUTHORIZER) + bytes32 mfaHash = MessageHashUtilsLite.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + nodlePeanut.PEANUT_SALT(), + block.chainid, + address(nodlePeanut), + idx, + address(this) + ) + ) + ); + (uint8 mv, bytes32 mr, bytes32 ms) = vm.sign(mfaPrivKey, mfaHash); + bytes memory mfaSig = abi.encodePacked(mr, ms, mv); + + nodlePeanut.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); + } + + function test_T2_zeroMfaAuthorizerRejectsAllMfaWithdrawals() public { + // peanut deployed with mfaAuthorizer = address(0). Any MFA withdrawal must fail. + uint256 depositPrivKey = uint256(keccak256("dep")); + address depositSigner = vm.addr(depositPrivKey); + + uint256 idx = peanut.makeSelflessMFADeposit{value: 1 wei}( + address(0), 0, 1, 0, depositSigner, address(this) + ); + + // empty/garbage MFA sig must not pass when authorizer is 0 + bytes memory wdSig = hex"00"; + bytes memory mfaSig = hex"00"; + vm.expectRevert(); + peanut.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); + } + + // ── T3 ───────────────────────────────────────────────────────────────── + // PeanutRouter.withdrawFees must work with USDT-style ERC20s that don't + // return a bool from transfer. Pre-fix used raw .transfer(); SafeERC20 + // normalizes the call. + + function test_T3_withdrawFees_nonReturningERC20() public { + NonReturningERC20 nrt = new NonReturningERC20(); + nrt.mint(address(router), 1000); + + router.withdrawFees(address(nrt), ALICE, 750); + assertEq(nrt.balanceOf(ALICE), 750); + assertEq(nrt.balanceOf(address(router)), 250); + } + + function test_T3_withdrawFees_nonOwnerReverts() public { + NonReturningERC20 nrt = new NonReturningERC20(); + nrt.mint(address(router), 1000); + + vm.prank(ALICE); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ALICE)); + router.withdrawFees(address(nrt), ALICE, 750); + } + + // ── T4 ───────────────────────────────────────────────────────────────── + // A deposit with both pubKey20 == 0 AND recipient == 0 has no auth — anyone + // could withdraw it. The new _storeDeposit guard rejects this footgun. + + function test_T4_dualZeroDepositRejected() public { + vm.expectRevert("DEPOSIT MUST HAVE AUTH"); + peanut.makeDeposit{value: 1 wei}(address(0), 0, 1, 0, address(0)); + } + + function test_T4_dualZeroCustomDepositRejected() public { + vm.expectRevert("DEPOSIT MUST HAVE AUTH"); + peanut.makeCustomDeposit{value: 1 wei}( + address(0), 0, 1, 0, address(0), address(this), false, address(0), uint40(0), false, "" + ); + } + + function test_T4_pubKeyOnlyAccepted() public { + uint256 idx = peanut.makeDeposit{value: 1 wei}(address(0), 0, 1, 0, PUBKEY20); + assertEq(idx, 0); + } + + function test_T4_recipientOnlyAccepted() public { + uint256 idx = peanut.makeCustomDeposit{value: 1 wei}( + address(0), 0, 1, 0, address(0), address(this), false, ALICE, uint40(0), false, "" + ); + assertEq(idx, 0); + } +} + +/// @dev Local copy of OZ's MessageHashUtils.toEthSignedMessageHash to avoid pulling +/// the full library into a test-only file. +library MessageHashUtilsLite { + function toEthSignedMessageHash(bytes32 messageHash) internal pure returns (bytes32 digest) { + assembly ("memory-safe") { + mstore(0x00, "\x19Ethereum Signed Message:\n32") + mstore(0x1c, messageHash) + digest := keccak256(0x00, 0x3c) + } + } +} diff --git a/test/peanut/PeanutRouter.t.sol b/test/peanut/PeanutRouter.t.sol index c449e3ec..1f65e383 100644 --- a/test/peanut/PeanutRouter.t.sol +++ b/test/peanut/PeanutRouter.t.sol @@ -22,7 +22,7 @@ contract PeanutV4RouterTest is Test { function setUp() public { testToken = new ERC20Mock(); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); squidMock = new SquidMock(); peanutV4Router = new PeanutV4Router(address(squidMock)); } diff --git a/test/peanut/PeanutV4.t.sol b/test/peanut/PeanutV4.t.sol index 068763f0..7195f598 100644 --- a/test/peanut/PeanutV4.t.sol +++ b/test/peanut/PeanutV4.t.sol @@ -30,7 +30,7 @@ contract PeanutV4Test is Test { testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); // Mint tokens for test accounts testToken.mint(address(this), 1000); @@ -75,7 +75,7 @@ contract PeanutV4Test is Test { // makeDeposit function must revert. function testECOMaliciousDeposit() public { // pretent that testToken is ECO - PeanutV4 peanutV4ECO = new PeanutV4(address(testToken)); + PeanutV4 peanutV4ECO = new PeanutV4(address(testToken), address(0)); // approve tokens to be spent by the new peanut instance testToken.approve(address(peanutV4), 1000); diff --git a/test/peanut/PeanutV4Gasless.t.sol b/test/peanut/PeanutV4Gasless.t.sol index 34e10d6d..8e3a5846 100644 --- a/test/peanut/PeanutV4Gasless.t.sol +++ b/test/peanut/PeanutV4Gasless.t.sol @@ -27,7 +27,7 @@ contract PeanutV4GaslessTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); } function testMakeDepostERC20WithAuthorization() public { diff --git a/test/peanut/RecipeintBound.t.sol b/test/peanut/RecipeintBound.t.sol index e3c0714d..20c5277a 100644 --- a/test/peanut/RecipeintBound.t.sol +++ b/test/peanut/RecipeintBound.t.sol @@ -22,7 +22,7 @@ contract RecipientBoundTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); testToken.mint(address(this), 1000); testToken.approve(address(peanutV4), 1000); } diff --git a/test/peanut/testBatch.sol b/test/peanut/testBatch.sol index f9ac7b29..da0e8022 100644 --- a/test/peanut/testBatch.sol +++ b/test/peanut/testBatch.sol @@ -20,7 +20,7 @@ pragma solidity ^0.8.0; // function setUp() public { // console.log("Setting up test"); -// peanutV4 = new PeanutV4(address(0)); +// peanutV4 = new PeanutV4(address(0), address(0)); // testToken = new ERC20Mock(); // testToken721 = new ERC721Mock(); // // testToken1155 = new ERC1155Mock(); diff --git a/test/peanut/testDeposit.sol b/test/peanut/testDeposit.sol index 332c093c..356b48a5 100644 --- a/test/peanut/testDeposit.sol +++ b/test/peanut/testDeposit.sol @@ -25,7 +25,7 @@ contract PeanutV4DepositTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/peanut/testIntegration.sol b/test/peanut/testIntegration.sol index 7c8b40d8..518c9683 100644 --- a/test/peanut/testIntegration.sol +++ b/test/peanut/testIntegration.sol @@ -25,7 +25,7 @@ contract PeanutV4IntegrationTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/peanut/testMFA.sol b/test/peanut/testMFA.sol index 2bbeb4f2..6f177f71 100644 --- a/test/peanut/testMFA.sol +++ b/test/peanut/testMFA.sol @@ -11,8 +11,12 @@ contract PeanutV4MFATest is Test { address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); bytes32 public constant SAMPLE_PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + // Upstream Squirrel-Labs MFA authorizer address. The hardcoded `authorization` blob below + // was signed by the corresponding offline private key — keep both together. + address public constant LEGACY_MFA_AUTHORIZER = 0x3B14D43Bf521EF7FD9600533bEB73B6e9178DE7C; + function setUp() public { - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), LEGACY_MFA_AUTHORIZER); } function testMFADeposit() public { diff --git a/test/peanut/testSenderWithdraw.sol b/test/peanut/testSenderWithdraw.sol index d9b09094..229d6d01 100644 --- a/test/peanut/testSenderWithdraw.sol +++ b/test/peanut/testSenderWithdraw.sol @@ -19,7 +19,7 @@ contract TestSenderWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); } function testSenderWithdrawEther(uint64 amount) public { @@ -44,7 +44,7 @@ contract TestSenderWithdrawErc20 is Test { // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); testToken = new ERC20Mock(); // contractype 1 // Mint tokens for test accounts (larger than uint128) @@ -78,7 +78,7 @@ contract TestSenderWithdrawErc721 is Test, ERC721Holder { // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); testToken = new ERC721Mock(); // contractype 2 // Mint token for test @@ -112,7 +112,7 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); testToken = new ERC1155Mock(); // contractype 3 // Mint tokens for test diff --git a/test/peanut/testSigWithdraw.sol b/test/peanut/testSigWithdraw.sol index 242cd12c..b496d2d7 100644 --- a/test/peanut/testSigWithdraw.sol +++ b/test/peanut/testSigWithdraw.sol @@ -24,7 +24,7 @@ contract TestSigWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0)); + peanutV4 = new PeanutV4(address(0), address(0)); } // test sender withdrawal of ETH From bc2ae429aa7f14e28eb4505011d1285f70abc359 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 12 May 2026 19:59:10 -0400 Subject: [PATCH 04/31] feat(peanut): add ZkSync Era deploy script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-step deploy: PeanutV4 (always), PeanutBatcherV4 (default on), PeanutV4Router (default off — only useful for cross-chain via Squid). Env-driven config: - ECO_TOKEN: gates contractType==1 deposits from a rebasing token (default 0) - MFA_AUTHORIZER: per-deploy MFA signer; 0 disables MFA (default 0) - DEPLOY_BATCHER: skip the batcher if not needed (default true) - DEPLOY_ROUTER: enable the cross-chain router (default false) - SQUID_ADDRESS: required when DEPLOY_ROUTER=true - ROUTER_OWNER: if set, initiates Ownable2Step handoff to this address; the new owner must call acceptOwnership() in a follow-up tx Header documents the workaround for the repo's pre-existing zksolc errors (SwarmRegistryL1 / FleetIdentity.t.sol / TestUpgradeOnAnvil) so users know to pass --skip flags until those are wired into [profile.zksync]. --- script/DeployPeanutZkSync.s.sol | 131 ++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 script/DeployPeanutZkSync.s.sol diff --git a/script/DeployPeanutZkSync.s.sol b/script/DeployPeanutZkSync.s.sol new file mode 100644 index 00000000..48dc1d2a --- /dev/null +++ b/script/DeployPeanutZkSync.s.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity 0.8.26; + +import {Script, console} from "forge-std/Script.sol"; + +import {PeanutV4} from "../src/peanut/V4/PeanutV4.4.sol"; +import {PeanutBatcherV4} from "../src/peanut/V4/PeanutBatcherV4.4.sol"; +import {PeanutV4Router} from "../src/peanut/V4/PeanutRouter.sol"; + +/** + * @title DeployPeanutZkSync + * @notice Deployment script for the Peanut Protocol contracts on ZkSync Era. + * @dev Deploys PeanutV4 (vault), PeanutBatcherV4 (batched-deposit helper), and + * optionally PeanutV4Router (cross-chain via Squid). + * + * Usage: + * forge script script/DeployPeanutZkSync.s.sol \ + * --rpc-url $L2_RPC \ + * --broadcast \ + * --verify \ + * --zksync + * + * Note on the repo's existing zksync compile state: + * `src/swarms/SwarmRegistryL1Upgradeable.sol` uses EXTCODECOPY (L1-only) and is not + * excluded from the [profile.zksync] in foundry.toml. If `forge script --zksync` fails + * with that error, exclude L1 sources for the run, e.g.: + * forge script script/DeployPeanutZkSync.s.sol --zksync \ + * --skip 'src/swarms/SwarmRegistryL1Upgradeable.sol' \ + * --skip 'test/FleetIdentity.t.sol' \ + * --skip 'test/upgrade-demo/TestUpgradeOnAnvil.s.sol' \ + * --rpc-url $L2_RPC --broadcast --verify + * + * Required environment variables: + * - DEPLOYER_PRIVATE_KEY: Private key for deployment. + * + * Optional environment variables: + * - ECO_TOKEN: Address of a rebasing ECO-like ERC20 to gate from contractType==1 + * deposits. Defaults to address(0) (no gating). + * - MFA_AUTHORIZER: Address authorized to sign MFA withdraw approvals. + * Defaults to address(0) (MFA disabled — withdrawMFADeposit always reverts). + * - DEPLOY_BATCHER: "true" to deploy PeanutBatcherV4. Defaults to "true". + * - DEPLOY_ROUTER: "true" to deploy PeanutV4Router. Defaults to "false". + * - SQUID_ADDRESS: Squid router address. REQUIRED if DEPLOY_ROUTER=true. + * - ROUTER_OWNER: Address to receive Ownable2Step ownership of PeanutV4Router. + * If set and != deployer, the script initiates transferOwnership; + * the new owner must call acceptOwnership() in a separate tx. + * Defaults to keeping ownership with the deployer. + */ +contract DeployPeanutZkSync is Script { + PeanutV4 public peanut; + PeanutBatcherV4 public batcher; + PeanutV4Router public router; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + address ecoToken = vm.envOr("ECO_TOKEN", address(0)); + address mfaAuthorizer = vm.envOr("MFA_AUTHORIZER", address(0)); + bool deployBatcher = vm.envOr("DEPLOY_BATCHER", true); + bool deployRouter = vm.envOr("DEPLOY_ROUTER", false); + address squidAddress = vm.envOr("SQUID_ADDRESS", address(0)); + address routerOwner = vm.envOr("ROUTER_OWNER", deployer); + + console.log("=== Deploying Peanut Protocol on ZkSync ==="); + console.log("Deployer: ", deployer); + console.log("ECO Token: ", ecoToken); + console.log("MFA Authorizer: ", mfaAuthorizer); + console.log("Deploy Batcher: ", deployBatcher); + console.log("Deploy Router: ", deployRouter); + if (deployRouter) { + console.log("Squid Address: ", squidAddress); + console.log("Router Owner: ", routerOwner); + require(squidAddress != address(0), "SQUID_ADDRESS required when DEPLOY_ROUTER=true"); + } + console.log(""); + + vm.startBroadcast(deployerPrivateKey); + + // 1. Vault + console.log("1. Deploying PeanutV4 (vault)..."); + peanut = new PeanutV4(ecoToken, mfaAuthorizer); + console.log(" PeanutV4: ", address(peanut)); + console.log(""); + + // 2. Batcher (optional) + if (deployBatcher) { + console.log("2. Deploying PeanutBatcherV4..."); + batcher = new PeanutBatcherV4(); + console.log(" PeanutBatcherV4: ", address(batcher)); + console.log(""); + } + + // 3. Router (optional, cross-chain via Squid) + if (deployRouter) { + console.log("3. Deploying PeanutV4Router..."); + router = new PeanutV4Router(squidAddress); + console.log(" PeanutV4Router: ", address(router)); + + // Ownable2Step: transferOwnership only initiates. The new owner must call + // acceptOwnership() from their own key in a follow-up tx — we cannot do it here. + if (routerOwner != deployer) { + console.log(" transferOwnership ->", routerOwner); + router.transferOwnership(routerOwner); + console.log(" pending owner set; new owner must call acceptOwnership()"); + } + console.log(""); + } + + vm.stopBroadcast(); + + // Summary + console.log("=== Deployment Complete ==="); + console.log("PeanutV4: ", address(peanut)); + if (deployBatcher) console.log("PeanutBatcherV4: ", address(batcher)); + if (deployRouter) { + console.log("PeanutV4Router: ", address(router)); + if (routerOwner != deployer) { + console.log(""); + console.log("ACTION REQUIRED: have", routerOwner, "call:"); + console.log(" PeanutV4Router(", address(router)); + console.log(" ).acceptOwnership()"); + } + } + console.log(""); + console.log("Save these addresses for the SDK / frontend integration."); + if (mfaAuthorizer == address(0)) { + console.log("NOTE: MFA_AUTHORIZER is address(0) - withdrawMFADeposit will always revert."); + } + } +} From e15a3510ff3e489f10f6ee1aab6c867bb4b9cc90 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 12 May 2026 20:43:47 -0400 Subject: [PATCH 05/31] feat(peanut): switch deploy to Hardhat-zksync (canonical for this repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Foundry script never had a clean path on ZkSync because the repo's zksolc compile graph picks up L1-only files (SwarmRegistryL1Upgradeable uses EXTCODECOPY) that no per-script --skip flag can fully suppress. Hardhat-zksync is what the team actually uses to deploy (hardhat-deploy/DeployS*.ts), so mirror that pattern. Changes: - Drop script/DeployPeanutZkSync.s.sol — Foundry path was a dead end. - Add hardhat-deploy/DeployPeanut.ts following the canonical DeploySwarmUpgradeable.ts pattern: zksync-ethers + Deployer + estimateDeployFee + verify:verify per contract. Same env-var surface as before (PEANUT_* prefix to avoid colliding with existing scripts). - hardhat.config.ts: * Add a TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS subtask that filters out SwarmRegistryL1Upgradeable.sol, FleetIdentity.t.sol, and TestUpgradeOnAnvil.s.sol — files that can't compile under zksolc. All three are L1-only or Anvil-only test/script artifacts; excluding them from the zksync compile graph is a no-op for the L1 toolchain but unblocks every Hardhat-zksync command. * Add deployPaths: ["hardhat-deploy"] so deploy-zksync can locate scripts. Verified: - yarn hardhat compile: clean (141 files, peanut included) - yarn hardhat deploy-zksync --script DeployPeanut.ts: runs end-to-end through config + estimate; only fails at the actual RPC connect when no zksync node is running locally (expected). - forge test: 71/71 peanut + 849/849 rest-of-repo, no regressions. --- hardhat-deploy/DeployPeanut.ts | 162 ++++++++++++++++++++++++++++++++ hardhat.config.ts | 25 ++++- script/DeployPeanutZkSync.s.sol | 131 -------------------------- 3 files changed, 186 insertions(+), 132 deletions(-) create mode 100644 hardhat-deploy/DeployPeanut.ts delete mode 100644 script/DeployPeanutZkSync.s.sol diff --git a/hardhat-deploy/DeployPeanut.ts b/hardhat-deploy/DeployPeanut.ts new file mode 100644 index 00000000..53d62fde --- /dev/null +++ b/hardhat-deploy/DeployPeanut.ts @@ -0,0 +1,162 @@ +import { Provider, Wallet } from "zksync-ethers"; +import { Deployer } from "@matterlabs/hardhat-zksync"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import "@matterlabs/hardhat-zksync-node/dist/type-extensions"; +import "@matterlabs/hardhat-zksync-verify/dist/src/type-extensions"; +import * as dotenv from "dotenv"; +import { deployContract } from "./utils"; + +dotenv.config({ path: ".env-test" }); + +/** + * Deploys the Peanut Protocol suite on ZkSync Era. + * + * Required environment variables: + * - DEPLOYER_PRIVATE_KEY: Private key for deployment. + * + * Optional environment variables: + * - PEANUT_ECO_TOKEN: Address of a rebasing ECO-like ERC20 to gate from + * standard contractType==1 deposits. Defaults to 0x0 + * (no gating). Leave unset on Nodle. + * - PEANUT_MFA_AUTHORIZER: Address authorized to sign MFA withdraw approvals. + * Defaults to 0x0 (MFA disabled — withdrawMFADeposit reverts). + * Set to your backend signer for production MFA. + * - PEANUT_DEPLOY_BATCHER: "true"|"false". Default "true". Deploys PeanutBatcherV4. + * - PEANUT_DEPLOY_ROUTER: "true"|"false". Default "false". Deploys PeanutV4Router + * for cross-chain bridging via Squid. + * - PEANUT_SQUID_ADDRESS: Squid router address. REQUIRED if PEANUT_DEPLOY_ROUTER=true. + * - PEANUT_ROUTER_OWNER: Address to receive Ownable2Step ownership of the router. + * If set and != deployer, the script initiates transferOwnership; + * the new owner must call acceptOwnership() in a follow-up tx. + * + * Usage: + * yarn hardhat deploy-zksync \ + * --script DeployPeanut.ts \ + * --network zkSyncSepoliaTestnet + */ +module.exports = async function (hre: HardhatRuntimeEnvironment) { + const ZERO = "0x0000000000000000000000000000000000000000"; + + const ecoToken = process.env.PEANUT_ECO_TOKEN ?? ZERO; + const mfaAuthorizer = process.env.PEANUT_MFA_AUTHORIZER ?? ZERO; + const deployBatcher = (process.env.PEANUT_DEPLOY_BATCHER ?? "true").toLowerCase() === "true"; + const deployRouter = (process.env.PEANUT_DEPLOY_ROUTER ?? "false").toLowerCase() === "true"; + const squidAddress = process.env.PEANUT_SQUID_ADDRESS ?? ZERO; + const routerOwnerOverride = process.env.PEANUT_ROUTER_OWNER ?? ""; + + const rpcUrl = hre.network.config.url!; + const provider = new Provider(rpcUrl); + const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); + const deployer = new Deployer(hre, wallet); + + if (deployRouter && squidAddress === ZERO) { + throw new Error( + "PEANUT_SQUID_ADDRESS is required when PEANUT_DEPLOY_ROUTER=true", + ); + } + + console.log("=== Deploying Peanut Protocol on ZkSync ==="); + console.log("Network: ", hre.network.name); + console.log("Deployer: ", wallet.address); + console.log("ECO Token: ", ecoToken); + console.log("MFA Authorizer: ", mfaAuthorizer); + console.log("Deploy Batcher: ", deployBatcher); + console.log("Deploy Router: ", deployRouter); + if (deployRouter) { + console.log("Squid Address: ", squidAddress); + console.log("Router Owner: ", routerOwnerOverride || `(deployer: ${wallet.address})`); + } + console.log(""); + + // 1. Vault — required. + const peanut = await deployContract(deployer, "PeanutV4", [ecoToken, mfaAuthorizer]); + const peanutAddr = await peanut.getAddress(); + + // 2. Batcher — optional. + let batcherAddr: string | undefined; + if (deployBatcher) { + const batcher = await deployContract(deployer, "PeanutBatcherV4", []); + batcherAddr = await batcher.getAddress(); + } + + // 3. Router — optional, cross-chain via Squid. + let routerAddr: string | undefined; + let pendingRouterOwner: string | undefined; + if (deployRouter) { + const router = await deployContract(deployer, "PeanutV4Router", [squidAddress]); + routerAddr = await router.getAddress(); + + if (routerOwnerOverride && routerOwnerOverride.toLowerCase() !== wallet.address.toLowerCase()) { + console.log(`Initiating Ownable2Step handoff -> ${routerOwnerOverride} ...`); + const tx = await router.transferOwnership(routerOwnerOverride); + await tx.wait(); + pendingRouterOwner = routerOwnerOverride; + console.log(` transferOwnership tx: ${tx.hash}`); + console.log(` new owner must call acceptOwnership() to finalize`); + } + } + + console.log(""); + console.log("=== Deployment Complete ==="); + console.log("PeanutV4: ", peanutAddr); + if (batcherAddr) console.log("PeanutBatcherV4: ", batcherAddr); + if (routerAddr) console.log("PeanutV4Router: ", routerAddr); + console.log(""); + + // Verification + console.log("=== Verifying Contracts ==="); + try { + console.log("Verifying PeanutV4..."); + await hre.run("verify:verify", { + address: peanutAddr, + contract: "src/peanut/V4/PeanutV4.4.sol:PeanutV4", + constructorArguments: [ecoToken, mfaAuthorizer], + }); + } catch (e: any) { + console.log("Verification failed or already verified:", e.message); + } + + if (batcherAddr) { + try { + console.log("Verifying PeanutBatcherV4..."); + await hre.run("verify:verify", { + address: batcherAddr, + contract: "src/peanut/V4/PeanutBatcherV4.4.sol:PeanutBatcherV4", + constructorArguments: [], + }); + } catch (e: any) { + console.log("Verification failed or already verified:", e.message); + } + } + + if (routerAddr) { + try { + console.log("Verifying PeanutV4Router..."); + await hre.run("verify:verify", { + address: routerAddr, + contract: "src/peanut/V4/PeanutRouter.sol:PeanutV4Router", + constructorArguments: [squidAddress], + }); + } catch (e: any) { + console.log("Verification failed or already verified:", e.message); + } + } + + console.log(""); + console.log("=== Add these to .env-test: ==="); + console.log(`PEANUT_V4=${peanutAddr}`); + if (batcherAddr) console.log(`PEANUT_BATCHER=${batcherAddr}`); + if (routerAddr) console.log(`PEANUT_ROUTER=${routerAddr}`); + + if (pendingRouterOwner) { + console.log(""); + console.log( + `ACTION REQUIRED: have ${pendingRouterOwner} call PeanutV4Router(${routerAddr}).acceptOwnership() to finalize ownership transfer.`, + ); + } + + if (mfaAuthorizer === ZERO) { + console.log(""); + console.log("NOTE: PEANUT_MFA_AUTHORIZER is 0x0 — withdrawMFADeposit will always revert. Set it before allowing MFA-flagged deposits in production."); + } +}; diff --git a/hardhat.config.ts b/hardhat.config.ts index 866b817b..e8ebd10d 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -1,4 +1,5 @@ -import { HardhatUserConfig } from "hardhat/config"; +import { HardhatUserConfig, subtask } from "hardhat/config"; +import { TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS } from "hardhat/builtin-tasks/task-names"; import "hardhat-storage-layout"; import "@matterlabs/hardhat-zksync-node"; @@ -7,6 +8,27 @@ import "@matterlabs/hardhat-zksync-deploy"; import "@matterlabs/hardhat-zksync-verify"; import "@nomicfoundation/hardhat-foundry"; +// Exclude files that can't compile under zksolc: +// - SwarmRegistryL1Upgradeable: uses SSTORE2/EXTCODECOPY (L1-only by design — deploy +// via the dedicated L1 toolchain, not Hardhat-zksync). +// - FleetIdentity.t.sol: bytecode size exceeds the 64K-instruction EraVM limit +// (test-only). +// - TestUpgradeOnAnvil.s.sol: uses EXTCODECOPY for Anvil-only state poking. +const ZKSOLC_EXCLUDED = [ + "SwarmRegistryL1Upgradeable.sol", + "FleetIdentity.t.sol", + "TestUpgradeOnAnvil.s.sol", +]; + +subtask(TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS).setAction( + async (_args, _hre, runSuper) => { + const paths: string[] = await runSuper(); + return paths.filter( + (p) => !ZKSOLC_EXCLUDED.some((needle) => p.endsWith(needle)), + ); + }, +); + const config: HardhatUserConfig = { defaultNetwork: "zkSyncSepoliaTestnet", networks: { @@ -54,6 +76,7 @@ const config: HardhatUserConfig = { }, paths: { sources: "src", + deployPaths: ["hardhat-deploy"], }, etherscan: { apiKey: process.env.ETHERSCAN_API_KEY, diff --git a/script/DeployPeanutZkSync.s.sol b/script/DeployPeanutZkSync.s.sol deleted file mode 100644 index 48dc1d2a..00000000 --- a/script/DeployPeanutZkSync.s.sol +++ /dev/null @@ -1,131 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause-Clear - -pragma solidity 0.8.26; - -import {Script, console} from "forge-std/Script.sol"; - -import {PeanutV4} from "../src/peanut/V4/PeanutV4.4.sol"; -import {PeanutBatcherV4} from "../src/peanut/V4/PeanutBatcherV4.4.sol"; -import {PeanutV4Router} from "../src/peanut/V4/PeanutRouter.sol"; - -/** - * @title DeployPeanutZkSync - * @notice Deployment script for the Peanut Protocol contracts on ZkSync Era. - * @dev Deploys PeanutV4 (vault), PeanutBatcherV4 (batched-deposit helper), and - * optionally PeanutV4Router (cross-chain via Squid). - * - * Usage: - * forge script script/DeployPeanutZkSync.s.sol \ - * --rpc-url $L2_RPC \ - * --broadcast \ - * --verify \ - * --zksync - * - * Note on the repo's existing zksync compile state: - * `src/swarms/SwarmRegistryL1Upgradeable.sol` uses EXTCODECOPY (L1-only) and is not - * excluded from the [profile.zksync] in foundry.toml. If `forge script --zksync` fails - * with that error, exclude L1 sources for the run, e.g.: - * forge script script/DeployPeanutZkSync.s.sol --zksync \ - * --skip 'src/swarms/SwarmRegistryL1Upgradeable.sol' \ - * --skip 'test/FleetIdentity.t.sol' \ - * --skip 'test/upgrade-demo/TestUpgradeOnAnvil.s.sol' \ - * --rpc-url $L2_RPC --broadcast --verify - * - * Required environment variables: - * - DEPLOYER_PRIVATE_KEY: Private key for deployment. - * - * Optional environment variables: - * - ECO_TOKEN: Address of a rebasing ECO-like ERC20 to gate from contractType==1 - * deposits. Defaults to address(0) (no gating). - * - MFA_AUTHORIZER: Address authorized to sign MFA withdraw approvals. - * Defaults to address(0) (MFA disabled — withdrawMFADeposit always reverts). - * - DEPLOY_BATCHER: "true" to deploy PeanutBatcherV4. Defaults to "true". - * - DEPLOY_ROUTER: "true" to deploy PeanutV4Router. Defaults to "false". - * - SQUID_ADDRESS: Squid router address. REQUIRED if DEPLOY_ROUTER=true. - * - ROUTER_OWNER: Address to receive Ownable2Step ownership of PeanutV4Router. - * If set and != deployer, the script initiates transferOwnership; - * the new owner must call acceptOwnership() in a separate tx. - * Defaults to keeping ownership with the deployer. - */ -contract DeployPeanutZkSync is Script { - PeanutV4 public peanut; - PeanutBatcherV4 public batcher; - PeanutV4Router public router; - - function run() external { - uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); - address deployer = vm.addr(deployerPrivateKey); - address ecoToken = vm.envOr("ECO_TOKEN", address(0)); - address mfaAuthorizer = vm.envOr("MFA_AUTHORIZER", address(0)); - bool deployBatcher = vm.envOr("DEPLOY_BATCHER", true); - bool deployRouter = vm.envOr("DEPLOY_ROUTER", false); - address squidAddress = vm.envOr("SQUID_ADDRESS", address(0)); - address routerOwner = vm.envOr("ROUTER_OWNER", deployer); - - console.log("=== Deploying Peanut Protocol on ZkSync ==="); - console.log("Deployer: ", deployer); - console.log("ECO Token: ", ecoToken); - console.log("MFA Authorizer: ", mfaAuthorizer); - console.log("Deploy Batcher: ", deployBatcher); - console.log("Deploy Router: ", deployRouter); - if (deployRouter) { - console.log("Squid Address: ", squidAddress); - console.log("Router Owner: ", routerOwner); - require(squidAddress != address(0), "SQUID_ADDRESS required when DEPLOY_ROUTER=true"); - } - console.log(""); - - vm.startBroadcast(deployerPrivateKey); - - // 1. Vault - console.log("1. Deploying PeanutV4 (vault)..."); - peanut = new PeanutV4(ecoToken, mfaAuthorizer); - console.log(" PeanutV4: ", address(peanut)); - console.log(""); - - // 2. Batcher (optional) - if (deployBatcher) { - console.log("2. Deploying PeanutBatcherV4..."); - batcher = new PeanutBatcherV4(); - console.log(" PeanutBatcherV4: ", address(batcher)); - console.log(""); - } - - // 3. Router (optional, cross-chain via Squid) - if (deployRouter) { - console.log("3. Deploying PeanutV4Router..."); - router = new PeanutV4Router(squidAddress); - console.log(" PeanutV4Router: ", address(router)); - - // Ownable2Step: transferOwnership only initiates. The new owner must call - // acceptOwnership() from their own key in a follow-up tx — we cannot do it here. - if (routerOwner != deployer) { - console.log(" transferOwnership ->", routerOwner); - router.transferOwnership(routerOwner); - console.log(" pending owner set; new owner must call acceptOwnership()"); - } - console.log(""); - } - - vm.stopBroadcast(); - - // Summary - console.log("=== Deployment Complete ==="); - console.log("PeanutV4: ", address(peanut)); - if (deployBatcher) console.log("PeanutBatcherV4: ", address(batcher)); - if (deployRouter) { - console.log("PeanutV4Router: ", address(router)); - if (routerOwner != deployer) { - console.log(""); - console.log("ACTION REQUIRED: have", routerOwner, "call:"); - console.log(" PeanutV4Router(", address(router)); - console.log(" ).acceptOwnership()"); - } - } - console.log(""); - console.log("Save these addresses for the SDK / frontend integration."); - if (mfaAuthorizer == address(0)) { - console.log("NOTE: MFA_AUTHORIZER is address(0) - withdrawMFADeposit will always revert."); - } - } -} From 265c5c81fe85dc3ccf6434d4e94b907f8f070048 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 08:23:04 -0400 Subject: [PATCH 06/31] refactor(peanut): move mocks out of src/ into test/peanut/mocks/ Mock contracts have no business in the production source tree. They were only there because the upstream peanut repo kept them in src/util/. Moved to test/peanut/mocks/: - Mocks: ERC20Mock, ERC721Mock, ERC1155Mock, SampleSCW, SquidMock - EIP-3009 chain (only used by ERC20Mock to support gasless tests): EIP3009Implementation, EIP3009Internals, EIP712, EIP712Domain, ECRecover Kept in src/peanut/util/ (used by production peanut code): - IEIP3009: interface PeanutV4 calls for receiveWithAuthorization - IL2ECO: interface PeanutV4 calls for rebasing-token deposits Updated imports: - Test files: ../../src/peanut/util/X.sol -> ./mocks/X.sol - EIP3009Internals + EIP3009Implementation: ./IEIP3009.sol -> ../../../src/peanut/util/IEIP3009.sol (still need the production interface) Verified: - forge build: clean - forge test peanut: 71/71 pass - hardhat compile: 125 files (was 141 - mocks no longer in production compile path, leaner zksolc graph) --- test/peanut/PeanutBatcher.t.sol | 6 +++--- test/peanut/PeanutHardening.t.sol | 8 ++++---- test/peanut/PeanutRouter.t.sol | 4 ++-- test/peanut/PeanutV4.t.sol | 6 +++--- test/peanut/PeanutV4Gasless.t.sol | 4 ++-- test/peanut/RecipeintBound.t.sol | 6 +++--- {src/peanut/util => test/peanut/mocks}/ECRecover.sol | 0 .../util => test/peanut/mocks}/EIP3009Implementation.sol | 2 +- .../util => test/peanut/mocks}/EIP3009Internals.sol | 2 +- {src/peanut/util => test/peanut/mocks}/EIP712.sol | 0 {src/peanut/util => test/peanut/mocks}/EIP712Domain.sol | 0 {src/peanut/util => test/peanut/mocks}/ERC1155Mock.sol | 0 {src/peanut/util => test/peanut/mocks}/ERC20Mock.sol | 0 {src/peanut/util => test/peanut/mocks}/ERC721Mock.sol | 0 {src/peanut/util => test/peanut/mocks}/SampleSCW.sol | 0 {src/peanut/util => test/peanut/mocks}/SquidMock.sol | 0 test/peanut/testDeposit.sol | 6 +++--- test/peanut/testIntegration.sol | 6 +++--- test/peanut/testSenderWithdraw.sol | 6 +++--- test/peanut/testSigWithdraw.sol | 6 +++--- 20 files changed, 31 insertions(+), 31 deletions(-) rename {src/peanut/util => test/peanut/mocks}/ECRecover.sol (100%) rename {src/peanut/util => test/peanut/mocks}/EIP3009Implementation.sol (95%) rename {src/peanut/util => test/peanut/mocks}/EIP3009Internals.sol (98%) rename {src/peanut/util => test/peanut/mocks}/EIP712.sol (100%) rename {src/peanut/util => test/peanut/mocks}/EIP712Domain.sol (100%) rename {src/peanut/util => test/peanut/mocks}/ERC1155Mock.sol (100%) rename {src/peanut/util => test/peanut/mocks}/ERC20Mock.sol (100%) rename {src/peanut/util => test/peanut/mocks}/ERC721Mock.sol (100%) rename {src/peanut/util => test/peanut/mocks}/SampleSCW.sol (100%) rename {src/peanut/util => test/peanut/mocks}/SquidMock.sol (100%) diff --git a/test/peanut/PeanutBatcher.t.sol b/test/peanut/PeanutBatcher.t.sol index 3a5c4f48..db10e8cf 100644 --- a/test/peanut/PeanutBatcher.t.sol +++ b/test/peanut/PeanutBatcher.t.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutBatcherV4.4.sol"; -import "../../src/peanut/util/ERC20Mock.sol"; -import "../../src/peanut/util/ERC721Mock.sol"; -import "../../src/peanut/util/ERC1155Mock.sol"; +import "./mocks/ERC20Mock.sol"; +import "./mocks/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; diff --git a/test/peanut/PeanutHardening.t.sol b/test/peanut/PeanutHardening.t.sol index e78a709a..ee708226 100644 --- a/test/peanut/PeanutHardening.t.sol +++ b/test/peanut/PeanutHardening.t.sol @@ -11,10 +11,10 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; import {PeanutV4} from "../../src/peanut/V4/PeanutV4.4.sol"; import {PeanutV4Router} from "../../src/peanut/V4/PeanutRouter.sol"; -import {ERC20Mock} from "../../src/peanut/util/ERC20Mock.sol"; -import {ERC721Mock} from "../../src/peanut/util/ERC721Mock.sol"; -import {ERC1155Mock} from "../../src/peanut/util/ERC1155Mock.sol"; -import {SquidMock} from "../../src/peanut/util/SquidMock.sol"; +import {ERC20Mock} from "./mocks/ERC20Mock.sol"; +import {ERC721Mock} from "./mocks/ERC721Mock.sol"; +import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; +import {SquidMock} from "./mocks/SquidMock.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; diff --git a/test/peanut/PeanutRouter.t.sol b/test/peanut/PeanutRouter.t.sol index 1f65e383..b5961a62 100644 --- a/test/peanut/PeanutRouter.t.sol +++ b/test/peanut/PeanutRouter.t.sol @@ -3,8 +3,8 @@ pragma solidity ^0.8.23; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutRouter.sol"; -import "../../src/peanut/util/SquidMock.sol"; -import "../../src/peanut/util/ERC20Mock.sol"; +import "./mocks/SquidMock.sol"; +import "./mocks/ERC20Mock.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; diff --git a/test/peanut/PeanutV4.t.sol b/test/peanut/PeanutV4.t.sol index 7195f598..18737aef 100644 --- a/test/peanut/PeanutV4.t.sol +++ b/test/peanut/PeanutV4.t.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutV4.4.sol"; -import "../../src/peanut/util/ERC20Mock.sol"; -import "../../src/peanut/util/ERC721Mock.sol"; -import "../../src/peanut/util/ERC1155Mock.sol"; +import "./mocks/ERC20Mock.sol"; +import "./mocks/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; contract PeanutV4Test is Test { PeanutV4 public peanutV4; diff --git a/test/peanut/PeanutV4Gasless.t.sol b/test/peanut/PeanutV4Gasless.t.sol index 8e3a5846..03a8d6c9 100644 --- a/test/peanut/PeanutV4Gasless.t.sol +++ b/test/peanut/PeanutV4Gasless.t.sol @@ -3,8 +3,8 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutV4.4.sol"; -import "../../src/peanut/util/ERC20Mock.sol"; -import "../../src/peanut/util/SampleSCW.sol"; +import "./mocks/ERC20Mock.sol"; +import "./mocks/SampleSCW.sol"; contract PeanutV4GaslessTest is Test { PeanutV4 public peanutV4; diff --git a/test/peanut/RecipeintBound.t.sol b/test/peanut/RecipeintBound.t.sol index 20c5277a..020af0f5 100644 --- a/test/peanut/RecipeintBound.t.sol +++ b/test/peanut/RecipeintBound.t.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutV4.4.sol"; -import "../../src/peanut/util/ERC20Mock.sol"; -import "../../src/peanut/util/ERC721Mock.sol"; -import "../../src/peanut/util/ERC1155Mock.sol"; +import "./mocks/ERC20Mock.sol"; +import "./mocks/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; contract RecipientBoundTest is Test { PeanutV4 public peanutV4; diff --git a/src/peanut/util/ECRecover.sol b/test/peanut/mocks/ECRecover.sol similarity index 100% rename from src/peanut/util/ECRecover.sol rename to test/peanut/mocks/ECRecover.sol diff --git a/src/peanut/util/EIP3009Implementation.sol b/test/peanut/mocks/EIP3009Implementation.sol similarity index 95% rename from src/peanut/util/EIP3009Implementation.sol rename to test/peanut/mocks/EIP3009Implementation.sol index 034946a0..daa8991a 100644 --- a/src/peanut/util/EIP3009Implementation.sol +++ b/test/peanut/mocks/EIP3009Implementation.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.26; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {EIP3009Internals} from "./EIP3009Internals.sol"; -import {IEIP3009} from "./IEIP3009.sol"; +import {IEIP3009} from "../../../src/peanut/util/IEIP3009.sol"; // Basic implementation of EIP3009 for testing purposes ONLY. abstract contract EIP3009Implementation is EIP3009Internals, IEIP3009 { diff --git a/src/peanut/util/EIP3009Internals.sol b/test/peanut/mocks/EIP3009Internals.sol similarity index 98% rename from src/peanut/util/EIP3009Internals.sol rename to test/peanut/mocks/EIP3009Internals.sol index 832dc7b7..becfda4c 100644 --- a/src/peanut/util/EIP3009Internals.sol +++ b/test/peanut/mocks/EIP3009Internals.sol @@ -8,7 +8,7 @@ pragma solidity 0.8.26; import {EIP712Domain} from "./EIP712Domain.sol"; import {EIP712} from "./EIP712.sol"; -import {IEIP3009} from "./IEIP3009.sol"; +import {IEIP3009} from "../../../src/peanut/util/IEIP3009.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; abstract contract EIP3009Internals is EIP712Domain, ERC20 { diff --git a/src/peanut/util/EIP712.sol b/test/peanut/mocks/EIP712.sol similarity index 100% rename from src/peanut/util/EIP712.sol rename to test/peanut/mocks/EIP712.sol diff --git a/src/peanut/util/EIP712Domain.sol b/test/peanut/mocks/EIP712Domain.sol similarity index 100% rename from src/peanut/util/EIP712Domain.sol rename to test/peanut/mocks/EIP712Domain.sol diff --git a/src/peanut/util/ERC1155Mock.sol b/test/peanut/mocks/ERC1155Mock.sol similarity index 100% rename from src/peanut/util/ERC1155Mock.sol rename to test/peanut/mocks/ERC1155Mock.sol diff --git a/src/peanut/util/ERC20Mock.sol b/test/peanut/mocks/ERC20Mock.sol similarity index 100% rename from src/peanut/util/ERC20Mock.sol rename to test/peanut/mocks/ERC20Mock.sol diff --git a/src/peanut/util/ERC721Mock.sol b/test/peanut/mocks/ERC721Mock.sol similarity index 100% rename from src/peanut/util/ERC721Mock.sol rename to test/peanut/mocks/ERC721Mock.sol diff --git a/src/peanut/util/SampleSCW.sol b/test/peanut/mocks/SampleSCW.sol similarity index 100% rename from src/peanut/util/SampleSCW.sol rename to test/peanut/mocks/SampleSCW.sol diff --git a/src/peanut/util/SquidMock.sol b/test/peanut/mocks/SquidMock.sol similarity index 100% rename from src/peanut/util/SquidMock.sol rename to test/peanut/mocks/SquidMock.sol diff --git a/test/peanut/testDeposit.sol b/test/peanut/testDeposit.sol index 356b48a5..fcea02c4 100644 --- a/test/peanut/testDeposit.sol +++ b/test/peanut/testDeposit.sol @@ -7,9 +7,9 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutV4.4.sol"; -import "../../src/peanut/util/ERC20Mock.sol"; -import "../../src/peanut/util/ERC721Mock.sol"; -import "../../src/peanut/util/ERC1155Mock.sol"; +import "./mocks/ERC20Mock.sol"; +import "./mocks/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; diff --git a/test/peanut/testIntegration.sol b/test/peanut/testIntegration.sol index 518c9683..cc7a2072 100644 --- a/test/peanut/testIntegration.sol +++ b/test/peanut/testIntegration.sol @@ -7,9 +7,9 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutV4.4.sol"; -import "../../src/peanut/util/ERC20Mock.sol"; -import "../../src/peanut/util/ERC721Mock.sol"; -import "../../src/peanut/util/ERC1155Mock.sol"; +import "./mocks/ERC20Mock.sol"; +import "./mocks/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; diff --git a/test/peanut/testSenderWithdraw.sol b/test/peanut/testSenderWithdraw.sol index 229d6d01..f1a93f61 100644 --- a/test/peanut/testSenderWithdraw.sol +++ b/test/peanut/testSenderWithdraw.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutV4.4.sol"; -import "../../src/peanut/util/ERC20Mock.sol"; -import "../../src/peanut/util/ERC721Mock.sol"; -import "../../src/peanut/util/ERC1155Mock.sol"; +import "./mocks/ERC20Mock.sol"; +import "./mocks/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; diff --git a/test/peanut/testSigWithdraw.sol b/test/peanut/testSigWithdraw.sol index b496d2d7..28f8903e 100644 --- a/test/peanut/testSigWithdraw.sol +++ b/test/peanut/testSigWithdraw.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutV4.4.sol"; -import "../../src/peanut/util/ERC20Mock.sol"; -import "../../src/peanut/util/ERC721Mock.sol"; -import "../../src/peanut/util/ERC1155Mock.sol"; +import "./mocks/ERC20Mock.sol"; +import "./mocks/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; From 051edcfa40dac91c99b8842d5f8c97c1802af742 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 10:29:44 -0400 Subject: [PATCH 07/31] =?UTF-8?q?feat(paymasters):=20PeanutApprovalPaymast?= =?UTF-8?q?er=20=E2=80=94=20sponsor=20approve/setApprovalForAll=20for=20th?= =?UTF-8?q?e=20peanut=20vault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing WhitelistPaymaster only inspects (from, to); it can't safely sponsor token approval txs because the inner selector and spender argument are invisible to it. This paymaster checks every layer: - tx.to must be on a per-token allowlist (admin-curated) - inner selector must be approve(address,uint256) or setApprovalForAll(address,bool) — same selectors cover ERC-20/721/1155 - inner first arg (spender/operator) must equal the configured peanutVault - tx.from must hold an unexpired EIP-712 grant signed by operatorSigner (signature passed in paymasterInput; nonce single-use; no per-user onchain whitelist tx needed) - global wei-per-period quota via QuotaControl (existing repo pattern) Doesn't extend BasePaymaster because that base hides transaction.data behind a (from, to, requiredETH) hook. Instead inherits IPaymaster + QuotaControl directly and re-implements the bootloader gate inline (~5 lines). EraVM rules permit writes to paymaster's own storage during validation (used here for nonce + quota state). Tests: 19/19 covering happy paths (approve, setApprovalForAll), all 9 revert paths (non-bootloader, wrong flow, expired grant, reused nonce, wrong signer, wrong user, disallowed token, unsupported selector, wrong spender, exceeded quota, insufficient balance), quota period rollover, and admin role gates. --- src/paymasters/PeanutApprovalPaymaster.sol | 199 +++++++++ test/paymasters/PeanutApprovalPaymaster.t.sol | 387 ++++++++++++++++++ 2 files changed, 586 insertions(+) create mode 100644 src/paymasters/PeanutApprovalPaymaster.sol create mode 100644 test/paymasters/PeanutApprovalPaymaster.t.sol diff --git a/src/paymasters/PeanutApprovalPaymaster.sol b/src/paymasters/PeanutApprovalPaymaster.sol new file mode 100644 index 00000000..b4086195 --- /dev/null +++ b/src/paymasters/PeanutApprovalPaymaster.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.26; + +import { + IPaymaster, + ExecutionResult, + PAYMASTER_VALIDATION_SUCCESS_MAGIC +} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymaster.sol"; +import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; +import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {QuotaControl} from "../QuotaControl.sol"; + +/// @dev Bootloader address (duplicated from era-contracts/system-contracts/Constants.sol — +/// the canonical file uses a template variable that can't be imported). +uint160 constant SYSTEM_CONTRACTS_OFFSET = 0x8000; +address payable constant BOOTLOADER_FORMAL_ADDRESS = payable(address(SYSTEM_CONTRACTS_OFFSET + 0x01)); + +/// @title Peanut Approval Paymaster +/// @notice Sponsors gas for a *narrow* set of operations: ERC-20 / ERC-721 `approve(peanut, ...)` +/// and ERC-721 / ERC-1155 `setApprovalForAll(peanut, ...)` — the txs needed to grant +/// PeanutV4 access to a user's tokens before the operator submits `makeCustomDeposit`. +/// @dev Validation enforced per call: +/// - tx.to is on the per-token allowlist +/// - inner selector is approve(address,uint256) or setApprovalForAll(address,bool) +/// - the spender/operator argument == peanutVault +/// - the user holds an unexpired EIP-712 grant signed by `operatorSigner` +/// - daily quota (in wei) hasn't been exhausted +/// Storage writes in validation (nonce, quota counters) are permitted by EraVM's +/// paymaster-validation rules. +contract PeanutApprovalPaymaster is IPaymaster, QuotaControl { + bytes32 public constant ALLOWLIST_ADMIN_ROLE = keccak256("ALLOWLIST_ADMIN_ROLE"); + bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE"); + + bytes4 internal constant APPROVE_SEL = 0x095ea7b3; // approve(address,uint256) — ERC-20 + ERC-721 + bytes4 internal constant SET_APPROVAL_FOR_ALL_SEL = 0xa22cb465; // setApprovalForAll(address,bool) — ERC-721 + ERC-1155 + + bytes32 public constant EIP712_DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 public constant GRANT_TYPEHASH = + keccak256("PeanutApprovalGrant(address user,uint256 deadline,bytes32 nonce)"); + + bytes32 public immutable DOMAIN_SEPARATOR; + address public immutable peanutVault; + + address public operatorSigner; + mapping(address => bool) public isAllowedToken; + mapping(bytes32 => bool) public isNonceUsed; + + event TokensAllowed(address[] tokens); + event TokensRevoked(address[] tokens); + event OperatorSignerUpdated(address indexed previousSigner, address indexed newSigner); + event ApprovalSponsored(address indexed user, address indexed token, bytes32 indexed nonce, uint256 gasPaid); + event Withdrawn(address indexed to, uint256 amount); + + error OnlyBootloader(); + error WrongFlow(); + error InvalidPaymasterInput(); + error GrantExpired(); + error NonceAlreadyUsed(); + error InvalidGrantSignature(); + error TokenNotAllowed(); + error UnsupportedSelector(); + error SpenderNotPeanut(); + error InsufficientPaymasterBalance(); + error WithdrawFailed(); + error ZeroAddress(); + + /// @param admin DEFAULT_ADMIN_ROLE + ALLOWLIST_ADMIN_ROLE + /// @param withdrawer WITHDRAWER_ROLE + /// @param operatorSigner_ EOA or contract whose ECDSA signatures the paymaster will accept as grants + /// @param peanut_ PeanutV4 vault address (the only allowed spender/operator for sponsored approvals) + /// @param initialQuota Total wei sponsorable per period + /// @param initialPeriod Period length in seconds (max 30 days, see QuotaControl) + constructor( + address admin, + address withdrawer, + address operatorSigner_, + address peanut_, + uint256 initialQuota, + uint256 initialPeriod + ) QuotaControl(initialQuota, initialPeriod, admin) { + if (admin == address(0) || peanut_ == address(0) || operatorSigner_ == address(0)) revert ZeroAddress(); + _grantRole(ALLOWLIST_ADMIN_ROLE, admin); + _grantRole(WITHDRAWER_ROLE, withdrawer); + + peanutVault = peanut_; + operatorSigner = operatorSigner_; + + DOMAIN_SEPARATOR = keccak256( + abi.encode( + EIP712_DOMAIN_TYPEHASH, + keccak256(bytes("PeanutApprovalPaymaster")), + keccak256(bytes("1")), + block.chainid, + address(this) + ) + ); + } + + function validateAndPayForPaymasterTransaction(bytes32, bytes32, Transaction calldata transaction) + external + payable + returns (bytes4 magic, bytes memory context) + { + if (msg.sender != BOOTLOADER_FORMAL_ADDRESS) revert OnlyBootloader(); + + // 1. Flow selector — only general supported. + if (transaction.paymasterInput.length < 4) revert InvalidPaymasterInput(); + bytes4 flow = bytes4(transaction.paymasterInput[0:4]); + if (flow != IPaymasterFlow.general.selector) revert WrongFlow(); + + // 2. Decode grant from the inner bytes. + bytes memory inner = abi.decode(transaction.paymasterInput[4:], (bytes)); + (uint256 deadline, bytes32 nonce, bytes memory signature) = abi.decode(inner, (uint256, bytes32, bytes)); + + if (block.timestamp > deadline) revert GrantExpired(); + if (isNonceUsed[nonce]) revert NonceAlreadyUsed(); + + address user = address(uint160(transaction.from)); + bytes32 structHash = keccak256(abi.encode(GRANT_TYPEHASH, user, deadline, nonce)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); + address signer = ECDSA.recover(digest, signature); + if (signer != operatorSigner) revert InvalidGrantSignature(); + + // 3. Token allowlist. + address token = address(uint160(transaction.to)); + if (!isAllowedToken[token]) revert TokenNotAllowed(); + + // 4. Inner selector + first arg (spender / operator) must equal peanut. + bytes calldata innerCall = transaction.data; + if (innerCall.length < 36) revert UnsupportedSelector(); + bytes4 sel = bytes4(innerCall[0:4]); + if (sel != APPROVE_SEL && sel != SET_APPROVAL_FOR_ALL_SEL) revert UnsupportedSelector(); + address spender; + // Both target selectors have an `address` as their first argument; read it directly. + assembly { + spender := calldataload(add(innerCall.offset, 0x04)) + } + if (spender != peanutVault) revert SpenderNotPeanut(); + + // 5. Settle. + uint256 requiredETH = transaction.gasLimit * transaction.maxFeePerGas; + if (address(this).balance < requiredETH) revert InsufficientPaymasterBalance(); + + _checkedResetClaimed(); + _checkedUpdateClaimed(requiredETH); + isNonceUsed[nonce] = true; + + (bool ok,) = BOOTLOADER_FORMAL_ADDRESS.call{value: requiredETH}(""); + if (!ok) revert InsufficientPaymasterBalance(); + + emit ApprovalSponsored(user, token, nonce, requiredETH); + magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC; + } + + function postTransaction( + bytes calldata, /*_context*/ + Transaction calldata, /*_transaction*/ + bytes32, /*_txHash*/ + bytes32, /*_suggestedSignedHash*/ + ExecutionResult, /*_txResult*/ + uint256 /*_maxRefundedGas*/ + ) external payable { + if (msg.sender != BOOTLOADER_FORMAL_ADDRESS) revert OnlyBootloader(); + // Refunds are not supported. + } + + receive() external payable {} + + // ── Admin ────────────────────────────────────────────────────────────── + + function addAllowedTokens(address[] calldata tokens) external onlyRole(ALLOWLIST_ADMIN_ROLE) { + for (uint256 i = 0; i < tokens.length; ++i) { + isAllowedToken[tokens[i]] = true; + } + emit TokensAllowed(tokens); + } + + function removeAllowedTokens(address[] calldata tokens) external onlyRole(ALLOWLIST_ADMIN_ROLE) { + for (uint256 i = 0; i < tokens.length; ++i) { + isAllowedToken[tokens[i]] = false; + } + emit TokensRevoked(tokens); + } + + function setOperatorSigner(address newSigner) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newSigner == address(0)) revert ZeroAddress(); + emit OperatorSignerUpdated(operatorSigner, newSigner); + operatorSigner = newSigner; + } + + /// @notice Withdraw native ETH from the paymaster. + function withdraw(address to, uint256 amount) external onlyRole(WITHDRAWER_ROLE) { + emit Withdrawn(to, amount); + (bool ok,) = payable(to).call{value: amount}(""); + if (!ok) revert WithdrawFailed(); + } +} diff --git a/test/paymasters/PeanutApprovalPaymaster.t.sol b/test/paymasters/PeanutApprovalPaymaster.t.sol new file mode 100644 index 00000000..a6dc5259 --- /dev/null +++ b/test/paymasters/PeanutApprovalPaymaster.t.sol @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {AccessControlUtils} from "../__helpers__/AccessControlUtils.sol"; +import {PeanutApprovalPaymaster} from "../../src/paymasters/PeanutApprovalPaymaster.sol"; +import {QuotaControl} from "../../src/QuotaControl.sol"; +import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; +import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; +import {PAYMASTER_VALIDATION_SUCCESS_MAGIC} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymaster.sol"; + +/// @dev Bootloader address — paymaster validation must be called from this address. +address constant BOOTLOADER = address(uint160(0x8001)); + +contract PeanutApprovalPaymasterTest is Test { + using AccessControlUtils for Vm; + + PeanutApprovalPaymaster paymaster; + + address admin = address(0xA1); + address withdrawer = address(0xA2); + address peanut = address(0xBEEF); + address allowedToken = address(0xCAFE); + address blockedToken = address(0xDEAD); + + uint256 operatorPk = uint256(keccak256("operator-signer")); + address operator; + + uint256 userPk = uint256(keccak256("test-user")); + address user; + + uint256 constant QUOTA = 1 ether; + uint256 constant PERIOD = 1 days; + + function setUp() public { + operator = vm.addr(operatorPk); + user = vm.addr(userPk); + + paymaster = new PeanutApprovalPaymaster(admin, withdrawer, operator, peanut, QUOTA, PERIOD); + vm.deal(address(paymaster), 10 ether); + + // Allowlist the test token. + address[] memory tokens = new address[](1); + tokens[0] = allowedToken; + vm.prank(admin); + paymaster.addAllowedTokens(tokens); + } + + // ── helpers ──────────────────────────────────────────────────────────── + + function _signGrant(uint256 deadline, bytes32 nonce, address grantedUser, uint256 signerPk) + internal + view + returns (bytes memory) + { + bytes32 structHash = + keccak256(abi.encode(paymaster.GRANT_TYPEHASH(), grantedUser, deadline, nonce)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", paymaster.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + return abi.encodePacked(r, s, v); + } + + function _buildPaymasterInput(uint256 deadline, bytes32 nonce, bytes memory signature) + internal + pure + returns (bytes memory) + { + bytes memory inner = abi.encode(deadline, nonce, signature); + return abi.encodeWithSelector(IPaymasterFlow.general.selector, inner); + } + + function _approveCall(address spender, uint256 amount) internal pure returns (bytes memory) { + return abi.encodeWithSelector(0x095ea7b3, spender, amount); + } + + function _setApprovalForAllCall(address operator_, bool approved) internal pure returns (bytes memory) { + return abi.encodeWithSelector(0xa22cb465, operator_, approved); + } + + function _txTo(address to, bytes memory data, bytes memory paymasterInput, uint256 gasLimit, uint256 gasPrice) + internal + view + returns (Transaction memory) + { + return Transaction({ + txType: 0x71, // EIP-712 zksync tx type + from: uint256(uint160(user)), + to: uint256(uint160(to)), + gasLimit: gasLimit, + gasPerPubdataByteLimit: 50000, + maxFeePerGas: gasPrice, + maxPriorityFeePerGas: 0, + paymaster: uint256(uint160(address(paymaster))), + nonce: 0, + value: 0, + reserved: [uint256(0), 0, 0, 0], + data: data, + signature: hex"", + factoryDeps: new bytes32[](0), + paymasterInput: paymasterInput, + reservedDynamic: hex"" + }); + } + + function _validate(Transaction memory tx_) internal { + vm.prank(BOOTLOADER); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), tx_); + } + + // ── Happy paths ──────────────────────────────────────────────────────── + + function test_sponsorsApprove() public { + bytes32 nonce = keccak256("nonce-1"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + bytes memory data = _approveCall(peanut, 1000); + + uint256 gasLimit = 100_000; + uint256 gasPrice = 1 gwei; + uint256 expectedPay = gasLimit * gasPrice; + + uint256 balBefore = address(paymaster).balance; + uint256 bootBefore = BOOTLOADER.balance; + _validate(_txTo(allowedToken, data, pmInput, gasLimit, gasPrice)); + + assertEq(address(paymaster).balance, balBefore - expectedPay, "paymaster paid wrong amount"); + assertEq(BOOTLOADER.balance, bootBefore + expectedPay, "bootloader didn't receive"); + assertTrue(paymaster.isNonceUsed(nonce), "nonce not marked used"); + assertEq(paymaster.claimed(), expectedPay, "quota counter not bumped"); + } + + function test_sponsorsSetApprovalForAll() public { + bytes32 nonce = keccak256("nonce-2"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + bytes memory data = _setApprovalForAllCall(peanut, true); + + _validate(_txTo(allowedToken, data, pmInput, 100_000, 1 gwei)); + assertTrue(paymaster.isNonceUsed(nonce)); + } + + // ── Reverts ──────────────────────────────────────────────────────────── + + function test_revertsIfNotBootloader() public { + bytes32 nonce = keccak256("n"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + Transaction memory tx_ = _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei); + + vm.expectRevert(PeanutApprovalPaymaster.OnlyBootloader.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), tx_); + } + + function test_revertsOnApprovalBasedFlow() public { + bytes memory wrongFlowInput = abi.encodeWithSelector( + IPaymasterFlow.approvalBased.selector, address(0), uint256(0), bytes("") + ); + Transaction memory tx_ = _txTo(allowedToken, _approveCall(peanut, 1), wrongFlowInput, 100_000, 1 gwei); + + vm.prank(BOOTLOADER); + vm.expectRevert(PeanutApprovalPaymaster.WrongFlow.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), tx_); + } + + function test_revertsOnExpiredGrant() public { + bytes32 nonce = keccak256("expired"); + uint256 deadline = block.timestamp + 100; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + + vm.warp(deadline + 1); + Transaction memory tx_ = _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei); + + vm.prank(BOOTLOADER); + vm.expectRevert(PeanutApprovalPaymaster.GrantExpired.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), tx_); + } + + function test_revertsOnReusedNonce() public { + bytes32 nonce = keccak256("nonce-replay"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + + _validate(_txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei)); + + vm.prank(BOOTLOADER); + vm.expectRevert(PeanutApprovalPaymaster.NonceAlreadyUsed.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + ); + } + + function test_revertsOnSignatureFromWrongSigner() public { + uint256 attackerPk = uint256(keccak256("attacker")); + bytes32 nonce = keccak256("nonce-attacker"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, attackerPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + + vm.prank(BOOTLOADER); + vm.expectRevert(PeanutApprovalPaymaster.InvalidGrantSignature.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + ); + } + + function test_revertsOnSignatureForDifferentUser() public { + // Operator signs grant for charlie; but tx.from = user. Recovered signer + // matches operator, BUT the structHash uses tx.from's user address, not the + // address baked into the sig. So the sig recovers to wrong signer and reverts. + address charlie = address(0xC); + bytes32 nonce = keccak256("nonce-other-user"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, charlie, operatorPk); // signed for charlie + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + + // tx.from = user (different from charlie) + vm.prank(BOOTLOADER); + vm.expectRevert(PeanutApprovalPaymaster.InvalidGrantSignature.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + ); + } + + function test_revertsOnDisallowedToken() public { + bytes32 nonce = keccak256("nonce-token"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + + vm.prank(BOOTLOADER); + vm.expectRevert(PeanutApprovalPaymaster.TokenNotAllowed.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(blockedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + ); + } + + function test_revertsOnUnsupportedSelector() public { + bytes32 nonce = keccak256("nonce-sel"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + // transfer(address,uint256) instead of approve + bytes memory data = abi.encodeWithSelector(0xa9059cbb, peanut, uint256(1)); + + vm.prank(BOOTLOADER); + vm.expectRevert(PeanutApprovalPaymaster.UnsupportedSelector.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(allowedToken, data, pmInput, 100_000, 1 gwei) + ); + } + + function test_revertsOnSpenderNotPeanut() public { + bytes32 nonce = keccak256("nonce-spender"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + // Approve attacker instead of peanut + bytes memory data = _approveCall(address(0xBAD), 1000); + + vm.prank(BOOTLOADER); + vm.expectRevert(PeanutApprovalPaymaster.SpenderNotPeanut.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(allowedToken, data, pmInput, 100_000, 1 gwei) + ); + } + + function test_revertsOnExceededQuota() public { + bytes32 nonce = keccak256("nonce-quota"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + + // gasLimit * gasPrice > QUOTA + uint256 gasLimit = 2_000_000; + uint256 gasPrice = 1 gwei; // 0.002 ether > 0.001? wait QUOTA is 1 ether — bump + // Make it definitely exceed: gasLimit huge. + gasLimit = uint256(QUOTA / gasPrice) + 1_000_000; + + vm.prank(BOOTLOADER); + vm.expectRevert(QuotaControl.QuotaExceeded.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(allowedToken, _approveCall(peanut, 1), pmInput, gasLimit, gasPrice) + ); + } + + function test_revertsOnInsufficientBalance() public { + // Drain the paymaster balance + vm.prank(withdrawer); + paymaster.withdraw(address(0x1), address(paymaster).balance); + + bytes32 nonce = keccak256("nonce-bal"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + + vm.prank(BOOTLOADER); + vm.expectRevert(PeanutApprovalPaymaster.InsufficientPaymasterBalance.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + ); + } + + // ── Quota period rollover ────────────────────────────────────────────── + + function test_quotaResetsAfterPeriod() public { + // Burn some quota + bytes32 nonce1 = keccak256("nonce-r1"); + uint256 deadline = block.timestamp + 7 days; + bytes memory sig1 = _signGrant(deadline, nonce1, user, operatorPk); + bytes memory pmInput1 = _buildPaymasterInput(deadline, nonce1, sig1); + _validate(_txTo(allowedToken, _approveCall(peanut, 1), pmInput1, 100_000, 1 gwei)); + uint256 claimed1 = paymaster.claimed(); + assertGt(claimed1, 0); + + // Roll past the period + vm.warp(block.timestamp + PERIOD + 1); + + bytes32 nonce2 = keccak256("nonce-r2"); + bytes memory sig2 = _signGrant(deadline, nonce2, user, operatorPk); + bytes memory pmInput2 = _buildPaymasterInput(deadline, nonce2, sig2); + _validate(_txTo(allowedToken, _approveCall(peanut, 1), pmInput2, 100_000, 1 gwei)); + + // Claimed should reset to just this tx's cost (not cumulative) + assertEq(paymaster.claimed(), 100_000 * 1 gwei); + } + + // ── Admin ────────────────────────────────────────────────────────────── + + function test_adminCanAddAndRemoveTokens() public { + address[] memory tokens = new address[](2); + tokens[0] = address(0x111); + tokens[1] = address(0x222); + + vm.prank(admin); + paymaster.addAllowedTokens(tokens); + assertTrue(paymaster.isAllowedToken(tokens[0])); + assertTrue(paymaster.isAllowedToken(tokens[1])); + + vm.prank(admin); + paymaster.removeAllowedTokens(tokens); + assertFalse(paymaster.isAllowedToken(tokens[0])); + } + + function test_nonAdminCannotAddTokens() public { + address[] memory tokens = new address[](1); + tokens[0] = address(0x111); + + vm.expectRevert(); + paymaster.addAllowedTokens(tokens); + } + + function test_adminCanRotateOperatorSigner() public { + address newSigner = address(0x99); + vm.prank(admin); + paymaster.setOperatorSigner(newSigner); + assertEq(paymaster.operatorSigner(), newSigner); + } + + function test_withdrawerCanDrainBalance() public { + uint256 amount = 1 ether; + address recipient = address(0x77); + uint256 before = recipient.balance; + + vm.prank(withdrawer); + paymaster.withdraw(recipient, amount); + assertEq(recipient.balance, before + amount); + } + + function test_nonWithdrawerCannotDrain() public { + vm.expectRevert(); + paymaster.withdraw(address(0x77), 1); + } +} From 040626c58eac00e04e756ead5507942b138a715f Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 10:42:07 -0400 Subject: [PATCH 08/31] refactor(paymasters): PeanutApprovalPaymaster inherits BasePaymaster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous standalone version duplicated bootloader-check logic, WITHDRAWER_ROLE, Withdrawn event, withdraw(), postTransaction(), receive(), and the BOOTLOADER_FORMAL_ADDRESS constant. One-keyword change to BasePaymaster: mark validateAndPayForPaymasterTransaction as `virtual` so subclasses can override it when they need access to the full Transaction calldata (the existing `_validateAndPayGeneralFlow` hook hides `transaction.data` and `transaction.paymasterInput` by design). WhitelistPaymaster and BondTreasuryPaymaster are untouched — they continue to override the internal hook through BasePaymaster's default outer-function implementation. PeanutApprovalPaymaster now: - is BasePaymaster, QuotaControl - overrides validateAndPayForPaymasterTransaction with full peanut- specific validation - implements the two abstract internal hooks as reverts (general: Unused; approvalBased: PaymasterFlowNotSupported) - drops 9 lines net of duplication (37 deleted, 28 added) - inherits withdraw / postTransaction / receive / Withdrawn / AccessRestrictedToBootloader / WITHDRAWER_ROLE / BOOTLOADER_FORMAL_ADDRESS Tests: 939/939 (19 paymaster-specific + 102 other paymaster tests including WhitelistPaymaster/BondTreasuryPaymaster suites untouched + all peanut and rest-of-repo tests). Behavior unchanged externally. Also adds hardhat-deploy/DeployPeanutPaymaster.ts (Hardhat-zksync deploy script that matches existing patterns; takes PEANUT_V4 and operator signer from env, optionally funds + seeds token allowlist). --- hardhat-deploy/DeployPeanutPaymaster.ts | 173 ++++++++++++++++++ src/paymasters/BasePaymaster.sol | 2 +- src/paymasters/PeanutApprovalPaymaster.sol | 63 +++---- test/paymasters/PeanutApprovalPaymaster.t.sol | 3 +- 4 files changed, 203 insertions(+), 38 deletions(-) create mode 100644 hardhat-deploy/DeployPeanutPaymaster.ts diff --git a/hardhat-deploy/DeployPeanutPaymaster.ts b/hardhat-deploy/DeployPeanutPaymaster.ts new file mode 100644 index 00000000..99b12207 --- /dev/null +++ b/hardhat-deploy/DeployPeanutPaymaster.ts @@ -0,0 +1,173 @@ +import { Provider, Wallet, utils } from "zksync-ethers"; +import { Deployer } from "@matterlabs/hardhat-zksync"; +import { ethers } from "ethers"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import "@matterlabs/hardhat-zksync-node/dist/type-extensions"; +import "@matterlabs/hardhat-zksync-verify/dist/src/type-extensions"; +import * as dotenv from "dotenv"; +import { deployContract } from "./utils"; + +dotenv.config({ path: ".env-test" }); + +/** + * Deploys PeanutApprovalPaymaster on ZkSync Era. + * + * Path C support: lets users submit gasless `approve(peanutVault, ...)` and + * `setApprovalForAll(peanutVault, ...)` txs against allowlisted tokens, gated by + * an EIP-712 grant signed off-chain by the operator. + * + * Required environment variables: + * - DEPLOYER_PRIVATE_KEY: Private key for deployment (also default admin / withdrawer). + * - PEANUT_V4: Address of the deployed PeanutV4 vault — used as the only + * allowed spender/operator for sponsored approvals. + * + * Optional environment variables (admin / signer): + * - PEANUT_PAYMASTER_ADMIN: DEFAULT_ADMIN_ROLE + ALLOWLIST_ADMIN_ROLE. + * Defaults to deployer. + * - PEANUT_PAYMASTER_WITHDRAWER: WITHDRAWER_ROLE. Defaults to deployer. + * - PEANUT_PAYMASTER_OPERATOR_SIGNER: EOA whose EIP-712 grant signatures are accepted. + * Defaults to PEANUT_MFA_AUTHORIZER if set, else deployer. + * + * Optional environment variables (config): + * - PEANUT_PAYMASTER_QUOTA: Wei sponsorable per period. Default: 0.1 ETH. + * - PEANUT_PAYMASTER_PERIOD: Period length in seconds. Default: 86400 (1 day). Max: 2592000 (30 days). + * - PEANUT_PAYMASTER_FUNDING: Amount of ETH to send to the paymaster post-deploy. + * Default: 0 (must fund manually before use). + * - PEANUT_PAYMASTER_TOKENS: Comma-separated token addresses to allowlist after deploy. + * Default: none (must seed via addAllowedTokens). + * + * Usage: + * yarn hardhat deploy-zksync \ + * --script DeployPeanutPaymaster.ts \ + * --network zkSyncSepoliaTestnet + */ +module.exports = async function (hre: HardhatRuntimeEnvironment) { + const ZERO = ethers.ZeroAddress; + + const rpcUrl = hre.network.config.url!; + const provider = new Provider(rpcUrl); + const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); + const deployer = new Deployer(hre, wallet); + + const peanutVault = process.env.PEANUT_V4; + if (!peanutVault || peanutVault === ZERO) { + throw new Error("PEANUT_V4 env var is required (the deployed PeanutV4 vault address)"); + } + + const admin = process.env.PEANUT_PAYMASTER_ADMIN ?? wallet.address; + const withdrawer = process.env.PEANUT_PAYMASTER_WITHDRAWER ?? wallet.address; + const operatorSigner = + process.env.PEANUT_PAYMASTER_OPERATOR_SIGNER ?? + process.env.PEANUT_MFA_AUTHORIZER ?? + wallet.address; + + const quota = ethers.toBigInt( + process.env.PEANUT_PAYMASTER_QUOTA ?? ethers.parseEther("0.1").toString(), + ); + const period = BigInt(process.env.PEANUT_PAYMASTER_PERIOD ?? "86400"); // 1 day + + const funding = process.env.PEANUT_PAYMASTER_FUNDING + ? ethers.toBigInt(process.env.PEANUT_PAYMASTER_FUNDING) + : 0n; + + const tokensToAllowlist = (process.env.PEANUT_PAYMASTER_TOKENS ?? "") + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0 && t !== ZERO); + + console.log("=== Deploying PeanutApprovalPaymaster on ZkSync ==="); + console.log("Network: ", hre.network.name); + console.log("Deployer: ", wallet.address); + console.log("Peanut Vault: ", peanutVault); + console.log("Admin: ", admin); + console.log("Withdrawer: ", withdrawer); + console.log("Operator Signer: ", operatorSigner); + console.log("Quota (wei): ", quota.toString(), `(${ethers.formatEther(quota)} ETH)`); + console.log("Period (seconds): ", period.toString(), `(${Number(period) / 86400} days)`); + console.log("Funding (wei): ", funding.toString(), `(${ethers.formatEther(funding)} ETH)`); + console.log("Tokens to allowlist:", tokensToAllowlist.length > 0 ? tokensToAllowlist : "(none — seed later)"); + console.log(""); + + // 1. Deploy the paymaster. + const paymaster = await deployContract(deployer, "PeanutApprovalPaymaster", [ + admin, + withdrawer, + operatorSigner, + peanutVault, + quota.toString(), + period.toString(), + ]); + const paymasterAddr = await paymaster.getAddress(); + + // 2. Fund the paymaster with ETH (so it can pay gas immediately). + if (funding > 0n) { + console.log(`Funding paymaster with ${ethers.formatEther(funding)} ETH...`); + const fundTx = await wallet.sendTransaction({ to: paymasterAddr, value: funding }); + await fundTx.wait(); + console.log(` fund tx: ${fundTx.hash}`); + } + + // 3. Seed token allowlist (deployer must hold ALLOWLIST_ADMIN_ROLE). + if (tokensToAllowlist.length > 0) { + if (admin.toLowerCase() !== wallet.address.toLowerCase()) { + console.log( + `Skipping token seeding: admin (${admin}) is not the deployer; have the admin call addAllowedTokens directly.`, + ); + } else { + console.log("Allowlisting tokens..."); + const tx = await paymaster.addAllowedTokens(tokensToAllowlist); + await tx.wait(); + console.log(` tx: ${tx.hash}`); + } + } + + console.log(""); + console.log("=== Deployment Complete ==="); + console.log("PeanutApprovalPaymaster:", paymasterAddr); + console.log("Balance:", ethers.formatEther(await provider.getBalance(paymasterAddr)), "ETH"); + console.log(""); + + // 4. Verification. + console.log("=== Verifying Contract ==="); + try { + await hre.run("verify:verify", { + address: paymasterAddr, + contract: "src/paymasters/PeanutApprovalPaymaster.sol:PeanutApprovalPaymaster", + constructorArguments: [ + admin, + withdrawer, + operatorSigner, + peanutVault, + quota.toString(), + period.toString(), + ], + }); + } catch (e: any) { + console.log("Verification failed or already verified:", e.message); + } + + console.log(""); + console.log("=== Add to .env-test ==="); + console.log(`PEANUT_PAYMASTER=${paymasterAddr}`); + + console.log(""); + console.log("=== Next steps ==="); + if (funding === 0n) { + console.log( + `- Fund the paymaster: wallet.sendTransaction({ to: ${paymasterAddr}, value: ... })`, + ); + } + if (tokensToAllowlist.length === 0) { + console.log( + `- Seed token allowlist via PeanutApprovalPaymaster(${paymasterAddr}).addAllowedTokens([...])`, + ); + } + console.log( + `- Operator backend: sign EIP-712 PeanutApprovalGrant(user, deadline, nonce) with the operatorSigner key (${operatorSigner})`, + ); + console.log( + " Domain: { name: 'PeanutApprovalPaymaster', version: '1', chainId, verifyingContract: " + + paymasterAddr + + " }", + ); +}; diff --git a/src/paymasters/BasePaymaster.sol b/src/paymasters/BasePaymaster.sol index 72af886b..b35c13bf 100644 --- a/src/paymasters/BasePaymaster.sol +++ b/src/paymasters/BasePaymaster.sol @@ -42,7 +42,7 @@ abstract contract BasePaymaster is IPaymaster, AccessControl { bytes32, /*_txHash*/ bytes32, /*_suggestedSignedHash*/ Transaction calldata transaction - ) external payable returns (bytes4 magic, bytes memory context) { + ) external payable virtual returns (bytes4 magic, bytes memory context) { _mustBeBootloader(); // By default we consider the transaction as accepted. diff --git a/src/paymasters/PeanutApprovalPaymaster.sol b/src/paymasters/PeanutApprovalPaymaster.sol index b4086195..58696f42 100644 --- a/src/paymasters/PeanutApprovalPaymaster.sol +++ b/src/paymasters/PeanutApprovalPaymaster.sol @@ -3,19 +3,14 @@ pragma solidity 0.8.26; import { IPaymaster, - ExecutionResult, PAYMASTER_VALIDATION_SUCCESS_MAGIC } from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymaster.sol"; import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "./BasePaymaster.sol"; import {QuotaControl} from "../QuotaControl.sol"; -/// @dev Bootloader address (duplicated from era-contracts/system-contracts/Constants.sol — -/// the canonical file uses a template variable that can't be imported). -uint160 constant SYSTEM_CONTRACTS_OFFSET = 0x8000; -address payable constant BOOTLOADER_FORMAL_ADDRESS = payable(address(SYSTEM_CONTRACTS_OFFSET + 0x01)); - /// @title Peanut Approval Paymaster /// @notice Sponsors gas for a *narrow* set of operations: ERC-20 / ERC-721 `approve(peanut, ...)` /// and ERC-721 / ERC-1155 `setApprovalForAll(peanut, ...)` — the txs needed to grant @@ -25,12 +20,15 @@ address payable constant BOOTLOADER_FORMAL_ADDRESS = payable(address(SYSTEM_CONT /// - inner selector is approve(address,uint256) or setApprovalForAll(address,bool) /// - the spender/operator argument == peanutVault /// - the user holds an unexpired EIP-712 grant signed by `operatorSigner` -/// - daily quota (in wei) hasn't been exhausted +/// - daily wei quota hasn't been exhausted (QuotaControl) +/// Overrides `validateAndPayForPaymasterTransaction` directly (instead of the +/// `_validateAndPayGeneralFlow` hook) because validation requires the full +/// `Transaction` calldata — the hook signature hides `transaction.data` and +/// `transaction.paymasterInput`. /// Storage writes in validation (nonce, quota counters) are permitted by EraVM's /// paymaster-validation rules. -contract PeanutApprovalPaymaster is IPaymaster, QuotaControl { +contract PeanutApprovalPaymaster is BasePaymaster, QuotaControl { bytes32 public constant ALLOWLIST_ADMIN_ROLE = keccak256("ALLOWLIST_ADMIN_ROLE"); - bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE"); bytes4 internal constant APPROVE_SEL = 0x095ea7b3; // approve(address,uint256) — ERC-20 + ERC-721 bytes4 internal constant SET_APPROVAL_FOR_ALL_SEL = 0xa22cb465; // setApprovalForAll(address,bool) — ERC-721 + ERC-1155 @@ -51,11 +49,8 @@ contract PeanutApprovalPaymaster is IPaymaster, QuotaControl { event TokensRevoked(address[] tokens); event OperatorSignerUpdated(address indexed previousSigner, address indexed newSigner); event ApprovalSponsored(address indexed user, address indexed token, bytes32 indexed nonce, uint256 gasPaid); - event Withdrawn(address indexed to, uint256 amount); - error OnlyBootloader(); error WrongFlow(); - error InvalidPaymasterInput(); error GrantExpired(); error NonceAlreadyUsed(); error InvalidGrantSignature(); @@ -63,8 +58,8 @@ contract PeanutApprovalPaymaster is IPaymaster, QuotaControl { error UnsupportedSelector(); error SpenderNotPeanut(); error InsufficientPaymasterBalance(); - error WithdrawFailed(); error ZeroAddress(); + error Unused(); /// @param admin DEFAULT_ADMIN_ROLE + ALLOWLIST_ADMIN_ROLE /// @param withdrawer WITHDRAWER_ROLE @@ -79,10 +74,9 @@ contract PeanutApprovalPaymaster is IPaymaster, QuotaControl { address peanut_, uint256 initialQuota, uint256 initialPeriod - ) QuotaControl(initialQuota, initialPeriod, admin) { + ) BasePaymaster(admin, withdrawer) QuotaControl(initialQuota, initialPeriod, admin) { if (admin == address(0) || peanut_ == address(0) || operatorSigner_ == address(0)) revert ZeroAddress(); _grantRole(ALLOWLIST_ADMIN_ROLE, admin); - _grantRole(WITHDRAWER_ROLE, withdrawer); peanutVault = peanut_; operatorSigner = operatorSigner_; @@ -101,12 +95,15 @@ contract PeanutApprovalPaymaster is IPaymaster, QuotaControl { function validateAndPayForPaymasterTransaction(bytes32, bytes32, Transaction calldata transaction) external payable - returns (bytes4 magic, bytes memory context) + override + returns (bytes4 magic, bytes memory) { - if (msg.sender != BOOTLOADER_FORMAL_ADDRESS) revert OnlyBootloader(); + _mustBeBootloader(); // 1. Flow selector — only general supported. - if (transaction.paymasterInput.length < 4) revert InvalidPaymasterInput(); + if (transaction.paymasterInput.length < 4) { + revert InvalidPaymasterInput("paymasterInput must contain at least a flow selector"); + } bytes4 flow = bytes4(transaction.paymasterInput[0:4]); if (flow != IPaymasterFlow.general.selector) revert WrongFlow(); @@ -154,19 +151,20 @@ contract PeanutApprovalPaymaster is IPaymaster, QuotaControl { magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC; } - function postTransaction( - bytes calldata, /*_context*/ - Transaction calldata, /*_transaction*/ - bytes32, /*_txHash*/ - bytes32, /*_suggestedSignedHash*/ - ExecutionResult, /*_txResult*/ - uint256 /*_maxRefundedGas*/ - ) external payable { - if (msg.sender != BOOTLOADER_FORMAL_ADDRESS) revert OnlyBootloader(); - // Refunds are not supported. + /// @dev Unused — full validation lives in `validateAndPayForPaymasterTransaction`. + /// Required because BasePaymaster declares this hook abstract. + function _validateAndPayGeneralFlow(address, address, uint256) internal pure override { + revert Unused(); } - receive() external payable {} + /// @dev Unused — only the `general` flow is supported. + function _validateAndPayApprovalBasedFlow(address, address, address, uint256, bytes memory, uint256) + internal + pure + override + { + revert PaymasterFlowNotSupported(); + } // ── Admin ────────────────────────────────────────────────────────────── @@ -189,11 +187,4 @@ contract PeanutApprovalPaymaster is IPaymaster, QuotaControl { emit OperatorSignerUpdated(operatorSigner, newSigner); operatorSigner = newSigner; } - - /// @notice Withdraw native ETH from the paymaster. - function withdraw(address to, uint256 amount) external onlyRole(WITHDRAWER_ROLE) { - emit Withdrawn(to, amount); - (bool ok,) = payable(to).call{value: amount}(""); - if (!ok) revert WithdrawFailed(); - } } diff --git a/test/paymasters/PeanutApprovalPaymaster.t.sol b/test/paymasters/PeanutApprovalPaymaster.t.sol index a6dc5259..28965644 100644 --- a/test/paymasters/PeanutApprovalPaymaster.t.sol +++ b/test/paymasters/PeanutApprovalPaymaster.t.sol @@ -5,6 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {AccessControlUtils} from "../__helpers__/AccessControlUtils.sol"; import {PeanutApprovalPaymaster} from "../../src/paymasters/PeanutApprovalPaymaster.sol"; +import {BasePaymaster} from "../../src/paymasters/BasePaymaster.sol"; import {QuotaControl} from "../../src/QuotaControl.sol"; import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; @@ -151,7 +152,7 @@ contract PeanutApprovalPaymasterTest is Test { bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); Transaction memory tx_ = _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei); - vm.expectRevert(PeanutApprovalPaymaster.OnlyBootloader.selector); + vm.expectRevert(BasePaymaster.AccessRestrictedToBootloader.selector); paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), tx_); } From cc12351d1870d67e4e6149bf38487b684f1a79b3 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 11:09:36 -0400 Subject: [PATCH 09/31] refactor(paymasters): split PeanutApprovalPaymaster validation into smaller helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateAndPayForPaymasterTransaction was too dense for zksolc's legacy codegen — 17 active locals tripped stack-too-deep at the explorer's verification compile (zksolc doesn't accept Solidity's viaIR flag because it translates legacy IR to EraVM directly). Split the validation into 4 internal helpers, each scope <16 locals: - _requireGeneralFlow(paymasterInput) — flow selector check - _verifyAndConsumeGrant(user, paymasterInput) — EIP-712 grant decode, expiry/nonce check, signature recover, nonce mark-used - _requireApprovalCallToPeanut(data) — inner selector + spender check - _payBootloader(requiredETH) — balance, quota, transfer Same behavior, just structurally lighter. All 19 paymaster tests pass. Deployed + verified on ZkSync Sepolia at 0x301DB88e0AdD434CBac07ef3F4207C16E4dEb6a0 (operator signer 0xc1F2A7b888e4837aFACfc5E914AB647476ceCD46, vault 0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44, quota 0.1 ETH/day). --- src/paymasters/PeanutApprovalPaymaster.sol | 69 +++++++++++++--------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/src/paymasters/PeanutApprovalPaymaster.sol b/src/paymasters/PeanutApprovalPaymaster.sol index 58696f42..8da328de 100644 --- a/src/paymasters/PeanutApprovalPaymaster.sol +++ b/src/paymasters/PeanutApprovalPaymaster.sol @@ -99,56 +99,71 @@ contract PeanutApprovalPaymaster is BasePaymaster, QuotaControl { returns (bytes4 magic, bytes memory) { _mustBeBootloader(); + _requireGeneralFlow(transaction.paymasterInput); - // 1. Flow selector — only general supported. - if (transaction.paymasterInput.length < 4) { + address user = address(uint160(transaction.from)); + bytes32 nonce = _verifyAndConsumeGrant(user, transaction.paymasterInput); + + address token = address(uint160(transaction.to)); + if (!isAllowedToken[token]) revert TokenNotAllowed(); + _requireApprovalCallToPeanut(transaction.data); + + uint256 requiredETH = transaction.gasLimit * transaction.maxFeePerGas; + _payBootloader(requiredETH); + + emit ApprovalSponsored(user, token, nonce, requiredETH); + magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC; + } + + /// @dev Reverts unless paymasterInput starts with the `general` flow selector. + function _requireGeneralFlow(bytes calldata paymasterInput) internal pure { + if (paymasterInput.length < 4) { revert InvalidPaymasterInput("paymasterInput must contain at least a flow selector"); } - bytes4 flow = bytes4(transaction.paymasterInput[0:4]); - if (flow != IPaymasterFlow.general.selector) revert WrongFlow(); + if (bytes4(paymasterInput[0:4]) != IPaymasterFlow.general.selector) revert WrongFlow(); + } - // 2. Decode grant from the inner bytes. - bytes memory inner = abi.decode(transaction.paymasterInput[4:], (bytes)); - (uint256 deadline, bytes32 nonce, bytes memory signature) = abi.decode(inner, (uint256, bytes32, bytes)); + /// @dev Decodes the EIP-712 grant from the inner bytes, verifies the signature, + /// checks deadline + nonce-uniqueness, and marks the nonce used. + function _verifyAndConsumeGrant(address user, bytes calldata paymasterInput) + internal + returns (bytes32 nonce) + { + bytes memory inner = abi.decode(paymasterInput[4:], (bytes)); + uint256 deadline; + bytes memory signature; + (deadline, nonce, signature) = abi.decode(inner, (uint256, bytes32, bytes)); if (block.timestamp > deadline) revert GrantExpired(); if (isNonceUsed[nonce]) revert NonceAlreadyUsed(); - address user = address(uint160(transaction.from)); bytes32 structHash = keccak256(abi.encode(GRANT_TYPEHASH, user, deadline, nonce)); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); - address signer = ECDSA.recover(digest, signature); - if (signer != operatorSigner) revert InvalidGrantSignature(); + if (ECDSA.recover(digest, signature) != operatorSigner) revert InvalidGrantSignature(); - // 3. Token allowlist. - address token = address(uint160(transaction.to)); - if (!isAllowedToken[token]) revert TokenNotAllowed(); + isNonceUsed[nonce] = true; + } - // 4. Inner selector + first arg (spender / operator) must equal peanut. - bytes calldata innerCall = transaction.data; - if (innerCall.length < 36) revert UnsupportedSelector(); - bytes4 sel = bytes4(innerCall[0:4]); + /// @dev Reverts unless the user's call is approve(peanut,...) or setApprovalForAll(peanut,...). + function _requireApprovalCallToPeanut(bytes calldata data) internal view { + if (data.length < 36) revert UnsupportedSelector(); + bytes4 sel = bytes4(data[0:4]); if (sel != APPROVE_SEL && sel != SET_APPROVAL_FOR_ALL_SEL) revert UnsupportedSelector(); address spender; - // Both target selectors have an `address` as their first argument; read it directly. + // Both target selectors have an `address` as their first argument. assembly { - spender := calldataload(add(innerCall.offset, 0x04)) + spender := calldataload(add(data.offset, 0x04)) } if (spender != peanutVault) revert SpenderNotPeanut(); + } - // 5. Settle. - uint256 requiredETH = transaction.gasLimit * transaction.maxFeePerGas; + /// @dev Checks balance, bumps quota counters, sends ETH to the bootloader. + function _payBootloader(uint256 requiredETH) internal { if (address(this).balance < requiredETH) revert InsufficientPaymasterBalance(); - _checkedResetClaimed(); _checkedUpdateClaimed(requiredETH); - isNonceUsed[nonce] = true; - (bool ok,) = BOOTLOADER_FORMAL_ADDRESS.call{value: requiredETH}(""); if (!ok) revert InsufficientPaymasterBalance(); - - emit ApprovalSponsored(user, token, nonce, requiredETH); - magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC; } /// @dev Unused — full validation lives in `validateAndPayForPaymasterTransaction`. From 2b2f0c647e7aab34cb4a9bf32c266d0fb7a55e89 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 11:22:25 -0400 Subject: [PATCH 10/31] =?UTF-8?q?refactor(paymasters):=20rename=20Peanut?= =?UTF-8?q?=E2=86=92Envelope,=20drop=20token=20allowlist,=20add=20per-tx?= =?UTF-8?q?=20ETH=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-token allowlist was operator-side ceremony with little marginal safety: the operator already curates which tokens get grants (by deciding what tx the backend builds in step 2 of Path C). Removing it cuts an admin workflow. Replaced with a per-tx ETH cap (`maxEthPerTx`, immutable, constructor-set) so the worst-case drain under operator-key compromise is bounded per tx, not per token. Combined with the existing daily QuotaControl cap, the security envelope is equivalent for honest operation, tighter under compromise. Renames (paymaster surface only; the vault keeps the upstream PeanutV4 name): - PeanutApprovalPaymaster → EnvelopeApprovalPaymaster - peanutVault state → envelopeVault - SpenderNotPeanut error → SpenderNotEnvelope - EIP-712 domain name string → "EnvelopeApprovalPaymaster" - GRANT_TYPEHASH → keccak256("EnvelopeApprovalGrant(...)") - file + test + deploy script names - all NatSpec and comments NOTE: changing the EIP-712 domain name invalidates the signatures that would verify against the previously-deployed paymaster at 0x301D...b6a0. That contract is functionally orphaned now — needs a redeploy of the new bytecode to a fresh address. Tests: 19/19 envelope-paymaster (covers per-tx-cap, exceeded-quota via constructor-tightened paymaster instance, sponsorship works on any token, all the per-gate reverts). Full repo: 939/939, no regressions. --- hardhat-deploy/DeployEnvelopePaymaster.ts | 146 +++++++++++++ hardhat-deploy/DeployPeanutPaymaster.ts | 173 --------------- ...ster.sol => EnvelopeApprovalPaymaster.sol} | 82 ++++--- ....t.sol => EnvelopeApprovalPaymaster.t.sol} | 202 ++++++++++-------- 4 files changed, 298 insertions(+), 305 deletions(-) create mode 100644 hardhat-deploy/DeployEnvelopePaymaster.ts delete mode 100644 hardhat-deploy/DeployPeanutPaymaster.ts rename src/paymasters/{PeanutApprovalPaymaster.sol => EnvelopeApprovalPaymaster.sol} (74%) rename test/paymasters/{PeanutApprovalPaymaster.t.sol => EnvelopeApprovalPaymaster.t.sol} (66%) diff --git a/hardhat-deploy/DeployEnvelopePaymaster.ts b/hardhat-deploy/DeployEnvelopePaymaster.ts new file mode 100644 index 00000000..a5664510 --- /dev/null +++ b/hardhat-deploy/DeployEnvelopePaymaster.ts @@ -0,0 +1,146 @@ +import { Provider, Wallet } from "zksync-ethers"; +import { Deployer } from "@matterlabs/hardhat-zksync"; +import { ethers } from "ethers"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import "@matterlabs/hardhat-zksync-node/dist/type-extensions"; +import "@matterlabs/hardhat-zksync-verify/dist/src/type-extensions"; +import * as dotenv from "dotenv"; +import { deployContract } from "./utils"; + +dotenv.config({ path: ".env-test" }); + +/** + * Deploys EnvelopeApprovalPaymaster on ZkSync Era. + * + * Path C support: lets users submit gasless `approve(envelopeVault, ...)` and + * `setApprovalForAll(envelopeVault, ...)` txs against any token, gated entirely + * by an EIP-712 grant signed off-chain by the operator. No per-token allowlist — + * defense-in-depth comes from the per-tx ETH cap and the daily quota. + * + * Required environment variables: + * - DEPLOYER_PRIVATE_KEY: Private key for deployment (also default admin / withdrawer). + * - PEANUT_V4: Address of the deployed Peanut/Envelope vault — the only + * allowed spender/operator for sponsored approvals. + * + * Optional environment variables (admin / signer): + * - ENVELOPE_PAYMASTER_ADMIN: DEFAULT_ADMIN_ROLE. Defaults to deployer. + * - ENVELOPE_PAYMASTER_WITHDRAWER: WITHDRAWER_ROLE. Defaults to deployer. + * - ENVELOPE_PAYMASTER_OPERATOR_SIGNER: EOA whose EIP-712 grant signatures are accepted. + * Defaults to PEANUT_MFA_AUTHORIZER if set, else deployer. + * + * Optional environment variables (config): + * - ENVELOPE_PAYMASTER_MAX_ETH_PER_TX: Hard ceiling on wei sponsored per single tx. + * Default: 0.001 ETH (1e15 wei). + * - ENVELOPE_PAYMASTER_QUOTA: Wei sponsorable per period. Default: 0.1 ETH. + * - ENVELOPE_PAYMASTER_PERIOD: Period length in seconds. Default: 86400 (1 day). + * - ENVELOPE_PAYMASTER_FUNDING: ETH (wei) to send to paymaster post-deploy. Default: 0. + * + * Usage: + * yarn hardhat deploy-zksync \ + * --script DeployEnvelopePaymaster.ts \ + * --network zkSyncSepoliaTestnet + */ +module.exports = async function (hre: HardhatRuntimeEnvironment) { + const ZERO = ethers.ZeroAddress; + + const rpcUrl = hre.network.config.url!; + const provider = new Provider(rpcUrl); + const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); + const deployer = new Deployer(hre, wallet); + + const envelopeVault = process.env.PEANUT_V4; + if (!envelopeVault || envelopeVault === ZERO) { + throw new Error("PEANUT_V4 env var is required (the deployed Envelope/Peanut vault address)"); + } + + const admin = process.env.ENVELOPE_PAYMASTER_ADMIN ?? wallet.address; + const withdrawer = process.env.ENVELOPE_PAYMASTER_WITHDRAWER ?? wallet.address; + const operatorSigner = + process.env.ENVELOPE_PAYMASTER_OPERATOR_SIGNER ?? + process.env.PEANUT_MFA_AUTHORIZER ?? + wallet.address; + + const maxEthPerTx = ethers.toBigInt( + process.env.ENVELOPE_PAYMASTER_MAX_ETH_PER_TX ?? ethers.parseEther("0.001").toString(), + ); + const quota = ethers.toBigInt( + process.env.ENVELOPE_PAYMASTER_QUOTA ?? ethers.parseEther("0.1").toString(), + ); + const period = BigInt(process.env.ENVELOPE_PAYMASTER_PERIOD ?? "86400"); + + const funding = process.env.ENVELOPE_PAYMASTER_FUNDING + ? ethers.toBigInt(process.env.ENVELOPE_PAYMASTER_FUNDING) + : 0n; + + console.log("=== Deploying EnvelopeApprovalPaymaster on ZkSync ==="); + console.log("Network: ", hre.network.name); + console.log("Deployer: ", wallet.address); + console.log("Envelope Vault: ", envelopeVault); + console.log("Admin: ", admin); + console.log("Withdrawer: ", withdrawer); + console.log("Operator Signer: ", operatorSigner); + console.log("Max ETH per tx: ", ethers.formatEther(maxEthPerTx), "ETH"); + console.log("Quota (wei): ", quota.toString(), `(${ethers.formatEther(quota)} ETH)`); + console.log("Period (seconds): ", period.toString(), `(${Number(period) / 86400} days)`); + console.log("Funding (wei): ", funding.toString(), `(${ethers.formatEther(funding)} ETH)`); + console.log(""); + + const paymaster = await deployContract(deployer, "EnvelopeApprovalPaymaster", [ + admin, + withdrawer, + operatorSigner, + envelopeVault, + maxEthPerTx.toString(), + quota.toString(), + period.toString(), + ]); + const paymasterAddr = await paymaster.getAddress(); + + if (funding > 0n) { + console.log(`Funding paymaster with ${ethers.formatEther(funding)} ETH...`); + const fundTx = await wallet.sendTransaction({ to: paymasterAddr, value: funding }); + await fundTx.wait(); + console.log(` fund tx: ${fundTx.hash}`); + } + + console.log(""); + console.log("=== Deployment Complete ==="); + console.log("EnvelopeApprovalPaymaster:", paymasterAddr); + console.log("Balance:", ethers.formatEther(await provider.getBalance(paymasterAddr)), "ETH"); + console.log(""); + + console.log("=== Verifying Contract ==="); + try { + await hre.run("verify:verify", { + address: paymasterAddr, + contract: "src/paymasters/EnvelopeApprovalPaymaster.sol:EnvelopeApprovalPaymaster", + constructorArguments: [ + admin, + withdrawer, + operatorSigner, + envelopeVault, + maxEthPerTx.toString(), + quota.toString(), + period.toString(), + ], + }); + } catch (e: any) { + console.log("Verification failed or already verified:", e.message); + } + + console.log(""); + console.log("=== Add to .env-test ==="); + console.log(`ENVELOPE_PAYMASTER=${paymasterAddr}`); + + console.log(""); + console.log("=== Next steps ==="); + if (funding === 0n) { + console.log(`- Fund the paymaster: wallet.sendTransaction({ to: ${paymasterAddr}, value: ... })`); + } + console.log( + `- Operator backend: sign EIP-712 EnvelopeApprovalGrant(user, deadline, nonce) with the operatorSigner key (${operatorSigner})`, + ); + console.log( + ` Domain: { name: 'EnvelopeApprovalPaymaster', version: '1', chainId, verifyingContract: ${paymasterAddr} }`, + ); +}; diff --git a/hardhat-deploy/DeployPeanutPaymaster.ts b/hardhat-deploy/DeployPeanutPaymaster.ts deleted file mode 100644 index 99b12207..00000000 --- a/hardhat-deploy/DeployPeanutPaymaster.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Provider, Wallet, utils } from "zksync-ethers"; -import { Deployer } from "@matterlabs/hardhat-zksync"; -import { ethers } from "ethers"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; -import "@matterlabs/hardhat-zksync-node/dist/type-extensions"; -import "@matterlabs/hardhat-zksync-verify/dist/src/type-extensions"; -import * as dotenv from "dotenv"; -import { deployContract } from "./utils"; - -dotenv.config({ path: ".env-test" }); - -/** - * Deploys PeanutApprovalPaymaster on ZkSync Era. - * - * Path C support: lets users submit gasless `approve(peanutVault, ...)` and - * `setApprovalForAll(peanutVault, ...)` txs against allowlisted tokens, gated by - * an EIP-712 grant signed off-chain by the operator. - * - * Required environment variables: - * - DEPLOYER_PRIVATE_KEY: Private key for deployment (also default admin / withdrawer). - * - PEANUT_V4: Address of the deployed PeanutV4 vault — used as the only - * allowed spender/operator for sponsored approvals. - * - * Optional environment variables (admin / signer): - * - PEANUT_PAYMASTER_ADMIN: DEFAULT_ADMIN_ROLE + ALLOWLIST_ADMIN_ROLE. - * Defaults to deployer. - * - PEANUT_PAYMASTER_WITHDRAWER: WITHDRAWER_ROLE. Defaults to deployer. - * - PEANUT_PAYMASTER_OPERATOR_SIGNER: EOA whose EIP-712 grant signatures are accepted. - * Defaults to PEANUT_MFA_AUTHORIZER if set, else deployer. - * - * Optional environment variables (config): - * - PEANUT_PAYMASTER_QUOTA: Wei sponsorable per period. Default: 0.1 ETH. - * - PEANUT_PAYMASTER_PERIOD: Period length in seconds. Default: 86400 (1 day). Max: 2592000 (30 days). - * - PEANUT_PAYMASTER_FUNDING: Amount of ETH to send to the paymaster post-deploy. - * Default: 0 (must fund manually before use). - * - PEANUT_PAYMASTER_TOKENS: Comma-separated token addresses to allowlist after deploy. - * Default: none (must seed via addAllowedTokens). - * - * Usage: - * yarn hardhat deploy-zksync \ - * --script DeployPeanutPaymaster.ts \ - * --network zkSyncSepoliaTestnet - */ -module.exports = async function (hre: HardhatRuntimeEnvironment) { - const ZERO = ethers.ZeroAddress; - - const rpcUrl = hre.network.config.url!; - const provider = new Provider(rpcUrl); - const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); - const deployer = new Deployer(hre, wallet); - - const peanutVault = process.env.PEANUT_V4; - if (!peanutVault || peanutVault === ZERO) { - throw new Error("PEANUT_V4 env var is required (the deployed PeanutV4 vault address)"); - } - - const admin = process.env.PEANUT_PAYMASTER_ADMIN ?? wallet.address; - const withdrawer = process.env.PEANUT_PAYMASTER_WITHDRAWER ?? wallet.address; - const operatorSigner = - process.env.PEANUT_PAYMASTER_OPERATOR_SIGNER ?? - process.env.PEANUT_MFA_AUTHORIZER ?? - wallet.address; - - const quota = ethers.toBigInt( - process.env.PEANUT_PAYMASTER_QUOTA ?? ethers.parseEther("0.1").toString(), - ); - const period = BigInt(process.env.PEANUT_PAYMASTER_PERIOD ?? "86400"); // 1 day - - const funding = process.env.PEANUT_PAYMASTER_FUNDING - ? ethers.toBigInt(process.env.PEANUT_PAYMASTER_FUNDING) - : 0n; - - const tokensToAllowlist = (process.env.PEANUT_PAYMASTER_TOKENS ?? "") - .split(",") - .map((t) => t.trim()) - .filter((t) => t.length > 0 && t !== ZERO); - - console.log("=== Deploying PeanutApprovalPaymaster on ZkSync ==="); - console.log("Network: ", hre.network.name); - console.log("Deployer: ", wallet.address); - console.log("Peanut Vault: ", peanutVault); - console.log("Admin: ", admin); - console.log("Withdrawer: ", withdrawer); - console.log("Operator Signer: ", operatorSigner); - console.log("Quota (wei): ", quota.toString(), `(${ethers.formatEther(quota)} ETH)`); - console.log("Period (seconds): ", period.toString(), `(${Number(period) / 86400} days)`); - console.log("Funding (wei): ", funding.toString(), `(${ethers.formatEther(funding)} ETH)`); - console.log("Tokens to allowlist:", tokensToAllowlist.length > 0 ? tokensToAllowlist : "(none — seed later)"); - console.log(""); - - // 1. Deploy the paymaster. - const paymaster = await deployContract(deployer, "PeanutApprovalPaymaster", [ - admin, - withdrawer, - operatorSigner, - peanutVault, - quota.toString(), - period.toString(), - ]); - const paymasterAddr = await paymaster.getAddress(); - - // 2. Fund the paymaster with ETH (so it can pay gas immediately). - if (funding > 0n) { - console.log(`Funding paymaster with ${ethers.formatEther(funding)} ETH...`); - const fundTx = await wallet.sendTransaction({ to: paymasterAddr, value: funding }); - await fundTx.wait(); - console.log(` fund tx: ${fundTx.hash}`); - } - - // 3. Seed token allowlist (deployer must hold ALLOWLIST_ADMIN_ROLE). - if (tokensToAllowlist.length > 0) { - if (admin.toLowerCase() !== wallet.address.toLowerCase()) { - console.log( - `Skipping token seeding: admin (${admin}) is not the deployer; have the admin call addAllowedTokens directly.`, - ); - } else { - console.log("Allowlisting tokens..."); - const tx = await paymaster.addAllowedTokens(tokensToAllowlist); - await tx.wait(); - console.log(` tx: ${tx.hash}`); - } - } - - console.log(""); - console.log("=== Deployment Complete ==="); - console.log("PeanutApprovalPaymaster:", paymasterAddr); - console.log("Balance:", ethers.formatEther(await provider.getBalance(paymasterAddr)), "ETH"); - console.log(""); - - // 4. Verification. - console.log("=== Verifying Contract ==="); - try { - await hre.run("verify:verify", { - address: paymasterAddr, - contract: "src/paymasters/PeanutApprovalPaymaster.sol:PeanutApprovalPaymaster", - constructorArguments: [ - admin, - withdrawer, - operatorSigner, - peanutVault, - quota.toString(), - period.toString(), - ], - }); - } catch (e: any) { - console.log("Verification failed or already verified:", e.message); - } - - console.log(""); - console.log("=== Add to .env-test ==="); - console.log(`PEANUT_PAYMASTER=${paymasterAddr}`); - - console.log(""); - console.log("=== Next steps ==="); - if (funding === 0n) { - console.log( - `- Fund the paymaster: wallet.sendTransaction({ to: ${paymasterAddr}, value: ... })`, - ); - } - if (tokensToAllowlist.length === 0) { - console.log( - `- Seed token allowlist via PeanutApprovalPaymaster(${paymasterAddr}).addAllowedTokens([...])`, - ); - } - console.log( - `- Operator backend: sign EIP-712 PeanutApprovalGrant(user, deadline, nonce) with the operatorSigner key (${operatorSigner})`, - ); - console.log( - " Domain: { name: 'PeanutApprovalPaymaster', version: '1', chainId, verifyingContract: " + - paymasterAddr + - " }", - ); -}; diff --git a/src/paymasters/PeanutApprovalPaymaster.sol b/src/paymasters/EnvelopeApprovalPaymaster.sol similarity index 74% rename from src/paymasters/PeanutApprovalPaymaster.sol rename to src/paymasters/EnvelopeApprovalPaymaster.sol index 8da328de..802a88d0 100644 --- a/src/paymasters/PeanutApprovalPaymaster.sol +++ b/src/paymasters/EnvelopeApprovalPaymaster.sol @@ -11,15 +11,20 @@ import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "./BasePaymaster.sol"; import {QuotaControl} from "../QuotaControl.sol"; -/// @title Peanut Approval Paymaster -/// @notice Sponsors gas for a *narrow* set of operations: ERC-20 / ERC-721 `approve(peanut, ...)` -/// and ERC-721 / ERC-1155 `setApprovalForAll(peanut, ...)` — the txs needed to grant -/// PeanutV4 access to a user's tokens before the operator submits `makeCustomDeposit`. -/// @dev Validation enforced per call: -/// - tx.to is on the per-token allowlist +/// @title Envelope Approval Paymaster +/// @notice Sponsors gas for a *narrow* set of operations: ERC-20 / ERC-721 `approve(envelope, ...)` +/// and ERC-721 / ERC-1155 `setApprovalForAll(envelope, ...)` — the txs needed to grant +/// the Envelope vault access to a user's tokens before the operator submits +/// `makeCustomDeposit`. +/// @dev Authorization is fully operator-driven: each sponsored tx must carry a fresh +/// EIP-712 grant signed by `operatorSigner`. No per-token allowlist — the strict +/// operator-grant gate + per-tx ETH cap + global daily quota together bound the +/// worst-case drain even under operator-key compromise. +/// Validation gates: +/// - tx.from holds an unexpired single-use EIP-712 grant signed by operatorSigner /// - inner selector is approve(address,uint256) or setApprovalForAll(address,bool) -/// - the spender/operator argument == peanutVault -/// - the user holds an unexpired EIP-712 grant signed by `operatorSigner` +/// - the spender/operator argument == envelopeVault +/// - requiredETH (= gasLimit * maxFeePerGas) ≤ maxEthPerTx /// - daily wei quota hasn't been exhausted (QuotaControl) /// Overrides `validateAndPayForPaymasterTransaction` directly (instead of the /// `_validateAndPayGeneralFlow` hook) because validation requires the full @@ -27,26 +32,24 @@ import {QuotaControl} from "../QuotaControl.sol"; /// `transaction.paymasterInput`. /// Storage writes in validation (nonce, quota counters) are permitted by EraVM's /// paymaster-validation rules. -contract PeanutApprovalPaymaster is BasePaymaster, QuotaControl { - bytes32 public constant ALLOWLIST_ADMIN_ROLE = keccak256("ALLOWLIST_ADMIN_ROLE"); - +contract EnvelopeApprovalPaymaster is BasePaymaster, QuotaControl { bytes4 internal constant APPROVE_SEL = 0x095ea7b3; // approve(address,uint256) — ERC-20 + ERC-721 bytes4 internal constant SET_APPROVAL_FOR_ALL_SEL = 0xa22cb465; // setApprovalForAll(address,bool) — ERC-721 + ERC-1155 bytes32 public constant EIP712_DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); bytes32 public constant GRANT_TYPEHASH = - keccak256("PeanutApprovalGrant(address user,uint256 deadline,bytes32 nonce)"); + keccak256("EnvelopeApprovalGrant(address user,uint256 deadline,bytes32 nonce)"); bytes32 public immutable DOMAIN_SEPARATOR; - address public immutable peanutVault; + address public immutable envelopeVault; + /// @notice Maximum wei the paymaster will sponsor for a single tx (defense-in-depth + /// against operator-key compromise; per-tx cost is bounded regardless of token). + uint256 public immutable maxEthPerTx; address public operatorSigner; - mapping(address => bool) public isAllowedToken; mapping(bytes32 => bool) public isNonceUsed; - event TokensAllowed(address[] tokens); - event TokensRevoked(address[] tokens); event OperatorSignerUpdated(address indexed previousSigner, address indexed newSigner); event ApprovalSponsored(address indexed user, address indexed token, bytes32 indexed nonce, uint256 gasPaid); @@ -54,37 +57,39 @@ contract PeanutApprovalPaymaster is BasePaymaster, QuotaControl { error GrantExpired(); error NonceAlreadyUsed(); error InvalidGrantSignature(); - error TokenNotAllowed(); error UnsupportedSelector(); - error SpenderNotPeanut(); + error SpenderNotEnvelope(); + error PerTxLimitExceeded(); error InsufficientPaymasterBalance(); error ZeroAddress(); error Unused(); - /// @param admin DEFAULT_ADMIN_ROLE + ALLOWLIST_ADMIN_ROLE + /// @param admin DEFAULT_ADMIN_ROLE /// @param withdrawer WITHDRAWER_ROLE /// @param operatorSigner_ EOA or contract whose ECDSA signatures the paymaster will accept as grants - /// @param peanut_ PeanutV4 vault address (the only allowed spender/operator for sponsored approvals) + /// @param envelope_ Envelope vault address (the only allowed spender/operator for sponsored approvals) + /// @param maxEthPerTx_ Hard ceiling on wei sponsored per single tx /// @param initialQuota Total wei sponsorable per period /// @param initialPeriod Period length in seconds (max 30 days, see QuotaControl) constructor( address admin, address withdrawer, address operatorSigner_, - address peanut_, + address envelope_, + uint256 maxEthPerTx_, uint256 initialQuota, uint256 initialPeriod ) BasePaymaster(admin, withdrawer) QuotaControl(initialQuota, initialPeriod, admin) { - if (admin == address(0) || peanut_ == address(0) || operatorSigner_ == address(0)) revert ZeroAddress(); - _grantRole(ALLOWLIST_ADMIN_ROLE, admin); + if (admin == address(0) || envelope_ == address(0) || operatorSigner_ == address(0)) revert ZeroAddress(); - peanutVault = peanut_; + envelopeVault = envelope_; operatorSigner = operatorSigner_; + maxEthPerTx = maxEthPerTx_; DOMAIN_SEPARATOR = keccak256( abi.encode( EIP712_DOMAIN_TYPEHASH, - keccak256(bytes("PeanutApprovalPaymaster")), + keccak256(bytes("EnvelopeApprovalPaymaster")), keccak256(bytes("1")), block.chainid, address(this) @@ -104,14 +109,13 @@ contract PeanutApprovalPaymaster is BasePaymaster, QuotaControl { address user = address(uint160(transaction.from)); bytes32 nonce = _verifyAndConsumeGrant(user, transaction.paymasterInput); - address token = address(uint160(transaction.to)); - if (!isAllowedToken[token]) revert TokenNotAllowed(); - _requireApprovalCallToPeanut(transaction.data); + _requireApprovalCallToEnvelope(transaction.data); uint256 requiredETH = transaction.gasLimit * transaction.maxFeePerGas; + if (requiredETH > maxEthPerTx) revert PerTxLimitExceeded(); _payBootloader(requiredETH); - emit ApprovalSponsored(user, token, nonce, requiredETH); + emit ApprovalSponsored(user, address(uint160(transaction.to)), nonce, requiredETH); magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC; } @@ -144,8 +148,8 @@ contract PeanutApprovalPaymaster is BasePaymaster, QuotaControl { isNonceUsed[nonce] = true; } - /// @dev Reverts unless the user's call is approve(peanut,...) or setApprovalForAll(peanut,...). - function _requireApprovalCallToPeanut(bytes calldata data) internal view { + /// @dev Reverts unless the user's call is approve(envelope,...) or setApprovalForAll(envelope,...). + function _requireApprovalCallToEnvelope(bytes calldata data) internal view { if (data.length < 36) revert UnsupportedSelector(); bytes4 sel = bytes4(data[0:4]); if (sel != APPROVE_SEL && sel != SET_APPROVAL_FOR_ALL_SEL) revert UnsupportedSelector(); @@ -154,7 +158,7 @@ contract PeanutApprovalPaymaster is BasePaymaster, QuotaControl { assembly { spender := calldataload(add(data.offset, 0x04)) } - if (spender != peanutVault) revert SpenderNotPeanut(); + if (spender != envelopeVault) revert SpenderNotEnvelope(); } /// @dev Checks balance, bumps quota counters, sends ETH to the bootloader. @@ -183,20 +187,6 @@ contract PeanutApprovalPaymaster is BasePaymaster, QuotaControl { // ── Admin ────────────────────────────────────────────────────────────── - function addAllowedTokens(address[] calldata tokens) external onlyRole(ALLOWLIST_ADMIN_ROLE) { - for (uint256 i = 0; i < tokens.length; ++i) { - isAllowedToken[tokens[i]] = true; - } - emit TokensAllowed(tokens); - } - - function removeAllowedTokens(address[] calldata tokens) external onlyRole(ALLOWLIST_ADMIN_ROLE) { - for (uint256 i = 0; i < tokens.length; ++i) { - isAllowedToken[tokens[i]] = false; - } - emit TokensRevoked(tokens); - } - function setOperatorSigner(address newSigner) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newSigner == address(0)) revert ZeroAddress(); emit OperatorSignerUpdated(operatorSigner, newSigner); diff --git a/test/paymasters/PeanutApprovalPaymaster.t.sol b/test/paymasters/EnvelopeApprovalPaymaster.t.sol similarity index 66% rename from test/paymasters/PeanutApprovalPaymaster.t.sol rename to test/paymasters/EnvelopeApprovalPaymaster.t.sol index 28965644..aab1f1b6 100644 --- a/test/paymasters/PeanutApprovalPaymaster.t.sol +++ b/test/paymasters/EnvelopeApprovalPaymaster.t.sol @@ -4,26 +4,24 @@ pragma solidity 0.8.26; import {Test} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {AccessControlUtils} from "../__helpers__/AccessControlUtils.sol"; -import {PeanutApprovalPaymaster} from "../../src/paymasters/PeanutApprovalPaymaster.sol"; +import {EnvelopeApprovalPaymaster} from "../../src/paymasters/EnvelopeApprovalPaymaster.sol"; import {BasePaymaster} from "../../src/paymasters/BasePaymaster.sol"; import {QuotaControl} from "../../src/QuotaControl.sol"; import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; -import {PAYMASTER_VALIDATION_SUCCESS_MAGIC} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymaster.sol"; /// @dev Bootloader address — paymaster validation must be called from this address. address constant BOOTLOADER = address(uint160(0x8001)); -contract PeanutApprovalPaymasterTest is Test { +contract EnvelopeApprovalPaymasterTest is Test { using AccessControlUtils for Vm; - PeanutApprovalPaymaster paymaster; + EnvelopeApprovalPaymaster paymaster; address admin = address(0xA1); address withdrawer = address(0xA2); - address peanut = address(0xBEEF); - address allowedToken = address(0xCAFE); - address blockedToken = address(0xDEAD); + address envelope = address(0xBEEF); + address sponsoredToken = address(0xCAFE); uint256 operatorPk = uint256(keccak256("operator-signer")); address operator; @@ -31,6 +29,7 @@ contract PeanutApprovalPaymasterTest is Test { uint256 userPk = uint256(keccak256("test-user")); address user; + uint256 constant MAX_ETH_PER_TX = 0.005 ether; uint256 constant QUOTA = 1 ether; uint256 constant PERIOD = 1 days; @@ -38,14 +37,10 @@ contract PeanutApprovalPaymasterTest is Test { operator = vm.addr(operatorPk); user = vm.addr(userPk); - paymaster = new PeanutApprovalPaymaster(admin, withdrawer, operator, peanut, QUOTA, PERIOD); + paymaster = new EnvelopeApprovalPaymaster( + admin, withdrawer, operator, envelope, MAX_ETH_PER_TX, QUOTA, PERIOD + ); vm.deal(address(paymaster), 10 ether); - - // Allowlist the test token. - address[] memory tokens = new address[](1); - tokens[0] = allowedToken; - vm.prank(admin); - paymaster.addAllowedTokens(tokens); } // ── helpers ──────────────────────────────────────────────────────────── @@ -116,7 +111,7 @@ contract PeanutApprovalPaymasterTest is Test { uint256 deadline = block.timestamp + 1 hours; bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); - bytes memory data = _approveCall(peanut, 1000); + bytes memory data = _approveCall(envelope, 1000); uint256 gasLimit = 100_000; uint256 gasPrice = 1 gwei; @@ -124,7 +119,7 @@ contract PeanutApprovalPaymasterTest is Test { uint256 balBefore = address(paymaster).balance; uint256 bootBefore = BOOTLOADER.balance; - _validate(_txTo(allowedToken, data, pmInput, gasLimit, gasPrice)); + _validate(_txTo(sponsoredToken, data, pmInput, gasLimit, gasPrice)); assertEq(address(paymaster).balance, balBefore - expectedPay, "paymaster paid wrong amount"); assertEq(BOOTLOADER.balance, bootBefore + expectedPay, "bootloader didn't receive"); @@ -137,9 +132,23 @@ contract PeanutApprovalPaymasterTest is Test { uint256 deadline = block.timestamp + 1 hours; bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); - bytes memory data = _setApprovalForAllCall(peanut, true); + bytes memory data = _setApprovalForAllCall(envelope, true); - _validate(_txTo(allowedToken, data, pmInput, 100_000, 1 gwei)); + _validate(_txTo(sponsoredToken, data, pmInput, 100_000, 1 gwei)); + assertTrue(paymaster.isNonceUsed(nonce)); + } + + function test_sponsorsApproveOnAnyToken() public { + // No token allowlist — operator's grant is the only auth. + // Prove an arbitrary token address still gets sponsored. + address randomToken = address(0xC0FFEE); + bytes32 nonce = keccak256("nonce-random-token"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + bytes memory data = _approveCall(envelope, 1); + + _validate(_txTo(randomToken, data, pmInput, 100_000, 1 gwei)); assertTrue(paymaster.isNonceUsed(nonce)); } @@ -150,7 +159,7 @@ contract PeanutApprovalPaymasterTest is Test { uint256 deadline = block.timestamp + 1 hours; bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); - Transaction memory tx_ = _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei); + Transaction memory tx_ = _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei); vm.expectRevert(BasePaymaster.AccessRestrictedToBootloader.selector); paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), tx_); @@ -160,10 +169,10 @@ contract PeanutApprovalPaymasterTest is Test { bytes memory wrongFlowInput = abi.encodeWithSelector( IPaymasterFlow.approvalBased.selector, address(0), uint256(0), bytes("") ); - Transaction memory tx_ = _txTo(allowedToken, _approveCall(peanut, 1), wrongFlowInput, 100_000, 1 gwei); + Transaction memory tx_ = _txTo(sponsoredToken, _approveCall(envelope, 1), wrongFlowInput, 100_000, 1 gwei); vm.prank(BOOTLOADER); - vm.expectRevert(PeanutApprovalPaymaster.WrongFlow.selector); + vm.expectRevert(EnvelopeApprovalPaymaster.WrongFlow.selector); paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), tx_); } @@ -174,10 +183,10 @@ contract PeanutApprovalPaymasterTest is Test { bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); vm.warp(deadline + 1); - Transaction memory tx_ = _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei); + Transaction memory tx_ = _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei); vm.prank(BOOTLOADER); - vm.expectRevert(PeanutApprovalPaymaster.GrantExpired.selector); + vm.expectRevert(EnvelopeApprovalPaymaster.GrantExpired.selector); paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), tx_); } @@ -187,13 +196,13 @@ contract PeanutApprovalPaymasterTest is Test { bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); - _validate(_txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei)); + _validate(_txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei)); vm.prank(BOOTLOADER); - vm.expectRevert(PeanutApprovalPaymaster.NonceAlreadyUsed.selector); + vm.expectRevert(EnvelopeApprovalPaymaster.NonceAlreadyUsed.selector); paymaster.validateAndPayForPaymasterTransaction( bytes32(0), bytes32(0), - _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei) ); } @@ -205,17 +214,14 @@ contract PeanutApprovalPaymasterTest is Test { bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); vm.prank(BOOTLOADER); - vm.expectRevert(PeanutApprovalPaymaster.InvalidGrantSignature.selector); + vm.expectRevert(EnvelopeApprovalPaymaster.InvalidGrantSignature.selector); paymaster.validateAndPayForPaymasterTransaction( bytes32(0), bytes32(0), - _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei) ); } function test_revertsOnSignatureForDifferentUser() public { - // Operator signs grant for charlie; but tx.from = user. Recovered signer - // matches operator, BUT the structHash uses tx.from's user address, not the - // address baked into the sig. So the sig recovers to wrong signer and reverts. address charlie = address(0xC); bytes32 nonce = keccak256("nonce-other-user"); uint256 deadline = block.timestamp + 1 hours; @@ -224,79 +230,121 @@ contract PeanutApprovalPaymasterTest is Test { // tx.from = user (different from charlie) vm.prank(BOOTLOADER); - vm.expectRevert(PeanutApprovalPaymaster.InvalidGrantSignature.selector); + vm.expectRevert(EnvelopeApprovalPaymaster.InvalidGrantSignature.selector); paymaster.validateAndPayForPaymasterTransaction( bytes32(0), bytes32(0), - _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei) ); } - function test_revertsOnDisallowedToken() public { - bytes32 nonce = keccak256("nonce-token"); + function test_revertsOnUnsupportedSelector() public { + bytes32 nonce = keccak256("nonce-sel"); uint256 deadline = block.timestamp + 1 hours; bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + // transfer(address,uint256) instead of approve + bytes memory data = abi.encodeWithSelector(0xa9059cbb, envelope, uint256(1)); vm.prank(BOOTLOADER); - vm.expectRevert(PeanutApprovalPaymaster.TokenNotAllowed.selector); + vm.expectRevert(EnvelopeApprovalPaymaster.UnsupportedSelector.selector); paymaster.validateAndPayForPaymasterTransaction( bytes32(0), bytes32(0), - _txTo(blockedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + _txTo(sponsoredToken, data, pmInput, 100_000, 1 gwei) ); } - function test_revertsOnUnsupportedSelector() public { - bytes32 nonce = keccak256("nonce-sel"); + function test_revertsOnSpenderNotEnvelope() public { + bytes32 nonce = keccak256("nonce-spender"); uint256 deadline = block.timestamp + 1 hours; bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); - // transfer(address,uint256) instead of approve - bytes memory data = abi.encodeWithSelector(0xa9059cbb, peanut, uint256(1)); + // Approve attacker instead of envelope + bytes memory data = _approveCall(address(0xBAD), 1000); vm.prank(BOOTLOADER); - vm.expectRevert(PeanutApprovalPaymaster.UnsupportedSelector.selector); + vm.expectRevert(EnvelopeApprovalPaymaster.SpenderNotEnvelope.selector); paymaster.validateAndPayForPaymasterTransaction( bytes32(0), bytes32(0), - _txTo(allowedToken, data, pmInput, 100_000, 1 gwei) + _txTo(sponsoredToken, data, pmInput, 100_000, 1 gwei) ); } - function test_revertsOnSpenderNotPeanut() public { - bytes32 nonce = keccak256("nonce-spender"); + function test_revertsOnPerTxLimitExceeded() public { + bytes32 nonce = keccak256("nonce-pertx"); uint256 deadline = block.timestamp + 1 hours; bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); - // Approve attacker instead of peanut - bytes memory data = _approveCall(address(0xBAD), 1000); + + // gasLimit * gasPrice > MAX_ETH_PER_TX (0.005 ether) + // Use gasPrice = 1 gwei, gasLimit large enough to exceed 5_000_000 gwei + uint256 gasPrice = 1 gwei; + uint256 gasLimit = (MAX_ETH_PER_TX / gasPrice) + 1; vm.prank(BOOTLOADER); - vm.expectRevert(PeanutApprovalPaymaster.SpenderNotPeanut.selector); + vm.expectRevert(EnvelopeApprovalPaymaster.PerTxLimitExceeded.selector); paymaster.validateAndPayForPaymasterTransaction( bytes32(0), bytes32(0), - _txTo(allowedToken, data, pmInput, 100_000, 1 gwei) + _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, gasLimit, gasPrice) ); } function test_revertsOnExceededQuota() public { - bytes32 nonce = keccak256("nonce-quota"); + // Use a dedicated paymaster with a tight quota = 2 * per-tx-cap so two max-cost + // sponsored txs fill it exactly; the third hits QuotaExceeded. + EnvelopeApprovalPaymaster tight = new EnvelopeApprovalPaymaster( + admin, withdrawer, operator, envelope, + MAX_ETH_PER_TX, MAX_ETH_PER_TX * 2, PERIOD + ); + vm.deal(address(tight), 10 ether); + + uint256 gasPrice = 1 gwei; + uint256 gasLimit = MAX_ETH_PER_TX / gasPrice; // exactly per-tx cap + + // tx 1 — fills half the quota + bytes32 n1 = keccak256("nq1"); uint256 deadline = block.timestamp + 1 hours; - bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); - bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + bytes32 typehash = tight.GRANT_TYPEHASH(); + bytes32 domain = tight.DOMAIN_SEPARATOR(); + bytes memory sig1 = _signTightGrant(typehash, domain, deadline, n1, user, operatorPk); + vm.prank(BOOTLOADER); + tight.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(sponsoredToken, _approveCall(envelope, 1), + _buildPaymasterInput(deadline, n1, sig1), gasLimit, gasPrice) + ); - // gasLimit * gasPrice > QUOTA - uint256 gasLimit = 2_000_000; - uint256 gasPrice = 1 gwei; // 0.002 ether > 0.001? wait QUOTA is 1 ether — bump - // Make it definitely exceed: gasLimit huge. - gasLimit = uint256(QUOTA / gasPrice) + 1_000_000; + // tx 2 — fills the other half + bytes32 n2 = keccak256("nq2"); + bytes memory sig2 = _signTightGrant(typehash, domain, deadline, n2, user, operatorPk); + vm.prank(BOOTLOADER); + tight.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), + _txTo(sponsoredToken, _approveCall(envelope, 1), + _buildPaymasterInput(deadline, n2, sig2), gasLimit, gasPrice) + ); + // tx 3 — over quota + bytes32 n3 = keccak256("nq3"); + bytes memory sig3 = _signTightGrant(typehash, domain, deadline, n3, user, operatorPk); vm.prank(BOOTLOADER); vm.expectRevert(QuotaControl.QuotaExceeded.selector); - paymaster.validateAndPayForPaymasterTransaction( + tight.validateAndPayForPaymasterTransaction( bytes32(0), bytes32(0), - _txTo(allowedToken, _approveCall(peanut, 1), pmInput, gasLimit, gasPrice) + _txTo(sponsoredToken, _approveCall(envelope, 1), + _buildPaymasterInput(deadline, n3, sig3), gasLimit, gasPrice) ); } + /// @dev Sign a grant against an arbitrary typehash+domain (for testing alt-paymaster instances). + function _signTightGrant( + bytes32 typehash, bytes32 domain, uint256 deadline, bytes32 nonce, address grantedUser, uint256 signerPk + ) internal view returns (bytes memory) { + bytes32 structHash = keccak256(abi.encode(typehash, grantedUser, deadline, nonce)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domain, structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + return abi.encodePacked(r, s, v); + } + function test_revertsOnInsufficientBalance() public { // Drain the paymaster balance vm.prank(withdrawer); @@ -308,10 +356,10 @@ contract PeanutApprovalPaymasterTest is Test { bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); vm.prank(BOOTLOADER); - vm.expectRevert(PeanutApprovalPaymaster.InsufficientPaymasterBalance.selector); + vm.expectRevert(EnvelopeApprovalPaymaster.InsufficientPaymasterBalance.selector); paymaster.validateAndPayForPaymasterTransaction( bytes32(0), bytes32(0), - _txTo(allowedToken, _approveCall(peanut, 1), pmInput, 100_000, 1 gwei) + _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei) ); } @@ -323,7 +371,7 @@ contract PeanutApprovalPaymasterTest is Test { uint256 deadline = block.timestamp + 7 days; bytes memory sig1 = _signGrant(deadline, nonce1, user, operatorPk); bytes memory pmInput1 = _buildPaymasterInput(deadline, nonce1, sig1); - _validate(_txTo(allowedToken, _approveCall(peanut, 1), pmInput1, 100_000, 1 gwei)); + _validate(_txTo(sponsoredToken, _approveCall(envelope, 1), pmInput1, 100_000, 1 gwei)); uint256 claimed1 = paymaster.claimed(); assertGt(claimed1, 0); @@ -333,7 +381,7 @@ contract PeanutApprovalPaymasterTest is Test { bytes32 nonce2 = keccak256("nonce-r2"); bytes memory sig2 = _signGrant(deadline, nonce2, user, operatorPk); bytes memory pmInput2 = _buildPaymasterInput(deadline, nonce2, sig2); - _validate(_txTo(allowedToken, _approveCall(peanut, 1), pmInput2, 100_000, 1 gwei)); + _validate(_txTo(sponsoredToken, _approveCall(envelope, 1), pmInput2, 100_000, 1 gwei)); // Claimed should reset to just this tx's cost (not cumulative) assertEq(paymaster.claimed(), 100_000 * 1 gwei); @@ -341,29 +389,6 @@ contract PeanutApprovalPaymasterTest is Test { // ── Admin ────────────────────────────────────────────────────────────── - function test_adminCanAddAndRemoveTokens() public { - address[] memory tokens = new address[](2); - tokens[0] = address(0x111); - tokens[1] = address(0x222); - - vm.prank(admin); - paymaster.addAllowedTokens(tokens); - assertTrue(paymaster.isAllowedToken(tokens[0])); - assertTrue(paymaster.isAllowedToken(tokens[1])); - - vm.prank(admin); - paymaster.removeAllowedTokens(tokens); - assertFalse(paymaster.isAllowedToken(tokens[0])); - } - - function test_nonAdminCannotAddTokens() public { - address[] memory tokens = new address[](1); - tokens[0] = address(0x111); - - vm.expectRevert(); - paymaster.addAllowedTokens(tokens); - } - function test_adminCanRotateOperatorSigner() public { address newSigner = address(0x99); vm.prank(admin); @@ -371,6 +396,11 @@ contract PeanutApprovalPaymasterTest is Test { assertEq(paymaster.operatorSigner(), newSigner); } + function test_nonAdminCannotRotateOperatorSigner() public { + vm.expectRevert(); + paymaster.setOperatorSigner(address(0x99)); + } + function test_withdrawerCanDrainBalance() public { uint256 amount = 1 ether; address recipient = address(0x77); From 15599a0be5dabc5678d7aac77cae6e35232f8db3 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 12:21:22 -0400 Subject: [PATCH 11/31] docs(peanut): spec sheet per contract under src/peanut/doc/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors src/swarms/doc/ convention. One markdown file per deployable contract: README.md — overview, deployed addresses, file map PeanutV4.md — vault: deposit + withdraw paths, signature scheme, dual-zero invariant, vendoring patches, threat model PeanutBatcherV4.md — batcher: stateless design, per-asset pull pattern, ERC-721-not-implemented rationale PeanutRouter.md — router: EIP-191 v0x00 routing sig, fee paths, Ownable2Step note EnvelopeApprovalPaymaster.md — paymaster: 5-gate validation, EIP-712 grant schema, backend signing skeleton, deliberate drops vs. earlier iterations 735 lines total. Lives in src/peanut/doc/ even though the paymaster source is at src/paymasters/ — the Envelope product spans both directories. --- src/peanut/doc/EnvelopeApprovalPaymaster.md | 266 ++++++++++++++++++++ src/peanut/doc/PeanutBatcherV4.md | 92 +++++++ src/peanut/doc/PeanutRouter.md | 138 ++++++++++ src/peanut/doc/PeanutV4.md | 174 +++++++++++++ src/peanut/doc/README.md | 65 +++++ 5 files changed, 735 insertions(+) create mode 100644 src/peanut/doc/EnvelopeApprovalPaymaster.md create mode 100644 src/peanut/doc/PeanutBatcherV4.md create mode 100644 src/peanut/doc/PeanutRouter.md create mode 100644 src/peanut/doc/PeanutV4.md create mode 100644 src/peanut/doc/README.md diff --git a/src/peanut/doc/EnvelopeApprovalPaymaster.md b/src/peanut/doc/EnvelopeApprovalPaymaster.md new file mode 100644 index 00000000..b005aeb1 --- /dev/null +++ b/src/peanut/doc/EnvelopeApprovalPaymaster.md @@ -0,0 +1,266 @@ +# EnvelopeApprovalPaymaster — Path-C gas sponsor + +`src/paymasters/EnvelopeApprovalPaymaster.sol` + +## Purpose + +Sponsors gas for the user-side **approval txs** needed before a Peanut deposit can be made on a token that doesn't support EIP-2612 / EIP-3009. Specifically: + +| Standard | Sponsored call | +|---|---| +| ERC-20 (no permit) | `token.approve(envelope, amount)` | +| ERC-721 | `token.approve(envelope, tokenId)` | +| ERC-1155 | `token.setApprovalForAll(envelope, true)` | + +The user pays 0 ETH. The operator's backend gates **every** sponsored tx by issuing an EIP-712 grant signed off-chain. The paymaster verifies the grant on-chain before paying the bootloader. + +## Deployment scope + +- **Authorization model** — signed grants from the operator. No on-chain user whitelist; the backend gates per request. +- **No token allowlist** — the operator's grant is the only auth surface. Defense-in-depth comes from a hard per-tx ETH cap and a global daily quota. +- **Operator-driven UX** — the user never sees the EIP-712 grant; only the operator's backend does. + +Deployed on ZkSync Sepolia at [`0xEE95bFF2240652e0f57aE3fcd57F87d85593c191`](https://sepolia.explorer.zksync.io/address/0xEE95bFF2240652e0f57aE3fcd57F87d85593c191#contract). + +## Inheritance + +``` +EnvelopeApprovalPaymaster is BasePaymaster, QuotaControl +``` + +- `BasePaymaster` (`src/paymasters/BasePaymaster.sol`) — IPaymaster + bootloader gate + `WITHDRAWER_ROLE` + ETH `withdraw` / `receive` / `postTransaction` stub. Its `validateAndPayForPaymasterTransaction` is marked `virtual` and overridden here, because the paymaster needs full `Transaction` calldata (the base hook signature `(from, to, requiredETH)` hides `transaction.data` and `transaction.paymasterInput`). +- `QuotaControl` (`src/QuotaControl.sol`) — global wei-per-period cap, period auto-rolls. + +## Constructor + +```solidity +constructor( + address admin, + address withdrawer, + address operatorSigner_, + address envelope_, + uint256 maxEthPerTx_, + uint256 initialQuota, + uint256 initialPeriod +) +``` + +| Param | Role / purpose | +|---|---| +| `admin` | `DEFAULT_ADMIN_ROLE` — can `setOperatorSigner` and `setQuota` / `setPeriod` | +| `withdrawer` | `WITHDRAWER_ROLE` — can `withdraw` ETH from the paymaster | +| `operatorSigner_` | EOA whose ECDSA grant signatures the paymaster accepts. Cannot be `address(0)` (constructor reverts `ZeroAddress`) | +| `envelope_` | Vault address — the **only** allowed `spender` / `operator` in sponsored approvals. Cannot be `address(0)` | +| `maxEthPerTx_` | Hard ceiling on `gasLimit * maxFeePerGas` per sponsored tx | +| `initialQuota` | Total wei sponsorable per period | +| `initialPeriod` | Period length in seconds (max 30 days per `QuotaControl`) | + +The constructor also computes and stores the immutable `DOMAIN_SEPARATOR` for the EIP-712 grant. + +## Storage + +```solidity +bytes32 public immutable DOMAIN_SEPARATOR; +address public immutable envelopeVault; +uint256 public immutable maxEthPerTx; + +address public operatorSigner; // admin-rotatable +mapping(bytes32 => bool) public isNonceUsed; // single-use replay protection +``` + +Plus inherited: +- `QuotaControl`: `period`, `quota`, `quotaRenewalTimestamp`, `claimed` +- `BasePaymaster`/`AccessControl`: roles + +## Constants + +| | Value | +|---|---| +| `APPROVE_SEL` | `0x095ea7b3` — `approve(address,uint256)`; covers ERC-20 and ERC-721 | +| `SET_APPROVAL_FOR_ALL_SEL` | `0xa22cb465` — `setApprovalForAll(address,bool)`; covers ERC-721 and ERC-1155 | +| `EIP712_DOMAIN_TYPEHASH` | `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")` | +| `GRANT_TYPEHASH` | `keccak256("EnvelopeApprovalGrant(address user,uint256 deadline,bytes32 nonce)")` | + +## EIP-712 grant + +The operator signs this typed-data struct off-chain: + +```ts +domain = { + name: "EnvelopeApprovalPaymaster", + version: "1", + chainId, + verifyingContract: , +}; + +types = { + EnvelopeApprovalGrant: [ + { name: "user", type: "address" }, + { name: "deadline", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], +}; + +value = { user, deadline, nonce }; +signature = await operatorWallet.signTypedData(domain, types, value); +``` + +The user attaches `abi.encode(deadline, nonce, signature)` inside the `general` paymaster flow: + +```ts +const innerInput = AbiCoder.defaultAbiCoder().encode( + ["uint256", "bytes32", "bytes"], [deadline, nonce, signature] +); +const paymasterParams = utils.getPaymasterParams(PAYMASTER, { + type: "General", innerInput, +}); +``` + +The user does NOT sign this grant — they just sign the outer ZkSync tx as usual. The grant proves to the paymaster that the **operator** authorized this tx. + +## `validateAndPayForPaymasterTransaction` — the 5 gates + +```text +A. msg.sender == BOOTLOADER_FORMAL_ADDRESS [AccessRestrictedToBootloader] +B. paymasterInput flow == IPaymasterFlow.general [WrongFlow] +C. Grant: + - paymasterInput length >= 4 [InvalidPaymasterInput] + - block.timestamp <= deadline [GrantExpired] + - !isNonceUsed[nonce] [NonceAlreadyUsed] + - ECDSA.recover(grantDigest, signature) == operatorSigner [InvalidGrantSignature] +D. Inner call: + - data.length >= 36 [UnsupportedSelector] + - selector ∈ {APPROVE_SEL, SET_APPROVAL_FOR_ALL_SEL} [UnsupportedSelector] + - first arg (spender/operator) == envelopeVault [SpenderNotEnvelope] +E. Pay: + - requiredETH (= gasLimit * maxFeePerGas) <= maxEthPerTx [PerTxLimitExceeded] + - paymaster.balance >= requiredETH [InsufficientPaymasterBalance] + - claimed + requiredETH <= quota (period auto-rolls) [QuotaControl.QuotaExceeded] +``` + +State writes during validation (allowed for paymasters under EraVM rules): +- `isNonceUsed[nonce] = true` +- `claimed += requiredETH` (with period rollover) + +Then `BOOTLOADER_FORMAL_ADDRESS.call{value: requiredETH}("")` and emit `ApprovalSponsored(user, token, nonce, gasPaid)`. + +The validation is split into four helper functions (`_requireGeneralFlow`, `_verifyAndConsumeGrant`, `_requireApprovalCallToEnvelope`, `_payBootloader`) so each scope has <16 locals — zksolc's legacy codegen otherwise hits stack-too-deep on the unified function and the block-explorer verification compile fails. + +## Admin functions + +```solidity +function setOperatorSigner(address newSigner) external onlyRole(DEFAULT_ADMIN_ROLE); +function setQuota(uint256 newQuota) external onlyRole(DEFAULT_ADMIN_ROLE); // inherited +function setPeriod(uint256 newPeriod) external onlyRole(DEFAULT_ADMIN_ROLE); // inherited +function withdraw(address to, uint256 amount) external onlyRole(WITHDRAWER_ROLE); // inherited +``` + +`setOperatorSigner(0)` reverts with `ZeroAddress` — the paymaster cannot be silently disabled. + +## Events / Errors + +```solidity +event OperatorSignerUpdated(address indexed previousSigner, address indexed newSigner); +event ApprovalSponsored(address indexed user, address indexed token, + bytes32 indexed nonce, uint256 gasPaid); + +error WrongFlow(); +error GrantExpired(); +error NonceAlreadyUsed(); +error InvalidGrantSignature(); +error UnsupportedSelector(); +error SpenderNotEnvelope(); +error PerTxLimitExceeded(); +error InsufficientPaymasterBalance(); +error ZeroAddress(); +error Unused(); // _validateAndPayGeneralFlow hook (BasePaymaster requirement; never reached) +``` + +Plus inherited: + +```solidity +error AccessRestrictedToBootloader(); // from BasePaymaster +error PaymasterFlowNotSupported(); // from BasePaymaster +error InvalidPaymasterInput(string message); +error FailedToWithdraw(); +error QuotaExceeded(); // from QuotaControl +error ZeroPeriod(); +error TooLongPeriod(); +``` + +## Threat model + +| Attack | Mitigation | +|---|---| +| Anyone tries to use the paymaster without operator sign-off | `_verifyAndConsumeGrant` — must hold a valid signature from `operatorSigner` | +| Replay a stale grant | `nonce` is single-use (`isNonceUsed`); also `deadline` | +| Use a grant signed for another user | `user` is part of the EIP-712 struct hash; sig won't verify if `tx.from` differs | +| Sponsor a transfer / mint / arbitrary state-change | Inner selector must be `approve` or `setApprovalForAll` | +| Approve attacker as spender | Inner first arg must equal `envelopeVault` | +| Drain via one huge tx (e.g. huge `gasLimit`) | `requiredETH > maxEthPerTx` reverts | +| Drain via many normal-sized txs | `QuotaControl` daily cap | +| Operator-signer key compromise | Bounded by `maxEthPerTx` per tx AND quota per day. Admin rotates via `setOperatorSigner` | +| Withdraw paymaster ETH without permission | `WITHDRAWER_ROLE` gate on `withdraw` | +| zkSync `
.transfer` issue | All ETH outflow uses `.call{value:}("")` (EraVM-safe) | +| Bootloader impersonation | `_mustBeBootloader()` (msg.sender == `BOOTLOADER_FORMAL_ADDRESS`) | + +## What was deliberately dropped (vs. earlier iterations) + +| Feature | Why removed | +|---|---| +| Per-token allowlist + `ALLOWLIST_ADMIN_ROLE` | The operator already curates which tokens get grants (off-chain decision in the API). On-chain allowlist was operator-side ceremony. Per-tx ETH cap + quota gives equivalent worst-case bound under key compromise. | +| `TokenNotAllowed` error | (See above) | +| `Witnessed` events for token add/remove | (See above) | + +## Backend signing code skeleton + +```ts +import { Wallet } from "zksync-ethers"; +import { ethers } from "ethers"; +import { randomBytes, hexlify } from "ethers"; + +const PAYMASTER = "0xEE95bFF2240652e0f57aE3fcd57F87d85593c191"; +const CHAIN_ID = 300; +const operatorWallet = new Wallet(process.env.OPERATOR_PK!); + +async function signGrant(user: string, ttlSec = 300) { + const deadline = BigInt(Math.floor(Date.now() / 1000) + ttlSec); + const nonce = hexlify(randomBytes(32)); + const signature = await operatorWallet.signTypedData( + { name: "EnvelopeApprovalPaymaster", version: "1", + chainId: CHAIN_ID, verifyingContract: PAYMASTER }, + { EnvelopeApprovalGrant: [ + { name: "user", type: "address" }, + { name: "deadline", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ]}, + { user, deadline, nonce }, + ); + return { deadline, nonce, signature }; +} +``` + +## Deploy + +```bash +# vault address already wired in .env-test as PEANUT_V4 +ENVELOPE_PAYMASTER_FUNDING=2000000000000000 # 0.002 ETH; optional +yarn hardhat deploy-zksync \ + --script DeployEnvelopePaymaster.ts \ + --network zkSyncSepoliaTestnet +``` + +Optional env vars (defaults documented in the script header): +- `ENVELOPE_PAYMASTER_ADMIN`, `_WITHDRAWER`, `_OPERATOR_SIGNER` +- `ENVELOPE_PAYMASTER_MAX_ETH_PER_TX` (default 0.001 ETH) +- `ENVELOPE_PAYMASTER_QUOTA` (default 0.1 ETH) +- `ENVELOPE_PAYMASTER_PERIOD` (default 86400) +- `ENVELOPE_PAYMASTER_FUNDING` (default 0) + +## Test coverage + +`test/paymasters/EnvelopeApprovalPaymaster.t.sol` — 19 tests: +- **Happy paths**: sponsors `approve`, sponsors `setApprovalForAll`, sponsors approval on ANY token (no allowlist) +- **Reverts per gate**: not-bootloader, approval-based-flow, expired grant, reused nonce, wrong signer, wrong user in sig, unsupported selector, spender-not-envelope, per-tx limit, insufficient balance, exceeded quota (via dedicated tight-quota paymaster instance) +- **Period rollover**: claimed counter resets after `period` elapsed +- **Admin gates**: rotate operator signer; non-admin can't; withdraw; non-withdrawer can't diff --git a/src/peanut/doc/PeanutBatcherV4.md b/src/peanut/doc/PeanutBatcherV4.md new file mode 100644 index 00000000..18cecfe1 --- /dev/null +++ b/src/peanut/doc/PeanutBatcherV4.md @@ -0,0 +1,92 @@ +# PeanutBatcherV4 — N-deposits-in-one-tx helper + +`src/peanut/V4/PeanutBatcherV4.4.sol` + +## Purpose + +A stateless helper that lets a single tx create N peanut deposits at once. The batcher pulls tokens from `msg.sender` once, then loops calling the vault's `makeSelflessDeposit` / `makeCustomDeposit` / `makeSelflessMFADeposit` for each pubKey. Common use case: airdrops or per-recipient claim links. + +Stateless by design — the `PeanutV4` reference is taken from the call argument each invocation, so the same batcher contract can fan out to multiple vault deployments. Also avoids EraVM pubdata cost on every batch call (`PeanutV4 public peanut` storage var was dropped during hardening). + +## Constructor + +```solidity +constructor() // no args +``` + +## Public entry points + +| Function | Use case | +|---|---| +| `batchMakeDeposit(peanut, token, contractType, amount, tokenId, pubKeys20[])` | N deposits, all the same shape; returns array of deposit indexes | +| `batchMakeDepositNoReturn(peanut, token, contractType, amount, tokenId, pubKeys20[])` | Same as above but skips the return-array allocation (cheaper). Only meaningful for a single deposit, or for ETH-only with msg.value reused per call (legacy upstream shape) | +| `batchMakeDepositArbitrary(peanut, tokens[], contractTypes[], amounts[], tokenIds[], pubKeys20[], withMFAs[])` | Heterogeneous batch — each deposit has its own token/type/amount/id/pubkey/MFA flag | +| `batchMakeDepositRaffle(peanut, token, contractType, amounts[], pubKey20)` | Raffle: many deposits sharing the same `pubKey20`, each with its own amount. Withdraw order = order claimed. ETH and ERC-20 only | +| `batchMakeDepositRaffleMFA(...)` | Same as raffle, but all deposits are MFA-gated | + +All call `peanut.makeSelflessDeposit(_, _, _, _, _, msg.sender)` (or its MFA / custom variants) under the hood — the **batcher caller** (`msg.sender`) becomes the `senderAddress` recorded in the vault, so they retain reclaim rights. + +## ERC-721 batch — intentionally not supported + +```solidity +} else if (_contractType == 2) { + revert("ERC721 batch not implemented"); +} +``` + +Each NFT has a unique `tokenId`, which doesn't fit the same-args-per-deposit shape of `batchMakeDeposit` / `batchMakeDepositArbitrary`. For multi-NFT airdrops, call `makeCustomDeposit` per token in your own client loop. + +## Token pulls + +| `contractType` | Path | +|---|---| +| 0 (ETH) | `msg.value == amount * pubKeys20.length` check; ETH is then forwarded per inner deposit | +| 1 (ERC-20) | `safeTransferFrom(msg.sender, address(this), totalAmount)`; one-time `forceApprove(peanut, MAX)` via `_setAllowanceIfZero` | +| 3 (ERC-1155) | `safeTransferFrom(msg.sender, address(this), tokenId, totalAmount, "")`; `setApprovalForAll(peanut, true)` | + +The batcher holds the assets transiently between pull and the inner `makeSelflessDeposit` calls. Each inner call pulls from the batcher (whom it just approved) into the vault. + +## `_setAllowanceIfZero` + +```solidity +function _setAllowanceIfZero(address tokenAddress, address spender) internal { + if (IERC20(tokenAddress).allowance(address(this), spender) == 0) { + IERC20(tokenAddress).forceApprove(spender, type(uint256).max); + } +} +``` + +Sets max allowance on first use, then no-ops. `forceApprove` (OZ v5) handles USDT-style non-bool-returning tokens; replaced upstream's `safeApprove` which was removed in OZ v5. + +## Receiver hooks (S1 hardening) + +Same self-only policy as the vault — direct ERC-721 / ERC-1155 transfers to the batcher revert with `"DIRECT TRANSFERS NOT ALLOWED"`. The legitimate path is the batcher itself initiating the inner `safeTransferFrom`, where the bootloader sees `operator == address(this)`. + +## Storage + +None. (`PeanutV4 public peanut` was removed during hardening — see ZkSync notes.) + +## Events / errors + +None of its own. Inner deposits emit `PeanutV4.DepositEvent`. + +## Vendoring patches + +| | Patch | +|---|---| +| OZ v5 | `safeApprove` → `forceApprove` | +| ZkSync (Z2) | Dropped `PeanutV4 public peanut` storage var; uses local per call | +| ZkSync (Z1) | Explicit `override(IERC165)` on `supportsInterface` | +| Hardening (S1) | Receivers revert on non-self operator | +| Modern | Named imports | +| Modern | Pragma pinned to `0.8.26` | +| Add | `_withMFAs.length` check in `batchMakeDepositArbitrary` (upstream was missing) | + +## Test coverage + +`test/peanut/PeanutBatcher.t.sol` — 13 tests: +- happy paths for ETH / ERC-20 / ERC-1155 batches +- ERC-721 batch reverts as designed (`test_RevertWhen_BatchERC721NotImplemented`) +- raffle (ETH + ERC-20) +- multiple batches in a row +- not-approved revert paths for all three asset types diff --git a/src/peanut/doc/PeanutRouter.md b/src/peanut/doc/PeanutRouter.md new file mode 100644 index 00000000..b7a80bdc --- /dev/null +++ b/src/peanut/doc/PeanutRouter.md @@ -0,0 +1,138 @@ +# PeanutV4Router — cross-chain peanut withdrawal via Squid + +`src/peanut/V4/PeanutRouter.sol` + +## Purpose + +Wraps a Peanut withdrawal with a Squid (Axelar) bridge call so a recipient can claim a peanut link on chain X and receive the value on chain Y in a single transaction. Without this contract the recipient would have to first claim peanut on X, then manually bridge. + +**Not deployed on Sepolia.** Deploy if/when you wire a Squid integration. + +## Constructor + +```solidity +constructor(address _squidAddress) Ownable(msg.sender) +``` + +| Param | Purpose | +|---|---| +| `_squidAddress` | Target Squid router on this chain. All bridge calls go to it | + +Inherits `Ownable2Step` (OZ v5) so ownership transfer happens in two transactions: +1. Current owner: `transferOwnership(newOwner)` → sets pending owner +2. New owner: `acceptOwnership()` → confirms + +Initial owner is `msg.sender`. Use `transferOwnership` + `acceptOwnership` to move ownership to a multisig. + +## Storage + +```solidity +address public squidAddress; // mutable (no setter exposed — set at deploy) +``` + +Plus inherited `Ownable2Step`: `_owner`, `_pendingOwner`. + +## External + +### `withdrawAndBridge` + +```solidity +function withdrawAndBridge( + address _peanutAddress, + uint256 _depositIndex, + bytes calldata _withdrawalSignature, + uint256 _squidFee, + uint256 _peanutFee, + bytes calldata _squidData, + bytes calldata _routingSignature +) public payable +``` + +Full flow: + +1. **Validate `_routingSignature` first** (EIP-191 v0x00) — signed by the deposit's `pubKey20` over `(routerAddress, chainId, peanutAddress, depositIndex, squidAddress, squidFee, peanutFee, squidData)`. This pins the relayer to the exact fees + bridge calldata the link-owner agreed to. Front-running with a different fee structure reverts with `WRONG ROUTING SIGNER`. +2. `msg.value == _squidFee` (`msg.value MUST BE THE SQUID FEE`). +3. `deposit.contractType ∈ {0, 1}` — ETH or ERC-20 only. ERC-721 / ERC-1155 can't be bridged this way (`X-CHAIN CLAIMS WORK ONLY FOR ETH AND ERC20 TOKENS`). +4. `_peanutFee < deposit.amount` (`TOO HIGH FEE`). +5. Call `peanut.withdrawDepositAsRecipient(_depositIndex, address(this), _withdrawalSignature)`. The vault transfers the asset to this router. +6. Compute `amountToBridge = deposit.amount - _peanutFee`. For ERC-20: `safeIncreaseAllowance(squidAddress, amountToBridge)`. For ETH: `ethAmountToSquid += amountToBridge`. +7. `(bool ok,) = payable(squidAddress).call{value: ethAmountToSquid}(_squidData);` — forwards the bridge call. Reverts on failure. + +The router retains `_peanutFee` as collectible revenue. + +### `withdrawFees` + +```solidity +function withdrawFees(address token, address to, uint256 amount) public onlyOwner +``` + +Owner-gated. For ETH: `payable(to).call{value: amount}("")`. For ERC-20: `SafeERC20.safeTransfer` (so USDT and other non-bool-returning tokens work). + +### `receive() external payable {}` + +Allows the router to receive ETH from the vault during a `withdrawAndBridge` ETH path. + +## Signature scheme + +The routing signature uses **EIP-191 version 0x00** (a personal-sign variant). The digest: + +```solidity +keccak256(abi.encodePacked( + bytes2(0x1900), + address(this), // verifying contract + block.chainid, + _peanutAddress, + _depositIndex, + squidAddress, + _squidFee, + _peanutFee, + _squidData +)) +``` + +The link owner signs this off-chain. `ECDSA.recover(digest, _routingSignature)` must equal `deposit.pubKey20`. This signature is **separate** from the withdrawal signature, which proves the link owner consents to the bridge (different digest, different purpose — withdrawal authorizes pulling from the vault, routing authorizes the bridge parameters). + +## Threat model + +| Attack | Mitigation | +|---|---| +| Relayer charges higher peanut fee than user agreed | `_routingSignature` verifies over the EXACT `_peanutFee`. Any change → different digest → wrong signer revert | +| Relayer pays lower squid fee than required by Axelar (tx stuck) | `msg.value == _squidFee` check + `_squidFee` is in the routing sig | +| Relayer modifies `_squidData` to redirect to a different destination chain / token | `_squidData` is in the routing sig digest | +| Front-runner submits the same tx with stolen sig | Idempotent for the relayer fee perspective; peanut withdrawal is single-use so the second attempt reverts inside `peanut.withdrawDepositAsRecipient` (deposit already claimed) | +| Stuck cross-chain tx (gas-price spike on destination) | Out of scope — Axelar fee adjustment is the recovery; this contract does not implement expiry | + +## Vendoring patches + +| | Patch | +|---|---| +| Import target | `./PeanutV4.2.sol` → `./PeanutV4.4.sol` | +| OZ v5 | `Ownable` constructor takes explicit `Ownable(msg.sender)` | +| Hardening (S2) | `IERC20.transfer` → `SafeERC20.safeTransfer` in `withdrawFees` (USDT-compatible) | +| Hardening (M2) | `Ownable` → `Ownable2Step` (handoff requires explicit acceptance) | +| Modern | Named imports | +| Modern | Pragma pinned to `0.8.26` | + +## Test coverage + +`test/peanut/PeanutRouter.t.sol` — 4 tests including: + +- happy path: withdraw + bridge for ETH (256-run fuzz) +- happy path: withdraw + bridge for ERC-20 (256-run fuzz, validates fee paths) +- owner-only `withdrawFees` (asserts `Ownable.OwnableUnauthorizedAccount` for non-owner) +- relayer cannot tamper with fees / squidData (all `WRONG ROUTING SIGNER` reverts) + +## Deploy + +Not deployed on Sepolia. To deploy: + +```bash +PEANUT_DEPLOY_ROUTER=true \ +PEANUT_SQUID_ADDRESS=0x... # required +PEANUT_ROUTER_OWNER=0x... # optional; defaults to deployer +yarn hardhat deploy-zksync \ + --script DeployPeanut.ts \ + --network zkSyncSepoliaTestnet +``` + +After deploy, if `PEANUT_ROUTER_OWNER` ≠ deployer, the new owner must call `acceptOwnership()` from their own key. diff --git a/src/peanut/doc/PeanutV4.md b/src/peanut/doc/PeanutV4.md new file mode 100644 index 00000000..6a2ae198 --- /dev/null +++ b/src/peanut/doc/PeanutV4.md @@ -0,0 +1,174 @@ +# PeanutV4 — link-based asset vault + +`src/peanut/V4/PeanutV4.4.sol` + +## Purpose + +A non-custodial vault that lets a sender deposit ETH / ERC-20 / ERC-721 / ERC-1155 assets against an arbitrary `pubKey20` (last 20 bytes of an ECDSA public key). Anyone holding the matching **private key** can later claim the asset to any recipient address by producing a signature. Optionally a deposit can be: + +- **Recipient-bound** — only a pre-named recipient address can claim +- **MFA-gated** — claim also requires a second signature from an admin-configured `MFA_AUTHORIZER` +- **Sender-reclaimable** — sender can reclaim after a configurable delay if the link is never used + +This is the vendored upstream contract from `peanutprotocol/peanut-contracts@main` with security hardening + ZkSync alignment patches applied during vendoring. + +## Constructor + +```solidity +constructor(address _ecoAddress, address _mfaAuthorizer) +``` + +| Param | Purpose | `address(0)` means | +|---|---|---| +| `_ecoAddress` | Rebasing ECO-like ERC-20 token to gate from regular ERC-20 deposits (forces it through `contractType==4`) | no token gating | +| `_mfaAuthorizer` | EOA whose ECDSA signatures unlock `withdrawMFADeposit` | MFA disabled — any deposit flagged `withMFA=true` is unrecoverable | + +Both stored `immutable`. The MFA authorizer was promoted from a hardcoded constant in upstream to per-deploy config during vendoring. + +The constructor also computes and stores `DOMAIN_SEPARATOR` for the gasless-reclaim EIP-712 signature flow. + +## Storage + +```solidity +struct Deposit { + address pubKey20; // 20 bytes — claim signature must recover to this + uint256 amount; // 32 bytes — asset amount (or 1 for ERC-721) + address tokenAddress; // 20 bytes — 0x0 for ETH + uint8 contractType; // 1 byte — 0=ETH 1=ERC20 2=ERC721 3=ERC1155 4=L2ECO + bool claimed; // 1 byte + bool requiresMFA; // 1 byte + uint40 timestamp; // 5 bytes — deposit time + uint256 tokenId; // 32 bytes — 0 for ERC-20 + address senderAddress; // 20 bytes — who owns reclaim rights + address recipient; // 20 bytes — if non-zero, only this address can claim + uint40 reclaimableAfter; // 5 bytes — sender reclaim earliest (for recipient-bound only) +} // 6 slots, packed + +Deposit[] public deposits; // index = depositIndex +address public ecoAddress; // immutable +address public immutable MFA_AUTHORIZER; +bytes32 public DOMAIN_SEPARATOR; // set at construction; not immutable for clarity +``` + +## Constants + +| Name | Value | Purpose | +|---|---|---| +| `PEANUT_SALT` | `keccak256("Konrad makes tokens go woosh tadam")` | Domain-tags every link signature; prevents the same signature being reused on a different Peanut deployment | +| `ANYONE_WITHDRAWAL_MODE` | `bytes32(0)` | Default mode — anyone holding the private key can withdraw on behalf of an arbitrary recipient | +| `RECIPIENT_WITHDRAWAL_MODE` | `keccak256("only recipient")` | Used for `withdrawDepositAsRecipient` — only the recipient address signs | +| `GASLESS_RECLAIM_TYPEHASH` | `keccak256("GaslessReclaim(uint256 depositIndex)")` | EIP-712 type for sender's gasless reclaim | + +## Deposit functions + +All deposit functions are `payable` (ETH path uses `msg.value`) and `nonReentrant`. They route through internal `_pullTokensViaApproval` / `_pullTokensVia3009Encoded` for asset transfer, then `_storeDeposit` for state update. + +| Function | Use case | +|---|---| +| `makeDeposit(token, contractType, amount, tokenId, pubKey20)` | Simplest — depositor is `msg.sender`, no MFA, no recipient bind | +| `makeMFADeposit(...)` | Same shape, but `withMFA=true` | +| `makeSelflessDeposit(..., onBehalfOf)` | Deposit credited to `onBehalfOf` (reclaim rights go to them, not msg.sender) — used by batcher | +| `makeSelflessMFADeposit(..., onBehalfOf)` | Selfless + MFA | +| `makeCustomDeposit(token, contractType, amount, tokenId, pubKey20, onBehalfOf, withMFA, recipient, reclaimableAfter, isGasless3009, args3009)` | All knobs exposed — the canonical entry point | +| `makeDepositWithAuthorization(token, from, amount, pubKey20, nonce, validAfter, validBefore, v, r, s)` | EIP-3009 path for USDC-style tokens — no pre-approval needed | + +The minimalistic deposit functions (`makeDeposit`, `makeMFADeposit`, `makeSelflessDeposit`, `makeSelflessMFADeposit`) are marked `@deprecated` upstream but kept for ABI compatibility; new integrations should call `makeCustomDeposit`. + +### `_storeDeposit` invariant — dual-zero rejection + +A deposit with both `pubKey20 == 0` AND `recipient == 0` has **no withdrawal authority** — `_withdrawDeposit` would accept any caller without a valid signature. The hardening patch added at vendor time enforces: + +```solidity +require(_pubKey20 != address(0) || _recipient != address(0), "DEPOSIT MUST HAVE AUTH"); +``` + +so the dual-zero footgun is impossible. + +## Withdraw functions + +| Function | Caller | Auth | +|---|---|---| +| `withdrawDeposit(index, recipient, signature)` | anyone | `signature` (recovers to `pubKey20`) signed over `keccak256(PEANUT_SALT, chainid, address(this), index, recipient, ANYONE_WITHDRAWAL_MODE)` | +| `withdrawMFADeposit(index, recipient, signature, MFASignature)` | anyone | Both above signature AND a signature from `MFA_AUTHORIZER` over `keccak256(PEANUT_SALT, chainid, address(this), index, recipient)` | +| `withdrawDepositAsRecipient(index, recipient, signature)` | `recipient` only (msg.sender) | `signature` signed with `RECIPIENT_WITHDRAWAL_MODE` instead of `ANYONE_WITHDRAWAL_MODE` | +| `withdrawDepositSender(index)` | original sender | none beyond `msg.sender == _deposit.senderAddress`; for recipient-bound deposits also requires `block.timestamp > reclaimableAfter` | +| `withdrawDepositSenderGasless(reclaim, signer, signature)` | anyone | EIP-712 signature from `signer` (must equal `senderAddress`) over `GaslessReclaim(depositIndex)` | + +All withdraws set `claimed = true` BEFORE the asset transfer (CEI). `nonReentrant` adds belt-and-suspenders. + +## Asset paths + +`contractType` determines how assets flow: + +| Code | Asset | Deposit | Withdraw | +|---|---|---|---| +| 0 | ETH | `msg.value` | `recipient.call{value: amount}("")` | +| 1 | ERC-20 | `SafeERC20.safeTransferFrom(msg.sender, this, amount)` | `SafeERC20.safeTransfer(recipient, amount)` | +| 2 | ERC-721 | `safeTransferFrom(msg.sender, this, tokenId, "Internal transfer")` | `safeTransferFrom(this, recipient, tokenId)` | +| 3 | ERC-1155 | `safeTransferFrom(msg.sender, this, tokenId, amount, "Internal transfer")` | `safeTransferFrom(this, recipient, tokenId, amount, "")` | +| 4 | L2ECO (rebasing) | `SafeERC20.safeTransferFrom`; stored amount multiplied by `linearInflationMultiplier()` for inflation-invariance | inverse: `amount / linearInflationMultiplier()`, then `SafeERC20.safeTransfer` | + +For ERC-20, the depositor must approve the vault first (Path C). The `EnvelopeApprovalPaymaster` exists to sponsor that approval tx. + +## Receiver hooks (S1 hardening) + +The vault implements `IERC721Receiver` + `IERC1155Receiver` because withdrawing NFTs goes through `safeTransferFrom` and the **recipient** may be a contract that needs the receiver-check; for the vault itself, the only legitimate calls to its own receiver hooks are when the vault itself is the operator (i.e. during withdraw). Direct deposits via `safeTransferFrom(user → vault, ...)` from outside this contract are explicitly rejected: + +```solidity +require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); +``` + +This closes the upstream footgun where the hooks silently returned `bytes4(0)`, causing some tokens to accept the transfer and strand the asset in the vault. + +## EIP-3009 path + +For tokens that implement EIP-3009 (USDC and forks), the user signs `ReceiveWithAuthorization(...)` off-chain; the relayer submits to the vault via `makeDepositWithAuthorization` (or `makeCustomDeposit` with `_isGasless3009=true`). No pre-approval is needed — this is Path B. + +The vault re-derives the nonce as `keccak256(pubKey20, _nonce)` before calling the token's `receiveWithAuthorization` — this binds the EIP-3009 signature to the specific link, preventing front-running where another link's owner steals the deposit. + +## Events + +```solidity +event DepositEvent(uint256 indexed _index, uint8 indexed _contractType, + uint256 _amount, address indexed _senderAddress); +event WithdrawEvent(uint256 indexed _index, uint8 indexed _contractType, + uint256 _amount, address indexed _recipientAddress); +event MessageEvent(string message); // emitted once at deploy ("Hello World, have a nutty day!") +``` + +## Views + +```solidity +function getDepositCount() external view returns (uint256); +function getDeposit(uint256 _index) external view returns (Deposit memory); +function getAllDeposits() external view returns (Deposit[] memory); +function getAllDepositsForAddress(address _address) external view returns (Deposit[] memory); +function getSigner(bytes32 messageHash, bytes memory signature) public pure returns (address); +``` + +Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with array length. Indexing services should listen to events instead. + +## Vendoring patches applied at import + +| | Patch | +|---|---| +| OZ v5 | `security/ReentrancyGuard.sol` → `utils/ReentrancyGuard.sol` | +| OZ v5 | `ECDSA.toEthSignedMessageHash` → `MessageHashUtils.toEthSignedMessageHash` | +| OZ v5 | `IL2ECO.transfer/transferFrom` → `SafeERC20.safeTransfer/safeTransferFrom` (cast IL2ECO → IERC20) | +| Hardening (S1) | `onERC{721,1155,1155Batch}Received` revert on non-self operator | +| Hardening (S3) | `MFA_AUTHORIZER` from `constant` to `immutable` constructor arg | +| Hardening (S4) | `_storeDeposit` rejects dual-zero pubKey20 + recipient | +| Bug fix | `_withdrawDeposit` L2ECO branch was sending to `senderAddress`; fixed to `_recipientAddress` | +| ZkSync | All raw IL2ECO calls switched to SafeERC20 | +| ZkSync | Explicit `override(IERC165)` on `supportsInterface` | +| Modern | Named imports throughout | +| Modern | Pragma pinned to `0.8.26` | + +## Test coverage + +| Suite | File | +|---|---| +| Vendored upstream tests | `test/peanut/PeanutV4.t.sol`, `testDeposit.sol`, `testSigWithdraw.sol`, `testSenderWithdraw.sol`, `testMFA.sol`, `RecipeintBound.t.sol`, `testIntegration.sol`, `PeanutV4Gasless.t.sol` | +| Hardening (S1–S4 + T1–T4) | `test/peanut/PeanutHardening.t.sol` | + +71 tests pass. diff --git a/src/peanut/doc/README.md b/src/peanut/doc/README.md new file mode 100644 index 00000000..a686d331 --- /dev/null +++ b/src/peanut/doc/README.md @@ -0,0 +1,65 @@ +# Envelope (Peanut) contracts + +The Envelope flow on Nodle is built on top of the vendored **Peanut Protocol V4.4** +contracts. Operators issue link-based asset transfers (ETH / ERC-20 / ERC-721 / +ERC-1155) that recipients claim with a per-link private key. A dedicated paymaster +sponsors the user-side approval txs so the UX is gasless from the holder's POV. + +## Layout + +| Contract | Source | Spec | +|---|---|---| +| `PeanutV4` (vault) | `src/peanut/V4/PeanutV4.4.sol` | [PeanutV4.md](./PeanutV4.md) | +| `PeanutBatcherV4` (batched deposits) | `src/peanut/V4/PeanutBatcherV4.4.sol` | [PeanutBatcherV4.md](./PeanutBatcherV4.md) | +| `PeanutV4Router` (cross-chain via Squid) | `src/peanut/V4/PeanutRouter.sol` | [PeanutRouter.md](./PeanutRouter.md) | +| `EnvelopeApprovalPaymaster` (Path-C gas sponsor) | `src/paymasters/EnvelopeApprovalPaymaster.sol` | [EnvelopeApprovalPaymaster.md](./EnvelopeApprovalPaymaster.md) | + +Interfaces (vendored, unmodified): + +| Interface | Source | Used by | +|---|---|---| +| `IEIP3009` | `src/peanut/util/IEIP3009.sol` | `PeanutV4` for gasless USDC-style deposits | +| `IL2ECO` | `src/peanut/util/IL2ECO.sol` | `PeanutV4` for rebasing-ERC20 deposits (`contractType==4`) | + +## Naming convention + +- **Peanut** — the vendored open-source primitive (`peanutprotocol/peanut-contracts@main`). The vault, batcher, and router keep upstream names so audits + diffs against upstream stay easy. +- **Envelope** — Nodle's product wrapper on top. The paymaster is named for this layer (operates against the Peanut vault, sponsored on Nodle's terms). + +## Deployed on ZkSync Sepolia (chain 300) + +| | Address | +|---|---| +| `PeanutV4` | [`0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44`](https://sepolia.explorer.zksync.io/address/0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44#contract) | +| `PeanutBatcherV4` | [`0x1676cD8B90e2E4388C032ae5Eb4BA50166Bb3426`](https://sepolia.explorer.zksync.io/address/0x1676cD8B90e2E4388C032ae5Eb4BA50166Bb3426#contract) | +| `EnvelopeApprovalPaymaster` | [`0xEE95bFF2240652e0f57aE3fcd57F87d85593c191`](https://sepolia.explorer.zksync.io/address/0xEE95bFF2240652e0f57aE3fcd57F87d85593c191#contract) | +| `PeanutV4Router` | not deployed (deploy when cross-chain is needed) | + +## Three deposit paths + +The vault itself supports three ways a sender can fund a link: + +| Path | Trigger | Approval | Gas sponsor needed | +|---|---|---|---| +| **A** — ETH | `msg.value` directly | n/a | no | +| **B** — EIP-2612 / EIP-3009 token | `makeDepositWithAuthorization` (EIP-3009) | embedded in signature | no | +| **C** — anything else (ERC-20 w/o permit, ERC-721, ERC-1155) | `makeCustomDeposit` after user calls `token.approve` / `setApprovalForAll` | separate approval tx | **yes** — see [EnvelopeApprovalPaymaster](./EnvelopeApprovalPaymaster.md) | + +## Deploy + +| Script | Purpose | +|---|---| +| `hardhat-deploy/DeployPeanut.ts` | vault + batcher (+ optional router) | +| `hardhat-deploy/DeployEnvelopePaymaster.ts` | paymaster | + +Both are Hardhat-zksync scripts. See each spec for env vars. + +## Test coverage + +| Suite | Tests | +|---|---| +| Peanut core (`test/peanut/`) | 71 (60 vendored + 11 hardening) | +| Paymaster (`test/paymasters/EnvelopeApprovalPaymaster.t.sol`) | 19 | +| Other paymasters (unchanged) | 102 | +| Rest of repo | 747 | +| **Total** | **939** | From d2b2c12732245ef24da6bc2d297c5c6c017cd13e Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 14:39:28 -0400 Subject: [PATCH 12/31] test(peanut): regression for upstream L2ECO withdrawal bug (T5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream PeanutV4.4 had a copy-paste error in _withdrawDeposit's contractType==4 branch: it transferred to _deposit.senderAddress instead of _recipientAddress, so a recipient claiming an L2ECO link with a valid signature would receive nothing — the tokens went back to the sender — while the deposit was still marked claimed=true. Two new tests pin the fix: test_T5_L2ECOWithdrawGoesToRecipientNotSender - sender deposits 100 L2ECO (multiplier=2 → 200 stored inflation-invariant) - recipient (not sender) claims with a valid signature - asserts: recipient gets 100, sender stays at 0, vault drained Confirmed to FAIL against the upstream-bug code path (verified by temporarily reintroducing the bug; test failed with 'recipient must receive the L2ECO tokens: 0 != 100'). test_T5_L2ECOSenderReclaimStillGoesToSender - sanity check: _withdrawDepositSender (separate function) still legitimately routes to senderAddress; the fix to _withdrawDeposit did not over-correct the parallel reclaim path Adds test/peanut/mocks/L2ECOMock.sol — minimal ERC20 with a settable linearInflationMultiplier(). No production code changes; bug fix itself is in commit 12a77ce. 941/941 repo tests pass. --- test/peanut/PeanutHardening.t.sol | 80 +++++++++++++++++++++++++++++++ test/peanut/mocks/L2ECOMock.sol | 27 +++++++++++ 2 files changed, 107 insertions(+) create mode 100644 test/peanut/mocks/L2ECOMock.sol diff --git a/test/peanut/PeanutHardening.t.sol b/test/peanut/PeanutHardening.t.sol index ee708226..e9ec1f45 100644 --- a/test/peanut/PeanutHardening.t.sol +++ b/test/peanut/PeanutHardening.t.sol @@ -7,6 +7,7 @@ pragma solidity 0.8.26; // T2 — MFA_AUTHORIZER is now a per-deploy constructor arg (fix for S3 hardcoded key) // T3 — PeanutRouter.withdrawFees uses safeTransfer for non-returning ERC20s (fix for S2) // T4 — _storeDeposit rejects deposits with no withdrawal authority (fix for S4) +// T5 — _withdrawDeposit L2ECO branch sends to recipient, not sender (upstream bug fix) import {Test} from "forge-std/Test.sol"; import {PeanutV4} from "../../src/peanut/V4/PeanutV4.4.sol"; @@ -15,6 +16,7 @@ import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; import {SquidMock} from "./mocks/SquidMock.sol"; +import {L2ECOMock} from "./mocks/L2ECOMock.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; @@ -221,6 +223,84 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { ); assertEq(idx, 0); } + + // ── T5 ───────────────────────────────────────────────────────────────── + // Upstream copy-paste bug: _withdrawDeposit's contractType==4 (L2ECO) branch + // transferred to _deposit.senderAddress instead of _recipientAddress. The + // recipient would receive nothing while the deposit was marked claimed. + // Patch sends to _recipientAddress (matching all other contractType branches) + // and routes through SafeERC20 (consistent with the contractType==1 branch). + + function test_T5_L2ECOWithdrawGoesToRecipientNotSender() public { + uint256 depositPrivKey = uint256(keccak256("l2eco-link-key")); + address pubKey20 = vm.addr(depositPrivKey); + uint256 senderPk = uint256(keccak256("l2eco-sender")); + address sender = vm.addr(senderPk); + address recipient = address(0xDECAF); + + // Multiplier = 2 → vault stores `amount * 2` (inflation-invariant). + L2ECOMock eco = new L2ECOMock(2); + eco.mint(sender, 100); + + vm.prank(sender); + eco.approve(address(peanut), 100); + + vm.prank(sender); + uint256 idx = peanut.makeDeposit(address(eco), 4, 100, 0, pubKey20); + + // Sanity: vault holds the raw tokens, deposit stores the scaled amount. + assertEq(eco.balanceOf(address(peanut)), 100, "vault should hold raw tokens"); + assertEq(eco.balanceOf(sender), 0, "sender's tokens should be in the vault"); + PeanutV4.Deposit memory d = peanut.getDeposit(idx); + assertEq(d.amount, 200, "deposit amount should be inflation-invariant (amount * multiplier)"); + + // Recipient (not sender) claims using the link's private key. + bytes32 digest = MessageHashUtilsLite.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + peanut.PEANUT_SALT(), + block.chainid, + address(peanut), + idx, + recipient, + peanut.ANYONE_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(depositPrivKey, digest); + bytes memory sig = abi.encodePacked(r, s, v); + peanut.withdrawDeposit(idx, recipient, sig); + + // The fix: recipient gets 100, sender stays at 0. + // If the bug were still present, sender would have 100 and recipient 0. + assertEq(eco.balanceOf(recipient), 100, "recipient must receive the L2ECO tokens"); + assertEq(eco.balanceOf(sender), 0, "sender must NOT receive the L2ECO tokens back"); + assertEq(eco.balanceOf(address(peanut)), 0, "vault should be drained"); + } + + function test_T5_L2ECOSenderReclaimStillGoesToSender() public { + // Counterpart sanity: _withdrawDepositSender (sender-initiated reclaim path) + // is correctly routed to senderAddress — we shouldn't have over-corrected. + uint256 senderPk = uint256(keccak256("l2eco-reclaim-sender")); + address sender = vm.addr(senderPk); + address pubKey20 = vm.addr(uint256(keccak256("l2eco-reclaim-key"))); + + L2ECOMock eco = new L2ECOMock(1); + eco.mint(sender, 50); + + vm.prank(sender); + eco.approve(address(peanut), 50); + vm.prank(sender); + uint256 idx = peanut.makeDeposit(address(eco), 4, 50, 0, pubKey20); + + assertEq(eco.balanceOf(sender), 0); + + vm.prank(sender); + peanut.withdrawDepositSender(idx); + + assertEq(eco.balanceOf(sender), 50, "sender reclaim should return the tokens"); + assertEq(eco.balanceOf(address(peanut)), 0); + } } /// @dev Local copy of OZ's MessageHashUtils.toEthSignedMessageHash to avoid pulling diff --git a/test/peanut/mocks/L2ECOMock.sol b/test/peanut/mocks/L2ECOMock.sol new file mode 100644 index 00000000..14de5225 --- /dev/null +++ b/test/peanut/mocks/L2ECOMock.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @dev Minimal L2ECO-shaped mock — standard ERC20 plus a configurable +/// `linearInflationMultiplier()` so the test can exercise PeanutV4's +/// `contractType == 4` rebasing-token paths. +contract L2ECOMock is ERC20 { + uint256 private _multiplier; + + constructor(uint256 initialMultiplier) ERC20("L2ECOMock", "ECO") { + _multiplier = initialMultiplier; + } + + function linearInflationMultiplier() external view returns (uint256) { + return _multiplier; + } + + function setMultiplier(uint256 m) external { + _multiplier = m; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} From 149e19276066da4d49b80bdbfbb8f6dace725a1c Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 15:08:56 -0400 Subject: [PATCH 13/31] chore(spellcheck): whitelist peanut/envelope vocabulary, fix own typos CI spell check reported 103 issues in 29 files (38 unique words) across the vendored Peanut suite + my new code. Cleanup: 1. Fixed two typos I introduced: - test/paymasters/...: 'nonce-pertx' -> 'nonce-per-tx' (nonce string) - src/paymasters/...: 'EraVM's paymaster-validation rules' -> 'EraVM paymaster-validation rules' (apostrophe-s tripped cspell) 2. Whitelisted 38 words in .cspell.json: - Legitimate domain terms: Axelar, IEIP, calldataload, SECZ, secp, tadam, footgun, peanutprotocol, rollup, PRIVKEY, keypair, scwallet, gaslessly, Customisable, authorisation, arrayify, nomiclabs, defi, MAGICVALUE, unhashed, Hashbinary - Vendored upstream typos kept for diff parity (would be a real fix to pull from upstream later if they ever clean it up): contractype, Recipeint, DOESNT, Suuuuper, talkin, wooooooosh, pretent, Depost, alwasy, auhorisation, authorizattion, funfction, gsalessly, provied, fuceted CI passes: 0 issues, 250 files checked. Repo tests unchanged. --- .cspell.json | 38 ++++++++++++++++++- src/paymasters/EnvelopeApprovalPaymaster.sol | 2 +- .../EnvelopeApprovalPaymaster.t.sol | 2 +- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.cspell.json b/.cspell.json index 77c5ae9b..7d695802 100644 --- a/.cspell.json +++ b/.cspell.json @@ -101,6 +101,42 @@ "hexlify", "repoint", "repointed", - "cutover" + "cutover", + "Axelar", + "IEIP", + "calldataload", + "SECZ", + "secp", + "tadam", + "footgun", + "peanutprotocol", + "rollup", + "PRIVKEY", + "keypair", + "scwallet", + "gaslessly", + "Customisable", + "authorisation", + "arrayify", + "nomiclabs", + "defi", + "MAGICVALUE", + "unhashed", + "Hashbinary", + "contractype", + "Recipeint", + "DOESNT", + "Suuuuper", + "talkin", + "wooooooosh", + "pretent", + "Depost", + "alwasy", + "auhorisation", + "authorizattion", + "funfction", + "gsalessly", + "provied", + "fuceted" ] } diff --git a/src/paymasters/EnvelopeApprovalPaymaster.sol b/src/paymasters/EnvelopeApprovalPaymaster.sol index 802a88d0..cdd5d1ef 100644 --- a/src/paymasters/EnvelopeApprovalPaymaster.sol +++ b/src/paymasters/EnvelopeApprovalPaymaster.sol @@ -30,7 +30,7 @@ import {QuotaControl} from "../QuotaControl.sol"; /// `_validateAndPayGeneralFlow` hook) because validation requires the full /// `Transaction` calldata — the hook signature hides `transaction.data` and /// `transaction.paymasterInput`. -/// Storage writes in validation (nonce, quota counters) are permitted by EraVM's +/// Storage writes in validation (nonce, quota counters) are permitted by EraVM /// paymaster-validation rules. contract EnvelopeApprovalPaymaster is BasePaymaster, QuotaControl { bytes4 internal constant APPROVE_SEL = 0x095ea7b3; // approve(address,uint256) — ERC-20 + ERC-721 diff --git a/test/paymasters/EnvelopeApprovalPaymaster.t.sol b/test/paymasters/EnvelopeApprovalPaymaster.t.sol index aab1f1b6..022fc1c4 100644 --- a/test/paymasters/EnvelopeApprovalPaymaster.t.sol +++ b/test/paymasters/EnvelopeApprovalPaymaster.t.sol @@ -270,7 +270,7 @@ contract EnvelopeApprovalPaymasterTest is Test { } function test_revertsOnPerTxLimitExceeded() public { - bytes32 nonce = keccak256("nonce-pertx"); + bytes32 nonce = keccak256("nonce-per-tx"); uint256 deadline = block.timestamp + 1 hours; bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); From 09812eea2ced9d3e71c0049b10bf8364ef9910ee Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 16:17:52 -0400 Subject: [PATCH 14/31] chore(peanut): fix upstream typos in vendored copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All in comments, error strings, function names, or one filename — no bytecode changes. With these fixed, the cspell whitelist shrinks by 12 entries; only intentional stylistic words remain (Suuuuper, talkin, wooooooosh — all Peanut Protocol's "nutty" branding). Source comments: src/peanut/V4/PeanutV4.4.sol - alwasy → always - auhorisation → authorisation - funfction → function Test comments / strings / identifiers: test/peanut/PeanutV4.t.sol pretent → pretend test/peanut/PeanutV4Gasless.t.sol provied → provided, gsalessly → gaslessly test/peanut/PeanutV4Gasless.t.sol testMakeDepost… → testMakeDeposit… test/peanut/PeanutRouter.t.sol fuceted → faucet test/peanut/testMFA.sol authorizattion → authorization test/peanut/testSenderWithdraw.sol contractype → contractType test/peanut/mocks/SquidMock.sol DOESNT → DOES NOT test/peanut/RecipeintBound.t.sol → RecipientBound.t.sol (file rename) src/peanut/doc/PeanutV4.md doc reference updated to new filename 941/941 tests pass. Spellcheck: 0 issues / 250 files. --- .cspell.json | 14 +------------- src/peanut/V4/PeanutV4.4.sol | 6 +++--- src/peanut/doc/PeanutV4.md | 2 +- test/peanut/PeanutRouter.t.sol | 2 +- test/peanut/PeanutV4.t.sol | 2 +- test/peanut/PeanutV4Gasless.t.sol | 6 +++--- .../{RecipeintBound.t.sol => RecipientBound.t.sol} | 0 test/peanut/mocks/SquidMock.sol | 2 +- test/peanut/testMFA.sol | 2 +- test/peanut/testSenderWithdraw.sol | 6 +++--- 10 files changed, 15 insertions(+), 27 deletions(-) rename test/peanut/{RecipeintBound.t.sol => RecipientBound.t.sol} (100%) diff --git a/.cspell.json b/.cspell.json index 7d695802..df4225c0 100644 --- a/.cspell.json +++ b/.cspell.json @@ -123,20 +123,8 @@ "MAGICVALUE", "unhashed", "Hashbinary", - "contractype", - "Recipeint", - "DOESNT", "Suuuuper", "talkin", - "wooooooosh", - "pretent", - "Depost", - "alwasy", - "auhorisation", - "authorizattion", - "funfction", - "gsalessly", - "provied", - "fuceted" + "wooooooosh" ] } diff --git a/src/peanut/V4/PeanutV4.4.sol b/src/peanut/V4/PeanutV4.4.sol index 629e8028..406f49cd 100644 --- a/src/peanut/V4/PeanutV4.4.sol +++ b/src/peanut/V4/PeanutV4.4.sol @@ -291,10 +291,10 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { * @param _tokenId uint256 of the id of the token being sent if erc721 or erc1155 * @param _pubKey20 last 20 bytes of the public key of the deposit signer * @param _onBehalfOf who will be able to reclaim the link if the private key is lost - * @param _withMFA whether an external auhorisation is required for withdrawal + * @param _withMFA whether an external authorisation is required for withdrawal * @param _recipient if not 0x00.00, only _recipient will be able to withdraw * @param _reclaimableAfter if _recipient is set, the sender will be able to reclaim only after this timestamp - * @param _isGasless3009 if true, the deposit will be made via eip-3009, see makeDepositWithAuthorization funfction for more info + * @param _isGasless3009 if true, the deposit will be made via eip-3009, see makeDepositWithAuthorization function for more info * @param _args3009 all the arguments for an EIP-3009 deposit, used if _isGasless3009 is true. Encoded with abi.encode, this is: address (from), bytes32 (_nonce), uint256 (_validAfter), uint256 (_validBefore), uint8 (_v), bytes32 (_r), bytes32 (_s). Unfortunately we have to encode it this way, because else we get a stack too deep error (EVM supports max 16 variables on the stack). * @return uint256 index of the deposit */ @@ -548,7 +548,7 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { _tokenAddress, 1, // contractType is always 1 here (ERC20) _amount, - 0, // it's alwasy ERC20, so tokenId doesn't matter + 0, // it's always ERC20, so tokenId doesn't matter _pubKey20, _from, false, // no MFA diff --git a/src/peanut/doc/PeanutV4.md b/src/peanut/doc/PeanutV4.md index 6a2ae198..f4061da4 100644 --- a/src/peanut/doc/PeanutV4.md +++ b/src/peanut/doc/PeanutV4.md @@ -168,7 +168,7 @@ Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with arra | Suite | File | |---|---| -| Vendored upstream tests | `test/peanut/PeanutV4.t.sol`, `testDeposit.sol`, `testSigWithdraw.sol`, `testSenderWithdraw.sol`, `testMFA.sol`, `RecipeintBound.t.sol`, `testIntegration.sol`, `PeanutV4Gasless.t.sol` | +| Vendored upstream tests | `test/peanut/PeanutV4.t.sol`, `testDeposit.sol`, `testSigWithdraw.sol`, `testSenderWithdraw.sol`, `testMFA.sol`, `RecipientBound.t.sol`, `testIntegration.sol`, `PeanutV4Gasless.t.sol` | | Hardening (S1–S4 + T1–T4) | `test/peanut/PeanutHardening.t.sol` | 71 tests pass. diff --git a/test/peanut/PeanutRouter.t.sol b/test/peanut/PeanutRouter.t.sol index b5961a62..03c0f591 100644 --- a/test/peanut/PeanutRouter.t.sol +++ b/test/peanut/PeanutRouter.t.sol @@ -64,7 +64,7 @@ contract PeanutV4RouterTest is Test { function testWithdrawERC20AndBridge( uint128 amountDeposited, // uint128 to prevent total supply overflow - uint96 requiredSquidFee, // uint96 to not run out of the default fuceted ETH amount + uint96 requiredSquidFee, // uint96 to not run out of the default faucet ETH amount uint256 requiredPeanutFee ) public { vm.assume(requiredPeanutFee < amountDeposited); diff --git a/test/peanut/PeanutV4.t.sol b/test/peanut/PeanutV4.t.sol index 18737aef..11fe7a72 100644 --- a/test/peanut/PeanutV4.t.sol +++ b/test/peanut/PeanutV4.t.sol @@ -74,7 +74,7 @@ contract PeanutV4Test is Test { // If we attempt to deposit ECO tokens as pure ERC20s (i.e. with _contractType = 1), // makeDeposit function must revert. function testECOMaliciousDeposit() public { - // pretent that testToken is ECO + // pretend that testToken is ECO PeanutV4 peanutV4ECO = new PeanutV4(address(testToken), address(0)); // approve tokens to be spent by the new peanut instance diff --git a/test/peanut/PeanutV4Gasless.t.sol b/test/peanut/PeanutV4Gasless.t.sol index 03a8d6c9..19bcdaa7 100644 --- a/test/peanut/PeanutV4Gasless.t.sol +++ b/test/peanut/PeanutV4Gasless.t.sol @@ -30,7 +30,7 @@ contract PeanutV4GaslessTest is Test { peanutV4 = new PeanutV4(address(0), address(0)); } - function testMakeDepostERC20WithAuthorization() public { + function testMakeDepositERC20WithAuthorization() public { testToken.mint(SAMPLE_ADDRESS, 1000); uint256 amount = 1000; @@ -115,7 +115,7 @@ contract PeanutV4GaslessTest is Test { _withdrawDepositSenderGaslessEOA(depositIndex2, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, "DEPOSIT ALREADY WITHDRAWN"); // Correct depositor address, but wrong private key. - // Private key and the provied address don't match. + // Private key and the provided address don't match. _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS, SAMPLE_PRIVKEY_2, "INVALID SIGNATURE"); // Provided address and private key do match, but they are wrong. @@ -132,7 +132,7 @@ contract PeanutV4GaslessTest is Test { _withdrawDepositSenderGaslessEOA(depositIndex3, SAMPLE_ADDRESS_2, SAMPLE_PRIVKEY_2, ""); } - // Test that smart contract wallets are able to withdraw gsalessly too + // Test that smart contract wallets are able to withdraw gaslessly too function testWithdrawDepositSenderGaslessSCW() public { // Make a deposit SampleWallet scwallet = new SampleWallet(); diff --git a/test/peanut/RecipeintBound.t.sol b/test/peanut/RecipientBound.t.sol similarity index 100% rename from test/peanut/RecipeintBound.t.sol rename to test/peanut/RecipientBound.t.sol diff --git a/test/peanut/mocks/SquidMock.sol b/test/peanut/mocks/SquidMock.sol index 09579c13..bd3eb6c2 100644 --- a/test/peanut/mocks/SquidMock.sol +++ b/test/peanut/mocks/SquidMock.sol @@ -15,7 +15,7 @@ contract SquidMock { function superPowerfulBridge(address bridgedToken, uint256 bridgedAmount) public payable { if (bridgedToken == address(0)) { - require(msg.value == bridgedAmount, "msg.value DOESNT MATCH bridgedAmount"); + require(msg.value == bridgedAmount, "msg.value DOES NOT MATCH bridgedAmount"); } else { IERC20(bridgedToken).safeTransferFrom(msg.sender, address(this), bridgedAmount); } diff --git a/test/peanut/testMFA.sol b/test/peanut/testMFA.sol index 6f177f71..df84e5c1 100644 --- a/test/peanut/testMFA.sol +++ b/test/peanut/testMFA.sol @@ -47,7 +47,7 @@ contract PeanutV4MFATest is Test { vm.expectRevert("REQUIRES AUTHORIZATION"); peanutV4.withdrawDeposit(depositIndex, address(this), signature); - // Withdrawing with incorrect authorizattion signature + // Withdrawing with incorrect authorization signature vm.expectRevert("WRONG MFA SIGNATURE"); peanutV4.withdrawMFADeposit(depositIndex, address(this), signature, signature); diff --git a/test/peanut/testSenderWithdraw.sol b/test/peanut/testSenderWithdraw.sol index f1a93f61..9f7cbbe8 100644 --- a/test/peanut/testSenderWithdraw.sol +++ b/test/peanut/testSenderWithdraw.sol @@ -45,7 +45,7 @@ contract TestSenderWithdrawErc20 is Test { function setUp() public { console.log("Setting up test"); peanutV4 = new PeanutV4(address(0), address(0)); - testToken = new ERC20Mock(); // contractype 1 + testToken = new ERC20Mock(); // contractType 1 // Mint tokens for test accounts (larger than uint128) testToken.mint(address(this), 2 ** 130); @@ -79,7 +79,7 @@ contract TestSenderWithdrawErc721 is Test, ERC721Holder { function setUp() public { console.log("Setting up test"); peanutV4 = new PeanutV4(address(0), address(0)); - testToken = new ERC721Mock(); // contractype 2 + testToken = new ERC721Mock(); // contractType 2 // Mint token for test testToken.mint(address(this), _tokenId); @@ -113,7 +113,7 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { function setUp() public { console.log("Setting up test"); peanutV4 = new PeanutV4(address(0), address(0)); - testToken = new ERC1155Mock(); // contractype 3 + testToken = new ERC1155Mock(); // contractType 3 // Mint tokens for test testToken.mint(address(this), _tokenId, _tokenAmount, ""); From f25eca5c7fef659928457c751ea17f29e76ed844 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 16:29:51 -0400 Subject: [PATCH 15/31] test(peanut): adapt to repo style + add edge-case coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style alignment with the rest of the repo: - File rename: testFoo.sol → Foo.t.sol (matches *.t.sol forge convention) testDeposit → Deposit.t.sol testIntegration → Integration.t.sol testMFA → MFA.t.sol testSenderWithdraw → SenderWithdraw.t.sol testSigWithdraw → SigWithdraw.t.sol - Delete dead stubs (all entirely commented out / unused): testBatch.sol test/peanut/Batch/{testBatchDeposit, testBatchDepositEther, testBatchDepositEtherOptimized}.sol test/peanut/hardhat/PeanutV4.1.spec.ts (Hardhat-ts test; repo is Foundry-primary) - Cleaned three casual comments to match the repo's serious tone (kept all serious/technical comments): "Suuuuper dumb squid mock" → real NatSpec "Now we talkin'!" → "selfless deposit's owner can reclaim" "wooooooosh! Controlling the time" → "advance past reclaimableAfter" - Dropped {Suuuuper, talkin, wooooooosh} from .cspell.json whitelist. New edge-case suite — test/peanut/PeanutEdgeCases.t.sol — 20 tests: PeanutV4 deposit input validation: - INVALID CONTRACT TYPE (contractType >= 5) - WRONG ETH AMOUNT (msg.value mismatch) - AMOUNT MUST BE 1 FOR ERC721 - ECO via plain ERC-20 path rejected PeanutV4 withdraw input validation: - DEPOSIT INDEX DOES NOT EXIST - DEPOSIT ALREADY WITHDRAWN (double-claim) - WRONG SIGNATURE (signer mismatch) - NOT THE RECIPIENT (withdrawDepositAsRecipient caller mismatch) - WRONG RECIPIENT (address-bound deposit claimed by other) - TOO EARLY TO RECLAIM (recipient-bound sender reclaim before deadline) - NOT THE SENDER (non-sender reclaim) - REQUIRES AUTHORIZATION (MFA deposit, MFA_AUTHORIZER == 0) Views: - getDepositCount tracks length - getAllDepositsForAddress filters by sender Reentrancy: - Malicious ERC-20 reentering withdrawDeposit during safeTransfer is caught by nonReentrant (proves the guard works end-to-end). PeanutBatcherV4 input validation: - INVALID TOTAL ETHER SENT - PARAMETERS LENGTH MISMATCH (arbitrary batch) - ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED (ERC-721 raffle path) - Zero-length pubKeys is a no-op L2ECO inflation accounting: - Withdraw at higher multiplier returns proportionally less (the inflation-invariant share is what the depositor banked). 961/961 repo tests pass (was 941; +20 new edge cases). Spellcheck: 0 issues / 246 files. --- .cspell.json | 5 +- src/peanut/doc/PeanutV4.md | 2 +- test/peanut/Batch/testBatchDeposit.sol | 111 ------ test/peanut/Batch/testBatchDepositEther.sol | 161 --------- .../Batch/testBatchDepositEtherOptimized.sol | 160 --------- .../peanut/{testDeposit.sol => Deposit.t.sol} | 0 ...{testIntegration.sol => Integration.t.sol} | 0 test/peanut/{testMFA.sol => MFA.t.sol} | 0 test/peanut/PeanutEdgeCases.t.sol | 327 ++++++++++++++++++ test/peanut/PeanutV4.t.sol | 2 +- test/peanut/RecipientBound.t.sol | 4 +- ...enderWithdraw.sol => SenderWithdraw.t.sol} | 0 ...{testSigWithdraw.sol => SigWithdraw.t.sol} | 0 test/peanut/hardhat/PeanutV4.1.spec.ts | 178 ---------- test/peanut/mocks/SquidMock.sol | 6 +- test/peanut/testBatch.sol | 111 ------ 16 files changed, 334 insertions(+), 733 deletions(-) delete mode 100644 test/peanut/Batch/testBatchDeposit.sol delete mode 100644 test/peanut/Batch/testBatchDepositEther.sol delete mode 100644 test/peanut/Batch/testBatchDepositEtherOptimized.sol rename test/peanut/{testDeposit.sol => Deposit.t.sol} (100%) rename test/peanut/{testIntegration.sol => Integration.t.sol} (100%) rename test/peanut/{testMFA.sol => MFA.t.sol} (100%) create mode 100644 test/peanut/PeanutEdgeCases.t.sol rename test/peanut/{testSenderWithdraw.sol => SenderWithdraw.t.sol} (100%) rename test/peanut/{testSigWithdraw.sol => SigWithdraw.t.sol} (100%) delete mode 100644 test/peanut/hardhat/PeanutV4.1.spec.ts delete mode 100644 test/peanut/testBatch.sol diff --git a/.cspell.json b/.cspell.json index df4225c0..e29af002 100644 --- a/.cspell.json +++ b/.cspell.json @@ -122,9 +122,6 @@ "defi", "MAGICVALUE", "unhashed", - "Hashbinary", - "Suuuuper", - "talkin", - "wooooooosh" + "Hashbinary" ] } diff --git a/src/peanut/doc/PeanutV4.md b/src/peanut/doc/PeanutV4.md index f4061da4..0d31ce21 100644 --- a/src/peanut/doc/PeanutV4.md +++ b/src/peanut/doc/PeanutV4.md @@ -168,7 +168,7 @@ Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with arra | Suite | File | |---|---| -| Vendored upstream tests | `test/peanut/PeanutV4.t.sol`, `testDeposit.sol`, `testSigWithdraw.sol`, `testSenderWithdraw.sol`, `testMFA.sol`, `RecipientBound.t.sol`, `testIntegration.sol`, `PeanutV4Gasless.t.sol` | +| Vendored upstream tests | `test/peanut/PeanutV4.t.sol`, `Deposit.t.sol`, `SigWithdraw.t.sol`, `SenderWithdraw.t.sol`, `MFA.t.sol`, `RecipientBound.t.sol`, `Integration.t.sol`, `PeanutV4Gasless.t.sol` | | Hardening (S1–S4 + T1–T4) | `test/peanut/PeanutHardening.t.sol` | 71 tests pass. diff --git a/test/peanut/Batch/testBatchDeposit.sol b/test/peanut/Batch/testBatchDeposit.sol deleted file mode 100644 index f4a836aa..00000000 --- a/test/peanut/Batch/testBatchDeposit.sol +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.0; - -// import "forge-std/Test.sol"; -// import "../src/V4/PeanutV4.2.sol"; -// import "../src/util/ERC20Mock.sol"; -// import "../src/util/ERC721Mock.sol"; -// import "../src/util/ERC1155Mock.sol"; - -// contract test is Test { -// PeanutV4 public peanutV4; -// ERC20Mock public testToken; -// ERC721Mock public testToken721; -// ERC1155Mock public testToken1155; - -// // a dummy private/public keypair to test withdrawals -// address public constant PUBKEY20 = -// address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); -// bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; - -// function setUp() public { -// console.log("Setting up test"); -// peanutV4 = new PeanutV4(address(0)); -// testToken = new ERC20Mock(); -// testToken721 = new ERC721Mock(); -// // testToken1155 = new ERC1155Mock(); - -// // Mint tokens for test accounts -// testToken.mint(address(this), 10000000); -// testToken721.mint(address(this), 1); -// // testToken1155.mint(address(this), 1, 1000, ""); - -// // Approve PeanutV4 to spend tokens -// testToken.approve(address(peanutV4), 100000000); -// testToken721.setApprovalForAll(address(peanutV4), true); -// // testToken1155.setApprovalForAll(address(peanutV4), true); -// } - -// function testBatchMakeDeposit() public { -// address[] memory tokenAddresses = new address[](3); -// uint8[] memory contractTypes = new uint8[](3); -// uint256[] memory amounts = new uint256[](3); -// uint256[] memory tokenIds = new uint256[](3); -// address[] memory pubKeys20 = new address[](3); - -// // Deposit 1: ERC20 -// tokenAddresses[0] = address(testToken); -// contractTypes[0] = 1; -// amounts[0] = 100; -// tokenIds[0] = 0; -// pubKeys20[0] = PUBKEY20; - -// // Deposit 2: ERC721 -// tokenAddresses[1] = address(testToken721); -// contractTypes[1] = 2; -// amounts[1] = 1; -// tokenIds[1] = 1; -// pubKeys20[1] = PUBKEY20; - -// // Deposit 3: Ether -// tokenAddresses[2] = address(0); -// contractTypes[2] = 0; -// amounts[2] = 1 ether; -// tokenIds[2] = 0; -// pubKeys20[2] = PUBKEY20; - -// // Moved minting and approval to the setup function -// uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( -// tokenAddresses, -// contractTypes, -// amounts, -// tokenIds, -// pubKeys20 -// ); - -// assertEq(depositIndexes.length, 3, "Batch deposit failed"); -// assertEq(peanutV4.getDepositCount(), 3, "Deposit count mismatch"); -// } - -// // fuzzy testing of batchMakeDeposit with varying length of input arrays -// function testFuzz_BatchMakeDeposit_number( -// uint8 arrayLength -// ) public { -// address[] memory tokenAddresses = new address[](arrayLength); -// uint8[] memory contractTypes = new uint8[](arrayLength); -// uint256[] memory amounts = new uint256[](arrayLength); -// uint256[] memory tokenIds = new uint256[](arrayLength); -// address[] memory pubKeys20 = new address[](arrayLength); - -// // fill in dummy values for the arrays -// for (uint256 i = 0; i < arrayLength; i++) { -// tokenAddresses[i] = address(testToken); -// contractTypes[i] = 1; -// amounts[i] = 100; -// tokenIds[i] = 0; -// pubKeys20[i] = PUBKEY20; -// } - -// uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( -// tokenAddresses, -// contractTypes, -// amounts, -// tokenIds, -// pubKeys20 -// ); - -// assertEq(depositIndexes.length, arrayLength, "Batch deposit failed"); -// assertEq(peanutV4.getDepositCount(), arrayLength, "Deposit count mismatch"); -// } - -// } diff --git a/test/peanut/Batch/testBatchDepositEther.sol b/test/peanut/Batch/testBatchDepositEther.sol deleted file mode 100644 index 3bac30b3..00000000 --- a/test/peanut/Batch/testBatchDepositEther.sol +++ /dev/null @@ -1,161 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.0; - -// import "forge-std/Test.sol"; -// import "../src/V4/PeanutV4.2.sol"; -// import "../src/util/ERC20Mock.sol"; -// import "../src/util/ERC721Mock.sol"; -// import "../src/util/ERC1155Mock.sol"; - -// contract test is Test { -// PeanutV4 public peanutV4; -// ERC20Mock public testToken; -// ERC721Mock public testToken721; -// ERC1155Mock public testToken1155; - -// // a dummy private/public keypair to test withdrawals -// address public constant PUBKEY20 = -// address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); -// bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; - -// function setUp() public { -// console.log("Setting up test"); -// peanutV4 = new PeanutV4(address(0)); -// testToken = new ERC20Mock(); -// testToken721 = new ERC721Mock(); -// // testToken1155 = new ERC1155Mock(); - -// // Mint tokens for test accounts -// testToken.mint(address(this), 10000000); -// testToken721.mint(address(this), 1); -// // testToken1155.mint(address(this), 1, 1000, ""); - -// // Approve PeanutV4 to spend tokens -// testToken.approve(address(peanutV4), 100000000); -// testToken721.setApprovalForAll(address(peanutV4), true); -// // testToken1155.setApprovalForAll(address(peanutV4), true); -// } - -// // /** -// // * @notice Batch ERC20 token deposit -// // * @param _tokenAddress address of the token being sent -// // * @param _amounts uint256 array of the amounts of tokens being sent -// // * @param _pubKeys20 array of the last 20 bytes of the public keys of the deposit signers -// // * @return uint256[] array of indices of the deposits -// // */ -// // function batchMakeDepositERC20( -// // address _tokenAddress, -// // uint256[] calldata _amounts, -// // address[] calldata _pubKeys20 -// // ) external returns (uint256[] memory) { -// // require( -// // _amounts.length == _pubKeys20.length, -// // "PARAMETERS LENGTH MISMATCH" -// // ); - -// // uint256[] memory depositIndexes = new uint256[](_amounts.length); - -// // for (uint256 i = 0; i < _amounts.length; i++) { -// // depositIndexes[i] = makeDeposit( -// // _tokenAddress, -// // 1, -// // _amounts[i], -// // 0, -// // _pubKeys20[i] -// // ); -// // } - -// // return depositIndexes; -// // } -// function testBatchMakeDepositEther() public { -// uint256[] memory amounts = new uint256[](3); -// address[] memory pubKeys20 = new address[](3); -// amounts[0] = 100; -// amounts[1] = 200; -// amounts[2] = 300; -// pubKeys20[0] = PUBKEY20; -// pubKeys20[1] = PUBKEY20; -// pubKeys20[2] = PUBKEY20; - -// // value should be sum of amounts -// uint256[] memory depositIndexes = peanutV4.batchMakeDepositEther{value: 600}( -// amounts, -// pubKeys20 -// ); - -// assertEq(depositIndexes.length, 3, "Batch deposit failed"); -// assertEq(peanutV4.getDepositCount(), 3, "Deposit count mismatch"); - -// // console log the deposit indexes -// for (uint256 i = 0; i < depositIndexes.length; i++) { -// console.log("Deposit index: %s", depositIndexes[i]); -// } -// // console log the deposits themselves -// for (uint256 i = 0; i < depositIndexes.length; i++) { -// // print deposit index -// console.log(" Deposit index: %s", depositIndexes[i]); -// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).pubKey20); -// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).amount); -// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).tokenAddress); -// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).contractType); -// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).tokenId); -// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).senderAddress); -// console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).timestamp); -// } - -// } - -// function testBatchMakeDepositEther100() public { -// uint256[] memory amounts = new uint256[](100); -// address[] memory pubKeys20 = new address[](100); -// uint256 totalValue = 0; - -// // fill the arrays -// for (uint256 i = 0; i < 100; i++) { -// amounts[i] = 100; // or any other amount -// pubKeys20[i] = PUBKEY20; // or any other public key -// totalValue += amounts[i]; -// } - -// // value should be sum of amounts -// uint256[] memory depositIndexes = peanutV4.batchMakeDepositEther{value: totalValue}( -// amounts, -// pubKeys20 -// ); - -// assertEq(depositIndexes.length, 100, "Batch deposit failed"); -// assertEq(peanutV4.getDepositCount(), 100, "Deposit count mismatch"); -// } - -// // // fuzzy testing of batchMakeDeposit with varying length of input arrays -// // function testFuzz_BatchMakeDeposit_number( -// // uint8 arrayLength -// // ) public { -// // address[] memory tokenAddresses = new address[](arrayLength); -// // uint8[] memory contractTypes = new uint8[](arrayLength); -// // uint256[] memory amounts = new uint256[](arrayLength); -// // uint256[] memory tokenIds = new uint256[](arrayLength); -// // address[] memory pubKeys20 = new address[](arrayLength); - -// // // fill in dummy values for the arrays -// // for (uint256 i = 0; i < arrayLength; i++) { -// // tokenAddresses[i] = address(testToken); -// // contractTypes[i] = 1; -// // amounts[i] = 100; -// // tokenIds[i] = 0; -// // pubKeys20[i] = PUBKEY20; -// // } - -// // uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( -// // tokenAddresses, -// // contractTypes, -// // amounts, -// // tokenIds, -// // pubKeys20 -// // ); - -// // assertEq(depositIndexes.length, arrayLength, "Batch deposit failed"); -// // assertEq(peanutV4.getDepositCount(), arrayLength, "Deposit count mismatch"); -// // } - -// } diff --git a/test/peanut/Batch/testBatchDepositEtherOptimized.sol b/test/peanut/Batch/testBatchDepositEtherOptimized.sol deleted file mode 100644 index 40dc429e..00000000 --- a/test/peanut/Batch/testBatchDepositEtherOptimized.sol +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.0; - -// import "forge-std/Test.sol"; -// import "../src/V4/PeanutV4.2.sol"; -// import "../src/util/ERC20Mock.sol"; -// import "../src/util/ERC721Mock.sol"; -// import "../src/util/ERC1155Mock.sol"; - -// contract test is Test { -// PeanutV4 public peanutV4; -// ERC20Mock public testToken; -// ERC721Mock public testToken721; -// ERC1155Mock public testToken1155; - -// // a dummy private/public keypair to test withdrawals -// address public constant PUBKEY20 = -// address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); -// bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; - -// function setUp() public { -// console.log("Setting up test"); -// peanutV4 = new PeanutV4(address(0)); -// testToken = new ERC20Mock(); -// testToken721 = new ERC721Mock(); -// // testToken1155 = new ERC1155Mock(); - -// // Mint tokens for test accounts -// testToken.mint(address(this), 10000000); -// testToken721.mint(address(this), 1); -// // testToken1155.mint(address(this), 1, 1000, ""); - -// // Approve PeanutV4 to spend tokens -// testToken.approve(address(peanutV4), 100000000); -// testToken721.setApprovalForAll(address(peanutV4), true); -// // testToken1155.setApprovalForAll(address(peanutV4), true); -// } - -// // /** -// // * @notice Batch ERC20 token deposit -// // * @param _tokenAddress address of the token being sent -// // * @param _amounts uint256 array of the amounts of tokens being sent -// // * @param _pubKeys20 array of the last 20 bytes of the public keys of the deposit signers -// // * @return uint256[] array of indices of the deposits -// // */ -// // function batchMakeDepositERC20( -// // address _tokenAddress, -// // uint256[] calldata _amounts, -// // address[] calldata _pubKeys20 -// // ) external returns (uint256[] memory) { -// // require( -// // _amounts.length == _pubKeys20.length, -// // "PARAMETERS LENGTH MISMATCH" -// // ); - -// // uint256[] memory depositIndexes = new uint256[](_amounts.length); - -// // for (uint256 i = 0; i < _amounts.length; i++) { -// // depositIndexes[i] = makeDeposit( -// // _tokenAddress, -// // 1, -// // _amounts[i], -// // 0, -// // _pubKeys20[i] -// // ); -// // } - -// // return depositIndexes; -// // } -// function testBatchMakeDepositEtherOptimized() public { -// uint256[] memory amounts = new uint256[](3); -// address[] memory pubKeys20 = new address[](3); -// amounts[0] = 100; -// amounts[1] = 200; -// amounts[2] = 300; -// pubKeys20[0] = PUBKEY20; -// pubKeys20[1] = PUBKEY20; -// pubKeys20[2] = PUBKEY20; - -// // value should be sum of amounts -// uint256[] memory depositIndexes = peanutV4.batchMakeDepositEtherOptimized{value: 600}( -// amounts, -// pubKeys20 -// ); - -// assertEq(depositIndexes.length, 3, "Batch deposit failed"); -// assertEq(peanutV4.getDepositCount(), 3, "Deposit count mismatch"); - -// // // console log the deposit indexes -// // for (uint256 i = 0; i < depositIndexes.length; i++) { -// // console.log("Deposit index: %s", depositIndexes[i]); -// // } -// // // console log the deposits themselves -// // for (uint256 i = 0; i < depositIndexes.length; i++) { -// // // print deposit index -// // console.log(" Deposit index: %s", depositIndexes[i]); -// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).pubKey20); -// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).amount); -// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).tokenAddress); -// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).contractType); -// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).tokenId); -// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).senderAddress); -// // console.log("Deposit: %s", peanutV4.getDeposit(depositIndexes[i]).timestamp); -// // } -// } - -// function testBatchMakeDepositEtherOptimized100() public { -// uint256[] memory amounts = new uint256[](100); -// address[] memory pubKeys20 = new address[](100); -// uint256 totalValue = 0; - -// // fill the arrays -// for (uint256 i = 0; i < 100; i++) { -// amounts[i] = 100; // or any other amount -// pubKeys20[i] = PUBKEY20; // or any other public key -// totalValue += amounts[i]; -// } - -// // value should be sum of amounts -// uint256[] memory depositIndexes = peanutV4.batchMakeDepositEtherOptimized{value: totalValue}( -// amounts, -// pubKeys20 -// ); - -// assertEq(depositIndexes.length, 100, "Batch deposit failed"); -// assertEq(peanutV4.getDepositCount(), 100, "Deposit count mismatch"); -// } - -// // // fuzzy testing of batchMakeDeposit with varying length of input arrays -// // function testFuzz_BatchMakeDeposit_number( -// // uint8 arrayLength -// // ) public { -// // address[] memory tokenAddresses = new address[](arrayLength); -// // uint8[] memory contractTypes = new uint8[](arrayLength); -// // uint256[] memory amounts = new uint256[](arrayLength); -// // uint256[] memory tokenIds = new uint256[](arrayLength); -// // address[] memory pubKeys20 = new address[](arrayLength); - -// // // fill in dummy values for the arrays -// // for (uint256 i = 0; i < arrayLength; i++) { -// // tokenAddresses[i] = address(testToken); -// // contractTypes[i] = 1; -// // amounts[i] = 100; -// // tokenIds[i] = 0; -// // pubKeys20[i] = PUBKEY20; -// // } - -// // uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( -// // tokenAddresses, -// // contractTypes, -// // amounts, -// // tokenIds, -// // pubKeys20 -// // ); - -// // assertEq(depositIndexes.length, arrayLength, "Batch deposit failed"); -// // assertEq(peanutV4.getDepositCount(), arrayLength, "Deposit count mismatch"); -// // } - -// } diff --git a/test/peanut/testDeposit.sol b/test/peanut/Deposit.t.sol similarity index 100% rename from test/peanut/testDeposit.sol rename to test/peanut/Deposit.t.sol diff --git a/test/peanut/testIntegration.sol b/test/peanut/Integration.t.sol similarity index 100% rename from test/peanut/testIntegration.sol rename to test/peanut/Integration.t.sol diff --git a/test/peanut/testMFA.sol b/test/peanut/MFA.t.sol similarity index 100% rename from test/peanut/testMFA.sol rename to test/peanut/MFA.t.sol diff --git a/test/peanut/PeanutEdgeCases.t.sol b/test/peanut/PeanutEdgeCases.t.sol new file mode 100644 index 00000000..3b0a3de1 --- /dev/null +++ b/test/peanut/PeanutEdgeCases.t.sol @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +// Edge-case coverage for PeanutV4 / PeanutBatcherV4 — gates the vendored happy-path +// tests don't exercise directly. Names follow the repo's test_RevertWhen_* / test_* +// convention. Each test is single-purpose; comments explain the *why*, not the *what*. + +import {Test} from "forge-std/Test.sol"; +import {PeanutV4} from "../../src/peanut/V4/PeanutV4.4.sol"; +import {PeanutBatcherV4} from "../../src/peanut/V4/PeanutBatcherV4.4.sol"; +import {ERC20Mock} from "./mocks/ERC20Mock.sol"; +import {ERC721Mock} from "./mocks/ERC721Mock.sol"; +import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; +import {L2ECOMock} from "./mocks/L2ECOMock.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + +/// @dev Reentrancy probe: tries to call back into `peanut.withdrawDeposit` from inside +/// `safeTransfer`. Guarded by PeanutV4's `nonReentrant` modifier, so the inner call +/// reverts and the outer flow surfaces the inner revert reason ("REENTRANCY"). +contract ReentrantToken is ERC20Mock { + PeanutV4 public peanut; + uint256 public targetIdx; + bytes public targetSig; + address public attacker; + bool public attempted; + + function arm(PeanutV4 p, uint256 idx, bytes calldata sig, address atk) external { + peanut = p; + targetIdx = idx; + targetSig = sig; + attacker = atk; + } + + function _update(address from, address to, uint256 value) internal override { + super._update(from, to, value); + // Reenter once during the outer safeTransfer back to the recipient. + if (!attempted && address(peanut) != address(0) && to == attacker) { + attempted = true; + // This call should revert because the outer call holds the reentrancy lock. + try peanut.withdrawDeposit(targetIdx, attacker, targetSig) { + revert("REENTRANCY GUARD MISSING"); + } catch { + // expected — guard caught it + } + } + } +} + +contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { + PeanutV4 public peanut; + PeanutBatcherV4 public batcher; + ERC20Mock public erc20; + ERC721Mock public erc721; + ERC1155Mock public erc1155; + + // Stable test keypair (private key → pubKey20). + uint256 internal constant LINK_PRIV = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + address internal LINK_PUBKEY20; + + address internal constant ALICE = address(0xA11CE); + address internal constant BOB = address(0xB0B); + + function setUp() public { + LINK_PUBKEY20 = vm.addr(LINK_PRIV); + peanut = new PeanutV4(address(0), address(0)); + batcher = new PeanutBatcherV4(); + erc20 = new ERC20Mock(); + erc721 = new ERC721Mock(); + erc1155 = new ERC1155Mock(); + } + + receive() external payable {} + + // ── helpers ──────────────────────────────────────────────────────────── + + function _signWithdrawal(uint256 idx, address recipient, uint256 privKey) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + peanut.PEANUT_SALT(), + block.chainid, + address(peanut), + idx, + recipient, + peanut.ANYONE_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, digest); + return abi.encodePacked(r, s, v); + } + + function _depositEth(uint256 amount) internal returns (uint256) { + return peanut.makeDeposit{value: amount}(address(0), 0, amount, 0, LINK_PUBKEY20); + } + + // ── PeanutV4 deposit input validation ────────────────────────────────── + + function test_RevertWhen_DepositInvalidContractType() public { + // _pullTokensViaApproval rejects contractType >= 5. + vm.expectRevert("INVALID CONTRACT TYPE"); + peanut.makeDeposit{value: 0}(address(0), 5, 0, 0, LINK_PUBKEY20); + } + + function test_RevertWhen_DepositEthAmountMismatch() public { + // contractType==0 requires _amount == msg.value. + vm.expectRevert("WRONG ETH AMOUNT"); + peanut.makeDeposit{value: 100}(address(0), 0, 50, 0, LINK_PUBKEY20); + } + + function test_RevertWhen_DepositErc721AmountNotOne() public { + // contractType==2 requires _amount == 1. + erc721.mint(address(this), 1); + erc721.approve(address(peanut), 1); + vm.expectRevert("AMOUNT MUST BE 1 FOR ERC721"); + peanut.makeDeposit(address(erc721), 2, 2, 1, LINK_PUBKEY20); + } + + function test_RevertWhen_DepositEcoTokenViaPlainErc20() public { + // Deploying with _ecoAddress = testToken forces contractType==4 for that token. + PeanutV4 ecoVault = new PeanutV4(address(erc20), address(0)); + erc20.mint(address(this), 100); + erc20.approve(address(ecoVault), 100); + vm.expectRevert("ECO DEPOSITS MUST USE _contractType 4"); + ecoVault.makeDeposit(address(erc20), 1, 100, 0, LINK_PUBKEY20); + } + + // ── PeanutV4 withdraw input validation ───────────────────────────────── + + function test_RevertWhen_WithdrawIndexOutOfBounds() public { + bytes memory sig = _signWithdrawal(99, ALICE, LINK_PRIV); + vm.expectRevert("DEPOSIT INDEX DOES NOT EXIST"); + peanut.withdrawDeposit(99, ALICE, sig); + } + + function test_RevertWhen_WithdrawTwice() public { + uint256 idx = _depositEth(1 ether); + bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); + peanut.withdrawDeposit(idx, ALICE, sig); + + vm.expectRevert("DEPOSIT ALREADY WITHDRAWN"); + peanut.withdrawDeposit(idx, ALICE, sig); + } + + function test_RevertWhen_WithdrawWithWrongSigner() public { + uint256 idx = _depositEth(1 ether); + // Sign with a private key that does NOT correspond to the deposit's pubKey20. + uint256 wrongKey = uint256(keccak256("wrong-signer")); + bytes memory sig = _signWithdrawal(idx, ALICE, wrongKey); + + vm.expectRevert("WRONG SIGNATURE"); + peanut.withdrawDeposit(idx, ALICE, sig); + } + + function test_RevertWhen_WithdrawAsRecipientCallerMismatch() public { + // Recipient-mode signature; caller must equal the recipient. + uint256 idx = _depositEth(1 ether); + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + peanut.PEANUT_SALT(), + block.chainid, + address(peanut), + idx, + ALICE, + peanut.RECIPIENT_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + // BOB tries to call on behalf of ALICE — caller must equal the recipient param. + vm.prank(BOB); + vm.expectRevert("NOT THE RECIPIENT"); + peanut.withdrawDepositAsRecipient(idx, ALICE, sig); + } + + function test_RevertWhen_RecipientBoundClaimedByOtherAddress() public { + // Address-bound deposit: recipient = ALICE. + uint256 idx = peanut.makeCustomDeposit{value: 1 ether}( + address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, 0, false, "" + ); + // Even with a valid pubKey signature, the contract-stored recipient blocks + // anyone else from being the named recipient on withdrawal. + bytes memory sig = _signWithdrawal(idx, BOB, LINK_PRIV); + vm.expectRevert("WRONG RECIPIENT"); + peanut.withdrawDeposit(idx, BOB, sig); + } + + function test_RecipientBoundSenderCannotReclaimBeforeDeadline() public { + uint40 reclaimAfter = uint40(block.timestamp + 1 days); + uint256 idx = peanut.makeCustomDeposit{value: 1 ether}( + address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, reclaimAfter, false, "" + ); + vm.expectRevert("TOO EARLY TO RECLAIM"); + peanut.withdrawDepositSender(idx); + + vm.warp(reclaimAfter + 1); + peanut.withdrawDepositSender(idx); // succeeds after the deadline + } + + function test_RevertWhen_SenderReclaimNotTheSender() public { + uint256 idx = _depositEth(1 ether); + vm.prank(ALICE); + vm.expectRevert("NOT THE SENDER"); + peanut.withdrawDepositSender(idx); + } + + function test_RevertWhen_MFADepositWithoutMFASignature() public { + // peanut is deployed with MFA_AUTHORIZER == address(0), so MFA-flagged + // deposits can never be withdrawn via withdrawDeposit (REQUIRES AUTHORIZATION). + uint256 idx = peanut.makeMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY20); + bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); + vm.expectRevert("REQUIRES AUTHORIZATION"); + peanut.withdrawDeposit(idx, ALICE, sig); + } + + // ── PeanutV4 views ───────────────────────────────────────────────────── + + function test_GetAllDepositsForAddressFiltersBySender() public { + _depositEth(1); + _depositEth(1); + // Same sender (address(this)) made both deposits. + PeanutV4.Deposit[] memory mine = peanut.getAllDepositsForAddress(address(this)); + assertEq(mine.length, 2); + + // Different sender → empty. + PeanutV4.Deposit[] memory aliceDeposits = peanut.getAllDepositsForAddress(ALICE); + assertEq(aliceDeposits.length, 0); + } + + function test_DepositCountTracksArrayLength() public { + assertEq(peanut.getDepositCount(), 0); + _depositEth(1); + _depositEth(1); + _depositEth(1); + assertEq(peanut.getDepositCount(), 3); + } + + // ── PeanutV4 reentrancy ──────────────────────────────────────────────── + + function test_NonReentrantBlocksReentryFromMaliciousToken() public { + ReentrantToken evil = new ReentrantToken(); + evil.mint(address(this), 100); + evil.approve(address(peanut), 100); + + // Deposit type-1 (ERC-20) so withdraw routes back through the token's transfer. + uint256 idx = peanut.makeDeposit(address(evil), 1, 100, 0, LINK_PUBKEY20); + bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); + + // Arm the token to reenter inside its _update during the outgoing safeTransfer. + evil.arm(peanut, idx, sig, ALICE); + + // Outer withdraw succeeds (inner reentrant attempt caught and swallowed by try/catch); + // the reentrancy guard ensured the inner call could not double-spend. + peanut.withdrawDeposit(idx, ALICE, sig); + assertEq(evil.balanceOf(ALICE), 100); + assertTrue(evil.attempted(), "reentrancy attempt should have run"); + } + + // ── PeanutBatcherV4 input validation ─────────────────────────────────── + + function test_RevertWhen_BatchEthAmountMismatch() public { + address[] memory pubKeys = new address[](3); + for (uint256 i = 0; i < 3; i++) pubKeys[i] = LINK_PUBKEY20; + vm.expectRevert("INVALID TOTAL ETHER SENT"); + batcher.batchMakeDeposit{value: 1 ether}(address(peanut), address(0), 0, 1 ether, 0, pubKeys); + // expected 3 * 1 ether, sent 1 ether + } + + function test_RevertWhen_BatchArbitraryArrayLengthMismatch() public { + // _withMFAs.length differs from the others. + address[] memory tokens = new address[](2); + uint8[] memory types = new uint8[](2); + uint256[] memory amounts = new uint256[](2); + uint256[] memory ids = new uint256[](2); + address[] memory pks = new address[](2); + bool[] memory mfa = new bool[](3); // wrong length + + vm.expectRevert("PARAMETERS LENGTH MISMATCH"); + batcher.batchMakeDepositArbitrary(address(peanut), tokens, types, amounts, ids, pks, mfa); + } + + function test_RevertWhen_BatchRaffleErc721NotSupported() public { + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1; + vm.expectRevert("ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED"); + batcher.batchMakeDepositRaffle(address(peanut), address(erc721), 2, amounts, LINK_PUBKEY20); + } + + function test_BatchZeroLengthDepositsIsNoop() public { + address[] memory pubKeys = new address[](0); + uint256[] memory ids = batcher.batchMakeDeposit(address(peanut), address(0), 0, 0, 0, pubKeys); + assertEq(ids.length, 0); + assertEq(peanut.getDepositCount(), 0); + } + + // ── L2ECO inflation-invariant accounting ─────────────────────────────── + + function test_L2ECOWithdrawAdjustsForChangedInflation() public { + // Deposit at multiplier=2 stores `amount * 2` as the inflation-invariant amount. + // If the multiplier changes before withdrawal, the recipient receives + // `stored / current` raw tokens — proportional to the depositor's share of the + // rebasing token's supply at deposit time. + L2ECOMock eco = new L2ECOMock(2); + eco.mint(address(this), 100); + eco.approve(address(peanut), 100); + uint256 idx = peanut.makeDeposit(address(eco), 4, 100, 0, LINK_PUBKEY20); + + // Multiplier increases from 2 → 4 (token supply doubled). The vault holds 100 + // raw tokens but the "share" is recorded as 200 (= 100 * 2). At multiplier 4 + // the share is now worth 200 / 4 = 50 raw tokens. Simulate the rebase by + // also reducing the vault's token balance to match (mock doesn't auto-rebase). + eco.setMultiplier(4); + // Burn half the vault's balance to mirror what a real rebase would do to it. + vm.prank(address(peanut)); + eco.transfer(address(0xdead), 50); + + bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); + peanut.withdrawDeposit(idx, ALICE, sig); + + assertEq(eco.balanceOf(ALICE), 50); + } +} diff --git a/test/peanut/PeanutV4.t.sol b/test/peanut/PeanutV4.t.sol index 11fe7a72..e4dcff53 100644 --- a/test/peanut/PeanutV4.t.sol +++ b/test/peanut/PeanutV4.t.sol @@ -67,7 +67,7 @@ contract PeanutV4Test is Test { vm.expectRevert("NOT THE SENDER"); peanutV4.withdrawDepositSender(depositIndex); - vm.prank(SAMPLE_ADDRESS); // Now we talkin'! + vm.prank(SAMPLE_ADDRESS); // selfless deposit's owner can reclaim peanutV4.withdrawDepositSender(depositIndex); } diff --git a/test/peanut/RecipientBound.t.sol b/test/peanut/RecipientBound.t.sol index 020af0f5..76c6ddcc 100644 --- a/test/peanut/RecipientBound.t.sol +++ b/test/peanut/RecipientBound.t.sol @@ -75,8 +75,8 @@ contract RecipientBoundTest is Test { vm.expectRevert("TOO EARLY TO RECLAIM"); peanutV4.withdrawDepositSender(depositIndex); - vm.warp(block.timestamp + 11); // wooooooosh! Controlling the time :) - peanutV4.withdrawDepositSender(depositIndex); // reclaim! + vm.warp(block.timestamp + 11); // advance past reclaimableAfter + peanutV4.withdrawDepositSender(depositIndex); require(testToken.balanceOf(address(this)) == 1000, "WAS NOT REFUNDED!"); } } diff --git a/test/peanut/testSenderWithdraw.sol b/test/peanut/SenderWithdraw.t.sol similarity index 100% rename from test/peanut/testSenderWithdraw.sol rename to test/peanut/SenderWithdraw.t.sol diff --git a/test/peanut/testSigWithdraw.sol b/test/peanut/SigWithdraw.t.sol similarity index 100% rename from test/peanut/testSigWithdraw.sol rename to test/peanut/SigWithdraw.t.sol diff --git a/test/peanut/hardhat/PeanutV4.1.spec.ts b/test/peanut/hardhat/PeanutV4.1.spec.ts deleted file mode 100644 index f740a5c1..00000000 --- a/test/peanut/hardhat/PeanutV4.1.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* eslint-disable camelcase */ -import { ethers } from 'hardhat' -import { Signer, Contract, constants, BigNumber } from 'ethers' -import { smock, FakeContract, MockContract } from '@defi-wonderland/smock' -import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' - -export const REGISTRY_DEPLOY_TX = - '0xf90a388085174876e800830c35008080b909e5608060405234801561001057600080fd5b506109c5806100206000396000f3fe608060405234801561001057600080fd5b50600436106100a5576000357c010000000000000000000000000000000000000000000000000000000090048063a41e7d5111610078578063a41e7d51146101d4578063aabbb8ca1461020a578063b705676514610236578063f712f3e814610280576100a5565b806329965a1d146100aa5780633d584063146100e25780635df8122f1461012457806365ba36c114610152575b600080fd5b6100e0600480360360608110156100c057600080fd5b50600160a060020a038135811691602081013591604090910135166102b6565b005b610108600480360360208110156100f857600080fd5b5035600160a060020a0316610570565b60408051600160a060020a039092168252519081900360200190f35b6100e06004803603604081101561013a57600080fd5b50600160a060020a03813581169160200135166105bc565b6101c26004803603602081101561016857600080fd5b81019060208101813564010000000081111561018357600080fd5b82018360208201111561019557600080fd5b803590602001918460018302840111640100000000831117156101b757600080fd5b5090925090506106b3565b60408051918252519081900360200190f35b6100e0600480360360408110156101ea57600080fd5b508035600160a060020a03169060200135600160e060020a0319166106ee565b6101086004803603604081101561022057600080fd5b50600160a060020a038135169060200135610778565b61026c6004803603604081101561024c57600080fd5b508035600160a060020a03169060200135600160e060020a0319166107ef565b604080519115158252519081900360200190f35b61026c6004803603604081101561029657600080fd5b508035600160a060020a03169060200135600160e060020a0319166108aa565b6000600160a060020a038416156102cd57836102cf565b335b9050336102db82610570565b600160a060020a031614610339576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b6103428361092a565b15610397576040805160e560020a62461bcd02815260206004820152601a60248201527f4d757374206e6f7420626520616e204552433136352068617368000000000000604482015290519081900360640190fd5b600160a060020a038216158015906103b85750600160a060020a0382163314155b156104ff5760405160200180807f455243313832305f4143434550545f4d4147494300000000000000000000000081525060140190506040516020818303038152906040528051906020012082600160a060020a031663249cb3fa85846040518363ffffffff167c01000000000000000000000000000000000000000000000000000000000281526004018083815260200182600160a060020a0316600160a060020a031681526020019250505060206040518083038186803b15801561047e57600080fd5b505afa158015610492573d6000803e3d6000fd5b505050506040513d60208110156104a857600080fd5b5051146104ff576040805160e560020a62461bcd02815260206004820181905260248201527f446f6573206e6f7420696d706c656d656e742074686520696e74657266616365604482015290519081900360640190fd5b600160a060020a03818116600081815260208181526040808320888452909152808220805473ffffffffffffffffffffffffffffffffffffffff19169487169485179055518692917f93baa6efbd2244243bfee6ce4cfdd1d04fc4c0e9a786abd3a41313bd352db15391a450505050565b600160a060020a03818116600090815260016020526040812054909116151561059a5750806105b7565b50600160a060020a03808216600090815260016020526040902054165b919050565b336105c683610570565b600160a060020a031614610624576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b81600160a060020a031681600160a060020a0316146106435780610646565b60005b600160a060020a03838116600081815260016020526040808220805473ffffffffffffffffffffffffffffffffffffffff19169585169590951790945592519184169290917f605c2dbf762e5f7d60a546d42e7205dcb1b011ebc62a61736a57c9089d3a43509190a35050565b600082826040516020018083838082843780830192505050925050506040516020818303038152906040528051906020012090505b92915050565b6106f882826107ef565b610703576000610705565b815b600160a060020a03928316600081815260208181526040808320600160e060020a031996909616808452958252808320805473ffffffffffffffffffffffffffffffffffffffff19169590971694909417909555908152600284528181209281529190925220805460ff19166001179055565b600080600160a060020a038416156107905783610792565b335b905061079d8361092a565b156107c357826107ad82826108aa565b6107b85760006107ba565b815b925050506106e8565b600160a060020a0390811660009081526020818152604080832086845290915290205416905092915050565b6000808061081d857f01ffc9a70000000000000000000000000000000000000000000000000000000061094c565b909250905081158061082d575080155b1561083d576000925050506106e8565b61084f85600160e060020a031961094c565b909250905081158061086057508015155b15610870576000925050506106e8565b61087a858561094c565b909250905060018214801561088f5750806001145b1561089f576001925050506106e8565b506000949350505050565b600160a060020a0382166000908152600260209081526040808320600160e060020a03198516845290915281205460ff1615156108f2576108eb83836107ef565b90506106e8565b50600160a060020a03808316600081815260208181526040808320600160e060020a0319871684529091529020549091161492915050565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff161590565b6040517f01ffc9a7000000000000000000000000000000000000000000000000000000008082526004820183905260009182919060208160248189617530fa90519096909550935050505056fea165627a7a72305820377f4a2d4301ede9949f163f319021a6e9c687c292a5e2b2c4734c126b524e6c00291ba01820182018201820182018201820182018201820182018201820182018201820a01820182018201820182018201820182018201820182018201820182018201820' -export const REGISTRY_DEPLOYER_ADDRESS = - '0xa990077c3205cbDf861e17Fa532eeB069cE9fF96' - -const UNUSED_ADDRESS = '0x1111111111111111111111111111111111111111' -const TOTAL_SUPPLY = ethers.utils.parseUnits('5', 'ether') // 5 ECO -const INITIAL_INFLATION_MULTIPLIER = ethers.utils.parseUnits('1', 'ether') // 1e18 - -describe('PeanutV3.1', () => { - let alice: SignerWithAddress - let bob: SignerWithAddress - let charlie: SignerWithAddress - - before(async () => { - ;[alice, bob, charlie] = await ethers.getSigners() - await ( - await alice.sendTransaction({ - to: REGISTRY_DEPLOYER_ADDRESS, - value: ethers.utils.parseEther('0.08'), - }) - ).wait() - if (alice.provider) { - await (await alice.provider.sendTransaction(REGISTRY_DEPLOY_TX)).wait() - } - }) - - let Peanut: MockContract - let ECO: MockContract - beforeEach(async () => { - Peanut = await (await smock.mock('PeanutV3')).deploy() - - // deploy an ECO mock to test against - ECO = await (await smock.mock( - 'PeanutECO') - ).deploy( - UNUSED_ADDRESS, // none of the constructor arguments are used - UNUSED_ADDRESS, - 0, - UNUSED_ADDRESS, - ) - - await ECO.connect(alice).freeMint(TOTAL_SUPPLY) - }) - - describe('makeDeposit', () => { - const depositAmount = ethers.utils.parseUnits('1', 'ether') - beforeEach(async () => { - await ECO.connect(alice).approve(Peanut.address, depositAmount) - }) - - it('can deposit', async () => { - await Peanut.connect(alice).makeDeposit( - ECO.address, - 4, - depositAmount, - 0, - bob.address, - ) - }) - - it('deposit emits the correct event', async () => { - await expect( - Peanut.connect(alice).makeDeposit( - ECO.address, - 4, - depositAmount, - 0, - bob.address, - ) - ).to.emit(Peanut, 'DepositEvent') - .withArgs( - 0, - 4, - depositAmount.mul(INITIAL_INFLATION_MULTIPLIER), - alice.address, - ) - }) - - it('stores the correct data', async () => { - await Peanut.connect(alice).makeDeposit( - ECO.address, - 4, - depositAmount, - 1, - bob.address, - ) - - const deposit = await Peanut.deposits(0) - expect(deposit.pubKey20 === bob.address).to.be.true - expect(deposit.amount.eq(depositAmount.mul(INITIAL_INFLATION_MULTIPLIER))).to.be.true - expect(deposit.tokenAddress === ECO.address).to.be.true - expect(deposit.contractType === 4).to.be.true - expect(deposit.tokenId.eq('1')).to.be.true - }) - }) - - describe('makeWithdrawal', () => { - const depositAmount = ethers.utils.parseUnits('1', 'ether') - let signature - let presignedAddrHash - - beforeEach(async () => { - await ECO.connect(alice).approve(Peanut.address, depositAmount) - await Peanut.connect(alice).makeDeposit( - ECO.address, - 4, - depositAmount, - 0, - bob.address, - ) - - const addrHash = ethers.utils.solidityKeccak256(['address'], [charlie.address.toLocaleLowerCase()]) - const addrHashbinary = ethers.utils.arrayify(addrHash) - presignedAddrHash = ethers.utils.hashMessage(addrHashbinary) - signature = await bob.signMessage(addrHashbinary); - }) - - it('can withdraw', async () => { - await Peanut.withdrawDeposit( - 0, - charlie.address, - presignedAddrHash, - signature - ) - }) - - it('withdraw emits the right event', async () => { - await expect(Peanut.withdrawDeposit( - 0, - charlie.address, - presignedAddrHash, - signature - )).to.emit(Peanut,'WithdrawEvent') - .withArgs( - 0, - 4, - depositAmount.mul(INITIAL_INFLATION_MULTIPLIER), - charlie.address, - ) - }) - - it('sends tokens', async () => { - expect((await ECO.balanceOf(charlie.address)).eq(0)).to.be.true - await Peanut.withdrawDeposit( - 0, - charlie.address, - presignedAddrHash, - signature - ) - expect((await ECO.balanceOf(charlie.address)).eq(depositAmount)).to.be.true - }) - - it('is rebase safe', async () => { - expect((await ECO.balanceOf(charlie.address)).eq(0)).to.be.true - await ECO.setVariable('_linearInflationCheckpoints', [ - { - fromBlock: (await alice.provider?.getBlock('latest'))?.number, - value: INITIAL_INFLATION_MULTIPLIER.div(2), - }, - ]) - await Peanut.withdrawDeposit( - 0, - charlie.address, - presignedAddrHash, - signature - ) - expect((await ECO.balanceOf(charlie.address)).eq(depositAmount.mul(2))).to.be.true - }) - }) -}) \ No newline at end of file diff --git a/test/peanut/mocks/SquidMock.sol b/test/peanut/mocks/SquidMock.sol index bd3eb6c2..54db78e2 100644 --- a/test/peanut/mocks/SquidMock.sol +++ b/test/peanut/mocks/SquidMock.sol @@ -4,10 +4,8 @@ pragma solidity 0.8.26; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -// Suuuuper dumb squid mock. -// We call squid router with just a blob of calldata and don't care about the details -// (e.g. which function was called, with what particular arguments, etc.), -// so here we just have a simple function that we encode into a calldata blob in tests. +/// @dev Test mock for the Squid router. PeanutRouter forwards an opaque calldata blob +/// to Squid; this mock just records that the blob was delivered. contract SquidMock { using SafeERC20 for IERC20; diff --git a/test/peanut/testBatch.sol b/test/peanut/testBatch.sol deleted file mode 100644 index da0e8022..00000000 --- a/test/peanut/testBatch.sol +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.0; - -// import "forge-std/Test.sol"; -// import "../../src/V4/PeanutV4.2.sol"; -// import "../../src/util/ERC20Mock.sol"; -// import "../../src/util/ERC721Mock.sol"; -// import "../../src/util/ERC1155Mock.sol"; - -// contract test is Test { -// PeanutV4 public peanutV4; -// ERC20Mock public testToken; -// ERC721Mock public testToken721; -// ERC1155Mock public testToken1155; - -// // a dummy private/public keypair to test withdrawals -// address public constant PUBKEY20 = -// address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); -// bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; - -// function setUp() public { -// console.log("Setting up test"); -// peanutV4 = new PeanutV4(address(0), address(0)); -// testToken = new ERC20Mock(); -// testToken721 = new ERC721Mock(); -// // testToken1155 = new ERC1155Mock(); - -// // Mint tokens for test accounts -// testToken.mint(address(this), 10000000); -// testToken721.mint(address(this), 1); -// // testToken1155.mint(address(this), 1, 1000, ""); - -// // Approve PeanutV4 to spend tokens -// testToken.approve(address(peanutV4), 100000000); -// testToken721.setApprovalForAll(address(peanutV4), true); -// // testToken1155.setApprovalForAll(address(peanutV4), true); -// } - -// function testBatchMakeDeposit() public { -// address[] memory tokenAddresses = new address[](3); -// uint8[] memory contractTypes = new uint8[](3); -// uint256[] memory amounts = new uint256[](3); -// uint256[] memory tokenIds = new uint256[](3); -// address[] memory pubKeys20 = new address[](3); - -// // Deposit 1: ERC20 -// tokenAddresses[0] = address(testToken); -// contractTypes[0] = 1; -// amounts[0] = 100; -// tokenIds[0] = 0; -// pubKeys20[0] = PUBKEY20; - -// // Deposit 2: ERC721 -// tokenAddresses[1] = address(testToken721); -// contractTypes[1] = 2; -// amounts[1] = 1; -// tokenIds[1] = 1; -// pubKeys20[1] = PUBKEY20; - -// // Deposit 3: Ether -// tokenAddresses[2] = address(0); -// contractTypes[2] = 0; -// amounts[2] = 1 ether; -// tokenIds[2] = 0; -// pubKeys20[2] = PUBKEY20; - -// // Moved minting and approval to the setup function -// uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( -// tokenAddresses, -// contractTypes, -// amounts, -// tokenIds, -// pubKeys20 -// ); - -// assertEq(depositIndexes.length, 3, "Batch deposit failed"); -// assertEq(peanutV4.getDepositCount(), 3, "Deposit count mismatch"); -// } - -// fuzzy testing of batchMakeDeposit with varying length of input arrays -// function testFuzz_BatchMakeDeposit_number( -// uint8 arrayLength -// ) public { -// address[] memory tokenAddresses = new address[](arrayLength); -// uint8[] memory contractTypes = new uint8[](arrayLength); -// uint256[] memory amounts = new uint256[](arrayLength); -// uint256[] memory tokenIds = new uint256[](arrayLength); -// address[] memory pubKeys20 = new address[](arrayLength); - -// // fill in dummy values for the arrays -// for (uint256 i = 0; i < arrayLength; i++) { -// tokenAddresses[i] = address(testToken); -// contractTypes[i] = 1; -// amounts[i] = 100; -// tokenIds[i] = 0; -// pubKeys20[i] = PUBKEY20; -// } - -// uint256[] memory depositIndexes = peanutV4.batchMakeDeposit{value: 1 ether}( -// tokenAddresses, -// contractTypes, -// amounts, -// tokenIds, -// pubKeys20 -// ); - -// assertEq(depositIndexes.length, arrayLength, "Batch deposit failed"); -// assertEq(peanutV4.getDepositCount(), arrayLength, "Deposit count mismatch"); -// } - -// } From 8fe7adb5ae08bbaface3545f299316fdef7315d6 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 16:44:30 -0400 Subject: [PATCH 16/31] chore(lint): exclude vendored peanut sources from solhint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo's solhint config treats gas-custom-errors as an error (not a warning). The vendored Peanut V4.4 / Batcher / Router use require-string patterns extensively (~40 instances). Converting them to custom errors would diverge significantly from upstream (peanutprotocol/peanut-contracts@main) without any security or correctness benefit — only a style change. Add the three vendored Solidity files to .solhintignore so CI's lint job passes. The new code in this PR (EnvelopeApprovalPaymaster, hardening tests, edge-case tests, deploy scripts) already uses custom errors and is NOT in the ignore list — it remains lint-clean. Local: yarn lint → 0 errors / 175 warnings (warnings are non-blocking and all pre-existing in non-peanut code). --- .solhintignore | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .solhintignore diff --git a/.solhintignore b/.solhintignore new file mode 100644 index 00000000..6255e0d3 --- /dev/null +++ b/.solhintignore @@ -0,0 +1,10 @@ +# Vendored Peanut Protocol V4.4 sources — kept close to upstream +# (peanutprotocol/peanut-contracts@main) for diff parity. Upstream uses +# require-string style; converting to custom errors would diverge +# significantly without any security/correctness benefit. +# +# Our own code (EnvelopeApprovalPaymaster, anything authored in this repo) +# is NOT in this list and remains lint-clean. +src/peanut/V4/PeanutV4.4.sol +src/peanut/V4/PeanutBatcherV4.4.sol +src/peanut/V4/PeanutRouter.sol From fb450f5b6ed2b5dece67bbece19f6b0280db2734 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 17:08:34 -0400 Subject: [PATCH 17/31] fix(peanut): address PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four issues raised by the Copilot review on PR #115: 1. EnvelopeApprovalPaymaster: switch operator-signature verification from ECDSA.recover to SignatureChecker.isValidSignatureNow, matching the constructor docstring's promise of EOA-or-contract signers. Now accepts EIP-1271 smart-contract operatorSigners (multisigs etc.). 2. PeanutBatcherV4.batchMakeDepositNoReturn: latent upstream bug — the inner call forwarded {value: msg.value} per loop iteration but the batcher only received msg.value once. For ETH batches with N > 1, the second iteration would revert with insufficient balance. Now requires msg.value == _amount * N for ETH and msg.value == 0 for non-ETH (prevents stuck dust in the vault too). 3. test/peanut/SigWithdraw.t.sol: SPDX `BUSL-1.1` → `UNLICENSED` to match the rest of the vendored test suite. 4. PeanutV4: `address public ecoAddress` → `immutable` (matches the doc + small gas saving; the value is set in constructor and never mutated). New tests: - test_acceptsEip1271ContractSigner — proves SignatureChecker path accepts a SampleWallet (EIP-1271) as operatorSigner - test_BatchNoReturnEth_HappyPath — 3-deposit ETH batch round-trips - test_RevertWhen_BatchNoReturnEthAmountMismatch — total mismatch - test_RevertWhen_BatchNoReturnEthSentForErc20 — msg.value > 0 with ERC-20 path is rejected forge test: 965/965 (was 961; +4 new). yarn lint: 0 errors. yarn spellcheck: 0 issues. --- src/paymasters/EnvelopeApprovalPaymaster.sol | 8 +++-- src/peanut/V4/PeanutBatcherV4.4.sol | 15 +++++++- src/peanut/V4/PeanutV4.4.sol | 2 +- .../EnvelopeApprovalPaymaster.t.sol | 31 ++++++++++++++++ test/peanut/PeanutEdgeCases.t.sol | 35 +++++++++++++++++++ test/peanut/SigWithdraw.t.sol | 2 +- 6 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/paymasters/EnvelopeApprovalPaymaster.sol b/src/paymasters/EnvelopeApprovalPaymaster.sol index cdd5d1ef..a11e9b57 100644 --- a/src/paymasters/EnvelopeApprovalPaymaster.sol +++ b/src/paymasters/EnvelopeApprovalPaymaster.sol @@ -7,7 +7,7 @@ import { } from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymaster.sol"; import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "./BasePaymaster.sol"; import {QuotaControl} from "../QuotaControl.sol"; @@ -143,7 +143,11 @@ contract EnvelopeApprovalPaymaster is BasePaymaster, QuotaControl { bytes32 structHash = keccak256(abi.encode(GRANT_TYPEHASH, user, deadline, nonce)); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); - if (ECDSA.recover(digest, signature) != operatorSigner) revert InvalidGrantSignature(); + // SignatureChecker supports both EOA ECDSA signatures and EIP-1271 contract signers, + // so operatorSigner can be a multisig / smart account in production. + if (!SignatureChecker.isValidSignatureNow(operatorSigner, digest, signature)) { + revert InvalidGrantSignature(); + } isNonceUsed[nonce] = true; } diff --git a/src/peanut/V4/PeanutBatcherV4.4.sol b/src/peanut/V4/PeanutBatcherV4.4.sol index 3408c1ce..7f6aae1b 100644 --- a/src/peanut/V4/PeanutBatcherV4.4.sol +++ b/src/peanut/V4/PeanutBatcherV4.4.sol @@ -110,8 +110,21 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { address[] calldata _pubKeys20 ) external payable { PeanutV4 peanut = PeanutV4(_peanutAddress); + // For ETH (contractType == 0), the batcher only receives msg.value once; forwarding + // {value: msg.value} per loop iteration would revert on iteration 2 with insufficient + // balance. Either require msg.value == _amount * N and forward _amount per call, or + // for non-ETH paths require msg.value == 0 (no stuck dust in the vault). + uint256 etherPerCall; + if (_contractType == 0) { + require(msg.value == _amount * _pubKeys20.length, "INVALID TOTAL ETHER SENT"); + etherPerCall = _amount; + } else { + require(msg.value == 0, "ETH NOT ACCEPTED FOR NON-ETH DEPOSIT"); + etherPerCall = 0; + } + for (uint256 i = 0; i < _pubKeys20.length; i++) { - peanut.makeSelflessDeposit{value: msg.value}( + peanut.makeSelflessDeposit{value: etherPerCall}( _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender ); } diff --git a/src/peanut/V4/PeanutV4.4.sol b/src/peanut/V4/PeanutV4.4.sol index 406f49cd..6e7e2f16 100644 --- a/src/peanut/V4/PeanutV4.4.sol +++ b/src/peanut/V4/PeanutV4.4.sol @@ -94,7 +94,7 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { } Deposit[] public deposits; // array of deposits - address public ecoAddress; // address of the ECO token + address public immutable ecoAddress; // address of the ECO token (set at deploy, never changes) // events event DepositEvent( diff --git a/test/paymasters/EnvelopeApprovalPaymaster.t.sol b/test/paymasters/EnvelopeApprovalPaymaster.t.sol index 022fc1c4..6b44eb11 100644 --- a/test/paymasters/EnvelopeApprovalPaymaster.t.sol +++ b/test/paymasters/EnvelopeApprovalPaymaster.t.sol @@ -9,6 +9,7 @@ import {BasePaymaster} from "../../src/paymasters/BasePaymaster.sol"; import {QuotaControl} from "../../src/QuotaControl.sol"; import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; +import {SampleWallet} from "../peanut/mocks/SampleSCW.sol"; /// @dev Bootloader address — paymaster validation must be called from this address. address constant BOOTLOADER = address(uint160(0x8001)); @@ -415,4 +416,34 @@ contract EnvelopeApprovalPaymasterTest is Test { vm.expectRevert(); paymaster.withdraw(address(0x77), 1); } + + // ── EIP-1271 contract signer support ─────────────────────────────────── + // The paymaster verifies grants via SignatureChecker.isValidSignatureNow so a + // smart-contract account (e.g. a multisig) can sign as operator. + + function test_acceptsEip1271ContractSigner() public { + SampleWallet scw = new SampleWallet(); + // SampleWallet.isValidSignature returns the magic value iff bytes32(sig) == hash. + // So a "valid signature" for this SCW is just the digest bytes themselves. + + // Deploy a fresh paymaster whose operatorSigner is the SCW. + EnvelopeApprovalPaymaster scwPaymaster = new EnvelopeApprovalPaymaster( + admin, withdrawer, address(scw), envelope, MAX_ETH_PER_TX, QUOTA, PERIOD + ); + vm.deal(address(scwPaymaster), 1 ether); + + bytes32 nonce = keccak256("scw-grant"); + uint256 deadline = block.timestamp + 1 hours; + bytes32 structHash = keccak256(abi.encode(scwPaymaster.GRANT_TYPEHASH(), user, deadline, nonce)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", scwPaymaster.DOMAIN_SEPARATOR(), structHash)); + bytes memory sig = abi.encodePacked(digest); // SampleWallet's "valid signature" semantics + + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + + vm.prank(BOOTLOADER); + scwPaymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei) + ); + assertTrue(scwPaymaster.isNonceUsed(nonce), "EIP-1271 path should mark nonce used"); + } } diff --git a/test/peanut/PeanutEdgeCases.t.sol b/test/peanut/PeanutEdgeCases.t.sol index 3b0a3de1..70e415d0 100644 --- a/test/peanut/PeanutEdgeCases.t.sol +++ b/test/peanut/PeanutEdgeCases.t.sol @@ -284,6 +284,41 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { batcher.batchMakeDepositArbitrary(address(peanut), tokens, types, amounts, ids, pks, mfa); } + // batchMakeDepositNoReturn — ETH path must require exact total, non-ETH path must reject msg.value. + // Both rules were added during PR review (upstream forwarded msg.value per iteration, which + // reverts on iteration 2 when length > 1). + + function test_BatchNoReturnEth_HappyPath() public { + address[] memory pubKeys = new address[](3); + for (uint256 i = 0; i < 3; i++) pubKeys[i] = LINK_PUBKEY20; + + batcher.batchMakeDepositNoReturn{value: 3 ether}( + address(peanut), address(0), 0, 1 ether, 0, pubKeys + ); + assertEq(peanut.getDepositCount(), 3); + } + + function test_RevertWhen_BatchNoReturnEthAmountMismatch() public { + address[] memory pubKeys = new address[](3); + for (uint256 i = 0; i < 3; i++) pubKeys[i] = LINK_PUBKEY20; + vm.expectRevert("INVALID TOTAL ETHER SENT"); + batcher.batchMakeDepositNoReturn{value: 1 ether}( + address(peanut), address(0), 0, 1 ether, 0, pubKeys + ); + } + + function test_RevertWhen_BatchNoReturnEthSentForErc20() public { + // ERC-20 path must reject msg.value — would otherwise strand dust in the vault. + erc20.mint(address(this), 1000); + erc20.approve(address(batcher), 1000); + address[] memory pubKeys = new address[](2); + for (uint256 i = 0; i < 2; i++) pubKeys[i] = LINK_PUBKEY20; + vm.expectRevert("ETH NOT ACCEPTED FOR NON-ETH DEPOSIT"); + batcher.batchMakeDepositNoReturn{value: 1 wei}( + address(peanut), address(erc20), 1, 100, 0, pubKeys + ); + } + function test_RevertWhen_BatchRaffleErc721NotSupported() public { uint256[] memory amounts = new uint256[](1); amounts[0] = 1; diff --git a/test/peanut/SigWithdraw.t.sol b/test/peanut/SigWithdraw.t.sol index 28f8903e..1eb81ceb 100644 --- a/test/peanut/SigWithdraw.t.sol +++ b/test/peanut/SigWithdraw.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.19; import "forge-std/Test.sol"; From 3a76b0113518bf17fde83d4178e67e43062477fe Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 20:11:33 -0400 Subject: [PATCH 18/31] =?UTF-8?q?feat(paymasters):=20add=20Mode=20B=20?= =?UTF-8?q?=E2=80=94=20operator-EOA=20+=20allowlisted-target=20sponsorship?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single paymaster, two modes, one ETH pool: Mode A (existing): user-side approve / setApprovalForAll, gated by an EIP-712 grant signed off-chain by operatorSigner. Single-use nonce, deadline, selector + spender checks. Mode B (new): caller is on isOperator allowlist + tx.to is on isAllowedTarget allowlist. No grant required (operator is a trusted persistent identity). Lets the operator call any function on the envelope vault — typically makeCustomDeposit, withdrawDeposit — without holding ETH itself. Both modes share maxEthPerTx and the QuotaControl daily counter, so a single ETH top-up funds both flows. Revoking an operator is a tx — no balance migration needed when rotating relayers. New state: - mapping(address => bool) public isOperator - mapping(address => bool) public isAllowedTarget New events: - OperatorSet(operator, allowed) - AllowedTargetSet(target, allowed) - OperatorCallSponsored(operator, target, gasPaid) — distinct from ApprovalSponsored so indexers can filter New admin functions (DEFAULT_ADMIN_ROLE): - setOperator(address, bool) - setAllowedTarget(address, bool) New error: - TargetNotAllowed Validation flow: if isOperator[tx.from]: Mode B — verify isAllowedTarget[tx.to], then per-tx cap + quota + pay else: Mode A — existing grant + selector + spender flow, then per-tx cap + quota + pay Tests: 7 new in test/paymasters/EnvelopeApprovalPaymaster.t.sol covering Mode B happy path, target-not-allowed, non-operator falls through to Mode A, per-tx cap shared, QuotaControl shared between modes, admin role gates on setOperator/setAllowedTarget, operator revocation. forge test 972/972 (was 965; +7). lint clean. spellcheck clean. Doc updated: src/peanut/doc/EnvelopeApprovalPaymaster.md describes both modes, gates per mode, post-deploy Mode B seeding. NOTE: Sepolia paymaster at 0xEE95bFF... is now stale bytecode (still functions for Mode A but doesn't have Mode B). Drain + redeploy needed. --- src/paymasters/EnvelopeApprovalPaymaster.sol | 83 ++++++++--- src/peanut/doc/EnvelopeApprovalPaymaster.md | 53 +++++-- .../EnvelopeApprovalPaymaster.t.sol | 141 ++++++++++++++++++ 3 files changed, 241 insertions(+), 36 deletions(-) diff --git a/src/paymasters/EnvelopeApprovalPaymaster.sol b/src/paymasters/EnvelopeApprovalPaymaster.sol index a11e9b57..cb1d2998 100644 --- a/src/paymasters/EnvelopeApprovalPaymaster.sol +++ b/src/paymasters/EnvelopeApprovalPaymaster.sol @@ -12,26 +12,29 @@ import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "./BasePaymaster.sol"; import {QuotaControl} from "../QuotaControl.sol"; /// @title Envelope Approval Paymaster -/// @notice Sponsors gas for a *narrow* set of operations: ERC-20 / ERC-721 `approve(envelope, ...)` -/// and ERC-721 / ERC-1155 `setApprovalForAll(envelope, ...)` — the txs needed to grant -/// the Envelope vault access to a user's tokens before the operator submits -/// `makeCustomDeposit`. -/// @dev Authorization is fully operator-driven: each sponsored tx must carry a fresh -/// EIP-712 grant signed by `operatorSigner`. No per-token allowlist — the strict -/// operator-grant gate + per-tx ETH cap + global daily quota together bound the -/// worst-case drain even under operator-key compromise. -/// Validation gates: -/// - tx.from holds an unexpired single-use EIP-712 grant signed by operatorSigner -/// - inner selector is approve(address,uint256) or setApprovalForAll(address,bool) -/// - the spender/operator argument == envelopeVault -/// - requiredETH (= gasLimit * maxFeePerGas) ≤ maxEthPerTx -/// - daily wei quota hasn't been exhausted (QuotaControl) -/// Overrides `validateAndPayForPaymasterTransaction` directly (instead of the +/// @notice Sponsors gas in two modes — both share one ETH pool and one daily QuotaControl. +/// +/// Mode A — User approval: caller is a regular user. Path-C support: the user's tx +/// is a token `approve(envelope, ...)` or `setApprovalForAll(envelope, true)` and +/// must carry a fresh EIP-712 grant signed by `operatorSigner` (single-use nonce, +/// deadline). Defends against arbitrary spend with: per-token-irrelevant + selector +/// + spender + grant. +/// +/// Mode B — Operator direct call: caller is on the operator allowlist (set by admin) +/// and the target (`tx.to`) is on the allowed-targets allowlist. No grant / selector / +/// spender check: the operator's EOA identity is the auth (the operator is a trusted +/// persistent identity, not an ephemeral grant holder). Used so the operator can call +/// the envelope vault (`makeCustomDeposit`, `withdrawDeposit`, etc.) without holding +/// ETH itself — the paymaster's pool funds those ops. +/// +/// Both modes apply the same per-tx ETH cap (`maxEthPerTx`) and contribute to the +/// same `QuotaControl` daily quota. +/// @dev Overrides `validateAndPayForPaymasterTransaction` directly (instead of the /// `_validateAndPayGeneralFlow` hook) because validation requires the full /// `Transaction` calldata — the hook signature hides `transaction.data` and /// `transaction.paymasterInput`. -/// Storage writes in validation (nonce, quota counters) are permitted by EraVM -/// paymaster-validation rules. +/// Storage writes in validation (nonce, quota counters, mode-tracking) are permitted +/// by EraVM paymaster-validation rules. contract EnvelopeApprovalPaymaster is BasePaymaster, QuotaControl { bytes4 internal constant APPROVE_SEL = 0x095ea7b3; // approve(address,uint256) — ERC-20 + ERC-721 bytes4 internal constant SET_APPROVAL_FOR_ALL_SEL = 0xa22cb465; // setApprovalForAll(address,bool) — ERC-721 + ERC-1155 @@ -49,9 +52,16 @@ contract EnvelopeApprovalPaymaster is BasePaymaster, QuotaControl { address public operatorSigner; mapping(bytes32 => bool) public isNonceUsed; + /// @notice Mode B — EOAs allowed to call any function on an allowlisted target. + mapping(address => bool) public isOperator; + /// @notice Mode B — contracts an operator EOA may call gaslessly. + mapping(address => bool) public isAllowedTarget; event OperatorSignerUpdated(address indexed previousSigner, address indexed newSigner); event ApprovalSponsored(address indexed user, address indexed token, bytes32 indexed nonce, uint256 gasPaid); + event OperatorCallSponsored(address indexed operator, address indexed target, uint256 gasPaid); + event OperatorSet(address indexed operator, bool allowed); + event AllowedTargetSet(address indexed target, bool allowed); error WrongFlow(); error GrantExpired(); @@ -59,6 +69,7 @@ contract EnvelopeApprovalPaymaster is BasePaymaster, QuotaControl { error InvalidGrantSignature(); error UnsupportedSelector(); error SpenderNotEnvelope(); + error TargetNotAllowed(); error PerTxLimitExceeded(); error InsufficientPaymasterBalance(); error ZeroAddress(); @@ -106,16 +117,24 @@ contract EnvelopeApprovalPaymaster is BasePaymaster, QuotaControl { _mustBeBootloader(); _requireGeneralFlow(transaction.paymasterInput); - address user = address(uint160(transaction.from)); - bytes32 nonce = _verifyAndConsumeGrant(user, transaction.paymasterInput); - - _requireApprovalCallToEnvelope(transaction.data); - + address from = address(uint160(transaction.from)); + address to = address(uint160(transaction.to)); uint256 requiredETH = transaction.gasLimit * transaction.maxFeePerGas; if (requiredETH > maxEthPerTx) revert PerTxLimitExceeded(); - _payBootloader(requiredETH); - emit ApprovalSponsored(user, address(uint160(transaction.to)), nonce, requiredETH); + if (isOperator[from]) { + // Mode B — operator EOA calls an allowlisted target. + if (!isAllowedTarget[to]) revert TargetNotAllowed(); + _payBootloader(requiredETH); + emit OperatorCallSponsored(from, to, requiredETH); + } else { + // Mode A — user-side approval gated by an operator EIP-712 grant. + bytes32 nonce = _verifyAndConsumeGrant(from, transaction.paymasterInput); + _requireApprovalCallToEnvelope(transaction.data); + _payBootloader(requiredETH); + emit ApprovalSponsored(from, to, nonce, requiredETH); + } + magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC; } @@ -196,4 +215,20 @@ contract EnvelopeApprovalPaymaster is BasePaymaster, QuotaControl { emit OperatorSignerUpdated(operatorSigner, newSigner); operatorSigner = newSigner; } + + /// @notice Add or remove a Mode-B operator EOA. Operators can call any function on + /// an allowlisted target with paymaster-funded gas; no EIP-712 grant required. + function setOperator(address operator, bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (operator == address(0)) revert ZeroAddress(); + isOperator[operator] = allowed; + emit OperatorSet(operator, allowed); + } + + /// @notice Add or remove a Mode-B target contract. Operator EOAs can call any function + /// on these targets with paymaster-funded gas. + function setAllowedTarget(address target, bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (target == address(0)) revert ZeroAddress(); + isAllowedTarget[target] = allowed; + emit AllowedTargetSet(target, allowed); + } } diff --git a/src/peanut/doc/EnvelopeApprovalPaymaster.md b/src/peanut/doc/EnvelopeApprovalPaymaster.md index b005aeb1..b2eb7fa8 100644 --- a/src/peanut/doc/EnvelopeApprovalPaymaster.md +++ b/src/peanut/doc/EnvelopeApprovalPaymaster.md @@ -4,15 +4,14 @@ ## Purpose -Sponsors gas for the user-side **approval txs** needed before a Peanut deposit can be made on a token that doesn't support EIP-2612 / EIP-3009. Specifically: +Sponsors gas in **two modes**, both funded from one ETH pool and bounded by the same per-tx cap + daily QuotaControl: -| Standard | Sponsored call | -|---|---| -| ERC-20 (no permit) | `token.approve(envelope, amount)` | -| ERC-721 | `token.approve(envelope, tokenId)` | -| ERC-1155 | `token.setApprovalForAll(envelope, true)` | +| Mode | Caller | Auth | What gets sponsored | +|---|---|---|---| +| **A — User approval** | regular user | EIP-712 grant signed off-chain by `operatorSigner` (single-use nonce, deadline) + selector + spender checks | `token.approve(envelopeVault, ...)` / `token.setApprovalForAll(envelopeVault, true)` for ERC-20 / 721 / 1155 — the user-side step in Path C | +| **B — Operator direct call** | operator EOA on the `isOperator` allowlist | target must be on the `isAllowedTarget` allowlist; no grant required | Anything the operator wants to call on an allowlisted target — typically `peanut.makeCustomDeposit`, `peanut.withdrawDeposit`, etc. | -The user pays 0 ETH. The operator's backend gates **every** sponsored tx by issuing an EIP-712 grant signed off-chain. The paymaster verifies the grant on-chain before paying the bootloader. +Mode B is the "single point we top up" pattern: instead of funding the operator's hot wallet directly, fund the paymaster and let the operator submit txs gaslessly. Bounded daily spend (QuotaControl), bounded per-tx spend (`maxEthPerTx`), and rotation just means flipping `isOperator` on a new EOA — no balance migration. ## Deployment scope @@ -118,7 +117,22 @@ const paymasterParams = utils.getPaymasterParams(PAYMASTER, { The user does NOT sign this grant — they just sign the outer ZkSync tx as usual. The grant proves to the paymaster that the **operator** authorized this tx. -## `validateAndPayForPaymasterTransaction` — the 5 gates +## `validateAndPayForPaymasterTransaction` — gates per mode + +The function branches on `isOperator[tx.from]`: + +```text +if isOperator[tx.from]: + Mode B + - isAllowedTarget[tx.to] [TargetNotAllowed] + - requiredETH ≤ maxEthPerTx [PerTxLimitExceeded] + - paymaster.balance ≥ requiredETH [InsufficientPaymasterBalance] + - claimed + requiredETH ≤ quota [QuotaControl.QuotaExceeded] +else: + Mode A — gates listed below +``` + +### Mode A (user-side approval) gates ```text A. msg.sender == BOOTLOADER_FORMAL_ADDRESS [AccessRestrictedToBootloader] @@ -149,13 +163,28 @@ The validation is split into four helper functions (`_requireGeneralFlow`, `_ver ## Admin functions ```solidity +// Mode A — rotate the EIP-712 grant signer function setOperatorSigner(address newSigner) external onlyRole(DEFAULT_ADMIN_ROLE); -function setQuota(uint256 newQuota) external onlyRole(DEFAULT_ADMIN_ROLE); // inherited -function setPeriod(uint256 newPeriod) external onlyRole(DEFAULT_ADMIN_ROLE); // inherited -function withdraw(address to, uint256 amount) external onlyRole(WITHDRAWER_ROLE); // inherited + +// Mode B — manage the operator EOA allowlist +function setOperator(address operator, bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE); + +// Mode B — manage the target-contract allowlist +function setAllowedTarget(address target, bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE); + +// Inherited from QuotaControl +function setQuota(uint256 newQuota) external onlyRole(DEFAULT_ADMIN_ROLE); +function setPeriod(uint256 newPeriod) external onlyRole(DEFAULT_ADMIN_ROLE); + +// Inherited from BasePaymaster +function withdraw(address to, uint256 amount) external onlyRole(WITHDRAWER_ROLE); ``` -`setOperatorSigner(0)` reverts with `ZeroAddress` — the paymaster cannot be silently disabled. +`setOperatorSigner(0)`, `setOperator(0, ...)`, and `setAllowedTarget(0, ...)` all revert with `ZeroAddress` — no silent disable. + +### Operational seeding (post-deploy) + +Mode B is dormant at deploy. To enable: admin calls `setAllowedTarget(envelopeVault, true)` and `setOperator(operatorEOA, true)`. Multiple operators / targets are allowed. ## Events / Errors diff --git a/test/paymasters/EnvelopeApprovalPaymaster.t.sol b/test/paymasters/EnvelopeApprovalPaymaster.t.sol index 6b44eb11..3259405e 100644 --- a/test/paymasters/EnvelopeApprovalPaymaster.t.sol +++ b/test/paymasters/EnvelopeApprovalPaymaster.t.sol @@ -417,6 +417,147 @@ contract EnvelopeApprovalPaymasterTest is Test { paymaster.withdraw(address(0x77), 1); } + // ── Mode B — Operator direct call ────────────────────────────────────── + // Operators (EOA whitelist) can call any function on allowlisted targets, + // no EIP-712 grant required. Same per-tx cap and quota as Mode A. + + address constant OPERATOR_EOA = address(0xCAFEBABE); + address constant ALLOWED_VAULT = address(0xBEEFCAFE); + + function _modeBPaymasterInput() internal pure returns (bytes memory) { + // Mode B doesn't decode the inner bytes, but the flow selector (general) is + // still required. Build a paymasterInput with the selector and an empty inner. + return abi.encodeWithSelector(IPaymasterFlow.general.selector, bytes("")); + } + + function _operatorTx(address from, address to, uint256 gasLimit, uint256 gasPrice) + internal + view + returns (Transaction memory) + { + return Transaction({ + txType: 0x71, + from: uint256(uint160(from)), + to: uint256(uint160(to)), + gasLimit: gasLimit, + gasPerPubdataByteLimit: 50000, + maxFeePerGas: gasPrice, + maxPriorityFeePerGas: 0, + paymaster: uint256(uint160(address(paymaster))), + nonce: 0, + value: 0, + reserved: [uint256(0), 0, 0, 0], + data: hex"deadbeef", // arbitrary payload — Mode B doesn't inspect + signature: hex"", + factoryDeps: new bytes32[](0), + paymasterInput: _modeBPaymasterInput(), + reservedDynamic: hex"" + }); + } + + function test_modeB_operatorCanCallAllowedTarget() public { + vm.prank(admin); + paymaster.setOperator(OPERATOR_EOA, true); + vm.prank(admin); + paymaster.setAllowedTarget(ALLOWED_VAULT, true); + + uint256 balBefore = address(paymaster).balance; + uint256 bootBefore = BOOTLOADER.balance; + vm.prank(BOOTLOADER); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), _operatorTx(OPERATOR_EOA, ALLOWED_VAULT, 200_000, 1 gwei) + ); + + uint256 expected = 200_000 * 1 gwei; + assertEq(address(paymaster).balance, balBefore - expected, "paymaster paid wrong amount"); + assertEq(BOOTLOADER.balance, bootBefore + expected, "bootloader didn't receive"); + assertEq(paymaster.claimed(), expected, "quota counter not bumped in mode B"); + } + + function test_modeB_revertsOnTargetNotAllowed() public { + vm.prank(admin); + paymaster.setOperator(OPERATOR_EOA, true); + // No setAllowedTarget — target is not on the allowlist. + + vm.prank(BOOTLOADER); + vm.expectRevert(EnvelopeApprovalPaymaster.TargetNotAllowed.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), _operatorTx(OPERATOR_EOA, ALLOWED_VAULT, 100_000, 1 gwei) + ); + } + + function test_modeB_nonOperatorFallsThroughToModeA() public { + // Caller is NOT on the operator allowlist → falls through to Mode A grant flow. + // Without a valid grant, Mode A reverts (the empty inner can't be decoded). + vm.prank(admin); + paymaster.setAllowedTarget(ALLOWED_VAULT, true); + + vm.prank(BOOTLOADER); + vm.expectRevert(); // grant decode fails on the bytes("") inner + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), _operatorTx(user, ALLOWED_VAULT, 100_000, 1 gwei) + ); + } + + function test_modeB_operatorRespectsPerTxCap() public { + vm.prank(admin); + paymaster.setOperator(OPERATOR_EOA, true); + vm.prank(admin); + paymaster.setAllowedTarget(ALLOWED_VAULT, true); + + // gasLimit * gasPrice > MAX_ETH_PER_TX + uint256 gasPrice = 1 gwei; + uint256 gasLimit = (MAX_ETH_PER_TX / gasPrice) + 1; + + vm.prank(BOOTLOADER); + vm.expectRevert(EnvelopeApprovalPaymaster.PerTxLimitExceeded.selector); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), _operatorTx(OPERATOR_EOA, ALLOWED_VAULT, gasLimit, gasPrice) + ); + } + + function test_modeB_operatorContributesToSameQuotaAsModeA() public { + // One Mode-A tx + one Mode-B tx burn into the same QuotaControl counter. + vm.prank(admin); + paymaster.setOperator(OPERATOR_EOA, true); + vm.prank(admin); + paymaster.setAllowedTarget(ALLOWED_VAULT, true); + + // Mode A: user submits a sponsored approve. + bytes32 nonce = keccak256("shared-quota-A"); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signGrant(deadline, nonce, user, operatorPk); + bytes memory pmInput = _buildPaymasterInput(deadline, nonce, sig); + _validate(_txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei)); + uint256 afterModeA = paymaster.claimed(); + + // Mode B: operator calls allowed target. + vm.prank(BOOTLOADER); + paymaster.validateAndPayForPaymasterTransaction( + bytes32(0), bytes32(0), _operatorTx(OPERATOR_EOA, ALLOWED_VAULT, 200_000, 1 gwei) + ); + + assertEq(paymaster.claimed(), afterModeA + 200_000 * 1 gwei, "modes share QuotaControl"); + } + + function test_modeB_adminCanRevokeOperator() public { + vm.prank(admin); + paymaster.setOperator(OPERATOR_EOA, true); + assertTrue(paymaster.isOperator(OPERATOR_EOA)); + + vm.prank(admin); + paymaster.setOperator(OPERATOR_EOA, false); + assertFalse(paymaster.isOperator(OPERATOR_EOA)); + } + + function test_modeB_nonAdminCannotManageOperators() public { + vm.expectRevert(); + paymaster.setOperator(OPERATOR_EOA, true); + + vm.expectRevert(); + paymaster.setAllowedTarget(ALLOWED_VAULT, true); + } + // ── EIP-1271 contract signer support ─────────────────────────────────── // The paymaster verifies grants via SignatureChecker.isValidSignatureNow so a // smart-contract account (e.g. a multisig) can sign as operator. From 0db2d21abff9679949d9ab5f56e2a419102326af Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 20:17:58 -0400 Subject: [PATCH 19/31] docs(peanut): catch up specs to Mode B + earlier hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five gaps in src/peanut/doc/EnvelopeApprovalPaymaster.md: - Storage section was missing isOperator and isAllowedTarget mappings - Mode A grant gate said ECDSA.recover; switched to SignatureChecker.isValidSignatureNow (was changed in fb450f5 for EIP-1271 support, doc didn't follow) - Events / Errors section missing OperatorSet, AllowedTargetSet, OperatorCallSponsored, TargetNotAllowed - Threat model missing Mode B specifics (random EOA, malicious target, operator key compromise, allowlist multiple operators) - Test coverage said 19; now 27 (Mode A + Mode B + EIP-1271) src/peanut/doc/README.md updates: - Paymaster description: "Path-C gas sponsor + operator gas pool" - Test totals refreshed to current numbers (peanut 96, paymaster 27, total 972) — earlier numbers were from before the hardening + edge case suites + Mode B added their tests --- src/peanut/doc/EnvelopeApprovalPaymaster.md | 86 ++++++++++++++++----- src/peanut/doc/README.md | 8 +- 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/peanut/doc/EnvelopeApprovalPaymaster.md b/src/peanut/doc/EnvelopeApprovalPaymaster.md index b2eb7fa8..e1ed5e13 100644 --- a/src/peanut/doc/EnvelopeApprovalPaymaster.md +++ b/src/peanut/doc/EnvelopeApprovalPaymaster.md @@ -63,8 +63,13 @@ bytes32 public immutable DOMAIN_SEPARATOR; address public immutable envelopeVault; uint256 public immutable maxEthPerTx; -address public operatorSigner; // admin-rotatable -mapping(bytes32 => bool) public isNonceUsed; // single-use replay protection +// Mode A +address public operatorSigner; // admin-rotatable EIP-712 signer +mapping(bytes32 => bool) public isNonceUsed; // single-use replay protection + +// Mode B +mapping(address => bool) public isOperator; // EOAs allowed to call any fn on a target +mapping(address => bool) public isAllowedTarget; // contracts an operator may call ``` Plus inherited: @@ -141,7 +146,9 @@ C. Grant: - paymasterInput length >= 4 [InvalidPaymasterInput] - block.timestamp <= deadline [GrantExpired] - !isNonceUsed[nonce] [NonceAlreadyUsed] - - ECDSA.recover(grantDigest, signature) == operatorSigner [InvalidGrantSignature] + - SignatureChecker.isValidSignatureNow(operatorSigner, grantDigest, signature) + [InvalidGrantSignature] + (supports both EOA ECDSA sigs and EIP-1271 contract signers) D. Inner call: - data.length >= 36 [UnsupportedSelector] - selector ∈ {APPROVE_SEL, SET_APPROVAL_FOR_ALL_SEL} [UnsupportedSelector] @@ -189,20 +196,28 @@ Mode B is dormant at deploy. To enable: admin calls `setAllowedTarget(envelopeVa ## Events / Errors ```solidity +// Mode A event OperatorSignerUpdated(address indexed previousSigner, address indexed newSigner); event ApprovalSponsored(address indexed user, address indexed token, bytes32 indexed nonce, uint256 gasPaid); +// Mode B +event OperatorSet(address indexed operator, bool allowed); +event AllowedTargetSet(address indexed target, bool allowed); +event OperatorCallSponsored(address indexed operator, address indexed target, uint256 gasPaid); + +// Validation reverts error WrongFlow(); -error GrantExpired(); -error NonceAlreadyUsed(); -error InvalidGrantSignature(); -error UnsupportedSelector(); -error SpenderNotEnvelope(); -error PerTxLimitExceeded(); -error InsufficientPaymasterBalance(); -error ZeroAddress(); -error Unused(); // _validateAndPayGeneralFlow hook (BasePaymaster requirement; never reached) +error GrantExpired(); // Mode A +error NonceAlreadyUsed(); // Mode A +error InvalidGrantSignature(); // Mode A +error UnsupportedSelector(); // Mode A +error SpenderNotEnvelope(); // Mode A +error TargetNotAllowed(); // Mode B +error PerTxLimitExceeded(); // both modes +error InsufficientPaymasterBalance(); // both modes +error ZeroAddress(); // admin functions + constructor +error Unused(); // _validateAndPayGeneralFlow hook (never reached) ``` Plus inherited: @@ -219,19 +234,35 @@ error TooLongPeriod(); ## Threat model +### Shared (both modes) + +| Attack | Mitigation | +|---|---| +| Drain via one huge tx (e.g. huge `gasLimit`) | `requiredETH > maxEthPerTx` reverts | +| Drain via many normal-sized txs | `QuotaControl` daily cap (shared across both modes) | +| Withdraw paymaster ETH without permission | `WITHDRAWER_ROLE` gate on `withdraw` | +| zkSync `
.transfer` issue | All ETH outflow uses `.call{value:}("")` (EraVM-safe) | +| Bootloader impersonation | `_mustBeBootloader()` (msg.sender == `BOOTLOADER_FORMAL_ADDRESS`) | + +### Mode A specific + | Attack | Mitigation | |---|---| -| Anyone tries to use the paymaster without operator sign-off | `_verifyAndConsumeGrant` — must hold a valid signature from `operatorSigner` | +| Anyone tries to use the paymaster without operator sign-off | `_verifyAndConsumeGrant` — must hold a valid signature from `operatorSigner` (via `SignatureChecker`, EOA or EIP-1271) | | Replay a stale grant | `nonce` is single-use (`isNonceUsed`); also `deadline` | | Use a grant signed for another user | `user` is part of the EIP-712 struct hash; sig won't verify if `tx.from` differs | | Sponsor a transfer / mint / arbitrary state-change | Inner selector must be `approve` or `setApprovalForAll` | | Approve attacker as spender | Inner first arg must equal `envelopeVault` | -| Drain via one huge tx (e.g. huge `gasLimit`) | `requiredETH > maxEthPerTx` reverts | -| Drain via many normal-sized txs | `QuotaControl` daily cap | | Operator-signer key compromise | Bounded by `maxEthPerTx` per tx AND quota per day. Admin rotates via `setOperatorSigner` | -| Withdraw paymaster ETH without permission | `WITHDRAWER_ROLE` gate on `withdraw` | -| zkSync `
.transfer` issue | All ETH outflow uses `.call{value:}("")` (EraVM-safe) | -| Bootloader impersonation | `_mustBeBootloader()` (msg.sender == `BOOTLOADER_FORMAL_ADDRESS`) | + +### Mode B specific + +| Attack | Mitigation | +|---|---| +| Random EOA tries to use the paymaster directly | `isOperator[tx.from]` check — only allowlisted EOAs enter Mode B; otherwise the call falls through to Mode A and fails on grant decode | +| Operator EOA calls a malicious contract | `isAllowedTarget[tx.to]` check — admin curates which contracts the operator may call | +| Operator-EOA key compromise | Same `maxEthPerTx` + quota bounds. Admin revokes via `setOperator(eoa, false)` (one tx, no balance migration) | +| Single operator becomes a bottleneck or single-point-of-failure | Allowlist multiple operator EOAs; rotate independently | ## What was deliberately dropped (vs. earlier iterations) @@ -288,8 +319,21 @@ Optional env vars (defaults documented in the script header): ## Test coverage -`test/paymasters/EnvelopeApprovalPaymaster.t.sol` — 19 tests: -- **Happy paths**: sponsors `approve`, sponsors `setApprovalForAll`, sponsors approval on ANY token (no allowlist) +`test/paymasters/EnvelopeApprovalPaymaster.t.sol` — **27 tests**: + +**Mode A (user approval) — 19 tests** +- **Happy paths**: sponsors `approve`, sponsors `setApprovalForAll`, sponsors on any token (no allowlist), accepts EIP-1271 contract signer - **Reverts per gate**: not-bootloader, approval-based-flow, expired grant, reused nonce, wrong signer, wrong user in sig, unsupported selector, spender-not-envelope, per-tx limit, insufficient balance, exceeded quota (via dedicated tight-quota paymaster instance) -- **Period rollover**: claimed counter resets after `period` elapsed +- **Period rollover**: `claimed` counter resets after `period` elapsed - **Admin gates**: rotate operator signer; non-admin can't; withdraw; non-withdrawer can't + +**Mode B (operator direct call) — 7 tests** +- Operator EOA on allowlist + allowlisted target → sponsored +- `TargetNotAllowed` when target isn't on the allowlist +- Non-operator caller falls through to Mode A grant flow +- `PerTxLimitExceeded` applies to Mode B too +- Mode A and Mode B contribute to the same `QuotaControl` counter +- Admin can revoke operators (`setOperator(eoa, false)`) +- Non-admin cannot manage operators or targets + +**Mode independence verified**: a Mode B success and a Mode A success drain into the same ETH pool and the same `claimed` counter, asserted in `test_modeB_operatorContributesToSameQuotaAsModeA`. diff --git a/src/peanut/doc/README.md b/src/peanut/doc/README.md index a686d331..f45d9fa4 100644 --- a/src/peanut/doc/README.md +++ b/src/peanut/doc/README.md @@ -12,7 +12,7 @@ sponsors the user-side approval txs so the UX is gasless from the holder's POV. | `PeanutV4` (vault) | `src/peanut/V4/PeanutV4.4.sol` | [PeanutV4.md](./PeanutV4.md) | | `PeanutBatcherV4` (batched deposits) | `src/peanut/V4/PeanutBatcherV4.4.sol` | [PeanutBatcherV4.md](./PeanutBatcherV4.md) | | `PeanutV4Router` (cross-chain via Squid) | `src/peanut/V4/PeanutRouter.sol` | [PeanutRouter.md](./PeanutRouter.md) | -| `EnvelopeApprovalPaymaster` (Path-C gas sponsor) | `src/paymasters/EnvelopeApprovalPaymaster.sol` | [EnvelopeApprovalPaymaster.md](./EnvelopeApprovalPaymaster.md) | +| `EnvelopeApprovalPaymaster` (Path-C gas sponsor + operator gas pool) | `src/paymasters/EnvelopeApprovalPaymaster.sol` | [EnvelopeApprovalPaymaster.md](./EnvelopeApprovalPaymaster.md) | Interfaces (vendored, unmodified): @@ -58,8 +58,8 @@ Both are Hardhat-zksync scripts. See each spec for env vars. | Suite | Tests | |---|---| -| Peanut core (`test/peanut/`) | 71 (60 vendored + 11 hardening) | -| Paymaster (`test/paymasters/EnvelopeApprovalPaymaster.t.sol`) | 19 | +| Peanut core (`test/peanut/`) | **96** (60 vendored + 13 hardening + 23 edge cases) | +| `EnvelopeApprovalPaymaster` (`test/paymasters/EnvelopeApprovalPaymaster.t.sol`) | **27** (19 Mode A + 7 Mode B + 1 EIP-1271 contract signer) | | Other paymasters (unchanged) | 102 | | Rest of repo | 747 | -| **Total** | **939** | +| **Total** | **972** | From db71727d2051e4b2a13f9f4ade0a024786407338 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 20:27:05 -0400 Subject: [PATCH 20/31] feat(deploy): seed Mode B from DeployEnvelopePaymaster + update Sepolia address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy script: - Two new env vars: ENVELOPE_PAYMASTER_INITIAL_OPERATORS (comma-list, default empty) and ENVELOPE_PAYMASTER_INITIAL_TARGETS (comma-list, default = PEANUT_V4). After deploy + funding, if the deployer is the admin, calls setOperator(...) and setAllowedTarget(...) per entry. - If admin != deployer, prints an instruction and skips (admin must seed themselves). Docs: - README + EnvelopeApprovalPaymaster.md updated with the new Sepolia address: 0x80EA078d599Bc63BB921Cf96CC6861731446e268 (Mode A + Mode B bytecode, verified, funded with 0.0015 ETH, deployer seeded as both operatorSigner AND Mode-B operator, peanut vault seeded as Mode-B target). Old paymaster (0xEE95bFF…) and a duplicate from a re-run (0x5E44c478…) were both drained back to deployer; only the new 0x80EA078d… remains. --- hardhat-deploy/DeployEnvelopePaymaster.ts | 37 +++++++++++++++++++++ src/peanut/doc/EnvelopeApprovalPaymaster.md | 4 +-- src/peanut/doc/README.md | 2 +- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/hardhat-deploy/DeployEnvelopePaymaster.ts b/hardhat-deploy/DeployEnvelopePaymaster.ts index a5664510..6e1fb51f 100644 --- a/hardhat-deploy/DeployEnvelopePaymaster.ts +++ b/hardhat-deploy/DeployEnvelopePaymaster.ts @@ -34,6 +34,10 @@ dotenv.config({ path: ".env-test" }); * - ENVELOPE_PAYMASTER_QUOTA: Wei sponsorable per period. Default: 0.1 ETH. * - ENVELOPE_PAYMASTER_PERIOD: Period length in seconds. Default: 86400 (1 day). * - ENVELOPE_PAYMASTER_FUNDING: ETH (wei) to send to paymaster post-deploy. Default: 0. + * - ENVELOPE_PAYMASTER_INITIAL_OPERATORS: Comma-separated EOA list to seed as Mode B operators. + * Default: empty (Mode B dormant; admin can call setOperator later). + * - ENVELOPE_PAYMASTER_INITIAL_TARGETS: Comma-separated contract list to seed as Mode B allowed targets. + * Default: PEANUT_V4 (so operator can call the vault directly). * * Usage: * yarn hardhat deploy-zksync \ @@ -72,6 +76,16 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { ? ethers.toBigInt(process.env.ENVELOPE_PAYMASTER_FUNDING) : 0n; + const initialOperators = (process.env.ENVELOPE_PAYMASTER_INITIAL_OPERATORS ?? "") + .split(",") + .map((a) => a.trim()) + .filter((a) => a.length > 0 && a !== ZERO); + + const initialTargets = (process.env.ENVELOPE_PAYMASTER_INITIAL_TARGETS ?? envelopeVault) + .split(",") + .map((a) => a.trim()) + .filter((a) => a.length > 0 && a !== ZERO); + console.log("=== Deploying EnvelopeApprovalPaymaster on ZkSync ==="); console.log("Network: ", hre.network.name); console.log("Deployer: ", wallet.address); @@ -83,6 +97,8 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log("Quota (wei): ", quota.toString(), `(${ethers.formatEther(quota)} ETH)`); console.log("Period (seconds): ", period.toString(), `(${Number(period) / 86400} days)`); console.log("Funding (wei): ", funding.toString(), `(${ethers.formatEther(funding)} ETH)`); + console.log("Mode B operators: ", initialOperators.length > 0 ? initialOperators : "(none — seed later)"); + console.log("Mode B targets: ", initialTargets); console.log(""); const paymaster = await deployContract(deployer, "EnvelopeApprovalPaymaster", [ @@ -103,6 +119,27 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(` fund tx: ${fundTx.hash}`); } + // Seed Mode B (only if deployer is the admin — otherwise admin must do this themselves). + if (admin.toLowerCase() === wallet.address.toLowerCase()) { + if (initialOperators.length > 0 || initialTargets.length > 0) { + console.log("Seeding Mode B (operators + targets)..."); + for (const op of initialOperators) { + const tx = await paymaster.setOperator(op, true); + await tx.wait(); + console.log(` setOperator(${op}, true) — tx: ${tx.hash}`); + } + for (const t of initialTargets) { + const tx = await paymaster.setAllowedTarget(t, true); + await tx.wait(); + console.log(` setAllowedTarget(${t}, true) — tx: ${tx.hash}`); + } + } + } else if (initialOperators.length > 0 || initialTargets.length > 0) { + console.log( + `Skipping Mode B seeding: admin (${admin}) is not the deployer; have the admin call setOperator / setAllowedTarget directly.`, + ); + } + console.log(""); console.log("=== Deployment Complete ==="); console.log("EnvelopeApprovalPaymaster:", paymasterAddr); diff --git a/src/peanut/doc/EnvelopeApprovalPaymaster.md b/src/peanut/doc/EnvelopeApprovalPaymaster.md index e1ed5e13..e89c43d2 100644 --- a/src/peanut/doc/EnvelopeApprovalPaymaster.md +++ b/src/peanut/doc/EnvelopeApprovalPaymaster.md @@ -19,7 +19,7 @@ Mode B is the "single point we top up" pattern: instead of funding the operator' - **No token allowlist** — the operator's grant is the only auth surface. Defense-in-depth comes from a hard per-tx ETH cap and a global daily quota. - **Operator-driven UX** — the user never sees the EIP-712 grant; only the operator's backend does. -Deployed on ZkSync Sepolia at [`0xEE95bFF2240652e0f57aE3fcd57F87d85593c191`](https://sepolia.explorer.zksync.io/address/0xEE95bFF2240652e0f57aE3fcd57F87d85593c191#contract). +Deployed on ZkSync Sepolia at [`0x80EA078d599Bc63BB921Cf96CC6861731446e268`](https://sepolia.explorer.zksync.io/address/0x80EA078d599Bc63BB921Cf96CC6861731446e268#contract). ## Inheritance @@ -279,7 +279,7 @@ import { Wallet } from "zksync-ethers"; import { ethers } from "ethers"; import { randomBytes, hexlify } from "ethers"; -const PAYMASTER = "0xEE95bFF2240652e0f57aE3fcd57F87d85593c191"; +const PAYMASTER = "0x80EA078d599Bc63BB921Cf96CC6861731446e268"; const CHAIN_ID = 300; const operatorWallet = new Wallet(process.env.OPERATOR_PK!); diff --git a/src/peanut/doc/README.md b/src/peanut/doc/README.md index f45d9fa4..80f46fea 100644 --- a/src/peanut/doc/README.md +++ b/src/peanut/doc/README.md @@ -32,7 +32,7 @@ Interfaces (vendored, unmodified): |---|---| | `PeanutV4` | [`0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44`](https://sepolia.explorer.zksync.io/address/0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44#contract) | | `PeanutBatcherV4` | [`0x1676cD8B90e2E4388C032ae5Eb4BA50166Bb3426`](https://sepolia.explorer.zksync.io/address/0x1676cD8B90e2E4388C032ae5Eb4BA50166Bb3426#contract) | -| `EnvelopeApprovalPaymaster` | [`0xEE95bFF2240652e0f57aE3fcd57F87d85593c191`](https://sepolia.explorer.zksync.io/address/0xEE95bFF2240652e0f57aE3fcd57F87d85593c191#contract) | +| `EnvelopeApprovalPaymaster` | [`0x80EA078d599Bc63BB921Cf96CC6861731446e268`](https://sepolia.explorer.zksync.io/address/0x80EA078d599Bc63BB921Cf96CC6861731446e268#contract) | | `PeanutV4Router` | not deployed (deploy when cross-chain is needed) | ## Three deposit paths From 1fcbcf0672e6cd109ea37995c268c194764f3ba1 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 21:02:24 -0400 Subject: [PATCH 21/31] chore(peanut): remove unused PeanutV4Router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router wraps a peanut withdraw with a Squid bridge call for cross-chain claims. Nodle's deployment doesn't currently use it (no Squid integration), and it's not deployed on Sepolia. Removing it now shrinks the audit surface and the test/build matrix; if cross-chain support becomes a requirement later, re-vendor the upstream router contract and add it back. Removed: src/peanut/V4/PeanutRouter.sol (vendored upstream router) test/peanut/PeanutRouter.t.sol (4 tests; no longer applicable) test/peanut/mocks/SquidMock.sol (only the router test used it) src/peanut/doc/PeanutRouter.md (138-line spec) Updated: test/peanut/PeanutHardening.t.sol — drop T3 (withdrawFees safeTransfer proof) since the router is gone. Also remove the NonReturningERC20 inline mock + Ownable + PeanutV4Router + SquidMock imports + router state in setUp. T1, T2, T4, T5 unchanged. hardhat-deploy/DeployPeanut.ts — drop PEANUT_DEPLOY_ROUTER / PEANUT_SQUID_ADDRESS / PEANUT_ROUTER_OWNER env vars and the third deploy + verification block. src/peanut/doc/README.md — drop router row from the layout / deployed addresses tables. Naming convention updated. Test totals refreshed (peanut 90, total 966). .solhintignore — drop the now-deleted PeanutRouter.sol entry. Test deltas: peanut suite: 96 → 90 (-4 router happy-path, -2 T3) repo total: 972 → 966 forge test: 966/966 pass. yarn lint: 0 errors. yarn spellcheck: 0 issues. --- .solhintignore | 1 - hardhat-deploy/DeployPeanut.ts | 59 -------- src/peanut/V4/PeanutRouter.sol | 106 ------------- src/peanut/doc/PeanutRouter.md | 138 ----------------- src/peanut/doc/README.md | 12 +- test/peanut/PeanutHardening.t.sol | 66 -------- test/peanut/PeanutRouter.t.sol | 241 ------------------------------ test/peanut/mocks/SquidMock.sol | 23 --- 8 files changed, 6 insertions(+), 640 deletions(-) delete mode 100644 src/peanut/V4/PeanutRouter.sol delete mode 100644 src/peanut/doc/PeanutRouter.md delete mode 100644 test/peanut/PeanutRouter.t.sol delete mode 100644 test/peanut/mocks/SquidMock.sol diff --git a/.solhintignore b/.solhintignore index 6255e0d3..9f356676 100644 --- a/.solhintignore +++ b/.solhintignore @@ -7,4 +7,3 @@ # is NOT in this list and remains lint-clean. src/peanut/V4/PeanutV4.4.sol src/peanut/V4/PeanutBatcherV4.4.sol -src/peanut/V4/PeanutRouter.sol diff --git a/hardhat-deploy/DeployPeanut.ts b/hardhat-deploy/DeployPeanut.ts index 53d62fde..2cbb54d0 100644 --- a/hardhat-deploy/DeployPeanut.ts +++ b/hardhat-deploy/DeployPeanut.ts @@ -22,12 +22,6 @@ dotenv.config({ path: ".env-test" }); * Defaults to 0x0 (MFA disabled — withdrawMFADeposit reverts). * Set to your backend signer for production MFA. * - PEANUT_DEPLOY_BATCHER: "true"|"false". Default "true". Deploys PeanutBatcherV4. - * - PEANUT_DEPLOY_ROUTER: "true"|"false". Default "false". Deploys PeanutV4Router - * for cross-chain bridging via Squid. - * - PEANUT_SQUID_ADDRESS: Squid router address. REQUIRED if PEANUT_DEPLOY_ROUTER=true. - * - PEANUT_ROUTER_OWNER: Address to receive Ownable2Step ownership of the router. - * If set and != deployer, the script initiates transferOwnership; - * the new owner must call acceptOwnership() in a follow-up tx. * * Usage: * yarn hardhat deploy-zksync \ @@ -40,32 +34,18 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { const ecoToken = process.env.PEANUT_ECO_TOKEN ?? ZERO; const mfaAuthorizer = process.env.PEANUT_MFA_AUTHORIZER ?? ZERO; const deployBatcher = (process.env.PEANUT_DEPLOY_BATCHER ?? "true").toLowerCase() === "true"; - const deployRouter = (process.env.PEANUT_DEPLOY_ROUTER ?? "false").toLowerCase() === "true"; - const squidAddress = process.env.PEANUT_SQUID_ADDRESS ?? ZERO; - const routerOwnerOverride = process.env.PEANUT_ROUTER_OWNER ?? ""; const rpcUrl = hre.network.config.url!; const provider = new Provider(rpcUrl); const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); const deployer = new Deployer(hre, wallet); - if (deployRouter && squidAddress === ZERO) { - throw new Error( - "PEANUT_SQUID_ADDRESS is required when PEANUT_DEPLOY_ROUTER=true", - ); - } - console.log("=== Deploying Peanut Protocol on ZkSync ==="); console.log("Network: ", hre.network.name); console.log("Deployer: ", wallet.address); console.log("ECO Token: ", ecoToken); console.log("MFA Authorizer: ", mfaAuthorizer); console.log("Deploy Batcher: ", deployBatcher); - console.log("Deploy Router: ", deployRouter); - if (deployRouter) { - console.log("Squid Address: ", squidAddress); - console.log("Router Owner: ", routerOwnerOverride || `(deployer: ${wallet.address})`); - } console.log(""); // 1. Vault — required. @@ -79,28 +59,10 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { batcherAddr = await batcher.getAddress(); } - // 3. Router — optional, cross-chain via Squid. - let routerAddr: string | undefined; - let pendingRouterOwner: string | undefined; - if (deployRouter) { - const router = await deployContract(deployer, "PeanutV4Router", [squidAddress]); - routerAddr = await router.getAddress(); - - if (routerOwnerOverride && routerOwnerOverride.toLowerCase() !== wallet.address.toLowerCase()) { - console.log(`Initiating Ownable2Step handoff -> ${routerOwnerOverride} ...`); - const tx = await router.transferOwnership(routerOwnerOverride); - await tx.wait(); - pendingRouterOwner = routerOwnerOverride; - console.log(` transferOwnership tx: ${tx.hash}`); - console.log(` new owner must call acceptOwnership() to finalize`); - } - } - console.log(""); console.log("=== Deployment Complete ==="); console.log("PeanutV4: ", peanutAddr); if (batcherAddr) console.log("PeanutBatcherV4: ", batcherAddr); - if (routerAddr) console.log("PeanutV4Router: ", routerAddr); console.log(""); // Verification @@ -129,31 +91,10 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { } } - if (routerAddr) { - try { - console.log("Verifying PeanutV4Router..."); - await hre.run("verify:verify", { - address: routerAddr, - contract: "src/peanut/V4/PeanutRouter.sol:PeanutV4Router", - constructorArguments: [squidAddress], - }); - } catch (e: any) { - console.log("Verification failed or already verified:", e.message); - } - } - console.log(""); console.log("=== Add these to .env-test: ==="); console.log(`PEANUT_V4=${peanutAddr}`); if (batcherAddr) console.log(`PEANUT_BATCHER=${batcherAddr}`); - if (routerAddr) console.log(`PEANUT_ROUTER=${routerAddr}`); - - if (pendingRouterOwner) { - console.log(""); - console.log( - `ACTION REQUIRED: have ${pendingRouterOwner} call PeanutV4Router(${routerAddr}).acceptOwnership() to finalize ownership transfer.`, - ); - } if (mfaAuthorizer === ZERO) { console.log(""); diff --git a/src/peanut/V4/PeanutRouter.sol b/src/peanut/V4/PeanutRouter.sol deleted file mode 100644 index 65b85cf7..00000000 --- a/src/peanut/V4/PeanutRouter.sol +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.26; - -////////////////////////////////////////////////////////////////////////////////////// -// @title Peanut Router -// @notice Bridges a Peanut V4 deposit to another chain via the Squid router. -// @version 0.2.0 -// @author Squirrel Labs (vendored + modernized for nodle/rollup) -////////////////////////////////////////////////////////////////////////////////////// - -import {PeanutV4} from "./PeanutV4.4.sol"; -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; - -contract PeanutV4Router is Ownable2Step { - using SafeERC20 for IERC20; - - address public squidAddress; - - /// @param _squidAddress target Squid router address to forward bridged value to. - constructor(address _squidAddress) Ownable(msg.sender) { - squidAddress = _squidAddress; - } - - /// @notice Withdraw a Peanut deposit and bridge it cross-chain via Squid. - /// @dev Validates the EIP-191 v0x00 routing signature first to prevent front-running: - /// the relayer is constrained to exactly the squidFee/peanutFee/squidData the - /// deposit owner signed off-chain. - /// @param _peanutAddress peanut vault to withdraw the deposit from. - /// @param _depositIndex index of the deposit in the peanut vault. - /// @param _withdrawalSignature signature authorizing the peanut withdrawal. - /// @param _squidFee squid router fee (must equal msg.value). - /// @param _peanutFee fee retained by this router (must be < deposit.amount). - /// @param _squidData calldata blob forwarded to the squid router. - /// @param _routingSignature signature over (squidFee, peanutFee, squidData), signed by deposit.pubKey20. - function withdrawAndBridge( - address _peanutAddress, - uint256 _depositIndex, - bytes calldata _withdrawalSignature, - uint256 _squidFee, - uint256 _peanutFee, - bytes calldata _squidData, - bytes calldata _routingSignature - ) public payable { - PeanutV4 peanut = PeanutV4(_peanutAddress); - PeanutV4.Deposit memory deposit = peanut.getDeposit(_depositIndex); - - // Validate routingSignature (EIP-191 v0x00). - bytes32 digest = keccak256( - abi.encodePacked( - bytes2(0x1900), - address(this), - block.chainid, - _peanutAddress, - _depositIndex, - squidAddress, - _squidFee, - _peanutFee, - _squidData - ) - ); - address routingSigner = ECDSA.recover(digest, _routingSignature); - require(routingSigner == deposit.pubKey20, "WRONG ROUTING SIGNER"); - - require(_squidFee == msg.value, "msg.value MUST BE THE SQUID FEE"); - require( - deposit.contractType == 0 || deposit.contractType == 1, "X-CHAIN CLAIMS WORK ONLY FOR ETH AND ERC20 TOKENS" - ); - require(_peanutFee < deposit.amount, "TOO HIGH FEE"); - - peanut.withdrawDepositAsRecipient(_depositIndex, address(this), _withdrawalSignature); - - uint256 amountToBridge = deposit.amount - _peanutFee; - uint256 ethAmountToSquid = msg.value; - if (deposit.contractType == 0) { - // ETH deposit - ethAmountToSquid += amountToBridge; - } else if (deposit.contractType == 1) { - // ERC20 deposit - IERC20(deposit.tokenAddress).safeIncreaseAllowance(squidAddress, amountToBridge); - } else { - revert("UNSUPPORTED contractType"); - } - - (bool success,) = payable(squidAddress).call{value: ethAmountToSquid}(_squidData); - require(success, "FAILED TO INITIATE SQUID TRANSFER"); - } - - /// @notice Withdraw collected fees. Owner-gated (Ownable2Step — handoff requires acceptance). - /// @param token address(0) for ETH, ERC20 contract otherwise. - /// @param to recipient of the fees. - /// @param amount amount to withdraw. - function withdrawFees(address token, address to, uint256 amount) public onlyOwner { - if (token == address(0)) { - (bool success,) = payable(to).call{value: amount}(""); - require(success, "FAILED TO WITHDRAW ETH"); - } else { - IERC20(token).safeTransfer(to, amount); - } - } - - receive() external payable {} // allow ETH transfers from peanut vault -} diff --git a/src/peanut/doc/PeanutRouter.md b/src/peanut/doc/PeanutRouter.md deleted file mode 100644 index b7a80bdc..00000000 --- a/src/peanut/doc/PeanutRouter.md +++ /dev/null @@ -1,138 +0,0 @@ -# PeanutV4Router — cross-chain peanut withdrawal via Squid - -`src/peanut/V4/PeanutRouter.sol` - -## Purpose - -Wraps a Peanut withdrawal with a Squid (Axelar) bridge call so a recipient can claim a peanut link on chain X and receive the value on chain Y in a single transaction. Without this contract the recipient would have to first claim peanut on X, then manually bridge. - -**Not deployed on Sepolia.** Deploy if/when you wire a Squid integration. - -## Constructor - -```solidity -constructor(address _squidAddress) Ownable(msg.sender) -``` - -| Param | Purpose | -|---|---| -| `_squidAddress` | Target Squid router on this chain. All bridge calls go to it | - -Inherits `Ownable2Step` (OZ v5) so ownership transfer happens in two transactions: -1. Current owner: `transferOwnership(newOwner)` → sets pending owner -2. New owner: `acceptOwnership()` → confirms - -Initial owner is `msg.sender`. Use `transferOwnership` + `acceptOwnership` to move ownership to a multisig. - -## Storage - -```solidity -address public squidAddress; // mutable (no setter exposed — set at deploy) -``` - -Plus inherited `Ownable2Step`: `_owner`, `_pendingOwner`. - -## External - -### `withdrawAndBridge` - -```solidity -function withdrawAndBridge( - address _peanutAddress, - uint256 _depositIndex, - bytes calldata _withdrawalSignature, - uint256 _squidFee, - uint256 _peanutFee, - bytes calldata _squidData, - bytes calldata _routingSignature -) public payable -``` - -Full flow: - -1. **Validate `_routingSignature` first** (EIP-191 v0x00) — signed by the deposit's `pubKey20` over `(routerAddress, chainId, peanutAddress, depositIndex, squidAddress, squidFee, peanutFee, squidData)`. This pins the relayer to the exact fees + bridge calldata the link-owner agreed to. Front-running with a different fee structure reverts with `WRONG ROUTING SIGNER`. -2. `msg.value == _squidFee` (`msg.value MUST BE THE SQUID FEE`). -3. `deposit.contractType ∈ {0, 1}` — ETH or ERC-20 only. ERC-721 / ERC-1155 can't be bridged this way (`X-CHAIN CLAIMS WORK ONLY FOR ETH AND ERC20 TOKENS`). -4. `_peanutFee < deposit.amount` (`TOO HIGH FEE`). -5. Call `peanut.withdrawDepositAsRecipient(_depositIndex, address(this), _withdrawalSignature)`. The vault transfers the asset to this router. -6. Compute `amountToBridge = deposit.amount - _peanutFee`. For ERC-20: `safeIncreaseAllowance(squidAddress, amountToBridge)`. For ETH: `ethAmountToSquid += amountToBridge`. -7. `(bool ok,) = payable(squidAddress).call{value: ethAmountToSquid}(_squidData);` — forwards the bridge call. Reverts on failure. - -The router retains `_peanutFee` as collectible revenue. - -### `withdrawFees` - -```solidity -function withdrawFees(address token, address to, uint256 amount) public onlyOwner -``` - -Owner-gated. For ETH: `payable(to).call{value: amount}("")`. For ERC-20: `SafeERC20.safeTransfer` (so USDT and other non-bool-returning tokens work). - -### `receive() external payable {}` - -Allows the router to receive ETH from the vault during a `withdrawAndBridge` ETH path. - -## Signature scheme - -The routing signature uses **EIP-191 version 0x00** (a personal-sign variant). The digest: - -```solidity -keccak256(abi.encodePacked( - bytes2(0x1900), - address(this), // verifying contract - block.chainid, - _peanutAddress, - _depositIndex, - squidAddress, - _squidFee, - _peanutFee, - _squidData -)) -``` - -The link owner signs this off-chain. `ECDSA.recover(digest, _routingSignature)` must equal `deposit.pubKey20`. This signature is **separate** from the withdrawal signature, which proves the link owner consents to the bridge (different digest, different purpose — withdrawal authorizes pulling from the vault, routing authorizes the bridge parameters). - -## Threat model - -| Attack | Mitigation | -|---|---| -| Relayer charges higher peanut fee than user agreed | `_routingSignature` verifies over the EXACT `_peanutFee`. Any change → different digest → wrong signer revert | -| Relayer pays lower squid fee than required by Axelar (tx stuck) | `msg.value == _squidFee` check + `_squidFee` is in the routing sig | -| Relayer modifies `_squidData` to redirect to a different destination chain / token | `_squidData` is in the routing sig digest | -| Front-runner submits the same tx with stolen sig | Idempotent for the relayer fee perspective; peanut withdrawal is single-use so the second attempt reverts inside `peanut.withdrawDepositAsRecipient` (deposit already claimed) | -| Stuck cross-chain tx (gas-price spike on destination) | Out of scope — Axelar fee adjustment is the recovery; this contract does not implement expiry | - -## Vendoring patches - -| | Patch | -|---|---| -| Import target | `./PeanutV4.2.sol` → `./PeanutV4.4.sol` | -| OZ v5 | `Ownable` constructor takes explicit `Ownable(msg.sender)` | -| Hardening (S2) | `IERC20.transfer` → `SafeERC20.safeTransfer` in `withdrawFees` (USDT-compatible) | -| Hardening (M2) | `Ownable` → `Ownable2Step` (handoff requires explicit acceptance) | -| Modern | Named imports | -| Modern | Pragma pinned to `0.8.26` | - -## Test coverage - -`test/peanut/PeanutRouter.t.sol` — 4 tests including: - -- happy path: withdraw + bridge for ETH (256-run fuzz) -- happy path: withdraw + bridge for ERC-20 (256-run fuzz, validates fee paths) -- owner-only `withdrawFees` (asserts `Ownable.OwnableUnauthorizedAccount` for non-owner) -- relayer cannot tamper with fees / squidData (all `WRONG ROUTING SIGNER` reverts) - -## Deploy - -Not deployed on Sepolia. To deploy: - -```bash -PEANUT_DEPLOY_ROUTER=true \ -PEANUT_SQUID_ADDRESS=0x... # required -PEANUT_ROUTER_OWNER=0x... # optional; defaults to deployer -yarn hardhat deploy-zksync \ - --script DeployPeanut.ts \ - --network zkSyncSepoliaTestnet -``` - -After deploy, if `PEANUT_ROUTER_OWNER` ≠ deployer, the new owner must call `acceptOwnership()` from their own key. diff --git a/src/peanut/doc/README.md b/src/peanut/doc/README.md index 80f46fea..45e089b6 100644 --- a/src/peanut/doc/README.md +++ b/src/peanut/doc/README.md @@ -11,9 +11,10 @@ sponsors the user-side approval txs so the UX is gasless from the holder's POV. |---|---|---| | `PeanutV4` (vault) | `src/peanut/V4/PeanutV4.4.sol` | [PeanutV4.md](./PeanutV4.md) | | `PeanutBatcherV4` (batched deposits) | `src/peanut/V4/PeanutBatcherV4.4.sol` | [PeanutBatcherV4.md](./PeanutBatcherV4.md) | -| `PeanutV4Router` (cross-chain via Squid) | `src/peanut/V4/PeanutRouter.sol` | [PeanutRouter.md](./PeanutRouter.md) | | `EnvelopeApprovalPaymaster` (Path-C gas sponsor + operator gas pool) | `src/paymasters/EnvelopeApprovalPaymaster.sol` | [EnvelopeApprovalPaymaster.md](./EnvelopeApprovalPaymaster.md) | +Upstream's `PeanutV4Router` (cross-chain via Squid) is intentionally not vendored — Nodle's deployment doesn't currently use it. If cross-chain claims become a requirement later, re-vendor the upstream router contract and add it back. + Interfaces (vendored, unmodified): | Interface | Source | Used by | @@ -23,7 +24,7 @@ Interfaces (vendored, unmodified): ## Naming convention -- **Peanut** — the vendored open-source primitive (`peanutprotocol/peanut-contracts@main`). The vault, batcher, and router keep upstream names so audits + diffs against upstream stay easy. +- **Peanut** — the vendored open-source primitive (`peanutprotocol/peanut-contracts@main`). The vault and batcher keep upstream names so audits + diffs against upstream stay easy. - **Envelope** — Nodle's product wrapper on top. The paymaster is named for this layer (operates against the Peanut vault, sponsored on Nodle's terms). ## Deployed on ZkSync Sepolia (chain 300) @@ -33,7 +34,6 @@ Interfaces (vendored, unmodified): | `PeanutV4` | [`0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44`](https://sepolia.explorer.zksync.io/address/0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44#contract) | | `PeanutBatcherV4` | [`0x1676cD8B90e2E4388C032ae5Eb4BA50166Bb3426`](https://sepolia.explorer.zksync.io/address/0x1676cD8B90e2E4388C032ae5Eb4BA50166Bb3426#contract) | | `EnvelopeApprovalPaymaster` | [`0x80EA078d599Bc63BB921Cf96CC6861731446e268`](https://sepolia.explorer.zksync.io/address/0x80EA078d599Bc63BB921Cf96CC6861731446e268#contract) | -| `PeanutV4Router` | not deployed (deploy when cross-chain is needed) | ## Three deposit paths @@ -49,7 +49,7 @@ The vault itself supports three ways a sender can fund a link: | Script | Purpose | |---|---| -| `hardhat-deploy/DeployPeanut.ts` | vault + batcher (+ optional router) | +| `hardhat-deploy/DeployPeanut.ts` | vault + batcher | | `hardhat-deploy/DeployEnvelopePaymaster.ts` | paymaster | Both are Hardhat-zksync scripts. See each spec for env vars. @@ -58,8 +58,8 @@ Both are Hardhat-zksync scripts. See each spec for env vars. | Suite | Tests | |---|---| -| Peanut core (`test/peanut/`) | **96** (60 vendored + 13 hardening + 23 edge cases) | +| Peanut core (`test/peanut/`) | **90** (56 vendored + 11 hardening + 23 edge cases) | | `EnvelopeApprovalPaymaster` (`test/paymasters/EnvelopeApprovalPaymaster.t.sol`) | **27** (19 Mode A + 7 Mode B + 1 EIP-1271 contract signer) | | Other paymasters (unchanged) | 102 | | Rest of repo | 747 | -| **Total** | **972** | +| **Total** | **966** | diff --git a/test/peanut/PeanutHardening.t.sol b/test/peanut/PeanutHardening.t.sol index e9ec1f45..1f2458d4 100644 --- a/test/peanut/PeanutHardening.t.sol +++ b/test/peanut/PeanutHardening.t.sol @@ -5,61 +5,20 @@ pragma solidity 0.8.26; // Each test maps back to a finding in the audit: // T1 — direct ERC721 / ERC1155 transfers must revert (fix for S1 receivers footgun) // T2 — MFA_AUTHORIZER is now a per-deploy constructor arg (fix for S3 hardcoded key) -// T3 — PeanutRouter.withdrawFees uses safeTransfer for non-returning ERC20s (fix for S2) // T4 — _storeDeposit rejects deposits with no withdrawal authority (fix for S4) // T5 — _withdrawDeposit L2ECO branch sends to recipient, not sender (upstream bug fix) import {Test} from "forge-std/Test.sol"; import {PeanutV4} from "../../src/peanut/V4/PeanutV4.4.sol"; -import {PeanutV4Router} from "../../src/peanut/V4/PeanutRouter.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; -import {SquidMock} from "./mocks/SquidMock.sol"; import {L2ECOMock} from "./mocks/L2ECOMock.sol"; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; -/// @dev Minimal ERC20 that does NOT return a bool from transfer (USDT-style). -/// Used to verify SafeERC20 normalizes the call. -contract NonReturningERC20 { - string public name = "NonRet"; - string public symbol = "NRT"; - uint8 public decimals = 18; - uint256 public totalSupply; - mapping(address => uint256) public balanceOf; - mapping(address => mapping(address => uint256)) public allowance; - - function mint(address to, uint256 amount) external { - balanceOf[to] += amount; - totalSupply += amount; - } - - /// @dev Note: NO return value, like USDT. - function transfer(address to, uint256 amount) external { - require(balanceOf[msg.sender] >= amount, "NRT: insufficient"); - balanceOf[msg.sender] -= amount; - balanceOf[to] += amount; - } - - function transferFrom(address from, address to, uint256 amount) external { - require(balanceOf[from] >= amount, "NRT: insufficient"); - require(allowance[from][msg.sender] >= amount, "NRT: not approved"); - allowance[from][msg.sender] -= amount; - balanceOf[from] -= amount; - balanceOf[to] += amount; - } - - function approve(address spender, uint256 amount) external { - allowance[msg.sender][spender] = amount; - } -} - contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { PeanutV4 public peanut; - PeanutV4Router public router; - SquidMock public squid; ERC721Mock public erc721; ERC1155Mock public erc1155; @@ -68,8 +27,6 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { function setUp() public { peanut = new PeanutV4(address(0), address(0)); - squid = new SquidMock(); - router = new PeanutV4Router(address(squid)); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); } @@ -173,29 +130,6 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { peanut.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); } - // ── T3 ───────────────────────────────────────────────────────────────── - // PeanutRouter.withdrawFees must work with USDT-style ERC20s that don't - // return a bool from transfer. Pre-fix used raw .transfer(); SafeERC20 - // normalizes the call. - - function test_T3_withdrawFees_nonReturningERC20() public { - NonReturningERC20 nrt = new NonReturningERC20(); - nrt.mint(address(router), 1000); - - router.withdrawFees(address(nrt), ALICE, 750); - assertEq(nrt.balanceOf(ALICE), 750); - assertEq(nrt.balanceOf(address(router)), 250); - } - - function test_T3_withdrawFees_nonOwnerReverts() public { - NonReturningERC20 nrt = new NonReturningERC20(); - nrt.mint(address(router), 1000); - - vm.prank(ALICE); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ALICE)); - router.withdrawFees(address(nrt), ALICE, 750); - } - // ── T4 ───────────────────────────────────────────────────────────────── // A deposit with both pubKey20 == 0 AND recipient == 0 has no auth — anyone // could withdraw it. The new _storeDeposit guard rejects this footgun. diff --git a/test/peanut/PeanutRouter.t.sol b/test/peanut/PeanutRouter.t.sol deleted file mode 100644 index 03c0f591..00000000 --- a/test/peanut/PeanutRouter.t.sol +++ /dev/null @@ -1,241 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.23; - -import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutRouter.sol"; -import "./mocks/SquidMock.sol"; -import "./mocks/ERC20Mock.sol"; -import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; - - -contract PeanutV4RouterTest is Test { - PeanutV4 public peanutV4; - SquidMock public squidMock; - PeanutV4Router public peanutV4Router; - ERC20Mock public testToken; - - address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); - bytes32 public constant SAMPLE_PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; - bytes4 SQUID_MOCK_FUNCTION_SIGNATURE = bytes4(keccak256("superPowerfulBridge(address,uint256)")); - - function setUp() public { - testToken = new ERC20Mock(); - peanutV4 = new PeanutV4(address(0), address(0)); - squidMock = new SquidMock(); - peanutV4Router = new PeanutV4Router(address(squidMock)); - } - - function _signPeanutWithdrawal(uint256 depositIndex, address recipientAddress, bytes32 privateKey) internal view returns (bytes memory signature) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - peanutV4.PEANUT_SALT(), - block.chainid, - address(peanutV4), - depositIndex, - recipientAddress, - peanutV4.RECIPIENT_WITHDRAWAL_MODE() - ) - ) - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(privateKey), digest); - signature = abi.encodePacked(r, s, v); - } - - function _signPeanutRouting(uint256 depositIndex, uint256 squidFee, uint256 peanutFee, bytes memory squidData, bytes32 privateKey) internal view returns (bytes memory signature) { - bytes32 digest = keccak256( - abi.encodePacked( - bytes2(0x1900), - address(peanutV4Router), - block.chainid, - address(peanutV4), - depositIndex, - address(squidMock), - squidFee, - peanutFee, - squidData - ) - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(privateKey), digest); - signature = abi.encodePacked(r, s, v); - } - - function testWithdrawERC20AndBridge( - uint128 amountDeposited, // uint128 to prevent total supply overflow - uint96 requiredSquidFee, // uint96 to not run out of the default faucet ETH amount - uint256 requiredPeanutFee - ) public { - vm.assume(requiredPeanutFee < amountDeposited); - - testToken.mint(address(this), amountDeposited); - testToken.approve(address(peanutV4), amountDeposited); - uint256 depositIndex = peanutV4.makeDeposit(address(testToken), 1, amountDeposited, 0, SAMPLE_ADDRESS); - - bytes memory withdrawalSignature = _signPeanutWithdrawal( - depositIndex, - address(peanutV4Router), - SAMPLE_PRIVKEY - ); - - bytes memory squidData = abi.encodePacked( - SQUID_MOCK_FUNCTION_SIGNATURE, - abi.encode( // args have to be 32-bytes padded - address(testToken), - amountDeposited - requiredPeanutFee // testToken amount to be transferred to the squid mock - ) - ); - - bytes memory routingSignature = _signPeanutRouting( - depositIndex, - requiredSquidFee, - requiredPeanutFee, - squidData, - SAMPLE_PRIVKEY - ); - - // Relayer attempts to charge a higher peanut fee - vm.expectRevert("WRONG ROUTING SIGNER"); - peanutV4Router.withdrawAndBridge{value: requiredSquidFee}( - address(peanutV4), - depositIndex, - withdrawalSignature, - requiredSquidFee, - requiredPeanutFee + 10, - squidData, - routingSignature - ); - - if (requiredSquidFee > 0) { - // Relayer attempts to pay a lower squid fee - vm.expectRevert("msg.value MUST BE THE SQUID FEE"); - peanutV4Router.withdrawAndBridge{value: requiredSquidFee - 1}( - address(peanutV4), - depositIndex, - withdrawalSignature, - requiredSquidFee, - requiredPeanutFee, - squidData, - routingSignature - ); - - // Relayer attempts to pay a lower squid fee and also modifies the arguments - vm.expectRevert("WRONG ROUTING SIGNER"); - peanutV4Router.withdrawAndBridge{value: requiredSquidFee - 1}( - address(peanutV4), - depositIndex, - withdrawalSignature, - requiredSquidFee - 1, - requiredPeanutFee, - squidData, - routingSignature - ); - } - - // Someone tries to front-run with malicious squidData - vm.expectRevert("WRONG ROUTING SIGNER"); - peanutV4Router.withdrawAndBridge{value: requiredSquidFee}( - address(peanutV4), - depositIndex, - withdrawalSignature, - requiredSquidFee, - requiredPeanutFee, - bytes("BAD BAD BAD BAD"), - routingSignature - ); - - // Withdraw and bridge! Withdraw and bridge! Withdraw and bridge! - peanutV4Router.withdrawAndBridge{value: requiredSquidFee}( - address(peanutV4), - depositIndex, - withdrawalSignature, - requiredSquidFee, - requiredPeanutFee, - squidData, - routingSignature - ); - - require(testToken.balanceOf(address(squidMock)) == amountDeposited - requiredPeanutFee, "TOKENS WERE NOT TRANSFERRED TO SQUID"); - require(testToken.balanceOf(address(peanutV4Router)) == requiredPeanutFee, "PEANUT FEE WAS NOT COLLECTED"); - require(address(squidMock).balance == requiredSquidFee, "FEE WAS NOT PAID TO SQUID"); - } - - function testWithdrawETHAndBridge( - uint96 amountDeposited, - uint96 requiredSquidFee, - uint96 requiredPeanutFee - ) public { - // prevent out of funds problems - vm.assume(uint256(amountDeposited) + uint256(requiredSquidFee) + uint256(requiredPeanutFee) < 2 ** 96); - vm.assume(amountDeposited > requiredPeanutFee); - - uint256 depositIndex = peanutV4.makeDeposit{value: amountDeposited}(address(0), 0, amountDeposited, 0, SAMPLE_ADDRESS); - - bytes memory withdrawalSignature = _signPeanutWithdrawal( - depositIndex, - address(peanutV4Router), - SAMPLE_PRIVKEY - ); - - // uint256 requiredSquidFee = 100; // 100 wei - // uint256 requiredPeanutFee = 130; // 130 wei - - bytes memory squidData = abi.encodePacked( - SQUID_MOCK_FUNCTION_SIGNATURE, - abi.encode( // args have to be 32-bytes padded - address(0), - amountDeposited + requiredSquidFee - requiredPeanutFee // ETH amount to be transferred to the squid mock - ) - ); - - bytes memory routingSignature = _signPeanutRouting( - depositIndex, - requiredSquidFee, - requiredPeanutFee, - squidData, - SAMPLE_PRIVKEY - ); - - // Withdraw and bridge! Withdraw and bridge! Withdraw and bridge! - peanutV4Router.withdrawAndBridge{value: requiredSquidFee}( - address(peanutV4), - depositIndex, - withdrawalSignature, - requiredSquidFee, - requiredPeanutFee, - squidData, - routingSignature - ); - - require(address(squidMock).balance == amountDeposited + requiredSquidFee - requiredPeanutFee, "AMOUNT OR FEE WAS NOT PAID TO SQUID"); - require(address(peanutV4Router).balance == requiredPeanutFee, "PEANUT FEE WAS NOT COLLECTED"); - } - - function testWithdrawFee( - uint96 collectedEth, - uint128 collectedTokens, - uint96 ethToWithdraw, - uint128 tokensToWithdraw - ) public { - vm.assume(ethToWithdraw <= collectedEth); - vm.assume(tokensToWithdraw <= collectedTokens); - - // Pretend that there were some transfers and some fee was collected in the peanut router - testToken.mint(address(this), collectedTokens); - testToken.transfer(address(peanutV4Router), collectedTokens); - (bool ok,) = payable(address(peanutV4Router)).call{value: collectedEth}(""); - require(ok, "ETH seed transfer failed"); - - // Non-owner can't withdraw - vm.prank(SAMPLE_ADDRESS); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, SAMPLE_ADDRESS)); - peanutV4Router.withdrawFees(address(0), SAMPLE_ADDRESS, ethToWithdraw); - - peanutV4Router.withdrawFees(address(0), SAMPLE_ADDRESS, ethToWithdraw); - require(address(SAMPLE_ADDRESS).balance == ethToWithdraw, "RECEIVED WRONG AMOUNT OF ETH"); - - peanutV4Router.withdrawFees(address(testToken), SAMPLE_ADDRESS, tokensToWithdraw); - require(testToken.balanceOf(SAMPLE_ADDRESS) == tokensToWithdraw, "RECEIVED WRONG AMOUNT OF testToken"); - } -} diff --git a/test/peanut/mocks/SquidMock.sol b/test/peanut/mocks/SquidMock.sol deleted file mode 100644 index 54db78e2..00000000 --- a/test/peanut/mocks/SquidMock.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.26; - -import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -/// @dev Test mock for the Squid router. PeanutRouter forwards an opaque calldata blob -/// to Squid; this mock just records that the blob was delivered. -contract SquidMock { - using SafeERC20 for IERC20; - - event SquidMockBridged(); - - function superPowerfulBridge(address bridgedToken, uint256 bridgedAmount) public payable { - if (bridgedToken == address(0)) { - require(msg.value == bridgedAmount, "msg.value DOES NOT MATCH bridgedAmount"); - } else { - IERC20(bridgedToken).safeTransferFrom(msg.sender, address(this), bridgedAmount); - } - - emit SquidMockBridged(); - } -} From c47e402043ee280b95d5d3af161cba40161b12f1 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 21:18:25 -0400 Subject: [PATCH 22/31] docs(peanut): drop residual router note from README --- src/peanut/doc/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/peanut/doc/README.md b/src/peanut/doc/README.md index 45e089b6..85dee45d 100644 --- a/src/peanut/doc/README.md +++ b/src/peanut/doc/README.md @@ -13,8 +13,6 @@ sponsors the user-side approval txs so the UX is gasless from the holder's POV. | `PeanutBatcherV4` (batched deposits) | `src/peanut/V4/PeanutBatcherV4.4.sol` | [PeanutBatcherV4.md](./PeanutBatcherV4.md) | | `EnvelopeApprovalPaymaster` (Path-C gas sponsor + operator gas pool) | `src/paymasters/EnvelopeApprovalPaymaster.sol` | [EnvelopeApprovalPaymaster.md](./EnvelopeApprovalPaymaster.md) | -Upstream's `PeanutV4Router` (cross-chain via Squid) is intentionally not vendored — Nodle's deployment doesn't currently use it. If cross-chain claims become a requirement later, re-vendor the upstream router contract and add it back. - Interfaces (vendored, unmodified): | Interface | Source | Used by | From be97cd12bc6c5b1b3f3d76a256670acb10a4f47f Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 21:45:26 -0400 Subject: [PATCH 23/31] chore(license): GPL-3.0-or-later compliance for vendored Peanut sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps closed: 1. Bundled the full GNU GPL v3 license text at src/peanut/V4/LICENSE-GPL (copied verbatim from peanutprotocol/peanut-contracts@main/LICENSE.md, 673 lines). GPL §4 wants a copyright notice distributed with the work; the SPDX header was the only marker before. 2. Added top-of-file modification notice on the two modified GPL files (PeanutV4.4.sol, PeanutBatcherV4.4.sol). GPL §5(a): "carry prominent notices stating that you modified it, and giving a relevant date." Notice points to the per-file vendoring-patches list in the spec docs and the git history for the full patch set. 3. Relicensed test files that import GPL-licensed sources from UNLICENSED / BSD-3-Clause-Clear → GPL-3.0-or-later. Strict reading of the GPL: a file that imports GPL code becomes a derivative work and must itself be GPL. Affected: 11 test files (Deposit, Integration, MFA, PeanutBatcher, PeanutEdgeCases, PeanutHardening, PeanutV4, PeanutV4Gasless, RecipientBound, SenderWithdraw, SigWithdraw). Plus added a "License notice" section to src/peanut/doc/README.md documenting the mixed-license layout (GPL parts + BSD parts + MIT parts) so future contributors don't have to reverse-engineer it. What stayed BSD-3-Clause-Clear: EnvelopeApprovalPaymaster (doesn't import any GPL source — references the vault by address only). Repo root LICENSE is unchanged. The OSI's "mere aggregation" interpretation covers everything else in the repo. Tests: 966/966. Lint: clean. Spellcheck: clean. This is a technical compliance pass, not legal advice — Nodle counsel should sign off before mainnet. --- src/peanut/V4/LICENSE-GPL | 674 ++++++++++++++++++++++++++++ src/peanut/V4/PeanutBatcherV4.4.sol | 5 + src/peanut/V4/PeanutV4.4.sol | 5 + src/peanut/doc/README.md | 15 + test/peanut/Deposit.t.sol | 2 +- test/peanut/Integration.t.sol | 2 +- test/peanut/MFA.t.sol | 2 +- test/peanut/PeanutBatcher.t.sol | 2 +- test/peanut/PeanutEdgeCases.t.sol | 2 +- test/peanut/PeanutHardening.t.sol | 2 +- test/peanut/PeanutV4.t.sol | 2 +- test/peanut/PeanutV4Gasless.t.sol | 2 +- test/peanut/RecipientBound.t.sol | 2 +- test/peanut/SenderWithdraw.t.sol | 2 +- test/peanut/SigWithdraw.t.sol | 2 +- 15 files changed, 710 insertions(+), 11 deletions(-) create mode 100644 src/peanut/V4/LICENSE-GPL diff --git a/src/peanut/V4/LICENSE-GPL b/src/peanut/V4/LICENSE-GPL new file mode 100644 index 00000000..96bd6eda --- /dev/null +++ b/src/peanut/V4/LICENSE-GPL @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/src/peanut/V4/PeanutBatcherV4.4.sol b/src/peanut/V4/PeanutBatcherV4.4.sol index 7f6aae1b..3305d84d 100644 --- a/src/peanut/V4/PeanutBatcherV4.4.sol +++ b/src/peanut/V4/PeanutBatcherV4.4.sol @@ -1,4 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later +// +// Modified by Nodle (2026-05-12) — see src/peanut/doc/PeanutBatcherV4.md ("Vendoring +// patches") and the git history of this file for the full patch set. The upstream source +// is peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license text is bundled +// at src/peanut/V4/LICENSE-GPL. pragma solidity 0.8.26; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; diff --git a/src/peanut/V4/PeanutV4.4.sol b/src/peanut/V4/PeanutV4.4.sol index 6e7e2f16..1f79e4b5 100644 --- a/src/peanut/V4/PeanutV4.4.sol +++ b/src/peanut/V4/PeanutV4.4.sol @@ -1,4 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later +// +// Modified by Nodle (2026-05-12) — see src/peanut/doc/PeanutV4.md ("Vendoring patches +// applied at import") and the git history of this file for the full patch set. The +// upstream source is peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license +// text is bundled at src/peanut/V4/LICENSE-GPL. pragma solidity 0.8.26; ////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/peanut/doc/README.md b/src/peanut/doc/README.md index 85dee45d..7c8e8861 100644 --- a/src/peanut/doc/README.md +++ b/src/peanut/doc/README.md @@ -20,6 +20,21 @@ Interfaces (vendored, unmodified): | `IEIP3009` | `src/peanut/util/IEIP3009.sol` | `PeanutV4` for gasless USDC-style deposits | | `IL2ECO` | `src/peanut/util/IL2ECO.sol` | `PeanutV4` for rebasing-ERC20 deposits (`contractType==4`) | +## License notice + +This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) doesn't apply uniformly here. + +| Files | License | Notes | +|---|---|---| +| `src/peanut/V4/PeanutV4.4.sol`, `PeanutBatcherV4.4.sol` | **GPL-3.0-or-later** | Modified copies of upstream Peanut Protocol V4.4. Full GPL v3 text bundled at `src/peanut/V4/LICENSE-GPL`. Each file carries a top-of-file modification notice per GPL §5(a). | +| `src/peanut/util/IEIP3009.sol`, `IL2ECO.sol` | **MIT** | Vendored interfaces, unchanged from upstream | +| `src/paymasters/EnvelopeApprovalPaymaster.sol` | **BSD-3-Clause-Clear** | Our own code; doesn't `import` any GPL source so it isn't a derivative work | +| `test/peanut/**/*.t.sol` (files that import Peanut sources) | **GPL-3.0-or-later** | Test files that `import` GPL-licensed contracts are derivative works under a strict reading of the GPL; relicensed for compliance | +| `test/peanut/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained | +| All other repo files | unchanged | Whatever they were | + +The GPL is "viral" only across `import` boundaries; non-importing files in the same repository remain under their own licenses (per the OSI's "mere aggregation" interpretation). + ## Naming convention - **Peanut** — the vendored open-source primitive (`peanutprotocol/peanut-contracts@main`). The vault and batcher keep upstream names so audits + diffs against upstream stay easy. diff --git a/test/peanut/Deposit.t.sol b/test/peanut/Deposit.t.sol index fcea02c4..70c21019 100644 --- a/test/peanut/Deposit.t.sol +++ b/test/peanut/Deposit.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; ////////////////////////////// diff --git a/test/peanut/Integration.t.sol b/test/peanut/Integration.t.sol index cc7a2072..50270909 100644 --- a/test/peanut/Integration.t.sol +++ b/test/peanut/Integration.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; ////////////////////////////// diff --git a/test/peanut/MFA.t.sol b/test/peanut/MFA.t.sol index df84e5c1..174243c7 100644 --- a/test/peanut/MFA.t.sol +++ b/test/peanut/MFA.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; import "forge-std/Test.sol"; diff --git a/test/peanut/PeanutBatcher.t.sol b/test/peanut/PeanutBatcher.t.sol index db10e8cf..7ae69e34 100644 --- a/test/peanut/PeanutBatcher.t.sol +++ b/test/peanut/PeanutBatcher.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "forge-std/Test.sol"; diff --git a/test/peanut/PeanutEdgeCases.t.sol b/test/peanut/PeanutEdgeCases.t.sol index 70e415d0..2a1fb772 100644 --- a/test/peanut/PeanutEdgeCases.t.sol +++ b/test/peanut/PeanutEdgeCases.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BSD-3-Clause-Clear +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; // Edge-case coverage for PeanutV4 / PeanutBatcherV4 — gates the vendored happy-path diff --git a/test/peanut/PeanutHardening.t.sol b/test/peanut/PeanutHardening.t.sol index 1f2458d4..836bd6b7 100644 --- a/test/peanut/PeanutHardening.t.sol +++ b/test/peanut/PeanutHardening.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.26; // Hardening tests added during the OZ-v5 / ZkSync-aligned refactor of Peanut V4.4. diff --git a/test/peanut/PeanutV4.t.sol b/test/peanut/PeanutV4.t.sol index e4dcff53..1146f804 100644 --- a/test/peanut/PeanutV4.t.sol +++ b/test/peanut/PeanutV4.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; import "forge-std/Test.sol"; diff --git a/test/peanut/PeanutV4Gasless.t.sol b/test/peanut/PeanutV4Gasless.t.sol index 19bcdaa7..de67a756 100644 --- a/test/peanut/PeanutV4Gasless.t.sol +++ b/test/peanut/PeanutV4Gasless.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; import "forge-std/Test.sol"; diff --git a/test/peanut/RecipientBound.t.sol b/test/peanut/RecipientBound.t.sol index 76c6ddcc..3ab4c5c8 100644 --- a/test/peanut/RecipientBound.t.sol +++ b/test/peanut/RecipientBound.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; import "forge-std/Test.sol"; diff --git a/test/peanut/SenderWithdraw.t.sol b/test/peanut/SenderWithdraw.t.sol index 9f7cbbe8..a3eb3da9 100644 --- a/test/peanut/SenderWithdraw.t.sol +++ b/test/peanut/SenderWithdraw.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "forge-std/Test.sol"; diff --git a/test/peanut/SigWithdraw.t.sol b/test/peanut/SigWithdraw.t.sol index 1eb81ceb..79047dad 100644 --- a/test/peanut/SigWithdraw.t.sol +++ b/test/peanut/SigWithdraw.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "forge-std/Test.sol"; From 9989954d1f33c91751af9e4d113a8d951fdf600a Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 22:11:53 -0400 Subject: [PATCH 24/31] =?UTF-8?q?refactor(peanut):=20rename=20contract=20s?= =?UTF-8?q?ymbols=20Peanut=20=E2=86=92=20Envelope=20(trademark=20hygiene)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the on-chain-visible contract symbols + EIP-712 domain string from Squirrel Labs' "Peanut" brand to Nodle's "Envelope" brand. Source file paths keep their upstream names so the audit lineage to peanutprotocol/peanut-contracts@main stays grep-friendly via path + git history + LICENSE-GPL + modification notice. contract PeanutV4 → EnvelopeVault contract PeanutBatcherV4 → EnvelopeBatcher EIP712Domain.name "Peanut" → "Envelope" (in PeanutV4.4.sol constructor) Why: GPL gives us the right to fork the code; it doesn't grant use of the upstream brand. Renaming the visible symbols closes a trademark vector independent of license. The previously-renamed paymaster (EnvelopeApprovalPaymaster) already followed this convention. What stayed: - File paths (PeanutV4.4.sol, PeanutBatcherV4.4.sol) — preserves upstream-diff - PEANUT_SALT constant — its on-chain hash is baked into every signature; changing the value would break compatibility with anything using the salt convention - Author attribution (Squirrel Labs) — kept per GPL §5(d) - LICENSE-GPL, top-of-file modification notices — kept Updated: - src/peanut/V4/PeanutV4.4.sol — contract name, EIP-712 domain string - src/peanut/V4/PeanutBatcherV4.4.sol — contract name + 5 type refs - 11 test files — type refs in imports + state vars + new() calls - test/peanut/mocks/L2ECOMock.sol — 1 type ref in comment - hardhat-deploy/DeployPeanut.ts — contract name strings for deploy + verify - src/peanut/doc/* — symbol references throughout, naming-convention section Test contract names also updated for consistency: PeanutV4Test → EnvelopeVaultTest, PeanutV4DepositTest → EnvelopeVaultDepositTest, etc. Sepolia redeployed (old addresses orphaned; old paymaster drained ~0.0015 ETH back): EnvelopeVault 0x32D02E54EaE5F8Bba75129e9306e0b8b70f05f6a EnvelopeBatcher 0x5DAe00DDFA1F96Aaf75d21F49B6FF5C756174816 EnvelopeApprovalPaymaster 0xc160C8F6faC916De00B55aA0a630eBdce43CD532 All three verified. Paymaster funded with 0.0015 ETH and seeded with the deployer EOA as Mode B operator + the new vault as Mode B target. The vault's EIP-712 domain change ("Peanut" → "Envelope") invalidates any gasless-reclaim signatures produced under the old domain. We had none in production, so nothing breaks. forge test 966/966. yarn lint 0 errors. yarn spellcheck 0 issues. --- hardhat-deploy/DeployPeanut.ts | 18 +++++------ src/peanut/V4/PeanutBatcherV4.4.sol | 20 ++++++------ src/peanut/V4/PeanutV4.4.sol | 4 +-- src/peanut/doc/EnvelopeApprovalPaymaster.md | 4 +-- src/peanut/doc/PeanutBatcherV4.md | 10 +++--- src/peanut/doc/PeanutV4.md | 4 +-- src/peanut/doc/README.md | 19 +++++------ test/peanut/Deposit.t.sol | 8 ++--- test/peanut/Integration.t.sol | 8 ++--- test/peanut/MFA.t.sol | 6 ++-- test/peanut/PeanutBatcher.t.sol | 20 ++++++------ test/peanut/PeanutEdgeCases.t.sol | 36 ++++++++++----------- test/peanut/PeanutHardening.t.sol | 14 ++++---- test/peanut/PeanutV4.t.sol | 10 +++--- test/peanut/PeanutV4Gasless.t.sol | 10 +++--- test/peanut/RecipientBound.t.sol | 4 +-- test/peanut/SenderWithdraw.t.sol | 16 ++++----- test/peanut/SigWithdraw.t.sol | 4 +-- test/peanut/mocks/L2ECOMock.sol | 2 +- 19 files changed, 109 insertions(+), 108 deletions(-) diff --git a/hardhat-deploy/DeployPeanut.ts b/hardhat-deploy/DeployPeanut.ts index 2cbb54d0..57b3c208 100644 --- a/hardhat-deploy/DeployPeanut.ts +++ b/hardhat-deploy/DeployPeanut.ts @@ -21,7 +21,7 @@ dotenv.config({ path: ".env-test" }); * - PEANUT_MFA_AUTHORIZER: Address authorized to sign MFA withdraw approvals. * Defaults to 0x0 (MFA disabled — withdrawMFADeposit reverts). * Set to your backend signer for production MFA. - * - PEANUT_DEPLOY_BATCHER: "true"|"false". Default "true". Deploys PeanutBatcherV4. + * - PEANUT_DEPLOY_BATCHER: "true"|"false". Default "true". Deploys EnvelopeBatcher. * * Usage: * yarn hardhat deploy-zksync \ @@ -49,29 +49,29 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); // 1. Vault — required. - const peanut = await deployContract(deployer, "PeanutV4", [ecoToken, mfaAuthorizer]); + const peanut = await deployContract(deployer, "EnvelopeVault", [ecoToken, mfaAuthorizer]); const peanutAddr = await peanut.getAddress(); // 2. Batcher — optional. let batcherAddr: string | undefined; if (deployBatcher) { - const batcher = await deployContract(deployer, "PeanutBatcherV4", []); + const batcher = await deployContract(deployer, "EnvelopeBatcher", []); batcherAddr = await batcher.getAddress(); } console.log(""); console.log("=== Deployment Complete ==="); - console.log("PeanutV4: ", peanutAddr); - if (batcherAddr) console.log("PeanutBatcherV4: ", batcherAddr); + console.log("EnvelopeVault: ", peanutAddr); + if (batcherAddr) console.log("EnvelopeBatcher: ", batcherAddr); console.log(""); // Verification console.log("=== Verifying Contracts ==="); try { - console.log("Verifying PeanutV4..."); + console.log("Verifying EnvelopeVault..."); await hre.run("verify:verify", { address: peanutAddr, - contract: "src/peanut/V4/PeanutV4.4.sol:PeanutV4", + contract: "src/peanut/V4/PeanutV4.4.sol:EnvelopeVault", constructorArguments: [ecoToken, mfaAuthorizer], }); } catch (e: any) { @@ -80,10 +80,10 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { if (batcherAddr) { try { - console.log("Verifying PeanutBatcherV4..."); + console.log("Verifying EnvelopeBatcher..."); await hre.run("verify:verify", { address: batcherAddr, - contract: "src/peanut/V4/PeanutBatcherV4.4.sol:PeanutBatcherV4", + contract: "src/peanut/V4/PeanutBatcherV4.4.sol:EnvelopeBatcher", constructorArguments: [], }); } catch (e: any) { diff --git a/src/peanut/V4/PeanutBatcherV4.4.sol b/src/peanut/V4/PeanutBatcherV4.4.sol index 3305d84d..34dd0fda 100644 --- a/src/peanut/V4/PeanutBatcherV4.4.sol +++ b/src/peanut/V4/PeanutBatcherV4.4.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -// Modified by Nodle (2026-05-12) — see src/peanut/doc/PeanutBatcherV4.md ("Vendoring +// Modified by Nodle (2026-05-12) — see src/peanut/doc/EnvelopeBatcher.md ("Vendoring // patches") and the git history of this file for the full patch set. The upstream source // is peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license text is bundled // at src/peanut/V4/LICENSE-GPL. @@ -12,15 +12,15 @@ import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Recei import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; -import {PeanutV4} from "./PeanutV4.4.sol"; +import {EnvelopeVault} from "./PeanutV4.4.sol"; /// @title Peanut Batcher V4.4 /// @notice Stateless helper that pulls tokens from msg.sender then forwards N deposits -/// to a target PeanutV4 vault. -/// @dev Holds no persistent state — the PeanutV4 reference is taken per call so the +/// to a target EnvelopeVault vault. +/// @dev Holds no persistent state — the EnvelopeVault reference is taken per call so the /// contract can fan out to multiple vaults and so EraVM doesn't charge pubdata /// for storage writes on the hot path. -contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { +contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { using SafeERC20 for IERC20; function _setAllowanceIfZero(address tokenAddress, address spender) internal { @@ -77,7 +77,7 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { uint256 _tokenId, address[] calldata _pubKeys20 ) external payable returns (uint256[] memory) { - PeanutV4 peanut = PeanutV4(_peanutAddress); + EnvelopeVault peanut = EnvelopeVault(_peanutAddress); uint256 totalAmount = _amount * _pubKeys20.length; uint256 etherAmount; @@ -114,7 +114,7 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { uint256 _tokenId, address[] calldata _pubKeys20 ) external payable { - PeanutV4 peanut = PeanutV4(_peanutAddress); + EnvelopeVault peanut = EnvelopeVault(_peanutAddress); // For ETH (contractType == 0), the batcher only receives msg.value once; forwarding // {value: msg.value} per loop iteration would revert on iteration 2 with insufficient // balance. Either require msg.value == _amount * N and forward _amount per call, or @@ -150,7 +150,7 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { && _withMFAs.length == _pubKeys20.length, "PARAMETERS LENGTH MISMATCH" ); - PeanutV4 peanut = PeanutV4(_peanutAddress); + EnvelopeVault peanut = EnvelopeVault(_peanutAddress); uint256[] memory depositIndexes = new uint256[](_amounts.length); for (uint256 i = 0; i < _amounts.length; i++) { @@ -193,7 +193,7 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { address _pubKey20 ) external payable returns (uint256[] memory) { require(_contractType == 0 || _contractType == 1, "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED"); - PeanutV4 peanut = PeanutV4(_peanutAddress); + EnvelopeVault peanut = EnvelopeVault(_peanutAddress); if (_contractType == 1) { _setAllowanceIfZero(_tokenAddress, _peanutAddress); @@ -225,7 +225,7 @@ contract PeanutBatcherV4 is IERC721Receiver, IERC1155Receiver { address _pubKey20 ) external payable returns (uint256[] memory) { require(_contractType == 0 || _contractType == 1, "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED"); - PeanutV4 peanut = PeanutV4(_peanutAddress); + EnvelopeVault peanut = EnvelopeVault(_peanutAddress); if (_contractType == 1) { _setAllowanceIfZero(_tokenAddress, _peanutAddress); diff --git a/src/peanut/V4/PeanutV4.4.sol b/src/peanut/V4/PeanutV4.4.sol index 1f79e4b5..b1c2af01 100644 --- a/src/peanut/V4/PeanutV4.4.sol +++ b/src/peanut/V4/PeanutV4.4.sol @@ -49,7 +49,7 @@ import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/Signa import {IL2ECO} from "../util/IL2ECO.sol"; import {IEIP3009} from "../util/IEIP3009.sol"; -contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { +contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { using SafeERC20 for IERC20; struct Deposit { @@ -117,7 +117,7 @@ contract PeanutV4 is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { ecoAddress = _ecoAddress; MFA_AUTHORIZER = _mfaAuthorizer; DOMAIN_SEPARATOR = hash( - EIP712Domain({name: "Peanut", version: "4.4", chainId: block.chainid, verifyingContract: address(this)}) + EIP712Domain({name: "Envelope", version: "4.4", chainId: block.chainid, verifyingContract: address(this)}) ); } diff --git a/src/peanut/doc/EnvelopeApprovalPaymaster.md b/src/peanut/doc/EnvelopeApprovalPaymaster.md index e89c43d2..9f3d10a9 100644 --- a/src/peanut/doc/EnvelopeApprovalPaymaster.md +++ b/src/peanut/doc/EnvelopeApprovalPaymaster.md @@ -19,7 +19,7 @@ Mode B is the "single point we top up" pattern: instead of funding the operator' - **No token allowlist** — the operator's grant is the only auth surface. Defense-in-depth comes from a hard per-tx ETH cap and a global daily quota. - **Operator-driven UX** — the user never sees the EIP-712 grant; only the operator's backend does. -Deployed on ZkSync Sepolia at [`0x80EA078d599Bc63BB921Cf96CC6861731446e268`](https://sepolia.explorer.zksync.io/address/0x80EA078d599Bc63BB921Cf96CC6861731446e268#contract). +Deployed on ZkSync Sepolia at [`0xc160C8F6faC916De00B55aA0a630eBdce43CD532`](https://sepolia.explorer.zksync.io/address/0xc160C8F6faC916De00B55aA0a630eBdce43CD532#contract). ## Inheritance @@ -279,7 +279,7 @@ import { Wallet } from "zksync-ethers"; import { ethers } from "ethers"; import { randomBytes, hexlify } from "ethers"; -const PAYMASTER = "0x80EA078d599Bc63BB921Cf96CC6861731446e268"; +const PAYMASTER = "0xc160C8F6faC916De00B55aA0a630eBdce43CD532"; const CHAIN_ID = 300; const operatorWallet = new Wallet(process.env.OPERATOR_PK!); diff --git a/src/peanut/doc/PeanutBatcherV4.md b/src/peanut/doc/PeanutBatcherV4.md index 18cecfe1..214331d1 100644 --- a/src/peanut/doc/PeanutBatcherV4.md +++ b/src/peanut/doc/PeanutBatcherV4.md @@ -1,4 +1,4 @@ -# PeanutBatcherV4 — N-deposits-in-one-tx helper +# EnvelopeBatcher — N-deposits-in-one-tx helper `src/peanut/V4/PeanutBatcherV4.4.sol` @@ -6,7 +6,7 @@ A stateless helper that lets a single tx create N peanut deposits at once. The batcher pulls tokens from `msg.sender` once, then loops calling the vault's `makeSelflessDeposit` / `makeCustomDeposit` / `makeSelflessMFADeposit` for each pubKey. Common use case: airdrops or per-recipient claim links. -Stateless by design — the `PeanutV4` reference is taken from the call argument each invocation, so the same batcher contract can fan out to multiple vault deployments. Also avoids EraVM pubdata cost on every batch call (`PeanutV4 public peanut` storage var was dropped during hardening). +Stateless by design — the `EnvelopeVault` reference is taken from the call argument each invocation, so the same batcher contract can fan out to multiple vault deployments. Also avoids EraVM pubdata cost on every batch call (`EnvelopeVault public peanut` storage var was dropped during hardening). ## Constructor @@ -64,18 +64,18 @@ Same self-only policy as the vault — direct ERC-721 / ERC-1155 transfers to th ## Storage -None. (`PeanutV4 public peanut` was removed during hardening — see ZkSync notes.) +None. (`EnvelopeVault public peanut` was removed during hardening — see ZkSync notes.) ## Events / errors -None of its own. Inner deposits emit `PeanutV4.DepositEvent`. +None of its own. Inner deposits emit `EnvelopeVault.DepositEvent`. ## Vendoring patches | | Patch | |---|---| | OZ v5 | `safeApprove` → `forceApprove` | -| ZkSync (Z2) | Dropped `PeanutV4 public peanut` storage var; uses local per call | +| ZkSync (Z2) | Dropped `EnvelopeVault public peanut` storage var; uses local per call | | ZkSync (Z1) | Explicit `override(IERC165)` on `supportsInterface` | | Hardening (S1) | Receivers revert on non-self operator | | Modern | Named imports | diff --git a/src/peanut/doc/PeanutV4.md b/src/peanut/doc/PeanutV4.md index 0d31ce21..2b7abeb6 100644 --- a/src/peanut/doc/PeanutV4.md +++ b/src/peanut/doc/PeanutV4.md @@ -1,4 +1,4 @@ -# PeanutV4 — link-based asset vault +# EnvelopeVault — link-based asset vault `src/peanut/V4/PeanutV4.4.sol` @@ -168,7 +168,7 @@ Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with arra | Suite | File | |---|---| -| Vendored upstream tests | `test/peanut/PeanutV4.t.sol`, `Deposit.t.sol`, `SigWithdraw.t.sol`, `SenderWithdraw.t.sol`, `MFA.t.sol`, `RecipientBound.t.sol`, `Integration.t.sol`, `PeanutV4Gasless.t.sol` | +| Vendored upstream tests | `test/peanut/EnvelopeVault.t.sol`, `Deposit.t.sol`, `SigWithdraw.t.sol`, `SenderWithdraw.t.sol`, `MFA.t.sol`, `RecipientBound.t.sol`, `Integration.t.sol`, `PeanutV4Gasless.t.sol` | | Hardening (S1–S4 + T1–T4) | `test/peanut/PeanutHardening.t.sol` | 71 tests pass. diff --git a/src/peanut/doc/README.md b/src/peanut/doc/README.md index 7c8e8861..04dac020 100644 --- a/src/peanut/doc/README.md +++ b/src/peanut/doc/README.md @@ -9,16 +9,16 @@ sponsors the user-side approval txs so the UX is gasless from the holder's POV. | Contract | Source | Spec | |---|---|---| -| `PeanutV4` (vault) | `src/peanut/V4/PeanutV4.4.sol` | [PeanutV4.md](./PeanutV4.md) | -| `PeanutBatcherV4` (batched deposits) | `src/peanut/V4/PeanutBatcherV4.4.sol` | [PeanutBatcherV4.md](./PeanutBatcherV4.md) | +| `EnvelopeVault` (vault) | `src/peanut/V4/PeanutV4.4.sol` | [PeanutV4.md](./PeanutV4.md) | +| `EnvelopeBatcher` (batched deposits) | `src/peanut/V4/PeanutBatcherV4.4.sol` | [PeanutBatcherV4.md](./PeanutBatcherV4.md) | | `EnvelopeApprovalPaymaster` (Path-C gas sponsor + operator gas pool) | `src/paymasters/EnvelopeApprovalPaymaster.sol` | [EnvelopeApprovalPaymaster.md](./EnvelopeApprovalPaymaster.md) | Interfaces (vendored, unmodified): | Interface | Source | Used by | |---|---|---| -| `IEIP3009` | `src/peanut/util/IEIP3009.sol` | `PeanutV4` for gasless USDC-style deposits | -| `IL2ECO` | `src/peanut/util/IL2ECO.sol` | `PeanutV4` for rebasing-ERC20 deposits (`contractType==4`) | +| `IEIP3009` | `src/peanut/util/IEIP3009.sol` | `EnvelopeVault` for gasless USDC-style deposits | +| `IL2ECO` | `src/peanut/util/IL2ECO.sol` | `EnvelopeVault` for rebasing-ERC20 deposits (`contractType==4`) | ## License notice @@ -37,16 +37,17 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s ## Naming convention -- **Peanut** — the vendored open-source primitive (`peanutprotocol/peanut-contracts@main`). The vault and batcher keep upstream names so audits + diffs against upstream stay easy. -- **Envelope** — Nodle's product wrapper on top. The paymaster is named for this layer (operates against the Peanut vault, sponsored on Nodle's terms). +- **Source files** keep the upstream `Peanut*` names (e.g. `PeanutV4.4.sol`) so diffs against `peanutprotocol/peanut-contracts@main` stay grep-friendly. The audit lineage is preserved by file path + the `// Modified by Nodle` notice + the bundled `LICENSE-GPL`. +- **Contract symbols** (the names visible on the explorer / in the SDK / in the EIP-712 domain) use the **Envelope** brand: `EnvelopeVault`, `EnvelopeBatcher`, `EnvelopeApprovalPaymaster`. This avoids any trademark confusion with Squirrel Labs' "Peanut Protocol" brand. +- **On-chain hashed constants** (e.g. `PEANUT_SALT`) keep upstream values — changing them would change every signature digest and break compatibility. Those values are internal and never user-visible. ## Deployed on ZkSync Sepolia (chain 300) | | Address | |---|---| -| `PeanutV4` | [`0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44`](https://sepolia.explorer.zksync.io/address/0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44#contract) | -| `PeanutBatcherV4` | [`0x1676cD8B90e2E4388C032ae5Eb4BA50166Bb3426`](https://sepolia.explorer.zksync.io/address/0x1676cD8B90e2E4388C032ae5Eb4BA50166Bb3426#contract) | -| `EnvelopeApprovalPaymaster` | [`0x80EA078d599Bc63BB921Cf96CC6861731446e268`](https://sepolia.explorer.zksync.io/address/0x80EA078d599Bc63BB921Cf96CC6861731446e268#contract) | +| `EnvelopeVault` | [`0x32D02E54EaE5F8Bba75129e9306e0b8b70f05f6a`](https://sepolia.explorer.zksync.io/address/0x32D02E54EaE5F8Bba75129e9306e0b8b70f05f6a#contract) | +| `EnvelopeBatcher` | [`0x5DAe00DDFA1F96Aaf75d21F49B6FF5C756174816`](https://sepolia.explorer.zksync.io/address/0x5DAe00DDFA1F96Aaf75d21F49B6FF5C756174816#contract) | +| `EnvelopeApprovalPaymaster` | [`0xc160C8F6faC916De00B55aA0a630eBdce43CD532`](https://sepolia.explorer.zksync.io/address/0xc160C8F6faC916De00B55aA0a630eBdce43CD532#contract) | ## Three deposit paths diff --git a/test/peanut/Deposit.t.sol b/test/peanut/Deposit.t.sol index 70c21019..ee1a32ab 100644 --- a/test/peanut/Deposit.t.sol +++ b/test/peanut/Deposit.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; ////////////////////////////// -// A few integration tests for the PeanutV4 contract +// A few integration tests for the EnvelopeVault contract ////////////////////////////// import "forge-std/Test.sol"; @@ -13,8 +13,8 @@ import "./mocks/ERC1155Mock.sol"; import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; -contract PeanutV4DepositTest is Test, ERC1155Holder, ERC721Holder { - PeanutV4 public peanutV4; +contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { + EnvelopeVault public peanutV4; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -25,7 +25,7 @@ contract PeanutV4DepositTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/peanut/Integration.t.sol b/test/peanut/Integration.t.sol index 50270909..478d3aeb 100644 --- a/test/peanut/Integration.t.sol +++ b/test/peanut/Integration.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; ////////////////////////////// -// A few integration tests for the PeanutV4 contract +// A few integration tests for the EnvelopeVault contract ////////////////////////////// import "forge-std/Test.sol"; @@ -13,8 +13,8 @@ import "./mocks/ERC1155Mock.sol"; import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; -contract PeanutV4IntegrationTest is Test, ERC1155Holder, ERC721Holder { - PeanutV4 public peanutV4; +contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { + EnvelopeVault public peanutV4; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -25,7 +25,7 @@ contract PeanutV4IntegrationTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/peanut/MFA.t.sol b/test/peanut/MFA.t.sol index 174243c7..f14ed51c 100644 --- a/test/peanut/MFA.t.sol +++ b/test/peanut/MFA.t.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../../src/peanut/V4/PeanutV4.4.sol"; -contract PeanutV4MFATest is Test { - PeanutV4 public peanutV4; +contract EnvelopeVaultMFATest is Test { + EnvelopeVault public peanutV4; // a dummy private/public keypair to test withdrawals address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); @@ -16,7 +16,7 @@ contract PeanutV4MFATest is Test { address public constant LEGACY_MFA_AUTHORIZER = 0x3B14D43Bf521EF7FD9600533bEB73B6e9178DE7C; function setUp() public { - peanutV4 = new PeanutV4(address(0), LEGACY_MFA_AUTHORIZER); + peanutV4 = new EnvelopeVault(address(0), LEGACY_MFA_AUTHORIZER); } function testMFADeposit() public { diff --git a/test/peanut/PeanutBatcher.t.sol b/test/peanut/PeanutBatcher.t.sol index 7ae69e34..e278e93b 100644 --- a/test/peanut/PeanutBatcher.t.sol +++ b/test/peanut/PeanutBatcher.t.sol @@ -10,16 +10,16 @@ import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { - PeanutBatcherV4 public batcher; - PeanutV4 public peanutV4; + EnvelopeBatcher public batcher; + EnvelopeVault public peanutV4; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; address public PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); function setUp() public { - batcher = new PeanutBatcherV4(); - peanutV4 = new PeanutV4(address(0), address(0)); + batcher = new EnvelopeBatcher(); + peanutV4 = new EnvelopeVault(address(0), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); @@ -90,7 +90,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { pubKeys20[i] = PUBKEY20; // mint a token to the caller testToken1155.mint(address(this), 1, 100, ""); - // approve the PeanutV4 contract to spend the tokens + // approve the EnvelopeVault contract to spend the tokens testToken1155.setApprovalForAll(address(batcher), true); } // make the batch deposit @@ -100,7 +100,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { assertEq(depositIndexes.length, numDeposits); } - // Test failure case where PeanutV4 contract is not approved to spend ERC20 tokens + // Test failure case where EnvelopeVault contract is not approved to spend ERC20 tokens function test_RevertWhen_BatchERC20DepositNotApproved() public { uint64 amount = 100; uint64 numDeposits = 10; @@ -114,7 +114,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { batcher.batchMakeDeposit(address(peanutV4), address(testToken), 1, amount, 0, pubKeys20); } - // Test failure case where PeanutV4 contract is not approved to spend ERC721 tokens + // Test failure case where EnvelopeVault contract is not approved to spend ERC721 tokens function test_RevertWhen_BatchERC721DepositNotApproved() public { uint64 numDeposits = 10; address[] memory pubKeys20 = new address[](numDeposits); @@ -128,7 +128,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { batcher.batchMakeDeposit(address(peanutV4), address(testToken721), 2, 1, numDeposits, pubKeys20); } - // Test failure case where PeanutV4 contract is not approved to spend ERC1155 tokens + // Test failure case where EnvelopeVault contract is not approved to spend ERC1155 tokens function test_RevertWhen_BatchERC1155DepositNotApproved() public { uint64 numDeposits = 10; address[] memory pubKeys20 = new address[](numDeposits); @@ -186,7 +186,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { ); for(uint256 i = 0; i < amounts.length; i++) { - PeanutV4.Deposit memory deposit = peanutV4.getDeposit(depositIndices[i]); + EnvelopeVault.Deposit memory deposit = peanutV4.getDeposit(depositIndices[i]); assert(deposit.amount == amounts[i]); // main assertion // a few sanity checks @@ -217,7 +217,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { ); for(uint256 i = 0; i < amounts.length; i++) { - PeanutV4.Deposit memory deposit = peanutV4.getDeposit(depositIndices[i]); + EnvelopeVault.Deposit memory deposit = peanutV4.getDeposit(depositIndices[i]); assert(deposit.amount == amounts[i]); // main assertion // a few sanity checks diff --git a/test/peanut/PeanutEdgeCases.t.sol b/test/peanut/PeanutEdgeCases.t.sol index 2a1fb772..579625ff 100644 --- a/test/peanut/PeanutEdgeCases.t.sol +++ b/test/peanut/PeanutEdgeCases.t.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; -// Edge-case coverage for PeanutV4 / PeanutBatcherV4 — gates the vendored happy-path +// Edge-case coverage for EnvelopeVault / EnvelopeBatcher — gates the vendored happy-path // tests don't exercise directly. Names follow the repo's test_RevertWhen_* / test_* // convention. Each test is single-purpose; comments explain the *why*, not the *what*. import {Test} from "forge-std/Test.sol"; -import {PeanutV4} from "../../src/peanut/V4/PeanutV4.4.sol"; -import {PeanutBatcherV4} from "../../src/peanut/V4/PeanutBatcherV4.4.sol"; +import {EnvelopeVault} from "../../src/peanut/V4/PeanutV4.4.sol"; +import {EnvelopeBatcher} from "../../src/peanut/V4/PeanutBatcherV4.4.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; @@ -17,16 +17,16 @@ import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Hol import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; /// @dev Reentrancy probe: tries to call back into `peanut.withdrawDeposit` from inside -/// `safeTransfer`. Guarded by PeanutV4's `nonReentrant` modifier, so the inner call +/// `safeTransfer`. Guarded by EnvelopeVault's `nonReentrant` modifier, so the inner call /// reverts and the outer flow surfaces the inner revert reason ("REENTRANCY"). contract ReentrantToken is ERC20Mock { - PeanutV4 public peanut; + EnvelopeVault public peanut; uint256 public targetIdx; bytes public targetSig; address public attacker; bool public attempted; - function arm(PeanutV4 p, uint256 idx, bytes calldata sig, address atk) external { + function arm(EnvelopeVault p, uint256 idx, bytes calldata sig, address atk) external { peanut = p; targetIdx = idx; targetSig = sig; @@ -49,8 +49,8 @@ contract ReentrantToken is ERC20Mock { } contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { - PeanutV4 public peanut; - PeanutBatcherV4 public batcher; + EnvelopeVault public peanut; + EnvelopeBatcher public batcher; ERC20Mock public erc20; ERC721Mock public erc721; ERC1155Mock public erc1155; @@ -64,8 +64,8 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function setUp() public { LINK_PUBKEY20 = vm.addr(LINK_PRIV); - peanut = new PeanutV4(address(0), address(0)); - batcher = new PeanutBatcherV4(); + peanut = new EnvelopeVault(address(0), address(0)); + batcher = new EnvelopeBatcher(); erc20 = new ERC20Mock(); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); @@ -96,7 +96,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { return peanut.makeDeposit{value: amount}(address(0), 0, amount, 0, LINK_PUBKEY20); } - // ── PeanutV4 deposit input validation ────────────────────────────────── + // ── EnvelopeVault deposit input validation ────────────────────────────────── function test_RevertWhen_DepositInvalidContractType() public { // _pullTokensViaApproval rejects contractType >= 5. @@ -120,14 +120,14 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function test_RevertWhen_DepositEcoTokenViaPlainErc20() public { // Deploying with _ecoAddress = testToken forces contractType==4 for that token. - PeanutV4 ecoVault = new PeanutV4(address(erc20), address(0)); + EnvelopeVault ecoVault = new EnvelopeVault(address(erc20), address(0)); erc20.mint(address(this), 100); erc20.approve(address(ecoVault), 100); vm.expectRevert("ECO DEPOSITS MUST USE _contractType 4"); ecoVault.makeDeposit(address(erc20), 1, 100, 0, LINK_PUBKEY20); } - // ── PeanutV4 withdraw input validation ───────────────────────────────── + // ── EnvelopeVault withdraw input validation ───────────────────────────────── function test_RevertWhen_WithdrawIndexOutOfBounds() public { bytes memory sig = _signWithdrawal(99, ALICE, LINK_PRIV); @@ -218,17 +218,17 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { peanut.withdrawDeposit(idx, ALICE, sig); } - // ── PeanutV4 views ───────────────────────────────────────────────────── + // ── EnvelopeVault views ───────────────────────────────────────────────────── function test_GetAllDepositsForAddressFiltersBySender() public { _depositEth(1); _depositEth(1); // Same sender (address(this)) made both deposits. - PeanutV4.Deposit[] memory mine = peanut.getAllDepositsForAddress(address(this)); + EnvelopeVault.Deposit[] memory mine = peanut.getAllDepositsForAddress(address(this)); assertEq(mine.length, 2); // Different sender → empty. - PeanutV4.Deposit[] memory aliceDeposits = peanut.getAllDepositsForAddress(ALICE); + EnvelopeVault.Deposit[] memory aliceDeposits = peanut.getAllDepositsForAddress(ALICE); assertEq(aliceDeposits.length, 0); } @@ -240,7 +240,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { assertEq(peanut.getDepositCount(), 3); } - // ── PeanutV4 reentrancy ──────────────────────────────────────────────── + // ── EnvelopeVault reentrancy ──────────────────────────────────────────────── function test_NonReentrantBlocksReentryFromMaliciousToken() public { ReentrantToken evil = new ReentrantToken(); @@ -261,7 +261,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { assertTrue(evil.attempted(), "reentrancy attempt should have run"); } - // ── PeanutBatcherV4 input validation ─────────────────────────────────── + // ── EnvelopeBatcher input validation ─────────────────────────────────── function test_RevertWhen_BatchEthAmountMismatch() public { address[] memory pubKeys = new address[](3); diff --git a/test/peanut/PeanutHardening.t.sol b/test/peanut/PeanutHardening.t.sol index 836bd6b7..b585c056 100644 --- a/test/peanut/PeanutHardening.t.sol +++ b/test/peanut/PeanutHardening.t.sol @@ -9,7 +9,7 @@ pragma solidity 0.8.26; // T5 — _withdrawDeposit L2ECO branch sends to recipient, not sender (upstream bug fix) import {Test} from "forge-std/Test.sol"; -import {PeanutV4} from "../../src/peanut/V4/PeanutV4.4.sol"; +import {EnvelopeVault} from "../../src/peanut/V4/PeanutV4.4.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; @@ -18,7 +18,7 @@ import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Hol import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { - PeanutV4 public peanut; + EnvelopeVault public peanut; ERC721Mock public erc721; ERC1155Mock public erc1155; @@ -26,7 +26,7 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); function setUp() public { - peanut = new PeanutV4(address(0), address(0)); + peanut = new EnvelopeVault(address(0), address(0)); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); } @@ -34,7 +34,7 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { receive() external payable {} // ── T1 ───────────────────────────────────────────────────────────────── - // Direct safeTransferFrom into PeanutV4 must revert (S1). Previously the + // Direct safeTransferFrom into EnvelopeVault must revert (S1). Previously the // receiver hooks fell off the end and returned bytes4(0); some token // implementations would treat that as accepted, leaving tokens stuck. @@ -62,14 +62,14 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { } // ── T2 ───────────────────────────────────────────────────────────────── - // MFA_AUTHORIZER is now per-deploy. Prove a freshly-deployed PeanutV4 + // MFA_AUTHORIZER is now per-deploy. Prove a freshly-deployed EnvelopeVault // accepts MFA signatures from a *test* signer rather than the upstream key. function test_T2_customMfaAuthorizerAcceptsItsSignature() public { uint256 mfaPrivKey = uint256(keccak256("nodle.peanut.mfa-test-signer")); address mfaSigner = vm.addr(mfaPrivKey); - PeanutV4 nodlePeanut = new PeanutV4(address(0), mfaSigner); + EnvelopeVault nodlePeanut = new EnvelopeVault(address(0), mfaSigner); assertEq(nodlePeanut.MFA_AUTHORIZER(), mfaSigner, "constructor arg ignored"); // make an MFA-gated deposit, then craft both signatures with our test keys. @@ -185,7 +185,7 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { // Sanity: vault holds the raw tokens, deposit stores the scaled amount. assertEq(eco.balanceOf(address(peanut)), 100, "vault should hold raw tokens"); assertEq(eco.balanceOf(sender), 0, "sender's tokens should be in the vault"); - PeanutV4.Deposit memory d = peanut.getDeposit(idx); + EnvelopeVault.Deposit memory d = peanut.getDeposit(idx); assertEq(d.amount, 200, "deposit amount should be inflation-invariant (amount * multiplier)"); // Recipient (not sender) claims using the link's private key. diff --git a/test/peanut/PeanutV4.t.sol b/test/peanut/PeanutV4.t.sol index 1146f804..82b3c77a 100644 --- a/test/peanut/PeanutV4.t.sol +++ b/test/peanut/PeanutV4.t.sol @@ -7,8 +7,8 @@ import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; -contract PeanutV4Test is Test { - PeanutV4 public peanutV4; +contract EnvelopeVaultTest is Test { + EnvelopeVault public peanutV4; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -30,14 +30,14 @@ contract PeanutV4Test is Test { testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); // Mint tokens for test accounts testToken.mint(address(this), 1000); testToken721.mint(address(this), 1); // testToken1155.mint(address(this), 1, 1000, ""); - // Approve PeanutV4 to spend tokens + // Approve EnvelopeVault to spend tokens testToken.approve(address(peanutV4), 1000); testToken721.setApprovalForAll(address(peanutV4), true); // testToken1155.setApprovalForAll(address(peanutV4), true); @@ -75,7 +75,7 @@ contract PeanutV4Test is Test { // makeDeposit function must revert. function testECOMaliciousDeposit() public { // pretend that testToken is ECO - PeanutV4 peanutV4ECO = new PeanutV4(address(testToken), address(0)); + EnvelopeVault peanutV4ECO = new EnvelopeVault(address(testToken), address(0)); // approve tokens to be spent by the new peanut instance testToken.approve(address(peanutV4), 1000); diff --git a/test/peanut/PeanutV4Gasless.t.sol b/test/peanut/PeanutV4Gasless.t.sol index de67a756..137da80c 100644 --- a/test/peanut/PeanutV4Gasless.t.sol +++ b/test/peanut/PeanutV4Gasless.t.sol @@ -6,8 +6,8 @@ import "../../src/peanut/V4/PeanutV4.4.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/SampleSCW.sol"; -contract PeanutV4GaslessTest is Test { - PeanutV4 public peanutV4; +contract EnvelopeVaultGaslessTest is Test { + EnvelopeVault public peanutV4; ERC20Mock public testToken; address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); @@ -27,7 +27,7 @@ contract PeanutV4GaslessTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); } function testMakeDepositERC20WithAuthorization() public { @@ -94,7 +94,7 @@ contract PeanutV4GaslessTest is Test { (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(privateKey), digest); bytes memory signature = abi.encodePacked(r, s, v); - PeanutV4.GaslessReclaim memory reclaimRequest = PeanutV4.GaslessReclaim(depositIndex); + EnvelopeVault.GaslessReclaim memory reclaimRequest = EnvelopeVault.GaslessReclaim(depositIndex); if (bytes(expectRevert).length > 0) { vm.expectRevert(bytes(expectRevert)); @@ -140,7 +140,7 @@ contract PeanutV4GaslessTest is Test { bytes32 digest = _calculateDigest(depositIndex); - PeanutV4.GaslessReclaim memory reclaimRequest = PeanutV4.GaslessReclaim(depositIndex); + EnvelopeVault.GaslessReclaim memory reclaimRequest = EnvelopeVault.GaslessReclaim(depositIndex); // Submit a wrong signature vm.expectRevert("INVALID SIGNATURE"); diff --git a/test/peanut/RecipientBound.t.sol b/test/peanut/RecipientBound.t.sol index 3ab4c5c8..a3d84eae 100644 --- a/test/peanut/RecipientBound.t.sol +++ b/test/peanut/RecipientBound.t.sol @@ -8,7 +8,7 @@ import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; contract RecipientBoundTest is Test { - PeanutV4 public peanutV4; + EnvelopeVault public peanutV4; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -22,7 +22,7 @@ contract RecipientBoundTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); testToken.mint(address(this), 1000); testToken.approve(address(peanutV4), 1000); } diff --git a/test/peanut/SenderWithdraw.t.sol b/test/peanut/SenderWithdraw.t.sol index a3eb3da9..2ac499a7 100644 --- a/test/peanut/SenderWithdraw.t.sol +++ b/test/peanut/SenderWithdraw.t.sol @@ -10,7 +10,7 @@ import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; contract TestSenderWithdrawEther is Test { - PeanutV4 public peanutV4; + EnvelopeVault public peanutV4; // a dummy private/public keypair to test withdrawals address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; @@ -19,7 +19,7 @@ contract TestSenderWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); } function testSenderWithdrawEther(uint64 amount) public { @@ -32,7 +32,7 @@ contract TestSenderWithdrawEther is Test { } contract TestSenderWithdrawErc20 is Test { - PeanutV4 public peanutV4; + EnvelopeVault public peanutV4; ERC20Mock public testToken; // a dummy private/public keypair to test withdrawals @@ -44,7 +44,7 @@ contract TestSenderWithdrawErc20 is Test { // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); testToken = new ERC20Mock(); // contractType 1 // Mint tokens for test accounts (larger than uint128) @@ -65,7 +65,7 @@ contract TestSenderWithdrawErc20 is Test { } contract TestSenderWithdrawErc721 is Test, ERC721Holder { - PeanutV4 public peanutV4; + EnvelopeVault public peanutV4; ERC721Mock public testToken; // a dummy private/public keypair to test withdrawals @@ -78,7 +78,7 @@ contract TestSenderWithdrawErc721 is Test, ERC721Holder { // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); testToken = new ERC721Mock(); // contractType 2 // Mint token for test @@ -98,7 +98,7 @@ contract TestSenderWithdrawErc721 is Test, ERC721Holder { } contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { - PeanutV4 public peanutV4; + EnvelopeVault public peanutV4; ERC1155Mock public testToken; // a dummy private/public keypair to test withdrawals @@ -112,7 +112,7 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); testToken = new ERC1155Mock(); // contractType 3 // Mint tokens for test diff --git a/test/peanut/SigWithdraw.t.sol b/test/peanut/SigWithdraw.t.sol index 79047dad..3fe0fd79 100644 --- a/test/peanut/SigWithdraw.t.sol +++ b/test/peanut/SigWithdraw.t.sol @@ -11,7 +11,7 @@ import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; contract TestSigWithdrawEther is Test { - PeanutV4 public peanutV4; + EnvelopeVault public peanutV4; // sample inputs address _pubkey20 = 0x8fd379246834eac74B8419FfdA202CF8051F7A03; @@ -24,7 +24,7 @@ contract TestSigWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - peanutV4 = new PeanutV4(address(0), address(0)); + peanutV4 = new EnvelopeVault(address(0), address(0)); } // test sender withdrawal of ETH diff --git a/test/peanut/mocks/L2ECOMock.sol b/test/peanut/mocks/L2ECOMock.sol index 14de5225..d920e767 100644 --- a/test/peanut/mocks/L2ECOMock.sol +++ b/test/peanut/mocks/L2ECOMock.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.26; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; /// @dev Minimal L2ECO-shaped mock — standard ERC20 plus a configurable -/// `linearInflationMultiplier()` so the test can exercise PeanutV4's +/// `linearInflationMultiplier()` so the test can exercise EnvelopeVault's /// `contractType == 4` rebasing-token paths. contract L2ECOMock is ERC20 { uint256 private _multiplier; From 8cf63ebefa5ed07c35f9956ef4c9dba5b4a5cc1c Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 22:26:23 -0400 Subject: [PATCH 25/31] =?UTF-8?q?chore(peanut):=20cosmetic=20Peanut=20?= =?UTF-8?q?=E2=86=92=20Envelope=20cleanup=20(env=20vars,=20test=20+=20doc?= =?UTF-8?q?=20filenames)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the rest of the developer-facing surface with the Envelope brand. Pure cosmetic — no behavior change, no redeploy. Env var renames (deploy scripts read the new names): PEANUT_V4 → ENVELOPE_VAULT PEANUT_BATCHER → ENVELOPE_BATCHER PEANUT_MFA_AUTHORIZER → ENVELOPE_MFA_AUTHORIZER PEANUT_ECO_TOKEN → ENVELOPE_ECO_TOKEN PEANUT_DEPLOY_BATCHER → ENVELOPE_DEPLOY_BATCHER Test file renames (no class names changed — those were already done in 9989954): test/peanut/PeanutV4.t.sol → EnvelopeVault.t.sol test/peanut/PeanutBatcher.t.sol → EnvelopeBatcher.t.sol test/peanut/PeanutHardening.t.sol → EnvelopeHardening.t.sol test/peanut/PeanutEdgeCases.t.sol → EnvelopeEdgeCases.t.sol test/peanut/PeanutV4Gasless.t.sol → EnvelopeGasless.t.sol Doc file renames: src/peanut/doc/PeanutV4.md → EnvelopeVault.md src/peanut/doc/PeanutBatcherV4.md → EnvelopeBatcher.md What stays "Peanut" (intentional): - File paths src/peanut/V4/PeanutV4.4.sol etc. — preserves upstream-diff lineage - PEANUT_SALT constant — its hash is in every signature digest - GPL §5(d) attribution comments (`// @author Squirrel Labs`, `peanutprotocol/peanut-contracts@main`) — required by the license - README mentions "Peanut Protocol V4.4" as the upstream — that's a fact ACTION REQUIRED for users with .env-test: - Rename PEANUT_V4 → ENVELOPE_VAULT - Rename PEANUT_BATCHER → ENVELOPE_BATCHER - Rename PEANUT_MFA_AUTHORIZER → ENVELOPE_MFA_AUTHORIZER (PEANUT_ECO_TOKEN and PEANUT_DEPLOY_BATCHER probably weren't set anyway.) forge test 966/966. yarn lint 0 errors. yarn spellcheck 0 issues. --- hardhat-deploy/DeployEnvelopePaymaster.ts | 12 ++++++------ hardhat-deploy/DeployPeanut.ts | 18 +++++++++--------- src/peanut/doc/EnvelopeApprovalPaymaster.md | 2 +- .../{PeanutBatcherV4.md => EnvelopeBatcher.md} | 2 +- .../doc/{PeanutV4.md => EnvelopeVault.md} | 4 ++-- src/peanut/doc/README.md | 4 ++-- ...anutBatcher.t.sol => EnvelopeBatcher.t.sol} | 0 ...EdgeCases.t.sol => EnvelopeEdgeCases.t.sol} | 0 ...utV4Gasless.t.sol => EnvelopeGasless.t.sol} | 0 ...Hardening.t.sol => EnvelopeHardening.t.sol} | 0 .../{PeanutV4.t.sol => EnvelopeVault.t.sol} | 0 11 files changed, 21 insertions(+), 21 deletions(-) rename src/peanut/doc/{PeanutBatcherV4.md => EnvelopeBatcher.md} (98%) rename src/peanut/doc/{PeanutV4.md => EnvelopeVault.md} (98%) rename test/peanut/{PeanutBatcher.t.sol => EnvelopeBatcher.t.sol} (100%) rename test/peanut/{PeanutEdgeCases.t.sol => EnvelopeEdgeCases.t.sol} (100%) rename test/peanut/{PeanutV4Gasless.t.sol => EnvelopeGasless.t.sol} (100%) rename test/peanut/{PeanutHardening.t.sol => EnvelopeHardening.t.sol} (100%) rename test/peanut/{PeanutV4.t.sol => EnvelopeVault.t.sol} (100%) diff --git a/hardhat-deploy/DeployEnvelopePaymaster.ts b/hardhat-deploy/DeployEnvelopePaymaster.ts index 6e1fb51f..dcf6af25 100644 --- a/hardhat-deploy/DeployEnvelopePaymaster.ts +++ b/hardhat-deploy/DeployEnvelopePaymaster.ts @@ -19,14 +19,14 @@ dotenv.config({ path: ".env-test" }); * * Required environment variables: * - DEPLOYER_PRIVATE_KEY: Private key for deployment (also default admin / withdrawer). - * - PEANUT_V4: Address of the deployed Peanut/Envelope vault — the only + * - ENVELOPE_VAULT: Address of the deployed Peanut/Envelope vault — the only * allowed spender/operator for sponsored approvals. * * Optional environment variables (admin / signer): * - ENVELOPE_PAYMASTER_ADMIN: DEFAULT_ADMIN_ROLE. Defaults to deployer. * - ENVELOPE_PAYMASTER_WITHDRAWER: WITHDRAWER_ROLE. Defaults to deployer. * - ENVELOPE_PAYMASTER_OPERATOR_SIGNER: EOA whose EIP-712 grant signatures are accepted. - * Defaults to PEANUT_MFA_AUTHORIZER if set, else deployer. + * Defaults to ENVELOPE_MFA_AUTHORIZER if set, else deployer. * * Optional environment variables (config): * - ENVELOPE_PAYMASTER_MAX_ETH_PER_TX: Hard ceiling on wei sponsored per single tx. @@ -37,7 +37,7 @@ dotenv.config({ path: ".env-test" }); * - ENVELOPE_PAYMASTER_INITIAL_OPERATORS: Comma-separated EOA list to seed as Mode B operators. * Default: empty (Mode B dormant; admin can call setOperator later). * - ENVELOPE_PAYMASTER_INITIAL_TARGETS: Comma-separated contract list to seed as Mode B allowed targets. - * Default: PEANUT_V4 (so operator can call the vault directly). + * Default: ENVELOPE_VAULT (so operator can call the vault directly). * * Usage: * yarn hardhat deploy-zksync \ @@ -52,16 +52,16 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); const deployer = new Deployer(hre, wallet); - const envelopeVault = process.env.PEANUT_V4; + const envelopeVault = process.env.ENVELOPE_VAULT; if (!envelopeVault || envelopeVault === ZERO) { - throw new Error("PEANUT_V4 env var is required (the deployed Envelope/Peanut vault address)"); + throw new Error("ENVELOPE_VAULT env var is required (the deployed Envelope/Peanut vault address)"); } const admin = process.env.ENVELOPE_PAYMASTER_ADMIN ?? wallet.address; const withdrawer = process.env.ENVELOPE_PAYMASTER_WITHDRAWER ?? wallet.address; const operatorSigner = process.env.ENVELOPE_PAYMASTER_OPERATOR_SIGNER ?? - process.env.PEANUT_MFA_AUTHORIZER ?? + process.env.ENVELOPE_MFA_AUTHORIZER ?? wallet.address; const maxEthPerTx = ethers.toBigInt( diff --git a/hardhat-deploy/DeployPeanut.ts b/hardhat-deploy/DeployPeanut.ts index 57b3c208..5ea624a6 100644 --- a/hardhat-deploy/DeployPeanut.ts +++ b/hardhat-deploy/DeployPeanut.ts @@ -15,13 +15,13 @@ dotenv.config({ path: ".env-test" }); * - DEPLOYER_PRIVATE_KEY: Private key for deployment. * * Optional environment variables: - * - PEANUT_ECO_TOKEN: Address of a rebasing ECO-like ERC20 to gate from + * - ENVELOPE_ECO_TOKEN: Address of a rebasing ECO-like ERC20 to gate from * standard contractType==1 deposits. Defaults to 0x0 * (no gating). Leave unset on Nodle. - * - PEANUT_MFA_AUTHORIZER: Address authorized to sign MFA withdraw approvals. + * - ENVELOPE_MFA_AUTHORIZER: Address authorized to sign MFA withdraw approvals. * Defaults to 0x0 (MFA disabled — withdrawMFADeposit reverts). * Set to your backend signer for production MFA. - * - PEANUT_DEPLOY_BATCHER: "true"|"false". Default "true". Deploys EnvelopeBatcher. + * - ENVELOPE_DEPLOY_BATCHER: "true"|"false". Default "true". Deploys EnvelopeBatcher. * * Usage: * yarn hardhat deploy-zksync \ @@ -31,9 +31,9 @@ dotenv.config({ path: ".env-test" }); module.exports = async function (hre: HardhatRuntimeEnvironment) { const ZERO = "0x0000000000000000000000000000000000000000"; - const ecoToken = process.env.PEANUT_ECO_TOKEN ?? ZERO; - const mfaAuthorizer = process.env.PEANUT_MFA_AUTHORIZER ?? ZERO; - const deployBatcher = (process.env.PEANUT_DEPLOY_BATCHER ?? "true").toLowerCase() === "true"; + const ecoToken = process.env.ENVELOPE_ECO_TOKEN ?? ZERO; + const mfaAuthorizer = process.env.ENVELOPE_MFA_AUTHORIZER ?? ZERO; + const deployBatcher = (process.env.ENVELOPE_DEPLOY_BATCHER ?? "true").toLowerCase() === "true"; const rpcUrl = hre.network.config.url!; const provider = new Provider(rpcUrl); @@ -93,11 +93,11 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); console.log("=== Add these to .env-test: ==="); - console.log(`PEANUT_V4=${peanutAddr}`); - if (batcherAddr) console.log(`PEANUT_BATCHER=${batcherAddr}`); + console.log(`ENVELOPE_VAULT=${peanutAddr}`); + if (batcherAddr) console.log(`ENVELOPE_BATCHER=${batcherAddr}`); if (mfaAuthorizer === ZERO) { console.log(""); - console.log("NOTE: PEANUT_MFA_AUTHORIZER is 0x0 — withdrawMFADeposit will always revert. Set it before allowing MFA-flagged deposits in production."); + console.log("NOTE: ENVELOPE_MFA_AUTHORIZER is 0x0 — withdrawMFADeposit will always revert. Set it before allowing MFA-flagged deposits in production."); } }; diff --git a/src/peanut/doc/EnvelopeApprovalPaymaster.md b/src/peanut/doc/EnvelopeApprovalPaymaster.md index 9f3d10a9..ef5aa90d 100644 --- a/src/peanut/doc/EnvelopeApprovalPaymaster.md +++ b/src/peanut/doc/EnvelopeApprovalPaymaster.md @@ -303,7 +303,7 @@ async function signGrant(user: string, ttlSec = 300) { ## Deploy ```bash -# vault address already wired in .env-test as PEANUT_V4 +# vault address already wired in .env-test as ENVELOPE_VAULT ENVELOPE_PAYMASTER_FUNDING=2000000000000000 # 0.002 ETH; optional yarn hardhat deploy-zksync \ --script DeployEnvelopePaymaster.ts \ diff --git a/src/peanut/doc/PeanutBatcherV4.md b/src/peanut/doc/EnvelopeBatcher.md similarity index 98% rename from src/peanut/doc/PeanutBatcherV4.md rename to src/peanut/doc/EnvelopeBatcher.md index 214331d1..cca29cf3 100644 --- a/src/peanut/doc/PeanutBatcherV4.md +++ b/src/peanut/doc/EnvelopeBatcher.md @@ -84,7 +84,7 @@ None of its own. Inner deposits emit `EnvelopeVault.DepositEvent`. ## Test coverage -`test/peanut/PeanutBatcher.t.sol` — 13 tests: +`test/peanut/EnvelopeBatcher.t.sol` — 13 tests: - happy paths for ETH / ERC-20 / ERC-1155 batches - ERC-721 batch reverts as designed (`test_RevertWhen_BatchERC721NotImplemented`) - raffle (ETH + ERC-20) diff --git a/src/peanut/doc/PeanutV4.md b/src/peanut/doc/EnvelopeVault.md similarity index 98% rename from src/peanut/doc/PeanutV4.md rename to src/peanut/doc/EnvelopeVault.md index 2b7abeb6..4161b842 100644 --- a/src/peanut/doc/PeanutV4.md +++ b/src/peanut/doc/EnvelopeVault.md @@ -168,7 +168,7 @@ Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with arra | Suite | File | |---|---| -| Vendored upstream tests | `test/peanut/EnvelopeVault.t.sol`, `Deposit.t.sol`, `SigWithdraw.t.sol`, `SenderWithdraw.t.sol`, `MFA.t.sol`, `RecipientBound.t.sol`, `Integration.t.sol`, `PeanutV4Gasless.t.sol` | -| Hardening (S1–S4 + T1–T4) | `test/peanut/PeanutHardening.t.sol` | +| Vendored upstream tests | `test/peanut/EnvelopeVault.t.sol`, `Deposit.t.sol`, `SigWithdraw.t.sol`, `SenderWithdraw.t.sol`, `MFA.t.sol`, `RecipientBound.t.sol`, `Integration.t.sol`, `EnvelopeGasless.t.sol` | +| Hardening (S1–S4 + T1–T4) | `test/peanut/EnvelopeHardening.t.sol` | 71 tests pass. diff --git a/src/peanut/doc/README.md b/src/peanut/doc/README.md index 04dac020..f2b72594 100644 --- a/src/peanut/doc/README.md +++ b/src/peanut/doc/README.md @@ -9,8 +9,8 @@ sponsors the user-side approval txs so the UX is gasless from the holder's POV. | Contract | Source | Spec | |---|---|---| -| `EnvelopeVault` (vault) | `src/peanut/V4/PeanutV4.4.sol` | [PeanutV4.md](./PeanutV4.md) | -| `EnvelopeBatcher` (batched deposits) | `src/peanut/V4/PeanutBatcherV4.4.sol` | [PeanutBatcherV4.md](./PeanutBatcherV4.md) | +| `EnvelopeVault` (vault) | `src/peanut/V4/PeanutV4.4.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | +| `EnvelopeBatcher` (batched deposits) | `src/peanut/V4/PeanutBatcherV4.4.sol` | [EnvelopeBatcher.md](./EnvelopeBatcher.md) | | `EnvelopeApprovalPaymaster` (Path-C gas sponsor + operator gas pool) | `src/paymasters/EnvelopeApprovalPaymaster.sol` | [EnvelopeApprovalPaymaster.md](./EnvelopeApprovalPaymaster.md) | Interfaces (vendored, unmodified): diff --git a/test/peanut/PeanutBatcher.t.sol b/test/peanut/EnvelopeBatcher.t.sol similarity index 100% rename from test/peanut/PeanutBatcher.t.sol rename to test/peanut/EnvelopeBatcher.t.sol diff --git a/test/peanut/PeanutEdgeCases.t.sol b/test/peanut/EnvelopeEdgeCases.t.sol similarity index 100% rename from test/peanut/PeanutEdgeCases.t.sol rename to test/peanut/EnvelopeEdgeCases.t.sol diff --git a/test/peanut/PeanutV4Gasless.t.sol b/test/peanut/EnvelopeGasless.t.sol similarity index 100% rename from test/peanut/PeanutV4Gasless.t.sol rename to test/peanut/EnvelopeGasless.t.sol diff --git a/test/peanut/PeanutHardening.t.sol b/test/peanut/EnvelopeHardening.t.sol similarity index 100% rename from test/peanut/PeanutHardening.t.sol rename to test/peanut/EnvelopeHardening.t.sol diff --git a/test/peanut/PeanutV4.t.sol b/test/peanut/EnvelopeVault.t.sol similarity index 100% rename from test/peanut/PeanutV4.t.sol rename to test/peanut/EnvelopeVault.t.sol From 74db895beebb2118e8d88c6325fb6fae73fe25b7 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 22:32:32 -0400 Subject: [PATCH 26/31] =?UTF-8?q?chore(envelope):=20rename=20directories?= =?UTF-8?q?=20src/peanut=20=E2=86=92=20src/envelope,=20test/peanut=20?= =?UTF-8?q?=E2=86=92=20test/envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final cosmetic cleanup. No behavior change, no redeploy. Path rewrites covered: - test imports: ../../src/peanut/V4/PeanutV4.4.sol → ../../src/envelope/V4/PeanutV4.4.sol - mock imports: ../../../src/peanut/util/IEIP3009.sol → ../../../src/envelope/util/IEIP3009.sol - paymaster test: ../peanut/mocks/SampleSCW.sol → ../envelope/mocks/SampleSCW.sol - .solhintignore: src/peanut/V4/* → src/envelope/V4/* - hardhat-deploy verify args: contract: "src/peanut/V4/PeanutV4.4.sol:EnvelopeVault" → "src/envelope/V4/PeanutV4.4.sol:EnvelopeVault" - all spec docs (README + EnvelopeVault.md + EnvelopeBatcher.md + EnvelopeApprovalPaymaster.md) - LICENSE-GPL stays where it is (now at src/envelope/V4/LICENSE-GPL); modification notices in PeanutV4.4.sol / PeanutBatcherV4.4.sol point at the new path. What stays "Peanut" in the tree (all intentional): - File names PeanutV4.4.sol, PeanutBatcherV4.4.sol — preserves per-file diff to upstream - PEANUT_SALT constant — its hash is in every signature - GPL §5(d) attribution comments — required by the license forge test 966/966. yarn lint 0 errors. yarn spellcheck 0 issues. --- .solhintignore | 4 ++-- hardhat-deploy/DeployPeanut.ts | 4 ++-- src/{peanut => envelope}/V4/LICENSE-GPL | 0 .../V4/PeanutBatcherV4.4.sol | 4 ++-- src/{peanut => envelope}/V4/PeanutV4.4.sol | 4 ++-- .../doc/EnvelopeApprovalPaymaster.md | 0 .../doc/EnvelopeBatcher.md | 4 ++-- src/{peanut => envelope}/doc/EnvelopeVault.md | 6 +++--- src/{peanut => envelope}/doc/README.md | 18 +++++++++--------- src/{peanut => envelope}/util/IEIP3009.sol | 0 src/{peanut => envelope}/util/IL2ECO.sol | 0 test/{peanut => envelope}/Deposit.t.sol | 2 +- .../{peanut => envelope}/EnvelopeBatcher.t.sol | 2 +- .../EnvelopeEdgeCases.t.sol | 4 ++-- .../{peanut => envelope}/EnvelopeGasless.t.sol | 2 +- .../EnvelopeHardening.t.sol | 2 +- test/{peanut => envelope}/EnvelopeVault.t.sol | 2 +- test/{peanut => envelope}/Integration.t.sol | 2 +- test/{peanut => envelope}/MFA.t.sol | 2 +- test/{peanut => envelope}/RecipientBound.t.sol | 2 +- test/{peanut => envelope}/SenderWithdraw.t.sol | 2 +- test/{peanut => envelope}/SigWithdraw.t.sol | 2 +- test/{peanut => envelope}/mocks/ECRecover.sol | 0 .../mocks/EIP3009Implementation.sol | 2 +- .../mocks/EIP3009Internals.sol | 2 +- test/{peanut => envelope}/mocks/EIP712.sol | 0 .../mocks/EIP712Domain.sol | 0 .../{peanut => envelope}/mocks/ERC1155Mock.sol | 0 test/{peanut => envelope}/mocks/ERC20Mock.sol | 0 test/{peanut => envelope}/mocks/ERC721Mock.sol | 0 test/{peanut => envelope}/mocks/L2ECOMock.sol | 0 test/{peanut => envelope}/mocks/SampleSCW.sol | 0 .../paymasters/EnvelopeApprovalPaymaster.t.sol | 2 +- 33 files changed, 37 insertions(+), 37 deletions(-) rename src/{peanut => envelope}/V4/LICENSE-GPL (100%) rename src/{peanut => envelope}/V4/PeanutBatcherV4.4.sol (98%) rename src/{peanut => envelope}/V4/PeanutV4.4.sol (99%) rename src/{peanut => envelope}/doc/EnvelopeApprovalPaymaster.md (100%) rename src/{peanut => envelope}/doc/EnvelopeBatcher.md (98%) rename src/{peanut => envelope}/doc/EnvelopeVault.md (96%) rename src/{peanut => envelope}/doc/README.md (74%) rename src/{peanut => envelope}/util/IEIP3009.sol (100%) rename src/{peanut => envelope}/util/IL2ECO.sol (100%) rename test/{peanut => envelope}/Deposit.t.sol (98%) rename test/{peanut => envelope}/EnvelopeBatcher.t.sol (99%) rename test/{peanut => envelope}/EnvelopeEdgeCases.t.sol (99%) rename test/{peanut => envelope}/EnvelopeGasless.t.sol (99%) rename test/{peanut => envelope}/EnvelopeHardening.t.sol (99%) rename test/{peanut => envelope}/EnvelopeVault.t.sol (99%) rename test/{peanut => envelope}/Integration.t.sol (99%) rename test/{peanut => envelope}/MFA.t.sol (98%) rename test/{peanut => envelope}/RecipientBound.t.sol (98%) rename test/{peanut => envelope}/SenderWithdraw.t.sol (99%) rename test/{peanut => envelope}/SigWithdraw.t.sol (98%) rename test/{peanut => envelope}/mocks/ECRecover.sol (100%) rename test/{peanut => envelope}/mocks/EIP3009Implementation.sol (95%) rename test/{peanut => envelope}/mocks/EIP3009Internals.sol (98%) rename test/{peanut => envelope}/mocks/EIP712.sol (100%) rename test/{peanut => envelope}/mocks/EIP712Domain.sol (100%) rename test/{peanut => envelope}/mocks/ERC1155Mock.sol (100%) rename test/{peanut => envelope}/mocks/ERC20Mock.sol (100%) rename test/{peanut => envelope}/mocks/ERC721Mock.sol (100%) rename test/{peanut => envelope}/mocks/L2ECOMock.sol (100%) rename test/{peanut => envelope}/mocks/SampleSCW.sol (100%) diff --git a/.solhintignore b/.solhintignore index 9f356676..58404c70 100644 --- a/.solhintignore +++ b/.solhintignore @@ -5,5 +5,5 @@ # # Our own code (EnvelopeApprovalPaymaster, anything authored in this repo) # is NOT in this list and remains lint-clean. -src/peanut/V4/PeanutV4.4.sol -src/peanut/V4/PeanutBatcherV4.4.sol +src/envelope/V4/PeanutV4.4.sol +src/envelope/V4/PeanutBatcherV4.4.sol diff --git a/hardhat-deploy/DeployPeanut.ts b/hardhat-deploy/DeployPeanut.ts index 5ea624a6..e431a51f 100644 --- a/hardhat-deploy/DeployPeanut.ts +++ b/hardhat-deploy/DeployPeanut.ts @@ -71,7 +71,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log("Verifying EnvelopeVault..."); await hre.run("verify:verify", { address: peanutAddr, - contract: "src/peanut/V4/PeanutV4.4.sol:EnvelopeVault", + contract: "src/envelope/V4/PeanutV4.4.sol:EnvelopeVault", constructorArguments: [ecoToken, mfaAuthorizer], }); } catch (e: any) { @@ -83,7 +83,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log("Verifying EnvelopeBatcher..."); await hre.run("verify:verify", { address: batcherAddr, - contract: "src/peanut/V4/PeanutBatcherV4.4.sol:EnvelopeBatcher", + contract: "src/envelope/V4/PeanutBatcherV4.4.sol:EnvelopeBatcher", constructorArguments: [], }); } catch (e: any) { diff --git a/src/peanut/V4/LICENSE-GPL b/src/envelope/V4/LICENSE-GPL similarity index 100% rename from src/peanut/V4/LICENSE-GPL rename to src/envelope/V4/LICENSE-GPL diff --git a/src/peanut/V4/PeanutBatcherV4.4.sol b/src/envelope/V4/PeanutBatcherV4.4.sol similarity index 98% rename from src/peanut/V4/PeanutBatcherV4.4.sol rename to src/envelope/V4/PeanutBatcherV4.4.sol index 34dd0fda..191be2e1 100644 --- a/src/peanut/V4/PeanutBatcherV4.4.sol +++ b/src/envelope/V4/PeanutBatcherV4.4.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -// Modified by Nodle (2026-05-12) — see src/peanut/doc/EnvelopeBatcher.md ("Vendoring +// Modified by Nodle (2026-05-12) — see src/envelope/doc/EnvelopeBatcher.md ("Vendoring // patches") and the git history of this file for the full patch set. The upstream source // is peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license text is bundled -// at src/peanut/V4/LICENSE-GPL. +// at src/envelope/V4/LICENSE-GPL. pragma solidity 0.8.26; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; diff --git a/src/peanut/V4/PeanutV4.4.sol b/src/envelope/V4/PeanutV4.4.sol similarity index 99% rename from src/peanut/V4/PeanutV4.4.sol rename to src/envelope/V4/PeanutV4.4.sol index b1c2af01..88b01451 100644 --- a/src/peanut/V4/PeanutV4.4.sol +++ b/src/envelope/V4/PeanutV4.4.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -// Modified by Nodle (2026-05-12) — see src/peanut/doc/PeanutV4.md ("Vendoring patches +// Modified by Nodle (2026-05-12) — see src/envelope/doc/PeanutV4.md ("Vendoring patches // applied at import") and the git history of this file for the full patch set. The // upstream source is peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license -// text is bundled at src/peanut/V4/LICENSE-GPL. +// text is bundled at src/envelope/V4/LICENSE-GPL. pragma solidity 0.8.26; ////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/peanut/doc/EnvelopeApprovalPaymaster.md b/src/envelope/doc/EnvelopeApprovalPaymaster.md similarity index 100% rename from src/peanut/doc/EnvelopeApprovalPaymaster.md rename to src/envelope/doc/EnvelopeApprovalPaymaster.md diff --git a/src/peanut/doc/EnvelopeBatcher.md b/src/envelope/doc/EnvelopeBatcher.md similarity index 98% rename from src/peanut/doc/EnvelopeBatcher.md rename to src/envelope/doc/EnvelopeBatcher.md index cca29cf3..cba59850 100644 --- a/src/peanut/doc/EnvelopeBatcher.md +++ b/src/envelope/doc/EnvelopeBatcher.md @@ -1,6 +1,6 @@ # EnvelopeBatcher — N-deposits-in-one-tx helper -`src/peanut/V4/PeanutBatcherV4.4.sol` +`src/envelope/V4/PeanutBatcherV4.4.sol` ## Purpose @@ -84,7 +84,7 @@ None of its own. Inner deposits emit `EnvelopeVault.DepositEvent`. ## Test coverage -`test/peanut/EnvelopeBatcher.t.sol` — 13 tests: +`test/envelope/EnvelopeBatcher.t.sol` — 13 tests: - happy paths for ETH / ERC-20 / ERC-1155 batches - ERC-721 batch reverts as designed (`test_RevertWhen_BatchERC721NotImplemented`) - raffle (ETH + ERC-20) diff --git a/src/peanut/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md similarity index 96% rename from src/peanut/doc/EnvelopeVault.md rename to src/envelope/doc/EnvelopeVault.md index 4161b842..f2d12a9d 100644 --- a/src/peanut/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -1,6 +1,6 @@ # EnvelopeVault — link-based asset vault -`src/peanut/V4/PeanutV4.4.sol` +`src/envelope/V4/PeanutV4.4.sol` ## Purpose @@ -168,7 +168,7 @@ Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with arra | Suite | File | |---|---| -| Vendored upstream tests | `test/peanut/EnvelopeVault.t.sol`, `Deposit.t.sol`, `SigWithdraw.t.sol`, `SenderWithdraw.t.sol`, `MFA.t.sol`, `RecipientBound.t.sol`, `Integration.t.sol`, `EnvelopeGasless.t.sol` | -| Hardening (S1–S4 + T1–T4) | `test/peanut/EnvelopeHardening.t.sol` | +| Vendored upstream tests | `test/envelope/EnvelopeVault.t.sol`, `Deposit.t.sol`, `SigWithdraw.t.sol`, `SenderWithdraw.t.sol`, `MFA.t.sol`, `RecipientBound.t.sol`, `Integration.t.sol`, `EnvelopeGasless.t.sol` | +| Hardening (S1–S4 + T1–T4) | `test/envelope/EnvelopeHardening.t.sol` | 71 tests pass. diff --git a/src/peanut/doc/README.md b/src/envelope/doc/README.md similarity index 74% rename from src/peanut/doc/README.md rename to src/envelope/doc/README.md index f2b72594..0bad15b3 100644 --- a/src/peanut/doc/README.md +++ b/src/envelope/doc/README.md @@ -9,16 +9,16 @@ sponsors the user-side approval txs so the UX is gasless from the holder's POV. | Contract | Source | Spec | |---|---|---| -| `EnvelopeVault` (vault) | `src/peanut/V4/PeanutV4.4.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | -| `EnvelopeBatcher` (batched deposits) | `src/peanut/V4/PeanutBatcherV4.4.sol` | [EnvelopeBatcher.md](./EnvelopeBatcher.md) | +| `EnvelopeVault` (vault) | `src/envelope/V4/PeanutV4.4.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | +| `EnvelopeBatcher` (batched deposits) | `src/envelope/V4/PeanutBatcherV4.4.sol` | [EnvelopeBatcher.md](./EnvelopeBatcher.md) | | `EnvelopeApprovalPaymaster` (Path-C gas sponsor + operator gas pool) | `src/paymasters/EnvelopeApprovalPaymaster.sol` | [EnvelopeApprovalPaymaster.md](./EnvelopeApprovalPaymaster.md) | Interfaces (vendored, unmodified): | Interface | Source | Used by | |---|---|---| -| `IEIP3009` | `src/peanut/util/IEIP3009.sol` | `EnvelopeVault` for gasless USDC-style deposits | -| `IL2ECO` | `src/peanut/util/IL2ECO.sol` | `EnvelopeVault` for rebasing-ERC20 deposits (`contractType==4`) | +| `IEIP3009` | `src/envelope/util/IEIP3009.sol` | `EnvelopeVault` for gasless USDC-style deposits | +| `IL2ECO` | `src/envelope/util/IL2ECO.sol` | `EnvelopeVault` for rebasing-ERC20 deposits (`contractType==4`) | ## License notice @@ -26,11 +26,11 @@ This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) doesn't apply u | Files | License | Notes | |---|---|---| -| `src/peanut/V4/PeanutV4.4.sol`, `PeanutBatcherV4.4.sol` | **GPL-3.0-or-later** | Modified copies of upstream Peanut Protocol V4.4. Full GPL v3 text bundled at `src/peanut/V4/LICENSE-GPL`. Each file carries a top-of-file modification notice per GPL §5(a). | -| `src/peanut/util/IEIP3009.sol`, `IL2ECO.sol` | **MIT** | Vendored interfaces, unchanged from upstream | +| `src/envelope/V4/PeanutV4.4.sol`, `PeanutBatcherV4.4.sol` | **GPL-3.0-or-later** | Modified copies of upstream Peanut Protocol V4.4. Full GPL v3 text bundled at `src/envelope/V4/LICENSE-GPL`. Each file carries a top-of-file modification notice per GPL §5(a). | +| `src/envelope/util/IEIP3009.sol`, `IL2ECO.sol` | **MIT** | Vendored interfaces, unchanged from upstream | | `src/paymasters/EnvelopeApprovalPaymaster.sol` | **BSD-3-Clause-Clear** | Our own code; doesn't `import` any GPL source so it isn't a derivative work | -| `test/peanut/**/*.t.sol` (files that import Peanut sources) | **GPL-3.0-or-later** | Test files that `import` GPL-licensed contracts are derivative works under a strict reading of the GPL; relicensed for compliance | -| `test/peanut/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained | +| `test/envelope/**/*.t.sol` (files that import Peanut sources) | **GPL-3.0-or-later** | Test files that `import` GPL-licensed contracts are derivative works under a strict reading of the GPL; relicensed for compliance | +| `test/envelope/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained | | All other repo files | unchanged | Whatever they were | The GPL is "viral" only across `import` boundaries; non-importing files in the same repository remain under their own licenses (per the OSI's "mere aggregation" interpretation). @@ -72,7 +72,7 @@ Both are Hardhat-zksync scripts. See each spec for env vars. | Suite | Tests | |---|---| -| Peanut core (`test/peanut/`) | **90** (56 vendored + 11 hardening + 23 edge cases) | +| Peanut core (`test/envelope/`) | **90** (56 vendored + 11 hardening + 23 edge cases) | | `EnvelopeApprovalPaymaster` (`test/paymasters/EnvelopeApprovalPaymaster.t.sol`) | **27** (19 Mode A + 7 Mode B + 1 EIP-1271 contract signer) | | Other paymasters (unchanged) | 102 | | Rest of repo | 747 | diff --git a/src/peanut/util/IEIP3009.sol b/src/envelope/util/IEIP3009.sol similarity index 100% rename from src/peanut/util/IEIP3009.sol rename to src/envelope/util/IEIP3009.sol diff --git a/src/peanut/util/IL2ECO.sol b/src/envelope/util/IL2ECO.sol similarity index 100% rename from src/peanut/util/IL2ECO.sol rename to src/envelope/util/IL2ECO.sol diff --git a/test/peanut/Deposit.t.sol b/test/envelope/Deposit.t.sol similarity index 98% rename from test/peanut/Deposit.t.sol rename to test/envelope/Deposit.t.sol index ee1a32ab..b4307657 100644 --- a/test/peanut/Deposit.t.sol +++ b/test/envelope/Deposit.t.sol @@ -6,7 +6,7 @@ pragma solidity ^0.8.19; ////////////////////////////// import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/PeanutV4.4.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/peanut/EnvelopeBatcher.t.sol b/test/envelope/EnvelopeBatcher.t.sol similarity index 99% rename from test/peanut/EnvelopeBatcher.t.sol rename to test/envelope/EnvelopeBatcher.t.sol index e278e93b..dc6b5ddf 100644 --- a/test/peanut/EnvelopeBatcher.t.sol +++ b/test/envelope/EnvelopeBatcher.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutBatcherV4.4.sol"; +import "../../src/envelope/V4/PeanutBatcherV4.4.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/peanut/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol similarity index 99% rename from test/peanut/EnvelopeEdgeCases.t.sol rename to test/envelope/EnvelopeEdgeCases.t.sol index 579625ff..21dde5e4 100644 --- a/test/peanut/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -6,8 +6,8 @@ pragma solidity ^0.8.26; // convention. Each test is single-purpose; comments explain the *why*, not the *what*. import {Test} from "forge-std/Test.sol"; -import {EnvelopeVault} from "../../src/peanut/V4/PeanutV4.4.sol"; -import {EnvelopeBatcher} from "../../src/peanut/V4/PeanutBatcherV4.4.sol"; +import {EnvelopeVault} from "../../src/envelope/V4/PeanutV4.4.sol"; +import {EnvelopeBatcher} from "../../src/envelope/V4/PeanutBatcherV4.4.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; diff --git a/test/peanut/EnvelopeGasless.t.sol b/test/envelope/EnvelopeGasless.t.sol similarity index 99% rename from test/peanut/EnvelopeGasless.t.sol rename to test/envelope/EnvelopeGasless.t.sol index 137da80c..70b3b61d 100644 --- a/test/peanut/EnvelopeGasless.t.sol +++ b/test/envelope/EnvelopeGasless.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/PeanutV4.4.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/SampleSCW.sol"; diff --git a/test/peanut/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol similarity index 99% rename from test/peanut/EnvelopeHardening.t.sol rename to test/envelope/EnvelopeHardening.t.sol index b585c056..5a641a37 100644 --- a/test/peanut/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -9,7 +9,7 @@ pragma solidity 0.8.26; // T5 — _withdrawDeposit L2ECO branch sends to recipient, not sender (upstream bug fix) import {Test} from "forge-std/Test.sol"; -import {EnvelopeVault} from "../../src/peanut/V4/PeanutV4.4.sol"; +import {EnvelopeVault} from "../../src/envelope/V4/PeanutV4.4.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; diff --git a/test/peanut/EnvelopeVault.t.sol b/test/envelope/EnvelopeVault.t.sol similarity index 99% rename from test/peanut/EnvelopeVault.t.sol rename to test/envelope/EnvelopeVault.t.sol index 82b3c77a..d59508e8 100644 --- a/test/peanut/EnvelopeVault.t.sol +++ b/test/envelope/EnvelopeVault.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/PeanutV4.4.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/peanut/Integration.t.sol b/test/envelope/Integration.t.sol similarity index 99% rename from test/peanut/Integration.t.sol rename to test/envelope/Integration.t.sol index 478d3aeb..a3519e89 100644 --- a/test/peanut/Integration.t.sol +++ b/test/envelope/Integration.t.sol @@ -6,7 +6,7 @@ pragma solidity ^0.8.19; ////////////////////////////// import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/PeanutV4.4.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/peanut/MFA.t.sol b/test/envelope/MFA.t.sol similarity index 98% rename from test/peanut/MFA.t.sol rename to test/envelope/MFA.t.sol index f14ed51c..abd664b6 100644 --- a/test/peanut/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/PeanutV4.4.sol"; contract EnvelopeVaultMFATest is Test { EnvelopeVault public peanutV4; diff --git a/test/peanut/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol similarity index 98% rename from test/peanut/RecipientBound.t.sol rename to test/envelope/RecipientBound.t.sol index a3d84eae..732c499c 100644 --- a/test/peanut/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/PeanutV4.4.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/peanut/SenderWithdraw.t.sol b/test/envelope/SenderWithdraw.t.sol similarity index 99% rename from test/peanut/SenderWithdraw.t.sol rename to test/envelope/SenderWithdraw.t.sol index 2ac499a7..d7f4a8a6 100644 --- a/test/peanut/SenderWithdraw.t.sol +++ b/test/envelope/SenderWithdraw.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/PeanutV4.4.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/peanut/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol similarity index 98% rename from test/peanut/SigWithdraw.t.sol rename to test/envelope/SigWithdraw.t.sol index 3fe0fd79..a4bf2cd9 100644 --- a/test/peanut/SigWithdraw.t.sol +++ b/test/envelope/SigWithdraw.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import "../../src/peanut/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/PeanutV4.4.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/peanut/mocks/ECRecover.sol b/test/envelope/mocks/ECRecover.sol similarity index 100% rename from test/peanut/mocks/ECRecover.sol rename to test/envelope/mocks/ECRecover.sol diff --git a/test/peanut/mocks/EIP3009Implementation.sol b/test/envelope/mocks/EIP3009Implementation.sol similarity index 95% rename from test/peanut/mocks/EIP3009Implementation.sol rename to test/envelope/mocks/EIP3009Implementation.sol index daa8991a..4165a392 100644 --- a/test/peanut/mocks/EIP3009Implementation.sol +++ b/test/envelope/mocks/EIP3009Implementation.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.26; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {EIP3009Internals} from "./EIP3009Internals.sol"; -import {IEIP3009} from "../../../src/peanut/util/IEIP3009.sol"; +import {IEIP3009} from "../../../src/envelope/util/IEIP3009.sol"; // Basic implementation of EIP3009 for testing purposes ONLY. abstract contract EIP3009Implementation is EIP3009Internals, IEIP3009 { diff --git a/test/peanut/mocks/EIP3009Internals.sol b/test/envelope/mocks/EIP3009Internals.sol similarity index 98% rename from test/peanut/mocks/EIP3009Internals.sol rename to test/envelope/mocks/EIP3009Internals.sol index becfda4c..9eda8ab9 100644 --- a/test/peanut/mocks/EIP3009Internals.sol +++ b/test/envelope/mocks/EIP3009Internals.sol @@ -8,7 +8,7 @@ pragma solidity 0.8.26; import {EIP712Domain} from "./EIP712Domain.sol"; import {EIP712} from "./EIP712.sol"; -import {IEIP3009} from "../../../src/peanut/util/IEIP3009.sol"; +import {IEIP3009} from "../../../src/envelope/util/IEIP3009.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; abstract contract EIP3009Internals is EIP712Domain, ERC20 { diff --git a/test/peanut/mocks/EIP712.sol b/test/envelope/mocks/EIP712.sol similarity index 100% rename from test/peanut/mocks/EIP712.sol rename to test/envelope/mocks/EIP712.sol diff --git a/test/peanut/mocks/EIP712Domain.sol b/test/envelope/mocks/EIP712Domain.sol similarity index 100% rename from test/peanut/mocks/EIP712Domain.sol rename to test/envelope/mocks/EIP712Domain.sol diff --git a/test/peanut/mocks/ERC1155Mock.sol b/test/envelope/mocks/ERC1155Mock.sol similarity index 100% rename from test/peanut/mocks/ERC1155Mock.sol rename to test/envelope/mocks/ERC1155Mock.sol diff --git a/test/peanut/mocks/ERC20Mock.sol b/test/envelope/mocks/ERC20Mock.sol similarity index 100% rename from test/peanut/mocks/ERC20Mock.sol rename to test/envelope/mocks/ERC20Mock.sol diff --git a/test/peanut/mocks/ERC721Mock.sol b/test/envelope/mocks/ERC721Mock.sol similarity index 100% rename from test/peanut/mocks/ERC721Mock.sol rename to test/envelope/mocks/ERC721Mock.sol diff --git a/test/peanut/mocks/L2ECOMock.sol b/test/envelope/mocks/L2ECOMock.sol similarity index 100% rename from test/peanut/mocks/L2ECOMock.sol rename to test/envelope/mocks/L2ECOMock.sol diff --git a/test/peanut/mocks/SampleSCW.sol b/test/envelope/mocks/SampleSCW.sol similarity index 100% rename from test/peanut/mocks/SampleSCW.sol rename to test/envelope/mocks/SampleSCW.sol diff --git a/test/paymasters/EnvelopeApprovalPaymaster.t.sol b/test/paymasters/EnvelopeApprovalPaymaster.t.sol index 3259405e..ce79ce0c 100644 --- a/test/paymasters/EnvelopeApprovalPaymaster.t.sol +++ b/test/paymasters/EnvelopeApprovalPaymaster.t.sol @@ -9,7 +9,7 @@ import {BasePaymaster} from "../../src/paymasters/BasePaymaster.sol"; import {QuotaControl} from "../../src/QuotaControl.sol"; import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; -import {SampleWallet} from "../peanut/mocks/SampleSCW.sol"; +import {SampleWallet} from "../envelope/mocks/SampleSCW.sol"; /// @dev Bootloader address — paymaster validation must be called from this address. address constant BOOTLOADER = address(uint160(0x8001)); From d0af3577a59ce3e49b429e47fe3418b98721c4ae Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 22:44:38 -0400 Subject: [PATCH 27/31] =?UTF-8?q?chore(envelope):=20full=20Peanut=20?= =?UTF-8?q?=E2=86=92=20Envelope=20sweep=20(rename=20DeployPeanut,=20test?= =?UTF-8?q?=20classes,=20locals,=20PEANUT=5FSALT,=20comments)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final cleanup pass after the directory rename. No on-chain bytecode change. Renames: - hardhat-deploy/DeployPeanut.ts → DeployEnvelope.ts - PEANUT_SALT constant → ENVELOPE_SALT (value unchanged at 0x70adbbeb…d94e0; preimage comment kept for auditor clarity). Per user decision (Option 2): symbol-only rename preserves signature- scheme interop with the upstream Peanut SDK. - test contract classes: PeanutHardeningTest → EnvelopeHardeningTest PeanutEdgeCasesTest → EnvelopeEdgeCasesTest PeanutBatcherTest → EnvelopeBatcherTest - local variables in source + tests + deploy script: peanut, peanutV4, nodlePeanut, peanutV4ECO → vault, nodleVault, vaultECO - function parameters in batcher source: _peanutAddress → _vaultAddress - test hash preimages (just nonces, safe to change): nodle.peanut.* → nodle.envelope.* - prose comments mentioning "Peanut" where it wasn't required attribution: "the new peanut instance" → "the new envelope vault" "peanut depositor" → "envelope depositor" "different Peanut deployment" → "different Envelope deployment" DeployPeanut.ts header "Peanut Protocol suite" → "Envelope (vendored Peanut V4.4) suite" What still says "Peanut" (every occurrence is intentional): - File names PeanutV4.4.sol, PeanutBatcherV4.4.sol — preserves per-file diff to upstream - GPL §5(d) attribution: `// @author Squirrel Labs`, `// @title Peanut Protocol`, `peanutprotocol/peanut-contracts@main` - ENVELOPE_SALT keccak preimage: "Konrad makes tokens go woosh tadam" (kept as documentation of how the constant value was derived) - Factual upstream identification in README + .solhintignore + deploy script header (e.g., "Vendored Envelope (Peanut V4.4) sources") Verified: forge test 966/966. yarn lint 0 errors. yarn spellcheck 0 issues / 242 files. Local build artifacts in deployments-zk/ remain gitignored. --- .solhintignore | 2 +- .../{DeployPeanut.ts => DeployEnvelope.ts} | 16 +-- hardhat-deploy/DeployEnvelopePaymaster.ts | 4 +- src/envelope/V4/PeanutBatcherV4.4.sol | 44 +++---- src/envelope/V4/PeanutV4.4.sol | 6 +- src/envelope/doc/EnvelopeApprovalPaymaster.md | 2 +- src/envelope/doc/EnvelopeBatcher.md | 22 ++-- src/envelope/doc/EnvelopeVault.md | 6 +- src/envelope/doc/README.md | 12 +- test/envelope/Deposit.t.sol | 20 ++-- test/envelope/EnvelopeBatcher.t.sol | 30 ++--- test/envelope/EnvelopeEdgeCases.t.sol | 108 +++++++++--------- test/envelope/EnvelopeGasless.t.sol | 34 +++--- test/envelope/EnvelopeHardening.t.sol | 76 ++++++------ test/envelope/EnvelopeVault.t.sol | 50 ++++---- test/envelope/Integration.t.sol | 68 +++++------ test/envelope/MFA.t.sol | 18 +-- test/envelope/RecipientBound.t.sol | 18 +-- test/envelope/SenderWithdraw.t.sol | 38 +++--- test/envelope/SigWithdraw.t.sol | 18 +-- 20 files changed, 296 insertions(+), 296 deletions(-) rename hardhat-deploy/{DeployPeanut.ts => DeployEnvelope.ts} (89%) diff --git a/.solhintignore b/.solhintignore index 58404c70..ad2f988b 100644 --- a/.solhintignore +++ b/.solhintignore @@ -1,4 +1,4 @@ -# Vendored Peanut Protocol V4.4 sources — kept close to upstream +# Vendored Envelope (Peanut V4.4) sources — kept close to upstream # (peanutprotocol/peanut-contracts@main) for diff parity. Upstream uses # require-string style; converting to custom errors would diverge # significantly without any security/correctness benefit. diff --git a/hardhat-deploy/DeployPeanut.ts b/hardhat-deploy/DeployEnvelope.ts similarity index 89% rename from hardhat-deploy/DeployPeanut.ts rename to hardhat-deploy/DeployEnvelope.ts index e431a51f..670d226e 100644 --- a/hardhat-deploy/DeployPeanut.ts +++ b/hardhat-deploy/DeployEnvelope.ts @@ -9,7 +9,7 @@ import { deployContract } from "./utils"; dotenv.config({ path: ".env-test" }); /** - * Deploys the Peanut Protocol suite on ZkSync Era. + * Deploys the Envelope (vendored Peanut V4.4) suite on ZkSync Era. * * Required environment variables: * - DEPLOYER_PRIVATE_KEY: Private key for deployment. @@ -25,7 +25,7 @@ dotenv.config({ path: ".env-test" }); * * Usage: * yarn hardhat deploy-zksync \ - * --script DeployPeanut.ts \ + * --script DeployEnvelope.ts \ * --network zkSyncSepoliaTestnet */ module.exports = async function (hre: HardhatRuntimeEnvironment) { @@ -40,7 +40,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); const deployer = new Deployer(hre, wallet); - console.log("=== Deploying Peanut Protocol on ZkSync ==="); + console.log("=== Deploying Envelope on ZkSync ==="); console.log("Network: ", hre.network.name); console.log("Deployer: ", wallet.address); console.log("ECO Token: ", ecoToken); @@ -49,8 +49,8 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); // 1. Vault — required. - const peanut = await deployContract(deployer, "EnvelopeVault", [ecoToken, mfaAuthorizer]); - const peanutAddr = await peanut.getAddress(); + const vault = await deployContract(deployer, "EnvelopeVault", [ecoToken, mfaAuthorizer]); + const vaultAddr = await vault.getAddress(); // 2. Batcher — optional. let batcherAddr: string | undefined; @@ -61,7 +61,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); console.log("=== Deployment Complete ==="); - console.log("EnvelopeVault: ", peanutAddr); + console.log("EnvelopeVault: ", vaultAddr); if (batcherAddr) console.log("EnvelopeBatcher: ", batcherAddr); console.log(""); @@ -70,7 +70,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { try { console.log("Verifying EnvelopeVault..."); await hre.run("verify:verify", { - address: peanutAddr, + address: vaultAddr, contract: "src/envelope/V4/PeanutV4.4.sol:EnvelopeVault", constructorArguments: [ecoToken, mfaAuthorizer], }); @@ -93,7 +93,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); console.log("=== Add these to .env-test: ==="); - console.log(`ENVELOPE_VAULT=${peanutAddr}`); + console.log(`ENVELOPE_VAULT=${vaultAddr}`); if (batcherAddr) console.log(`ENVELOPE_BATCHER=${batcherAddr}`); if (mfaAuthorizer === ZERO) { diff --git a/hardhat-deploy/DeployEnvelopePaymaster.ts b/hardhat-deploy/DeployEnvelopePaymaster.ts index dcf6af25..88a99722 100644 --- a/hardhat-deploy/DeployEnvelopePaymaster.ts +++ b/hardhat-deploy/DeployEnvelopePaymaster.ts @@ -19,7 +19,7 @@ dotenv.config({ path: ".env-test" }); * * Required environment variables: * - DEPLOYER_PRIVATE_KEY: Private key for deployment (also default admin / withdrawer). - * - ENVELOPE_VAULT: Address of the deployed Peanut/Envelope vault — the only + * - ENVELOPE_VAULT: Address of the deployed Envelope vault — the only * allowed spender/operator for sponsored approvals. * * Optional environment variables (admin / signer): @@ -54,7 +54,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { const envelopeVault = process.env.ENVELOPE_VAULT; if (!envelopeVault || envelopeVault === ZERO) { - throw new Error("ENVELOPE_VAULT env var is required (the deployed Envelope/Peanut vault address)"); + throw new Error("ENVELOPE_VAULT env var is required (the deployed Envelope vault address)"); } const admin = process.env.ENVELOPE_PAYMASTER_ADMIN ?? wallet.address; diff --git a/src/envelope/V4/PeanutBatcherV4.4.sol b/src/envelope/V4/PeanutBatcherV4.4.sol index 191be2e1..c6c6a35a 100644 --- a/src/envelope/V4/PeanutBatcherV4.4.sol +++ b/src/envelope/V4/PeanutBatcherV4.4.sol @@ -2,7 +2,7 @@ // // Modified by Nodle (2026-05-12) — see src/envelope/doc/EnvelopeBatcher.md ("Vendoring // patches") and the git history of this file for the full patch set. The upstream source -// is peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license text is bundled +// is peanutprotocol/vault-contracts@main; the full GNU GPL v3 license text is bundled // at src/envelope/V4/LICENSE-GPL. pragma solidity 0.8.26; @@ -70,14 +70,14 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { } function batchMakeDeposit( - address _peanutAddress, + address _vaultAddress, address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, address[] calldata _pubKeys20 ) external payable returns (uint256[] memory) { - EnvelopeVault peanut = EnvelopeVault(_peanutAddress); + EnvelopeVault vault = EnvelopeVault(_vaultAddress); uint256 totalAmount = _amount * _pubKeys20.length; uint256 etherAmount; @@ -86,17 +86,17 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { etherAmount = _amount; } else if (_contractType == 1) { IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); - _setAllowanceIfZero(_tokenAddress, address(peanut)); + _setAllowanceIfZero(_tokenAddress, address(vault)); } else if (_contractType == 2) { revert("ERC721 batch not implemented"); } else if (_contractType == 3) { IERC1155(_tokenAddress).safeTransferFrom(msg.sender, address(this), _tokenId, totalAmount, ""); - IERC1155(_tokenAddress).setApprovalForAll(address(peanut), true); + IERC1155(_tokenAddress).setApprovalForAll(address(vault), true); } uint256[] memory depositIndexes = new uint256[](_pubKeys20.length); for (uint256 i = 0; i < _pubKeys20.length; i++) { - depositIndexes[i] = peanut.makeSelflessDeposit{value: etherAmount}( + depositIndexes[i] = vault.makeSelflessDeposit{value: etherAmount}( _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender ); } @@ -107,14 +107,14 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { /// @dev Assumes all deposits are the same; uses msg.value as etherAmount per call /// (only meaningful when called with a single deposit, or when sending only ETH dust). function batchMakeDepositNoReturn( - address _peanutAddress, + address _vaultAddress, address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, address[] calldata _pubKeys20 ) external payable { - EnvelopeVault peanut = EnvelopeVault(_peanutAddress); + EnvelopeVault vault = EnvelopeVault(_vaultAddress); // For ETH (contractType == 0), the batcher only receives msg.value once; forwarding // {value: msg.value} per loop iteration would revert on iteration 2 with insufficient // balance. Either require msg.value == _amount * N and forward _amount per call, or @@ -129,14 +129,14 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { } for (uint256 i = 0; i < _pubKeys20.length; i++) { - peanut.makeSelflessDeposit{value: etherPerCall}( + vault.makeSelflessDeposit{value: etherPerCall}( _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender ); } } function batchMakeDepositArbitrary( - address _peanutAddress, + address _vaultAddress, address[] memory _tokenAddresses, uint8[] memory _contractTypes, uint256[] memory _amounts, @@ -150,7 +150,7 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { && _withMFAs.length == _pubKeys20.length, "PARAMETERS LENGTH MISMATCH" ); - EnvelopeVault peanut = EnvelopeVault(_peanutAddress); + EnvelopeVault vault = EnvelopeVault(_vaultAddress); uint256[] memory depositIndexes = new uint256[](_amounts.length); for (uint256 i = 0; i < _amounts.length; i++) { @@ -160,15 +160,15 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { etherAmount = _amounts[i]; } else if (_contractTypes[i] == 1) { IERC20(_tokenAddresses[i]).safeTransferFrom(msg.sender, address(this), _amounts[i]); - _setAllowanceIfZero(_tokenAddresses[i], _peanutAddress); + _setAllowanceIfZero(_tokenAddresses[i], _vaultAddress); } else if (_contractTypes[i] == 2) { revert("ERC721 batch not implemented"); } else if (_contractTypes[i] == 3) { IERC1155(_tokenAddresses[i]).safeTransferFrom(msg.sender, address(this), _tokenIds[i], _amounts[i], ""); - IERC1155(_tokenAddresses[i]).setApprovalForAll(_peanutAddress, true); + IERC1155(_tokenAddresses[i]).setApprovalForAll(_vaultAddress, true); } - depositIndexes[i] = peanut.makeCustomDeposit{value: etherAmount}( + depositIndexes[i] = vault.makeCustomDeposit{value: etherAmount}( _tokenAddresses[i], _contractTypes[i], _amounts[i], @@ -186,17 +186,17 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { } function batchMakeDepositRaffle( - address _peanutAddress, + address _vaultAddress, address _tokenAddress, uint8 _contractType, uint256[] calldata _amounts, address _pubKey20 ) external payable returns (uint256[] memory) { require(_contractType == 0 || _contractType == 1, "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED"); - EnvelopeVault peanut = EnvelopeVault(_peanutAddress); + EnvelopeVault vault = EnvelopeVault(_vaultAddress); if (_contractType == 1) { - _setAllowanceIfZero(_tokenAddress, _peanutAddress); + _setAllowanceIfZero(_tokenAddress, _vaultAddress); uint256 totalAmount; for (uint256 i = 0; i < _amounts.length; i++) { totalAmount += _amounts[i]; @@ -210,7 +210,7 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { if (_contractType == 0) { etherAmount = _amounts[i]; } - depositIndexes[i] = peanut.makeSelflessDeposit{value: etherAmount}( + depositIndexes[i] = vault.makeSelflessDeposit{value: etherAmount}( _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender ); } @@ -218,17 +218,17 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { } function batchMakeDepositRaffleMFA( - address _peanutAddress, + address _vaultAddress, address _tokenAddress, uint8 _contractType, uint256[] calldata _amounts, address _pubKey20 ) external payable returns (uint256[] memory) { require(_contractType == 0 || _contractType == 1, "ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED"); - EnvelopeVault peanut = EnvelopeVault(_peanutAddress); + EnvelopeVault vault = EnvelopeVault(_vaultAddress); if (_contractType == 1) { - _setAllowanceIfZero(_tokenAddress, _peanutAddress); + _setAllowanceIfZero(_tokenAddress, _vaultAddress); uint256 totalAmount; for (uint256 i = 0; i < _amounts.length; i++) { totalAmount += _amounts[i]; @@ -242,7 +242,7 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { if (_contractType == 0) { etherAmount = _amounts[i]; } - depositIndexes[i] = peanut.makeSelflessMFADeposit{value: etherAmount}( + depositIndexes[i] = vault.makeSelflessMFADeposit{value: etherAmount}( _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender ); } diff --git a/src/envelope/V4/PeanutV4.4.sol b/src/envelope/V4/PeanutV4.4.sol index 88b01451..23439b40 100644 --- a/src/envelope/V4/PeanutV4.4.sol +++ b/src/envelope/V4/PeanutV4.4.sol @@ -71,7 +71,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { // We may include this hash in peanut-specific signatures to make sure // that the message signed by the user has effects only in peanut contracts. - bytes32 public constant PEANUT_SALT = 0x70adbbeba9d4f0c82e28dd574f15466f75df0543b65f24460fc445813b5d94e0; // keccak256("Konrad makes tokens go woosh tadam"); + bytes32 public constant ENVELOPE_SALT = 0x70adbbeba9d4f0c82e28dd574f15466f75df0543b65f24460fc445813b5d94e0; // keccak256("Konrad makes tokens go woosh tadam"); bytes32 public constant ANYONE_WITHDRAWAL_MODE = 0x0000000000000000000000000000000000000000000000000000000000000000; // default. Any address can trigger the withdrawal function bytes32 public constant RECIPIENT_WITHDRAWAL_MODE = 0x2bb5bef2b248d3edba501ad918c3ab524cce2aea54d4c914414e1c4401dc4ff4; // keccak256("only recipient") - only the signed recipient can trigger the withdrawal function @@ -630,7 +630,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - PEANUT_SALT, + ENVELOPE_SALT, block.chainid, address(this), _index, @@ -702,7 +702,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { bytes32 _recipientAddressHash = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - PEANUT_SALT, + ENVELOPE_SALT, block.chainid, address(this), _index, diff --git a/src/envelope/doc/EnvelopeApprovalPaymaster.md b/src/envelope/doc/EnvelopeApprovalPaymaster.md index ef5aa90d..cfc8a545 100644 --- a/src/envelope/doc/EnvelopeApprovalPaymaster.md +++ b/src/envelope/doc/EnvelopeApprovalPaymaster.md @@ -9,7 +9,7 @@ Sponsors gas in **two modes**, both funded from one ETH pool and bounded by the | Mode | Caller | Auth | What gets sponsored | |---|---|---|---| | **A — User approval** | regular user | EIP-712 grant signed off-chain by `operatorSigner` (single-use nonce, deadline) + selector + spender checks | `token.approve(envelopeVault, ...)` / `token.setApprovalForAll(envelopeVault, true)` for ERC-20 / 721 / 1155 — the user-side step in Path C | -| **B — Operator direct call** | operator EOA on the `isOperator` allowlist | target must be on the `isAllowedTarget` allowlist; no grant required | Anything the operator wants to call on an allowlisted target — typically `peanut.makeCustomDeposit`, `peanut.withdrawDeposit`, etc. | +| **B — Operator direct call** | operator EOA on the `isOperator` allowlist | target must be on the `isAllowedTarget` allowlist; no grant required | Anything the operator wants to call on an allowlisted target — typically `vault.makeCustomDeposit`, `vault.withdrawDeposit`, etc. | Mode B is the "single point we top up" pattern: instead of funding the operator's hot wallet directly, fund the paymaster and let the operator submit txs gaslessly. Bounded daily spend (QuotaControl), bounded per-tx spend (`maxEthPerTx`), and rotation just means flipping `isOperator` on a new EOA — no balance migration. diff --git a/src/envelope/doc/EnvelopeBatcher.md b/src/envelope/doc/EnvelopeBatcher.md index cba59850..183d7b96 100644 --- a/src/envelope/doc/EnvelopeBatcher.md +++ b/src/envelope/doc/EnvelopeBatcher.md @@ -4,9 +4,9 @@ ## Purpose -A stateless helper that lets a single tx create N peanut deposits at once. The batcher pulls tokens from `msg.sender` once, then loops calling the vault's `makeSelflessDeposit` / `makeCustomDeposit` / `makeSelflessMFADeposit` for each pubKey. Common use case: airdrops or per-recipient claim links. +A stateless helper that lets a single tx create N envelope deposits at once. The batcher pulls tokens from `msg.sender` once, then loops calling the vault's `makeSelflessDeposit` / `makeCustomDeposit` / `makeSelflessMFADeposit` for each pubKey. Common use case: airdrops or per-recipient claim links. -Stateless by design — the `EnvelopeVault` reference is taken from the call argument each invocation, so the same batcher contract can fan out to multiple vault deployments. Also avoids EraVM pubdata cost on every batch call (`EnvelopeVault public peanut` storage var was dropped during hardening). +Stateless by design — the `EnvelopeVault` reference is taken from the call argument each invocation, so the same batcher contract can fan out to multiple vault deployments. Also avoids EraVM pubdata cost on every batch call (`EnvelopeVault public vault` storage var was dropped during hardening). ## Constructor @@ -18,13 +18,13 @@ constructor() // no args | Function | Use case | |---|---| -| `batchMakeDeposit(peanut, token, contractType, amount, tokenId, pubKeys20[])` | N deposits, all the same shape; returns array of deposit indexes | -| `batchMakeDepositNoReturn(peanut, token, contractType, amount, tokenId, pubKeys20[])` | Same as above but skips the return-array allocation (cheaper). Only meaningful for a single deposit, or for ETH-only with msg.value reused per call (legacy upstream shape) | -| `batchMakeDepositArbitrary(peanut, tokens[], contractTypes[], amounts[], tokenIds[], pubKeys20[], withMFAs[])` | Heterogeneous batch — each deposit has its own token/type/amount/id/pubkey/MFA flag | -| `batchMakeDepositRaffle(peanut, token, contractType, amounts[], pubKey20)` | Raffle: many deposits sharing the same `pubKey20`, each with its own amount. Withdraw order = order claimed. ETH and ERC-20 only | +| `batchMakeDeposit(vault, token, contractType, amount, tokenId, pubKeys20[])` | N deposits, all the same shape; returns array of deposit indexes | +| `batchMakeDepositNoReturn(vault, token, contractType, amount, tokenId, pubKeys20[])` | Same as above but skips the return-array allocation (cheaper). Only meaningful for a single deposit, or for ETH-only with msg.value reused per call (legacy upstream shape) | +| `batchMakeDepositArbitrary(vault, tokens[], contractTypes[], amounts[], tokenIds[], pubKeys20[], withMFAs[])` | Heterogeneous batch — each deposit has its own token/type/amount/id/pubkey/MFA flag | +| `batchMakeDepositRaffle(vault, token, contractType, amounts[], pubKey20)` | Raffle: many deposits sharing the same `pubKey20`, each with its own amount. Withdraw order = order claimed. ETH and ERC-20 only | | `batchMakeDepositRaffleMFA(...)` | Same as raffle, but all deposits are MFA-gated | -All call `peanut.makeSelflessDeposit(_, _, _, _, _, msg.sender)` (or its MFA / custom variants) under the hood — the **batcher caller** (`msg.sender`) becomes the `senderAddress` recorded in the vault, so they retain reclaim rights. +All call `vault.makeSelflessDeposit(_, _, _, _, _, msg.sender)` (or its MFA / custom variants) under the hood — the **batcher caller** (`msg.sender`) becomes the `senderAddress` recorded in the vault, so they retain reclaim rights. ## ERC-721 batch — intentionally not supported @@ -41,8 +41,8 @@ Each NFT has a unique `tokenId`, which doesn't fit the same-args-per-deposit sha | `contractType` | Path | |---|---| | 0 (ETH) | `msg.value == amount * pubKeys20.length` check; ETH is then forwarded per inner deposit | -| 1 (ERC-20) | `safeTransferFrom(msg.sender, address(this), totalAmount)`; one-time `forceApprove(peanut, MAX)` via `_setAllowanceIfZero` | -| 3 (ERC-1155) | `safeTransferFrom(msg.sender, address(this), tokenId, totalAmount, "")`; `setApprovalForAll(peanut, true)` | +| 1 (ERC-20) | `safeTransferFrom(msg.sender, address(this), totalAmount)`; one-time `forceApprove(vault, MAX)` via `_setAllowanceIfZero` | +| 3 (ERC-1155) | `safeTransferFrom(msg.sender, address(this), tokenId, totalAmount, "")`; `setApprovalForAll(vault, true)` | The batcher holds the assets transiently between pull and the inner `makeSelflessDeposit` calls. Each inner call pulls from the batcher (whom it just approved) into the vault. @@ -64,7 +64,7 @@ Same self-only policy as the vault — direct ERC-721 / ERC-1155 transfers to th ## Storage -None. (`EnvelopeVault public peanut` was removed during hardening — see ZkSync notes.) +None. (`EnvelopeVault public vault` was removed during hardening — see ZkSync notes.) ## Events / errors @@ -75,7 +75,7 @@ None of its own. Inner deposits emit `EnvelopeVault.DepositEvent`. | | Patch | |---|---| | OZ v5 | `safeApprove` → `forceApprove` | -| ZkSync (Z2) | Dropped `EnvelopeVault public peanut` storage var; uses local per call | +| ZkSync (Z2) | Dropped `EnvelopeVault public vault` storage var; uses local per call | | ZkSync (Z1) | Explicit `override(IERC165)` on `supportsInterface` | | Hardening (S1) | Receivers revert on non-self operator | | Modern | Named imports | diff --git a/src/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md index f2d12a9d..0c14955a 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -54,7 +54,7 @@ bytes32 public DOMAIN_SEPARATOR; // set at construction; not immutable for clari | Name | Value | Purpose | |---|---|---| -| `PEANUT_SALT` | `keccak256("Konrad makes tokens go woosh tadam")` | Domain-tags every link signature; prevents the same signature being reused on a different Peanut deployment | +| `ENVELOPE_SALT` | `keccak256("Konrad makes tokens go woosh tadam")` | Domain-tags every link signature; prevents the same signature being reused on a different Envelope deployment | | `ANYONE_WITHDRAWAL_MODE` | `bytes32(0)` | Default mode — anyone holding the private key can withdraw on behalf of an arbitrary recipient | | `RECIPIENT_WITHDRAWAL_MODE` | `keccak256("only recipient")` | Used for `withdrawDepositAsRecipient` — only the recipient address signs | | `GASLESS_RECLAIM_TYPEHASH` | `keccak256("GaslessReclaim(uint256 depositIndex)")` | EIP-712 type for sender's gasless reclaim | @@ -88,8 +88,8 @@ so the dual-zero footgun is impossible. | Function | Caller | Auth | |---|---|---| -| `withdrawDeposit(index, recipient, signature)` | anyone | `signature` (recovers to `pubKey20`) signed over `keccak256(PEANUT_SALT, chainid, address(this), index, recipient, ANYONE_WITHDRAWAL_MODE)` | -| `withdrawMFADeposit(index, recipient, signature, MFASignature)` | anyone | Both above signature AND a signature from `MFA_AUTHORIZER` over `keccak256(PEANUT_SALT, chainid, address(this), index, recipient)` | +| `withdrawDeposit(index, recipient, signature)` | anyone | `signature` (recovers to `pubKey20`) signed over `keccak256(ENVELOPE_SALT, chainid, address(this), index, recipient, ANYONE_WITHDRAWAL_MODE)` | +| `withdrawMFADeposit(index, recipient, signature, MFASignature)` | anyone | Both above signature AND a signature from `MFA_AUTHORIZER` over `keccak256(ENVELOPE_SALT, chainid, address(this), index, recipient)` | | `withdrawDepositAsRecipient(index, recipient, signature)` | `recipient` only (msg.sender) | `signature` signed with `RECIPIENT_WITHDRAWAL_MODE` instead of `ANYONE_WITHDRAWAL_MODE` | | `withdrawDepositSender(index)` | original sender | none beyond `msg.sender == _deposit.senderAddress`; for recipient-bound deposits also requires `block.timestamp > reclaimableAfter` | | `withdrawDepositSenderGasless(reclaim, signer, signature)` | anyone | EIP-712 signature from `signer` (must equal `senderAddress`) over `GaslessReclaim(depositIndex)` | diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 0bad15b3..8ef445bf 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -1,4 +1,4 @@ -# Envelope (Peanut) contracts +# Envelope contracts The Envelope flow on Nodle is built on top of the vendored **Peanut Protocol V4.4** contracts. Operators issue link-based asset transfers (ETH / ERC-20 / ERC-721 / @@ -29,7 +29,7 @@ This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) doesn't apply u | `src/envelope/V4/PeanutV4.4.sol`, `PeanutBatcherV4.4.sol` | **GPL-3.0-or-later** | Modified copies of upstream Peanut Protocol V4.4. Full GPL v3 text bundled at `src/envelope/V4/LICENSE-GPL`. Each file carries a top-of-file modification notice per GPL §5(a). | | `src/envelope/util/IEIP3009.sol`, `IL2ECO.sol` | **MIT** | Vendored interfaces, unchanged from upstream | | `src/paymasters/EnvelopeApprovalPaymaster.sol` | **BSD-3-Clause-Clear** | Our own code; doesn't `import` any GPL source so it isn't a derivative work | -| `test/envelope/**/*.t.sol` (files that import Peanut sources) | **GPL-3.0-or-later** | Test files that `import` GPL-licensed contracts are derivative works under a strict reading of the GPL; relicensed for compliance | +| `test/envelope/**/*.t.sol` (files that import the vault/batcher sources) | **GPL-3.0-or-later** | Test files that `import` GPL-licensed contracts are derivative works under a strict reading of the GPL; relicensed for compliance | | `test/envelope/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained | | All other repo files | unchanged | Whatever they were | @@ -38,8 +38,8 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s ## Naming convention - **Source files** keep the upstream `Peanut*` names (e.g. `PeanutV4.4.sol`) so diffs against `peanutprotocol/peanut-contracts@main` stay grep-friendly. The audit lineage is preserved by file path + the `// Modified by Nodle` notice + the bundled `LICENSE-GPL`. -- **Contract symbols** (the names visible on the explorer / in the SDK / in the EIP-712 domain) use the **Envelope** brand: `EnvelopeVault`, `EnvelopeBatcher`, `EnvelopeApprovalPaymaster`. This avoids any trademark confusion with Squirrel Labs' "Peanut Protocol" brand. -- **On-chain hashed constants** (e.g. `PEANUT_SALT`) keep upstream values — changing them would change every signature digest and break compatibility. Those values are internal and never user-visible. +- **Contract symbols** (the names visible on the explorer / in the SDK / in the EIP-712 domain) use the **Envelope** brand: `EnvelopeVault`, `EnvelopeBatcher`, `EnvelopeApprovalPaymaster`. This avoids any trademark confusion with upstream Peanut Protocol brand. +- **On-chain hashed constants** (e.g. `ENVELOPE_SALT`) keep upstream values — changing them would change every signature digest and break compatibility. Those values are internal and never user-visible. ## Deployed on ZkSync Sepolia (chain 300) @@ -63,7 +63,7 @@ The vault itself supports three ways a sender can fund a link: | Script | Purpose | |---|---| -| `hardhat-deploy/DeployPeanut.ts` | vault + batcher | +| `hardhat-deploy/DeployEnvelope.ts` | vault + batcher | | `hardhat-deploy/DeployEnvelopePaymaster.ts` | paymaster | Both are Hardhat-zksync scripts. See each spec for env vars. @@ -72,7 +72,7 @@ Both are Hardhat-zksync scripts. See each spec for env vars. | Suite | Tests | |---|---| -| Peanut core (`test/envelope/`) | **90** (56 vendored + 11 hardening + 23 edge cases) | +| Envelope core (`test/envelope/`) | **90** (56 vendored + 11 hardening + 23 edge cases) | | `EnvelopeApprovalPaymaster` (`test/paymasters/EnvelopeApprovalPaymaster.t.sol`) | **27** (19 Mode A + 7 Mode B + 1 EIP-1271 contract signer) | | Other paymasters (unchanged) | 102 | | Rest of repo | 747 | diff --git a/test/envelope/Deposit.t.sol b/test/envelope/Deposit.t.sol index b4307657..1b072182 100644 --- a/test/envelope/Deposit.t.sol +++ b/test/envelope/Deposit.t.sol @@ -14,7 +14,7 @@ import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -25,7 +25,7 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); @@ -38,7 +38,7 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { // check invariants function testDepositEther(uint64 amount, address randomAddress) public { vm.assume(amount > 0); - peanutV4.makeDeposit{value: amount}(randomAddress, 0, amount, 0, PUBKEY20); + vault.makeDeposit{value: amount}(randomAddress, 0, amount, 0, PUBKEY20); } function testDepositERC20(uint64 amount) public { @@ -46,11 +46,11 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { // mint tokens to the contract testToken.mint(address(this), amount); // approve the contract to spend the tokens - testToken.approve(address(peanutV4), amount); + testToken.approve(address(vault), amount); // console log allowance and amount - console.log("Allowance: ", testToken.allowance(address(this), address(peanutV4))); + console.log("Allowance: ", testToken.allowance(address(this), address(vault))); console.log("Amount: ", amount); - peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); } // Test for ERC721 Token @@ -58,8 +58,8 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { // mint a token to the contract testToken721.mint(address(this), tokenId); // approve the contract to spend the tokens - testToken721.approve(address(peanutV4), tokenId); - peanutV4.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); + testToken721.approve(address(vault), tokenId); + vault.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); } // Test for ERC1155 Token @@ -68,7 +68,7 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { // mint tokens to the contract testToken1155.mint(address(this), tokenId, amount, ""); // approve the contract to spend the tokens - testToken1155.setApprovalForAll(address(peanutV4), true); - peanutV4.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); + testToken1155.setApprovalForAll(address(vault), true); + vault.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); } } diff --git a/test/envelope/EnvelopeBatcher.t.sol b/test/envelope/EnvelopeBatcher.t.sol index dc6b5ddf..1c10d320 100644 --- a/test/envelope/EnvelopeBatcher.t.sol +++ b/test/envelope/EnvelopeBatcher.t.sol @@ -9,9 +9,9 @@ import "./mocks/ERC1155Mock.sol"; import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; -contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { +contract EnvelopeBatcherTest is Test, ERC1155Holder, ERC721Holder { EnvelopeBatcher public batcher; - EnvelopeVault public peanutV4; + EnvelopeVault public vault; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -19,7 +19,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { batcher = new EnvelopeBatcher(); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); @@ -40,7 +40,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { uint256 totalAmount = amount * numDeposits; // make the batch deposit uint256[] memory depositIndexes = - batcher.batchMakeDeposit{value: totalAmount}(address(peanutV4), address(0), 0, amount, 0, pubKeys20); + batcher.batchMakeDeposit{value: totalAmount}(address(vault), address(0), 0, amount, 0, pubKeys20); // check that the correct number of deposits were made assertEq(depositIndexes.length, numDeposits); } @@ -59,7 +59,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { // make the batch deposit uint256[] memory depositIndexes = - batcher.batchMakeDeposit(address(peanutV4), address(testToken), 1, amount, 0, pubKeys20); + batcher.batchMakeDeposit(address(vault), address(testToken), 1, amount, 0, pubKeys20); // check that the correct number of deposits were made assertEq(depositIndexes.length, numDeposits); } @@ -78,7 +78,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { testToken721.approve(address(batcher), tokenId); } vm.expectRevert("ERC721 batch not implemented"); - batcher.batchMakeDeposit(address(peanutV4), address(testToken721), 2, 1, 1, pubKeys20); + batcher.batchMakeDeposit(address(vault), address(testToken721), 2, 1, 1, pubKeys20); } // Test making a batch deposit of ERC1155 tokens @@ -95,7 +95,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { } // make the batch deposit uint256[] memory depositIndexes = - batcher.batchMakeDeposit(address(peanutV4), address(testToken1155), 3, 1, 1, pubKeys20); + batcher.batchMakeDeposit(address(vault), address(testToken1155), 3, 1, 1, pubKeys20); // check that the correct number of deposits were made assertEq(depositIndexes.length, numDeposits); } @@ -111,7 +111,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { testToken.mint(address(this), amount * numDeposits); // Do NOT approve the batcher to spend the tokens vm.expectRevert(); - batcher.batchMakeDeposit(address(peanutV4), address(testToken), 1, amount, 0, pubKeys20); + batcher.batchMakeDeposit(address(vault), address(testToken), 1, amount, 0, pubKeys20); } // Test failure case where EnvelopeVault contract is not approved to spend ERC721 tokens @@ -125,7 +125,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { // Do NOT approve the batcher to spend the tokens } vm.expectRevert(); - batcher.batchMakeDeposit(address(peanutV4), address(testToken721), 2, 1, numDeposits, pubKeys20); + batcher.batchMakeDeposit(address(vault), address(testToken721), 2, 1, numDeposits, pubKeys20); } // Test failure case where EnvelopeVault contract is not approved to spend ERC1155 tokens @@ -139,7 +139,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { // Do NOT approve the batcher to transfer the tokens } vm.expectRevert(); - batcher.batchMakeDeposit(address(peanutV4), address(testToken1155), 3, 1, numDeposits, pubKeys20); + batcher.batchMakeDeposit(address(vault), address(testToken1155), 3, 1, numDeposits, pubKeys20); } // Test making multiple batch deposits of ERC20 tokens in a row @@ -162,7 +162,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { // Make the batch deposit uint256[] memory depositIndexes = - batcher.batchMakeDeposit(address(peanutV4), address(testToken), 1, amount, 0, pubKeys20); + batcher.batchMakeDeposit(address(vault), address(testToken), 1, amount, 0, pubKeys20); // Check that the correct number of deposits were made assertEq(depositIndexes.length, numDeposits); @@ -178,7 +178,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { amounts[3] = 40; uint256[] memory depositIndices = batcher.batchMakeDepositRaffle{value: 100}( - address(peanutV4), + address(vault), address(testToken), 0, amounts, @@ -186,7 +186,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { ); for(uint256 i = 0; i < amounts.length; i++) { - EnvelopeVault.Deposit memory deposit = peanutV4.getDeposit(depositIndices[i]); + EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); assert(deposit.amount == amounts[i]); // main assertion // a few sanity checks @@ -209,7 +209,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { testToken.approve(address(batcher), 100); uint256[] memory depositIndices = batcher.batchMakeDepositRaffle( - address(peanutV4), + address(vault), address(testToken), 1, amounts, @@ -217,7 +217,7 @@ contract PeanutBatcherTest is Test, ERC1155Holder, ERC721Holder { ); for(uint256 i = 0; i < amounts.length; i++) { - EnvelopeVault.Deposit memory deposit = peanutV4.getDeposit(depositIndices[i]); + EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); assert(deposit.amount == amounts[i]); // main assertion // a few sanity checks diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index 21dde5e4..1f46dd80 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -16,18 +16,18 @@ import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/Messa import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; -/// @dev Reentrancy probe: tries to call back into `peanut.withdrawDeposit` from inside +/// @dev Reentrancy probe: tries to call back into `vault.withdrawDeposit` from inside /// `safeTransfer`. Guarded by EnvelopeVault's `nonReentrant` modifier, so the inner call /// reverts and the outer flow surfaces the inner revert reason ("REENTRANCY"). contract ReentrantToken is ERC20Mock { - EnvelopeVault public peanut; + EnvelopeVault public vault; uint256 public targetIdx; bytes public targetSig; address public attacker; bool public attempted; function arm(EnvelopeVault p, uint256 idx, bytes calldata sig, address atk) external { - peanut = p; + vault = p; targetIdx = idx; targetSig = sig; attacker = atk; @@ -36,10 +36,10 @@ contract ReentrantToken is ERC20Mock { function _update(address from, address to, uint256 value) internal override { super._update(from, to, value); // Reenter once during the outer safeTransfer back to the recipient. - if (!attempted && address(peanut) != address(0) && to == attacker) { + if (!attempted && address(vault) != address(0) && to == attacker) { attempted = true; // This call should revert because the outer call holds the reentrancy lock. - try peanut.withdrawDeposit(targetIdx, attacker, targetSig) { + try vault.withdrawDeposit(targetIdx, attacker, targetSig) { revert("REENTRANCY GUARD MISSING"); } catch { // expected — guard caught it @@ -48,8 +48,8 @@ contract ReentrantToken is ERC20Mock { } } -contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { - EnvelopeVault public peanut; +contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { + EnvelopeVault public vault; EnvelopeBatcher public batcher; ERC20Mock public erc20; ERC721Mock public erc721; @@ -64,7 +64,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function setUp() public { LINK_PUBKEY20 = vm.addr(LINK_PRIV); - peanut = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); batcher = new EnvelopeBatcher(); erc20 = new ERC20Mock(); erc721 = new ERC721Mock(); @@ -79,12 +79,12 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - peanut.PEANUT_SALT(), + vault.ENVELOPE_SALT(), block.chainid, - address(peanut), + address(vault), idx, recipient, - peanut.ANYONE_WITHDRAWAL_MODE() + vault.ANYONE_WITHDRAWAL_MODE() ) ) ); @@ -93,7 +93,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { } function _depositEth(uint256 amount) internal returns (uint256) { - return peanut.makeDeposit{value: amount}(address(0), 0, amount, 0, LINK_PUBKEY20); + return vault.makeDeposit{value: amount}(address(0), 0, amount, 0, LINK_PUBKEY20); } // ── EnvelopeVault deposit input validation ────────────────────────────────── @@ -101,21 +101,21 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function test_RevertWhen_DepositInvalidContractType() public { // _pullTokensViaApproval rejects contractType >= 5. vm.expectRevert("INVALID CONTRACT TYPE"); - peanut.makeDeposit{value: 0}(address(0), 5, 0, 0, LINK_PUBKEY20); + vault.makeDeposit{value: 0}(address(0), 5, 0, 0, LINK_PUBKEY20); } function test_RevertWhen_DepositEthAmountMismatch() public { // contractType==0 requires _amount == msg.value. vm.expectRevert("WRONG ETH AMOUNT"); - peanut.makeDeposit{value: 100}(address(0), 0, 50, 0, LINK_PUBKEY20); + vault.makeDeposit{value: 100}(address(0), 0, 50, 0, LINK_PUBKEY20); } function test_RevertWhen_DepositErc721AmountNotOne() public { // contractType==2 requires _amount == 1. erc721.mint(address(this), 1); - erc721.approve(address(peanut), 1); + erc721.approve(address(vault), 1); vm.expectRevert("AMOUNT MUST BE 1 FOR ERC721"); - peanut.makeDeposit(address(erc721), 2, 2, 1, LINK_PUBKEY20); + vault.makeDeposit(address(erc721), 2, 2, 1, LINK_PUBKEY20); } function test_RevertWhen_DepositEcoTokenViaPlainErc20() public { @@ -132,16 +132,16 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function test_RevertWhen_WithdrawIndexOutOfBounds() public { bytes memory sig = _signWithdrawal(99, ALICE, LINK_PRIV); vm.expectRevert("DEPOSIT INDEX DOES NOT EXIST"); - peanut.withdrawDeposit(99, ALICE, sig); + vault.withdrawDeposit(99, ALICE, sig); } function test_RevertWhen_WithdrawTwice() public { uint256 idx = _depositEth(1 ether); bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); - peanut.withdrawDeposit(idx, ALICE, sig); + vault.withdrawDeposit(idx, ALICE, sig); vm.expectRevert("DEPOSIT ALREADY WITHDRAWN"); - peanut.withdrawDeposit(idx, ALICE, sig); + vault.withdrawDeposit(idx, ALICE, sig); } function test_RevertWhen_WithdrawWithWrongSigner() public { @@ -151,7 +151,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes memory sig = _signWithdrawal(idx, ALICE, wrongKey); vm.expectRevert("WRONG SIGNATURE"); - peanut.withdrawDeposit(idx, ALICE, sig); + vault.withdrawDeposit(idx, ALICE, sig); } function test_RevertWhen_WithdrawAsRecipientCallerMismatch() public { @@ -160,12 +160,12 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - peanut.PEANUT_SALT(), + vault.ENVELOPE_SALT(), block.chainid, - address(peanut), + address(vault), idx, ALICE, - peanut.RECIPIENT_WITHDRAWAL_MODE() + vault.RECIPIENT_WITHDRAWAL_MODE() ) ) ); @@ -175,47 +175,47 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // BOB tries to call on behalf of ALICE — caller must equal the recipient param. vm.prank(BOB); vm.expectRevert("NOT THE RECIPIENT"); - peanut.withdrawDepositAsRecipient(idx, ALICE, sig); + vault.withdrawDepositAsRecipient(idx, ALICE, sig); } function test_RevertWhen_RecipientBoundClaimedByOtherAddress() public { // Address-bound deposit: recipient = ALICE. - uint256 idx = peanut.makeCustomDeposit{value: 1 ether}( + uint256 idx = vault.makeCustomDeposit{value: 1 ether}( address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, 0, false, "" ); // Even with a valid pubKey signature, the contract-stored recipient blocks // anyone else from being the named recipient on withdrawal. bytes memory sig = _signWithdrawal(idx, BOB, LINK_PRIV); vm.expectRevert("WRONG RECIPIENT"); - peanut.withdrawDeposit(idx, BOB, sig); + vault.withdrawDeposit(idx, BOB, sig); } function test_RecipientBoundSenderCannotReclaimBeforeDeadline() public { uint40 reclaimAfter = uint40(block.timestamp + 1 days); - uint256 idx = peanut.makeCustomDeposit{value: 1 ether}( + uint256 idx = vault.makeCustomDeposit{value: 1 ether}( address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, reclaimAfter, false, "" ); vm.expectRevert("TOO EARLY TO RECLAIM"); - peanut.withdrawDepositSender(idx); + vault.withdrawDepositSender(idx); vm.warp(reclaimAfter + 1); - peanut.withdrawDepositSender(idx); // succeeds after the deadline + vault.withdrawDepositSender(idx); // succeeds after the deadline } function test_RevertWhen_SenderReclaimNotTheSender() public { uint256 idx = _depositEth(1 ether); vm.prank(ALICE); vm.expectRevert("NOT THE SENDER"); - peanut.withdrawDepositSender(idx); + vault.withdrawDepositSender(idx); } function test_RevertWhen_MFADepositWithoutMFASignature() public { - // peanut is deployed with MFA_AUTHORIZER == address(0), so MFA-flagged + // vault is deployed with MFA_AUTHORIZER == address(0), so MFA-flagged // deposits can never be withdrawn via withdrawDeposit (REQUIRES AUTHORIZATION). - uint256 idx = peanut.makeMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY20); + uint256 idx = vault.makeMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY20); bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); vm.expectRevert("REQUIRES AUTHORIZATION"); - peanut.withdrawDeposit(idx, ALICE, sig); + vault.withdrawDeposit(idx, ALICE, sig); } // ── EnvelopeVault views ───────────────────────────────────────────────────── @@ -224,20 +224,20 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { _depositEth(1); _depositEth(1); // Same sender (address(this)) made both deposits. - EnvelopeVault.Deposit[] memory mine = peanut.getAllDepositsForAddress(address(this)); + EnvelopeVault.Deposit[] memory mine = vault.getAllDepositsForAddress(address(this)); assertEq(mine.length, 2); // Different sender → empty. - EnvelopeVault.Deposit[] memory aliceDeposits = peanut.getAllDepositsForAddress(ALICE); + EnvelopeVault.Deposit[] memory aliceDeposits = vault.getAllDepositsForAddress(ALICE); assertEq(aliceDeposits.length, 0); } function test_DepositCountTracksArrayLength() public { - assertEq(peanut.getDepositCount(), 0); + assertEq(vault.getDepositCount(), 0); _depositEth(1); _depositEth(1); _depositEth(1); - assertEq(peanut.getDepositCount(), 3); + assertEq(vault.getDepositCount(), 3); } // ── EnvelopeVault reentrancy ──────────────────────────────────────────────── @@ -245,18 +245,18 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function test_NonReentrantBlocksReentryFromMaliciousToken() public { ReentrantToken evil = new ReentrantToken(); evil.mint(address(this), 100); - evil.approve(address(peanut), 100); + evil.approve(address(vault), 100); // Deposit type-1 (ERC-20) so withdraw routes back through the token's transfer. - uint256 idx = peanut.makeDeposit(address(evil), 1, 100, 0, LINK_PUBKEY20); + uint256 idx = vault.makeDeposit(address(evil), 1, 100, 0, LINK_PUBKEY20); bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); // Arm the token to reenter inside its _update during the outgoing safeTransfer. - evil.arm(peanut, idx, sig, ALICE); + evil.arm(vault, idx, sig, ALICE); // Outer withdraw succeeds (inner reentrant attempt caught and swallowed by try/catch); // the reentrancy guard ensured the inner call could not double-spend. - peanut.withdrawDeposit(idx, ALICE, sig); + vault.withdrawDeposit(idx, ALICE, sig); assertEq(evil.balanceOf(ALICE), 100); assertTrue(evil.attempted(), "reentrancy attempt should have run"); } @@ -267,7 +267,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { address[] memory pubKeys = new address[](3); for (uint256 i = 0; i < 3; i++) pubKeys[i] = LINK_PUBKEY20; vm.expectRevert("INVALID TOTAL ETHER SENT"); - batcher.batchMakeDeposit{value: 1 ether}(address(peanut), address(0), 0, 1 ether, 0, pubKeys); + batcher.batchMakeDeposit{value: 1 ether}(address(vault), address(0), 0, 1 ether, 0, pubKeys); // expected 3 * 1 ether, sent 1 ether } @@ -281,7 +281,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bool[] memory mfa = new bool[](3); // wrong length vm.expectRevert("PARAMETERS LENGTH MISMATCH"); - batcher.batchMakeDepositArbitrary(address(peanut), tokens, types, amounts, ids, pks, mfa); + batcher.batchMakeDepositArbitrary(address(vault), tokens, types, amounts, ids, pks, mfa); } // batchMakeDepositNoReturn — ETH path must require exact total, non-ETH path must reject msg.value. @@ -293,9 +293,9 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { for (uint256 i = 0; i < 3; i++) pubKeys[i] = LINK_PUBKEY20; batcher.batchMakeDepositNoReturn{value: 3 ether}( - address(peanut), address(0), 0, 1 ether, 0, pubKeys + address(vault), address(0), 0, 1 ether, 0, pubKeys ); - assertEq(peanut.getDepositCount(), 3); + assertEq(vault.getDepositCount(), 3); } function test_RevertWhen_BatchNoReturnEthAmountMismatch() public { @@ -303,7 +303,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { for (uint256 i = 0; i < 3; i++) pubKeys[i] = LINK_PUBKEY20; vm.expectRevert("INVALID TOTAL ETHER SENT"); batcher.batchMakeDepositNoReturn{value: 1 ether}( - address(peanut), address(0), 0, 1 ether, 0, pubKeys + address(vault), address(0), 0, 1 ether, 0, pubKeys ); } @@ -315,7 +315,7 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { for (uint256 i = 0; i < 2; i++) pubKeys[i] = LINK_PUBKEY20; vm.expectRevert("ETH NOT ACCEPTED FOR NON-ETH DEPOSIT"); batcher.batchMakeDepositNoReturn{value: 1 wei}( - address(peanut), address(erc20), 1, 100, 0, pubKeys + address(vault), address(erc20), 1, 100, 0, pubKeys ); } @@ -323,14 +323,14 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { uint256[] memory amounts = new uint256[](1); amounts[0] = 1; vm.expectRevert("ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED"); - batcher.batchMakeDepositRaffle(address(peanut), address(erc721), 2, amounts, LINK_PUBKEY20); + batcher.batchMakeDepositRaffle(address(vault), address(erc721), 2, amounts, LINK_PUBKEY20); } function test_BatchZeroLengthDepositsIsNoop() public { address[] memory pubKeys = new address[](0); - uint256[] memory ids = batcher.batchMakeDeposit(address(peanut), address(0), 0, 0, 0, pubKeys); + uint256[] memory ids = batcher.batchMakeDeposit(address(vault), address(0), 0, 0, 0, pubKeys); assertEq(ids.length, 0); - assertEq(peanut.getDepositCount(), 0); + assertEq(vault.getDepositCount(), 0); } // ── L2ECO inflation-invariant accounting ─────────────────────────────── @@ -342,8 +342,8 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // rebasing token's supply at deposit time. L2ECOMock eco = new L2ECOMock(2); eco.mint(address(this), 100); - eco.approve(address(peanut), 100); - uint256 idx = peanut.makeDeposit(address(eco), 4, 100, 0, LINK_PUBKEY20); + eco.approve(address(vault), 100); + uint256 idx = vault.makeDeposit(address(eco), 4, 100, 0, LINK_PUBKEY20); // Multiplier increases from 2 → 4 (token supply doubled). The vault holds 100 // raw tokens but the "share" is recorded as 200 (= 100 * 2). At multiplier 4 @@ -351,11 +351,11 @@ contract PeanutEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // also reducing the vault's token balance to match (mock doesn't auto-rebase). eco.setMultiplier(4); // Burn half the vault's balance to mirror what a real rebase would do to it. - vm.prank(address(peanut)); + vm.prank(address(vault)); eco.transfer(address(0xdead), 50); bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); - peanut.withdrawDeposit(idx, ALICE, sig); + vault.withdrawDeposit(idx, ALICE, sig); assertEq(eco.balanceOf(ALICE), 50); } diff --git a/test/envelope/EnvelopeGasless.t.sol b/test/envelope/EnvelopeGasless.t.sol index 70b3b61d..d14fd425 100644 --- a/test/envelope/EnvelopeGasless.t.sol +++ b/test/envelope/EnvelopeGasless.t.sol @@ -7,7 +7,7 @@ import "./mocks/ERC20Mock.sol"; import "./mocks/SampleSCW.sol"; contract EnvelopeVaultGaslessTest is Test { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; ERC20Mock public testToken; address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); @@ -27,7 +27,7 @@ contract EnvelopeVaultGaslessTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); } function testMakeDepositERC20WithAuthorization() public { @@ -39,8 +39,8 @@ contract EnvelopeVaultGaslessTest is Test { bytes memory typeHashAndData = abi.encode( RECEIVE_WITH_AUTHORIZATION_TYPEHASH, - SAMPLE_ADDRESS, // the spender & peanut depositor address - address(peanutV4), // receiver of the tokens + SAMPLE_ADDRESS, // the spender & vault depositor address + address(vault), // receiver of the tokens amount, block.timestamp - 1, // validUntil block.timestamp + 1, // validBefore @@ -51,7 +51,7 @@ contract EnvelopeVaultGaslessTest is Test { (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), digest); - uint256 depositIndex = peanutV4.makeDepositWithAuthorization( + uint256 depositIndex = vault.makeDepositWithAuthorization( address(testToken), SAMPLE_ADDRESS, // who makes the deposit amount, @@ -65,7 +65,7 @@ contract EnvelopeVaultGaslessTest is Test { ); assertEq(depositIndex, 0, "Deposit failed"); - assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); } function _makeDeposit(address depositor) internal returns (uint256 depositIndex) { @@ -73,15 +73,15 @@ contract EnvelopeVaultGaslessTest is Test { testToken.mint(depositor, 1000); uint256 amount = 100; vm.prank(depositor); - testToken.approve(address(peanutV4), amount); + testToken.approve(address(vault), amount); vm.prank(depositor); - depositIndex = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + depositIndex = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); } function _calculateDigest(uint256 depositIndex) internal view returns (bytes32 digest) { - bytes32 hashedReclaimRequest = keccak256(abi.encode(peanutV4.GASLESS_RECLAIM_TYPEHASH(), depositIndex)); + bytes32 hashedReclaimRequest = keccak256(abi.encode(vault.GASLESS_RECLAIM_TYPEHASH(), depositIndex)); // Prepare data for the withdrawal - digest = keccak256(abi.encodePacked("\x19\x01", peanutV4.DOMAIN_SEPARATOR(), hashedReclaimRequest)); + digest = keccak256(abi.encodePacked("\x19\x01", vault.DOMAIN_SEPARATOR(), hashedReclaimRequest)); } function _withdrawDepositSenderGaslessEOA( @@ -100,7 +100,7 @@ contract EnvelopeVaultGaslessTest is Test { vm.expectRevert(bytes(expectRevert)); } - peanutV4.withdrawDepositSenderGasless(reclaimRequest, depositorAddress, signature); + vault.withdrawDepositSenderGasless(reclaimRequest, depositorAddress, signature); } function testWithdrawDepositSenderGaslessEOA() public { @@ -144,7 +144,7 @@ contract EnvelopeVaultGaslessTest is Test { // Submit a wrong signature vm.expectRevert("INVALID SIGNATURE"); - peanutV4.withdrawDepositSenderGasless( + vault.withdrawDepositSenderGasless( reclaimRequest, address(scwallet), bytes("LOL THIS IS DEFINITELY NOT THE SIGNATURE") ); @@ -152,7 +152,7 @@ contract EnvelopeVaultGaslessTest is Test { _withdrawDepositSenderGaslessEOA(depositIndex, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, "NOT THE SENDER"); // Withdraw! - peanutV4.withdrawDepositSenderGasless( + vault.withdrawDepositSenderGasless( reclaimRequest, address(scwallet), // In our sample SCW the digest will be the right signature @@ -172,8 +172,8 @@ contract EnvelopeVaultGaslessTest is Test { bytes memory typeHashAndData = abi.encode( RECEIVE_WITH_AUTHORIZATION_TYPEHASH, - SAMPLE_ADDRESS, // the spender & peanut depositor address - address(peanutV4), // receiver of the tokens + SAMPLE_ADDRESS, // the spender & vault depositor address + address(vault), // receiver of the tokens amount, block.timestamp - 1, // validUntil block.timestamp + 1, // validBefore @@ -194,7 +194,7 @@ contract EnvelopeVaultGaslessTest is Test { s ); - uint256 depositIndex = peanutV4.makeCustomDeposit( + uint256 depositIndex = vault.makeCustomDeposit( address(testToken), 1, // contract type - erc 20 amount, @@ -209,6 +209,6 @@ contract EnvelopeVaultGaslessTest is Test { ); assertEq(depositIndex, 0, "Deposit failed"); - assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); } } diff --git a/test/envelope/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index 5a641a37..e678145f 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.26; -// Hardening tests added during the OZ-v5 / ZkSync-aligned refactor of Peanut V4.4. +// Hardening tests added during the OZ-v5 / ZkSync-aligned refactor of the vendored vault. // Each test maps back to a finding in the audit: // T1 — direct ERC721 / ERC1155 transfers must revert (fix for S1 receivers footgun) // T2 — MFA_AUTHORIZER is now a per-deploy constructor arg (fix for S3 hardcoded key) @@ -17,8 +17,8 @@ import {L2ECOMock} from "./mocks/L2ECOMock.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; -contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { - EnvelopeVault public peanut; +contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { + EnvelopeVault public vault; ERC721Mock public erc721; ERC1155Mock public erc1155; @@ -26,7 +26,7 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); function setUp() public { - peanut = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); } @@ -41,13 +41,13 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { function test_T1_directERC721TransferReverts() public { erc721.mint(address(this), 42); vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); - erc721.safeTransferFrom(address(this), address(peanut), 42); + erc721.safeTransferFrom(address(this), address(vault), 42); } function test_T1_directERC1155TransferReverts() public { erc1155.mint(address(this), 7, 1, ""); vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); - erc1155.safeTransferFrom(address(this), address(peanut), 7, 1, ""); + erc1155.safeTransferFrom(address(this), address(vault), 7, 1, ""); } function test_T1_directERC1155BatchTransferReverts() public { @@ -58,7 +58,7 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { erc1155.mint(address(this), 1, 1, ""); erc1155.mint(address(this), 2, 1, ""); vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); - erc1155.safeBatchTransferFrom(address(this), address(peanut), ids, amounts, ""); + erc1155.safeBatchTransferFrom(address(this), address(vault), ids, amounts, ""); } // ── T2 ───────────────────────────────────────────────────────────────── @@ -66,17 +66,17 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { // accepts MFA signatures from a *test* signer rather than the upstream key. function test_T2_customMfaAuthorizerAcceptsItsSignature() public { - uint256 mfaPrivKey = uint256(keccak256("nodle.peanut.mfa-test-signer")); + uint256 mfaPrivKey = uint256(keccak256("nodle.vault.mfa-test-signer")); address mfaSigner = vm.addr(mfaPrivKey); - EnvelopeVault nodlePeanut = new EnvelopeVault(address(0), mfaSigner); - assertEq(nodlePeanut.MFA_AUTHORIZER(), mfaSigner, "constructor arg ignored"); + EnvelopeVault nodleVault = new EnvelopeVault(address(0), mfaSigner); + assertEq(nodleVault.MFA_AUTHORIZER(), mfaSigner, "constructor arg ignored"); // make an MFA-gated deposit, then craft both signatures with our test keys. - uint256 depositPrivKey = uint256(keccak256("nodle.peanut.deposit-key")); + uint256 depositPrivKey = uint256(keccak256("nodle.vault.deposit-key")); address depositSigner = vm.addr(depositPrivKey); - uint256 idx = nodlePeanut.makeSelflessMFADeposit{value: 1 wei}( + uint256 idx = nodleVault.makeSelflessMFADeposit{value: 1 wei}( address(0), 0, 1, 0, depositSigner, address(this) ); @@ -84,12 +84,12 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { bytes32 wdHash = MessageHashUtilsLite.toEthSignedMessageHash( keccak256( abi.encodePacked( - nodlePeanut.PEANUT_SALT(), + nodleVault.ENVELOPE_SALT(), block.chainid, - address(nodlePeanut), + address(nodleVault), idx, address(this), - nodlePeanut.ANYONE_WITHDRAWAL_MODE() + nodleVault.ANYONE_WITHDRAWAL_MODE() ) ) ); @@ -100,9 +100,9 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { bytes32 mfaHash = MessageHashUtilsLite.toEthSignedMessageHash( keccak256( abi.encodePacked( - nodlePeanut.PEANUT_SALT(), + nodleVault.ENVELOPE_SALT(), block.chainid, - address(nodlePeanut), + address(nodleVault), idx, address(this) ) @@ -111,15 +111,15 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { (uint8 mv, bytes32 mr, bytes32 ms) = vm.sign(mfaPrivKey, mfaHash); bytes memory mfaSig = abi.encodePacked(mr, ms, mv); - nodlePeanut.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); + nodleVault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); } function test_T2_zeroMfaAuthorizerRejectsAllMfaWithdrawals() public { - // peanut deployed with mfaAuthorizer = address(0). Any MFA withdrawal must fail. + // vault deployed with mfaAuthorizer = address(0). Any MFA withdrawal must fail. uint256 depositPrivKey = uint256(keccak256("dep")); address depositSigner = vm.addr(depositPrivKey); - uint256 idx = peanut.makeSelflessMFADeposit{value: 1 wei}( + uint256 idx = vault.makeSelflessMFADeposit{value: 1 wei}( address(0), 0, 1, 0, depositSigner, address(this) ); @@ -127,7 +127,7 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { bytes memory wdSig = hex"00"; bytes memory mfaSig = hex"00"; vm.expectRevert(); - peanut.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); + vault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); } // ── T4 ───────────────────────────────────────────────────────────────── @@ -136,23 +136,23 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { function test_T4_dualZeroDepositRejected() public { vm.expectRevert("DEPOSIT MUST HAVE AUTH"); - peanut.makeDeposit{value: 1 wei}(address(0), 0, 1, 0, address(0)); + vault.makeDeposit{value: 1 wei}(address(0), 0, 1, 0, address(0)); } function test_T4_dualZeroCustomDepositRejected() public { vm.expectRevert("DEPOSIT MUST HAVE AUTH"); - peanut.makeCustomDeposit{value: 1 wei}( + vault.makeCustomDeposit{value: 1 wei}( address(0), 0, 1, 0, address(0), address(this), false, address(0), uint40(0), false, "" ); } function test_T4_pubKeyOnlyAccepted() public { - uint256 idx = peanut.makeDeposit{value: 1 wei}(address(0), 0, 1, 0, PUBKEY20); + uint256 idx = vault.makeDeposit{value: 1 wei}(address(0), 0, 1, 0, PUBKEY20); assertEq(idx, 0); } function test_T4_recipientOnlyAccepted() public { - uint256 idx = peanut.makeCustomDeposit{value: 1 wei}( + uint256 idx = vault.makeCustomDeposit{value: 1 wei}( address(0), 0, 1, 0, address(0), address(this), false, ALICE, uint40(0), false, "" ); assertEq(idx, 0); @@ -177,39 +177,39 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { eco.mint(sender, 100); vm.prank(sender); - eco.approve(address(peanut), 100); + eco.approve(address(vault), 100); vm.prank(sender); - uint256 idx = peanut.makeDeposit(address(eco), 4, 100, 0, pubKey20); + uint256 idx = vault.makeDeposit(address(eco), 4, 100, 0, pubKey20); // Sanity: vault holds the raw tokens, deposit stores the scaled amount. - assertEq(eco.balanceOf(address(peanut)), 100, "vault should hold raw tokens"); + assertEq(eco.balanceOf(address(vault)), 100, "vault should hold raw tokens"); assertEq(eco.balanceOf(sender), 0, "sender's tokens should be in the vault"); - EnvelopeVault.Deposit memory d = peanut.getDeposit(idx); + EnvelopeVault.Deposit memory d = vault.getDeposit(idx); assertEq(d.amount, 200, "deposit amount should be inflation-invariant (amount * multiplier)"); // Recipient (not sender) claims using the link's private key. bytes32 digest = MessageHashUtilsLite.toEthSignedMessageHash( keccak256( abi.encodePacked( - peanut.PEANUT_SALT(), + vault.ENVELOPE_SALT(), block.chainid, - address(peanut), + address(vault), idx, recipient, - peanut.ANYONE_WITHDRAWAL_MODE() + vault.ANYONE_WITHDRAWAL_MODE() ) ) ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(depositPrivKey, digest); bytes memory sig = abi.encodePacked(r, s, v); - peanut.withdrawDeposit(idx, recipient, sig); + vault.withdrawDeposit(idx, recipient, sig); // The fix: recipient gets 100, sender stays at 0. // If the bug were still present, sender would have 100 and recipient 0. assertEq(eco.balanceOf(recipient), 100, "recipient must receive the L2ECO tokens"); assertEq(eco.balanceOf(sender), 0, "sender must NOT receive the L2ECO tokens back"); - assertEq(eco.balanceOf(address(peanut)), 0, "vault should be drained"); + assertEq(eco.balanceOf(address(vault)), 0, "vault should be drained"); } function test_T5_L2ECOSenderReclaimStillGoesToSender() public { @@ -223,17 +223,17 @@ contract PeanutHardeningTest is Test, ERC721Holder, ERC1155Holder { eco.mint(sender, 50); vm.prank(sender); - eco.approve(address(peanut), 50); + eco.approve(address(vault), 50); vm.prank(sender); - uint256 idx = peanut.makeDeposit(address(eco), 4, 50, 0, pubKey20); + uint256 idx = vault.makeDeposit(address(eco), 4, 50, 0, pubKey20); assertEq(eco.balanceOf(sender), 0); vm.prank(sender); - peanut.withdrawDepositSender(idx); + vault.withdrawDepositSender(idx); assertEq(eco.balanceOf(sender), 50, "sender reclaim should return the tokens"); - assertEq(eco.balanceOf(address(peanut)), 0); + assertEq(eco.balanceOf(address(vault)), 0); } } diff --git a/test/envelope/EnvelopeVault.t.sol b/test/envelope/EnvelopeVault.t.sol index d59508e8..106ebbac 100644 --- a/test/envelope/EnvelopeVault.t.sol +++ b/test/envelope/EnvelopeVault.t.sol @@ -8,7 +8,7 @@ import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; contract EnvelopeVaultTest is Test { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -30,7 +30,7 @@ contract EnvelopeVaultTest is Test { testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); // Mint tokens for test accounts testToken.mint(address(this), 1000); @@ -38,61 +38,61 @@ contract EnvelopeVaultTest is Test { // testToken1155.mint(address(this), 1, 1000, ""); // Approve EnvelopeVault to spend tokens - testToken.approve(address(peanutV4), 1000); - testToken721.setApprovalForAll(address(peanutV4), true); - // testToken1155.setApprovalForAll(address(peanutV4), true); + testToken.approve(address(vault), 1000); + testToken721.setApprovalForAll(address(vault), true); + // testToken1155.setApprovalForAll(address(vault), true); } function testContractCreation() public { - assertTrue(address(peanutV4) != address(0), "Contract creation failed"); + assertTrue(address(vault) != address(0), "Contract creation failed"); } function testMakeDepositERC20() public { uint256 amount = 100; // Moved minting and approval to the setup function - uint256 depositIndex = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + uint256 depositIndex = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); assertEq(depositIndex, 0, "Deposit failed"); - assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); } function testMakeSelflessDepositERC20() public { uint256 amount = 100; // Make a deposit on behalf of SAMPLE_ADDRESS - uint256 depositIndex = peanutV4.makeSelflessDeposit(address(testToken), 1, amount, 0, PUBKEY20, SAMPLE_ADDRESS); + uint256 depositIndex = vault.makeSelflessDeposit(address(testToken), 1, amount, 0, PUBKEY20, SAMPLE_ADDRESS); // Deposit was made on behalf of other address, so we can't withdraw :((( vm.expectRevert("NOT THE SENDER"); - peanutV4.withdrawDepositSender(depositIndex); + vault.withdrawDepositSender(depositIndex); vm.prank(SAMPLE_ADDRESS); // selfless deposit's owner can reclaim - peanutV4.withdrawDepositSender(depositIndex); + vault.withdrawDepositSender(depositIndex); } // If we attempt to deposit ECO tokens as pure ERC20s (i.e. with _contractType = 1), // makeDeposit function must revert. function testECOMaliciousDeposit() public { // pretend that testToken is ECO - EnvelopeVault peanutV4ECO = new EnvelopeVault(address(testToken), address(0)); + EnvelopeVault vaultECO = new EnvelopeVault(address(testToken), address(0)); - // approve tokens to be spent by the new peanut instance - testToken.approve(address(peanutV4), 1000); + // approve tokens to be spent by the new vault instance + testToken.approve(address(vault), 1000); // Test!!!!!!!! vm.expectRevert("ECO DEPOSITS MUST USE _contractType 4"); - peanutV4ECO.makeDeposit(address(testToken), 1, 100, 0, address(0)); + vaultECO.makeDeposit(address(testToken), 1, 100, 0, address(0)); } function testMakeDepositERC721() public { uint256 tokenId = 1; // Moved minting and approval to the setup function - uint256 depositIndex = peanutV4.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); + uint256 depositIndex = vault.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); assertEq(depositIndex, 0, "Deposit failed"); - assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); } // function testMakeDepositERC1155() public { @@ -100,7 +100,7 @@ contract EnvelopeVaultTest is Test { // uint256 amount = 100; // // Moved minting and approval to the setup function - // uint256 depositIndex = peanutV4.makeDeposit( + // uint256 depositIndex = vault.makeDeposit( // address(testToken1155), // 3, // amount, @@ -109,29 +109,29 @@ contract EnvelopeVaultTest is Test { // ); // assertEq(depositIndex, 0, "Deposit failed"); - // assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); + // assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); // } // test sender withdrawal function testSenderTimeWithdraw() public { uint256 amount = 1000; - assertEq(testToken.balanceOf(address(peanutV4)), 0, "Contract balance mismatch"); + assertEq(testToken.balanceOf(address(vault)), 0, "Contract balance mismatch"); // Moved minting and approval to the setup function - uint256 depositIndex = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + uint256 depositIndex = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); assertEq(depositIndex, 0, "Deposit failed"); - assertEq(peanutV4.getDepositCount(), 1, "Deposit count mismatch"); - assertEq(testToken.balanceOf(address(peanutV4)), 1000, "Contract balance mismatch"); + assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); + assertEq(testToken.balanceOf(address(vault)), 1000, "Contract balance mismatch"); // wait 25 hours vm.warp(block.timestamp + 25 hours); // Withdraw the deposit - peanutV4.withdrawDepositSender(depositIndex); + vault.withdrawDepositSender(depositIndex); // Check that the contract has the correct balance - assertEq(testToken.balanceOf(address(peanutV4)), 0, "Contract balance mismatch"); + assertEq(testToken.balanceOf(address(vault)), 0, "Contract balance mismatch"); assertEq(testToken.balanceOf(address(this)), 1000, "Sender balance mismatch"); } } diff --git a/test/envelope/Integration.t.sol b/test/envelope/Integration.t.sol index a3519e89..6c408f05 100644 --- a/test/envelope/Integration.t.sol +++ b/test/envelope/Integration.t.sol @@ -14,7 +14,7 @@ import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -25,7 +25,7 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); @@ -37,22 +37,22 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { // check invariants function testIntegrationEtherSenderWithdraw(uint64 amount) public { vm.assume(amount > 0); - assertEq(peanutV4.getDepositCount(), 0); // deposit count invariant - assertEq(address(peanutV4).balance, 0); // contract balance invariant + assertEq(vault.getDepositCount(), 0); // deposit count invariant + assertEq(address(vault).balance, 0); // contract balance invariant uint256 senderBalance = address(this).balance; // sender balance invariant - uint256 depositIdx = peanutV4.makeDeposit{value: amount}(address(0), 0, amount, 0, PUBKEY20); + uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, PUBKEY20); assertEq(depositIdx, 0); // deposit index invariant - assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant - assertEq(address(peanutV4).balance, amount); // contract balance invariant + assertEq(vault.getDepositCount(), 1); // deposit count invariant + assertEq(address(vault).balance, amount); // contract balance invariant assertEq(address(this).balance, senderBalance - amount); // sender balance invariant // wait 25 hours vm.warp(block.timestamp + 25 hours); // Withdraw the deposit - peanutV4.withdrawDepositSender(depositIdx); - assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant - assertEq(address(peanutV4).balance, 0); // contract balance invariant + vault.withdrawDepositSender(depositIdx); + assertEq(vault.getDepositCount(), 1); // deposit count invariant + assertEq(address(vault).balance, 0); // contract balance invariant assertEq(address(this).balance, senderBalance); // sender balance invariant } @@ -61,21 +61,21 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { // mint tokens to the contract testToken.mint(address(this), amount); // approve the contract to spend the tokens - testToken.approve(address(peanutV4), amount); + testToken.approve(address(vault), amount); assertEq(testToken.balanceOf(address(this)), amount); // contract token balance invariant - uint256 depositIdx = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + uint256 depositIdx = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); assertEq(depositIdx, 0); // deposit index invariant - assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant - assertEq(testToken.balanceOf(address(peanutV4)), amount); // contract token balance invariant + assertEq(vault.getDepositCount(), 1); // deposit count invariant + assertEq(testToken.balanceOf(address(vault)), amount); // contract token balance invariant assertEq(testToken.balanceOf(address(this)), 0); // sender token balance invariant // wait 25 hours vm.warp(block.timestamp + 25 hours); // Withdraw the deposit - peanutV4.withdrawDepositSender(depositIdx); - assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant - assertEq(testToken.balanceOf(address(peanutV4)), 0); // contract token balance invariant + vault.withdrawDepositSender(depositIdx); + assertEq(vault.getDepositCount(), 1); // deposit count invariant + assertEq(testToken.balanceOf(address(vault)), 0); // contract token balance invariant assertEq(testToken.balanceOf(address(this)), amount); // sender token balance invariant } @@ -83,32 +83,32 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { function testIntegrationERC721SenderWithdraw(uint64 tokenId) public { // setup testToken721.mint(address(this), tokenId); - testToken721.approve(address(peanutV4), tokenId); + testToken721.approve(address(vault), tokenId); // invariant checks - assertEq(peanutV4.getDepositCount(), 0); + assertEq(vault.getDepositCount(), 0); assertEq(testToken721.ownerOf(tokenId), address(this)); - assertEq(testToken721.balanceOf(address(peanutV4)), 0); + assertEq(testToken721.balanceOf(address(vault)), 0); assertEq(testToken721.balanceOf(address(this)), 1); - uint256 depositIdx = peanutV4.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); + uint256 depositIdx = vault.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); // invariant checks assertEq(depositIdx, 0); - assertEq(peanutV4.getDepositCount(), 1); - assertEq(testToken721.ownerOf(tokenId), address(peanutV4)); - assertEq(testToken721.balanceOf(address(peanutV4)), 1); + assertEq(vault.getDepositCount(), 1); + assertEq(testToken721.ownerOf(tokenId), address(vault)); + assertEq(testToken721.balanceOf(address(vault)), 1); assertEq(testToken721.balanceOf(address(this)), 0); // wait 25 hours vm.warp(block.timestamp + 25 hours); // Withdraw the deposit - peanutV4.withdrawDepositSender(depositIdx); + vault.withdrawDepositSender(depositIdx); // invariant checks - assertEq(peanutV4.getDepositCount(), 1); + assertEq(vault.getDepositCount(), 1); assertEq(testToken721.ownerOf(tokenId), address(this)); - assertEq(testToken721.balanceOf(address(peanutV4)), 0); + assertEq(testToken721.balanceOf(address(vault)), 0); assertEq(testToken721.balanceOf(address(this)), 1); } @@ -117,21 +117,21 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { vm.assume(amount > 0); // mint tokens to the contract testToken1155.mint(address(this), tokenId, amount, ""); - testToken1155.setApprovalForAll(address(peanutV4), true); + testToken1155.setApprovalForAll(address(vault), true); assertEq(testToken1155.balanceOf(address(this), tokenId), amount); // contract token balance invariant - uint256 depositIdx = peanutV4.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); + uint256 depositIdx = vault.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); assertEq(depositIdx, 0); // deposit index invariant - assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant - assertEq(testToken1155.balanceOf(address(peanutV4), tokenId), amount); // contract token balance invariant + assertEq(vault.getDepositCount(), 1); // deposit count invariant + assertEq(testToken1155.balanceOf(address(vault), tokenId), amount); // contract token balance invariant assertEq(testToken1155.balanceOf(address(this), tokenId), 0); // sender token balance invariant // wait 25 hours vm.warp(block.timestamp + 25 hours); // Withdraw the deposit - peanutV4.withdrawDepositSender(depositIdx); - assertEq(peanutV4.getDepositCount(), 1); // deposit count invariant - assertEq(testToken1155.balanceOf(address(peanutV4), tokenId), 0); // contract token balance invariant + vault.withdrawDepositSender(depositIdx); + assertEq(vault.getDepositCount(), 1); // deposit count invariant + assertEq(testToken1155.balanceOf(address(vault), tokenId), 0); // contract token balance invariant assertEq(testToken1155.balanceOf(address(this), tokenId), amount); // sender token balance invariant } } diff --git a/test/envelope/MFA.t.sol b/test/envelope/MFA.t.sol index abd664b6..d3d0481e 100644 --- a/test/envelope/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -5,7 +5,7 @@ import "forge-std/Test.sol"; import "../../src/envelope/V4/PeanutV4.4.sol"; contract EnvelopeVaultMFATest is Test { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; // a dummy private/public keypair to test withdrawals address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); @@ -16,11 +16,11 @@ contract EnvelopeVaultMFATest is Test { address public constant LEGACY_MFA_AUTHORIZER = 0x3B14D43Bf521EF7FD9600533bEB73B6e9178DE7C; function setUp() public { - peanutV4 = new EnvelopeVault(address(0), LEGACY_MFA_AUTHORIZER); + vault = new EnvelopeVault(address(0), LEGACY_MFA_AUTHORIZER); } function testMFADeposit() public { - uint256 depositIndex = peanutV4.makeSelflessMFADeposit{value: 1}( + uint256 depositIndex = vault.makeSelflessMFADeposit{value: 1}( 0x0000000000000000000000000000000000000000, 0, 1, @@ -31,12 +31,12 @@ contract EnvelopeVaultMFATest is Test { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - peanutV4.PEANUT_SALT(), + vault.ENVELOPE_SALT(), block.chainid, - address(peanutV4), + address(vault), depositIndex, address(this), // recipient - peanutV4.ANYONE_WITHDRAWAL_MODE() + vault.ANYONE_WITHDRAWAL_MODE() ) ) ); @@ -45,15 +45,15 @@ contract EnvelopeVaultMFATest is Test { // Withdrawing without authorization, so should fail vm.expectRevert("REQUIRES AUTHORIZATION"); - peanutV4.withdrawDeposit(depositIndex, address(this), signature); + vault.withdrawDeposit(depositIndex, address(this), signature); // Withdrawing with incorrect authorization signature vm.expectRevert("WRONG MFA SIGNATURE"); - peanutV4.withdrawMFADeposit(depositIndex, address(this), signature, signature); + vault.withdrawMFADeposit(depositIndex, address(this), signature, signature); // Authorization is correct! Withdrawal has to be successful! bytes memory authorization = hex"41caae599d693a31ea45aab95c8d166e9709cb450f1c76a2b06306ee61cb28b37ed0cad0d47d055580ce204ac9973b671a0970d02f9ee6572a9234f3130707321c"; - peanutV4.withdrawMFADeposit(depositIndex, address(this), signature, authorization); + vault.withdrawMFADeposit(depositIndex, address(this), signature, authorization); } receive () payable external {} diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index 732c499c..58d94a0d 100644 --- a/test/envelope/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -8,7 +8,7 @@ import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; contract RecipientBoundTest is Test { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -22,13 +22,13 @@ contract RecipientBoundTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); testToken.mint(address(this), 1000); - testToken.approve(address(peanutV4), 1000); + testToken.approve(address(vault), 1000); } function testRecipientBoundDeposit() public { - uint256 depositIndex = peanutV4.makeCustomDeposit( + uint256 depositIndex = vault.makeCustomDeposit( address(testToken), 1, // contract type - erc 20 1000, // amount @@ -46,9 +46,9 @@ contract RecipientBoundTest is Test { // Should not be able to withdraw to anybody except SAMPLE_ADDRESS vm.expectRevert("WRONG RECIPIENT"); - peanutV4.withdrawDeposit(depositIndex, address(this), bytes("")); + vault.withdrawDeposit(depositIndex, address(this), bytes("")); - peanutV4.withdrawDeposit(depositIndex, SAMPLE_ADDRESS, bytes("")); + vault.withdrawDeposit(depositIndex, SAMPLE_ADDRESS, bytes("")); require(testToken.balanceOf(SAMPLE_ADDRESS) == 1000, "SAMPLE_ADDRESS SHOULD HAVE RECEIVED TOKENS!"); } @@ -56,7 +56,7 @@ contract RecipientBoundTest is Test { * Reclaim an address-bound deposit. */ function testRecipientBoundReclaim() public { - uint256 depositIndex = peanutV4.makeCustomDeposit( + uint256 depositIndex = vault.makeCustomDeposit( address(testToken), 1, // contract type - erc 20 1000, // amount @@ -73,10 +73,10 @@ contract RecipientBoundTest is Test { // Try to reclaim, but it's too early vm.expectRevert("TOO EARLY TO RECLAIM"); - peanutV4.withdrawDepositSender(depositIndex); + vault.withdrawDepositSender(depositIndex); vm.warp(block.timestamp + 11); // advance past reclaimableAfter - peanutV4.withdrawDepositSender(depositIndex); + vault.withdrawDepositSender(depositIndex); require(testToken.balanceOf(address(this)) == 1000, "WAS NOT REFUNDED!"); } } diff --git a/test/envelope/SenderWithdraw.t.sol b/test/envelope/SenderWithdraw.t.sol index d7f4a8a6..611b96b8 100644 --- a/test/envelope/SenderWithdraw.t.sol +++ b/test/envelope/SenderWithdraw.t.sol @@ -10,7 +10,7 @@ import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; contract TestSenderWithdrawEther is Test { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; // a dummy private/public keypair to test withdrawals address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; @@ -19,20 +19,20 @@ contract TestSenderWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); } function testSenderWithdrawEther(uint64 amount) public { vm.assume(amount > 0); - uint256 depositIdx = peanutV4.makeDeposit{value: amount}(address(0), 0, amount, 0, PUBKEY20); + uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, PUBKEY20); // Withdraw the deposit - peanutV4.withdrawDepositSender(depositIdx); + vault.withdrawDepositSender(depositIdx); } } contract TestSenderWithdrawErc20 is Test { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; ERC20Mock public testToken; // a dummy private/public keypair to test withdrawals @@ -44,28 +44,28 @@ contract TestSenderWithdrawErc20 is Test { // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); testToken = new ERC20Mock(); // contractType 1 // Mint tokens for test accounts (larger than uint128) testToken.mint(address(this), 2 ** 130); // Approve the contract to spend the tokens - testToken.approve(address(peanutV4), 2 ** 130); + testToken.approve(address(vault), 2 ** 130); // Make a deposit uint256 amount = 2 ** 128; - _depositIdx = peanutV4.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + _depositIdx = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); } function testSenderWithdrawErc20() public { // Withdraw the deposit - peanutV4.withdrawDepositSender(_depositIdx); + vault.withdrawDepositSender(_depositIdx); } } contract TestSenderWithdrawErc721 is Test, ERC721Holder { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; ERC721Mock public testToken; // a dummy private/public keypair to test withdrawals @@ -78,27 +78,27 @@ contract TestSenderWithdrawErc721 is Test, ERC721Holder { // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); testToken = new ERC721Mock(); // contractType 2 // Mint token for test testToken.mint(address(this), _tokenId); // Approve the contract to spend the tokens - testToken.approve(address(peanutV4), _tokenId); + testToken.approve(address(vault), _tokenId); // Make a deposit - _depositIdx = peanutV4.makeDeposit(address(testToken), 2, 1, _tokenId, PUBKEY20); + _depositIdx = vault.makeDeposit(address(testToken), 2, 1, _tokenId, PUBKEY20); } function testSenderWithdrawErc721() public { // Withdraw the deposit - peanutV4.withdrawDepositSender(_depositIdx); + vault.withdrawDepositSender(_depositIdx); } } contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; ERC1155Mock public testToken; // a dummy private/public keypair to test withdrawals @@ -112,21 +112,21 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); testToken = new ERC1155Mock(); // contractType 3 // Mint tokens for test testToken.mint(address(this), _tokenId, _tokenAmount, ""); // Approve the contract to spend the tokens - testToken.setApprovalForAll(address(peanutV4), true); + testToken.setApprovalForAll(address(vault), true); // Make a deposit - _depositIdx = peanutV4.makeDeposit(address(testToken), 3, _tokenAmount, _tokenId, PUBKEY20); + _depositIdx = vault.makeDeposit(address(testToken), 3, _tokenAmount, _tokenId, PUBKEY20); } function testSenderWithdrawErc1155() public { // Withdraw the deposit - peanutV4.withdrawDepositSender(_depositIdx); + vault.withdrawDepositSender(_depositIdx); } } diff --git a/test/envelope/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol index a4bf2cd9..355c4045 100644 --- a/test/envelope/SigWithdraw.t.sol +++ b/test/envelope/SigWithdraw.t.sol @@ -11,7 +11,7 @@ import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; contract TestSigWithdrawEther is Test { - EnvelopeVault public peanutV4; + EnvelopeVault public vault; // sample inputs address _pubkey20 = 0x8fd379246834eac74B8419FfdA202CF8051F7A03; @@ -24,35 +24,35 @@ contract TestSigWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - peanutV4 = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0), address(0)); } // test sender withdrawal of ETH function testSigWithdrawEther(uint64 amount) public { vm.assume(amount > 0); - uint256 depositIdx = peanutV4.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); + uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); // Can't use withdrawDepositAsRecipient vm.expectRevert("NOT THE RECIPIENT"); - peanutV4.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureAnybody); + vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureAnybody); // Anybody can withdraw - peanutV4.withdrawDeposit(depositIdx, _recipientAddress, signatureAnybody); + vault.withdrawDeposit(depositIdx, _recipientAddress, signatureAnybody); } function testWithdrawDepositAsRecipient(uint64 amount) public { vm.assume(amount > 0); - uint256 depositIdx = peanutV4.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); + uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); // Can't use pure withdrawDeposit vm.expectRevert("WRONG SIGNATURE"); - peanutV4.withdrawDeposit(depositIdx, _recipientAddress, signatureRecipient); + vault.withdrawDeposit(depositIdx, _recipientAddress, signatureRecipient); // Only the recipient is able to withdraw via withdrawDepositAsRecipient vm.expectRevert("NOT THE RECIPIENT"); - peanutV4.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); + vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); vm.prank(_recipientAddress); // Withdraw! - peanutV4.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); + vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); } } From 9bca40a4fe8d625594c1d6411793df341476821e Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 22:49:34 -0400 Subject: [PATCH 28/31] =?UTF-8?q?chore(envelope):=20rename=20source=20file?= =?UTF-8?q?s=20PeanutV4.4.sol=20=E2=86=92=20EnvelopeVault.sol,=20PeanutBat?= =?UTF-8?q?cherV4.4.sol=20=E2=86=92=20EnvelopeBatcher.sol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final consistency pass after the directory rename + contract symbol rename + env var rename + deploy script rename. The "V4.4" suffix is upstream's versioning, not Envelope's; dropped. What changed (path-string rewrites only): - Batcher's `import {EnvelopeVault} from "./PeanutV4.4.sol"` → `EnvelopeVault.sol` - 11 test imports + 2 mock cross-tree imports - DeployEnvelope.ts verify args: contract: "src/envelope/V4/PeanutV4.4.sol:EnvelopeVault" → "src/envelope/V4/EnvelopeVault.sol:EnvelopeVault" - Same for batcher - .solhintignore (2 entries) - Doc spec references throughout Audit lineage to upstream peanutprotocol/peanut-contracts@main is still preserved by: - GPL §5(d) attribution (`// @title Peanut Protocol`, `// @author Squirrel Labs`) - `// Modified by Nodle (2026-05-12)` notice atop each file - LICENSE-GPL bundled at src/envelope/V4/LICENSE-GPL - Git history (rename detected as 99% similarity) - README mention of "vendored Peanut Protocol V4.4" forge test 966/966. yarn lint 0 errors. yarn spellcheck 0 issues. --- .solhintignore | 4 ++-- hardhat-deploy/DeployEnvelope.ts | 4 ++-- .../V4/{PeanutBatcherV4.4.sol => EnvelopeBatcher.sol} | 2 +- src/envelope/V4/{PeanutV4.4.sol => EnvelopeVault.sol} | 0 src/envelope/doc/EnvelopeBatcher.md | 2 +- src/envelope/doc/EnvelopeVault.md | 2 +- src/envelope/doc/README.md | 8 ++++---- src/paymasters/EnvelopeApprovalPaymaster.sol | 2 +- test/envelope/Deposit.t.sol | 2 +- test/envelope/EnvelopeBatcher.t.sol | 2 +- test/envelope/EnvelopeEdgeCases.t.sol | 4 ++-- test/envelope/EnvelopeGasless.t.sol | 2 +- test/envelope/EnvelopeHardening.t.sol | 2 +- test/envelope/EnvelopeVault.t.sol | 2 +- test/envelope/Integration.t.sol | 2 +- test/envelope/MFA.t.sol | 2 +- test/envelope/RecipientBound.t.sol | 2 +- test/envelope/SenderWithdraw.t.sol | 2 +- test/envelope/SigWithdraw.t.sol | 2 +- 19 files changed, 24 insertions(+), 24 deletions(-) rename src/envelope/V4/{PeanutBatcherV4.4.sol => EnvelopeBatcher.sol} (99%) rename src/envelope/V4/{PeanutV4.4.sol => EnvelopeVault.sol} (100%) diff --git a/.solhintignore b/.solhintignore index ad2f988b..f139a58c 100644 --- a/.solhintignore +++ b/.solhintignore @@ -5,5 +5,5 @@ # # Our own code (EnvelopeApprovalPaymaster, anything authored in this repo) # is NOT in this list and remains lint-clean. -src/envelope/V4/PeanutV4.4.sol -src/envelope/V4/PeanutBatcherV4.4.sol +src/envelope/V4/EnvelopeVault.sol +src/envelope/V4/EnvelopeBatcher.sol diff --git a/hardhat-deploy/DeployEnvelope.ts b/hardhat-deploy/DeployEnvelope.ts index 670d226e..7fd63abe 100644 --- a/hardhat-deploy/DeployEnvelope.ts +++ b/hardhat-deploy/DeployEnvelope.ts @@ -71,7 +71,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log("Verifying EnvelopeVault..."); await hre.run("verify:verify", { address: vaultAddr, - contract: "src/envelope/V4/PeanutV4.4.sol:EnvelopeVault", + contract: "src/envelope/V4/EnvelopeVault.sol:EnvelopeVault", constructorArguments: [ecoToken, mfaAuthorizer], }); } catch (e: any) { @@ -83,7 +83,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log("Verifying EnvelopeBatcher..."); await hre.run("verify:verify", { address: batcherAddr, - contract: "src/envelope/V4/PeanutBatcherV4.4.sol:EnvelopeBatcher", + contract: "src/envelope/V4/EnvelopeBatcher.sol:EnvelopeBatcher", constructorArguments: [], }); } catch (e: any) { diff --git a/src/envelope/V4/PeanutBatcherV4.4.sol b/src/envelope/V4/EnvelopeBatcher.sol similarity index 99% rename from src/envelope/V4/PeanutBatcherV4.4.sol rename to src/envelope/V4/EnvelopeBatcher.sol index c6c6a35a..d4e37c53 100644 --- a/src/envelope/V4/PeanutBatcherV4.4.sol +++ b/src/envelope/V4/EnvelopeBatcher.sol @@ -12,7 +12,7 @@ import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Recei import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; -import {EnvelopeVault} from "./PeanutV4.4.sol"; +import {EnvelopeVault} from "./EnvelopeVault.sol"; /// @title Peanut Batcher V4.4 /// @notice Stateless helper that pulls tokens from msg.sender then forwards N deposits diff --git a/src/envelope/V4/PeanutV4.4.sol b/src/envelope/V4/EnvelopeVault.sol similarity index 100% rename from src/envelope/V4/PeanutV4.4.sol rename to src/envelope/V4/EnvelopeVault.sol diff --git a/src/envelope/doc/EnvelopeBatcher.md b/src/envelope/doc/EnvelopeBatcher.md index 183d7b96..75995839 100644 --- a/src/envelope/doc/EnvelopeBatcher.md +++ b/src/envelope/doc/EnvelopeBatcher.md @@ -1,6 +1,6 @@ # EnvelopeBatcher — N-deposits-in-one-tx helper -`src/envelope/V4/PeanutBatcherV4.4.sol` +`src/envelope/V4/EnvelopeBatcher.sol` ## Purpose diff --git a/src/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md index 0c14955a..b7b79999 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -1,6 +1,6 @@ # EnvelopeVault — link-based asset vault -`src/envelope/V4/PeanutV4.4.sol` +`src/envelope/V4/EnvelopeVault.sol` ## Purpose diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 8ef445bf..44a91728 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -9,8 +9,8 @@ sponsors the user-side approval txs so the UX is gasless from the holder's POV. | Contract | Source | Spec | |---|---|---| -| `EnvelopeVault` (vault) | `src/envelope/V4/PeanutV4.4.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | -| `EnvelopeBatcher` (batched deposits) | `src/envelope/V4/PeanutBatcherV4.4.sol` | [EnvelopeBatcher.md](./EnvelopeBatcher.md) | +| `EnvelopeVault` (vault) | `src/envelope/V4/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | +| `EnvelopeBatcher` (batched deposits) | `src/envelope/V4/EnvelopeBatcher.sol` | [EnvelopeBatcher.md](./EnvelopeBatcher.md) | | `EnvelopeApprovalPaymaster` (Path-C gas sponsor + operator gas pool) | `src/paymasters/EnvelopeApprovalPaymaster.sol` | [EnvelopeApprovalPaymaster.md](./EnvelopeApprovalPaymaster.md) | Interfaces (vendored, unmodified): @@ -26,7 +26,7 @@ This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) doesn't apply u | Files | License | Notes | |---|---|---| -| `src/envelope/V4/PeanutV4.4.sol`, `PeanutBatcherV4.4.sol` | **GPL-3.0-or-later** | Modified copies of upstream Peanut Protocol V4.4. Full GPL v3 text bundled at `src/envelope/V4/LICENSE-GPL`. Each file carries a top-of-file modification notice per GPL §5(a). | +| `src/envelope/V4/EnvelopeVault.sol`, `EnvelopeBatcher.sol` | **GPL-3.0-or-later** | Modified copies of upstream Peanut Protocol V4.4. Full GPL v3 text bundled at `src/envelope/V4/LICENSE-GPL`. Each file carries a top-of-file modification notice per GPL §5(a). | | `src/envelope/util/IEIP3009.sol`, `IL2ECO.sol` | **MIT** | Vendored interfaces, unchanged from upstream | | `src/paymasters/EnvelopeApprovalPaymaster.sol` | **BSD-3-Clause-Clear** | Our own code; doesn't `import` any GPL source so it isn't a derivative work | | `test/envelope/**/*.t.sol` (files that import the vault/batcher sources) | **GPL-3.0-or-later** | Test files that `import` GPL-licensed contracts are derivative works under a strict reading of the GPL; relicensed for compliance | @@ -37,7 +37,7 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s ## Naming convention -- **Source files** keep the upstream `Peanut*` names (e.g. `PeanutV4.4.sol`) so diffs against `peanutprotocol/peanut-contracts@main` stay grep-friendly. The audit lineage is preserved by file path + the `// Modified by Nodle` notice + the bundled `LICENSE-GPL`. +- **Source files** keep the upstream `Peanut*` names (e.g. `EnvelopeVault.sol`) so diffs against `peanutprotocol/peanut-contracts@main` stay grep-friendly. The audit lineage is preserved by file path + the `// Modified by Nodle` notice + the bundled `LICENSE-GPL`. - **Contract symbols** (the names visible on the explorer / in the SDK / in the EIP-712 domain) use the **Envelope** brand: `EnvelopeVault`, `EnvelopeBatcher`, `EnvelopeApprovalPaymaster`. This avoids any trademark confusion with upstream Peanut Protocol brand. - **On-chain hashed constants** (e.g. `ENVELOPE_SALT`) keep upstream values — changing them would change every signature digest and break compatibility. Those values are internal and never user-visible. diff --git a/src/paymasters/EnvelopeApprovalPaymaster.sol b/src/paymasters/EnvelopeApprovalPaymaster.sol index cb1d2998..1d66eb83 100644 --- a/src/paymasters/EnvelopeApprovalPaymaster.sol +++ b/src/paymasters/EnvelopeApprovalPaymaster.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause-Clear -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IPaymaster, diff --git a/test/envelope/Deposit.t.sol b/test/envelope/Deposit.t.sol index 1b072182..1edf8e42 100644 --- a/test/envelope/Deposit.t.sol +++ b/test/envelope/Deposit.t.sol @@ -6,7 +6,7 @@ pragma solidity ^0.8.19; ////////////////////////////// import "forge-std/Test.sol"; -import "../../src/envelope/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/EnvelopeBatcher.t.sol b/test/envelope/EnvelopeBatcher.t.sol index 1c10d320..bf9f856b 100644 --- a/test/envelope/EnvelopeBatcher.t.sol +++ b/test/envelope/EnvelopeBatcher.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import "../../src/envelope/V4/PeanutBatcherV4.4.sol"; +import "../../src/envelope/V4/EnvelopeBatcher.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index 1f46dd80..fc2b8fd2 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -6,8 +6,8 @@ pragma solidity ^0.8.26; // convention. Each test is single-purpose; comments explain the *why*, not the *what*. import {Test} from "forge-std/Test.sol"; -import {EnvelopeVault} from "../../src/envelope/V4/PeanutV4.4.sol"; -import {EnvelopeBatcher} from "../../src/envelope/V4/PeanutBatcherV4.4.sol"; +import {EnvelopeVault} from "../../src/envelope/V4/EnvelopeVault.sol"; +import {EnvelopeBatcher} from "../../src/envelope/V4/EnvelopeBatcher.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/EnvelopeGasless.t.sol b/test/envelope/EnvelopeGasless.t.sol index d14fd425..949bb2d3 100644 --- a/test/envelope/EnvelopeGasless.t.sol +++ b/test/envelope/EnvelopeGasless.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/envelope/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/SampleSCW.sol"; diff --git a/test/envelope/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index e678145f..241f9929 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -9,7 +9,7 @@ pragma solidity 0.8.26; // T5 — _withdrawDeposit L2ECO branch sends to recipient, not sender (upstream bug fix) import {Test} from "forge-std/Test.sol"; -import {EnvelopeVault} from "../../src/envelope/V4/PeanutV4.4.sol"; +import {EnvelopeVault} from "../../src/envelope/V4/EnvelopeVault.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/EnvelopeVault.t.sol b/test/envelope/EnvelopeVault.t.sol index 106ebbac..717d4e90 100644 --- a/test/envelope/EnvelopeVault.t.sol +++ b/test/envelope/EnvelopeVault.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/envelope/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/Integration.t.sol b/test/envelope/Integration.t.sol index 6c408f05..985cda1c 100644 --- a/test/envelope/Integration.t.sol +++ b/test/envelope/Integration.t.sol @@ -6,7 +6,7 @@ pragma solidity ^0.8.19; ////////////////////////////// import "forge-std/Test.sol"; -import "../../src/envelope/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/MFA.t.sol b/test/envelope/MFA.t.sol index d3d0481e..e1da7ff0 100644 --- a/test/envelope/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/envelope/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; contract EnvelopeVaultMFATest is Test { EnvelopeVault public vault; diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index 58d94a0d..d49c9514 100644 --- a/test/envelope/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/envelope/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/SenderWithdraw.t.sol b/test/envelope/SenderWithdraw.t.sol index 611b96b8..a289ed3c 100644 --- a/test/envelope/SenderWithdraw.t.sol +++ b/test/envelope/SenderWithdraw.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import "../../src/envelope/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol index 355c4045..ba551091 100644 --- a/test/envelope/SigWithdraw.t.sol +++ b/test/envelope/SigWithdraw.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import "../../src/envelope/V4/PeanutV4.4.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; From 7988c8219d6dec77f7ddd075a78c83c97b68f6fe Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 22:54:03 -0400 Subject: [PATCH 29/31] chore(envelope): update Solidity pragma version to ^0.8.26 in EnvelopeBatcher and EnvelopeVault This change modifies the Solidity pragma directive in both EnvelopeBatcher.sol and EnvelopeVault.sol to ensure compatibility with newer compiler versions while maintaining the existing functionality. --- src/envelope/V4/EnvelopeBatcher.sol | 2 +- src/envelope/V4/EnvelopeVault.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/envelope/V4/EnvelopeBatcher.sol b/src/envelope/V4/EnvelopeBatcher.sol index d4e37c53..7097dd76 100644 --- a/src/envelope/V4/EnvelopeBatcher.sol +++ b/src/envelope/V4/EnvelopeBatcher.sol @@ -4,7 +4,7 @@ // patches") and the git history of this file for the full patch set. The upstream source // is peanutprotocol/vault-contracts@main; the full GNU GPL v3 license text is bundled // at src/envelope/V4/LICENSE-GPL. -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; diff --git a/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index 23439b40..10c4685d 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -4,7 +4,7 @@ // applied at import") and the git history of this file for the full patch set. The // upstream source is peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license // text is bundled at src/envelope/V4/LICENSE-GPL. -pragma solidity 0.8.26; +pragma solidity ^0.8.26; ////////////////////////////////////////////////////////////////////////////////////// // @title Peanut Protocol From 5f9f8ca8f2f303fe453775feb2839e0951a58079 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Wed, 13 May 2026 23:23:07 -0400 Subject: [PATCH 30/31] docs(envelope): refresh Sepolia addresses after post-rebrand redeploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vault, batcher, and paymaster were redeployed on 2026-05-13 because the EIP-712 vault domain `name` flipped from "Peanut" to "Envelope" — the prior on-chain instances were inconsistent with the renamed source. --- src/envelope/doc/EnvelopeApprovalPaymaster.md | 4 ++-- src/envelope/doc/README.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/envelope/doc/EnvelopeApprovalPaymaster.md b/src/envelope/doc/EnvelopeApprovalPaymaster.md index cfc8a545..677e25f7 100644 --- a/src/envelope/doc/EnvelopeApprovalPaymaster.md +++ b/src/envelope/doc/EnvelopeApprovalPaymaster.md @@ -19,7 +19,7 @@ Mode B is the "single point we top up" pattern: instead of funding the operator' - **No token allowlist** — the operator's grant is the only auth surface. Defense-in-depth comes from a hard per-tx ETH cap and a global daily quota. - **Operator-driven UX** — the user never sees the EIP-712 grant; only the operator's backend does. -Deployed on ZkSync Sepolia at [`0xc160C8F6faC916De00B55aA0a630eBdce43CD532`](https://sepolia.explorer.zksync.io/address/0xc160C8F6faC916De00B55aA0a630eBdce43CD532#contract). +Deployed on ZkSync Sepolia at [`0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268`](https://sepolia.explorer.zksync.io/address/0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268#contract). ## Inheritance @@ -279,7 +279,7 @@ import { Wallet } from "zksync-ethers"; import { ethers } from "ethers"; import { randomBytes, hexlify } from "ethers"; -const PAYMASTER = "0xc160C8F6faC916De00B55aA0a630eBdce43CD532"; +const PAYMASTER = "0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268"; const CHAIN_ID = 300; const operatorWallet = new Wallet(process.env.OPERATOR_PK!); diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 44a91728..9f96d26c 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -45,9 +45,9 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s | | Address | |---|---| -| `EnvelopeVault` | [`0x32D02E54EaE5F8Bba75129e9306e0b8b70f05f6a`](https://sepolia.explorer.zksync.io/address/0x32D02E54EaE5F8Bba75129e9306e0b8b70f05f6a#contract) | -| `EnvelopeBatcher` | [`0x5DAe00DDFA1F96Aaf75d21F49B6FF5C756174816`](https://sepolia.explorer.zksync.io/address/0x5DAe00DDFA1F96Aaf75d21F49B6FF5C756174816#contract) | -| `EnvelopeApprovalPaymaster` | [`0xc160C8F6faC916De00B55aA0a630eBdce43CD532`](https://sepolia.explorer.zksync.io/address/0xc160C8F6faC916De00B55aA0a630eBdce43CD532#contract) | +| `EnvelopeVault` | [`0x37dbCC12784727AdE2A78AFbcb686b0eb915574f`](https://sepolia.explorer.zksync.io/address/0x37dbCC12784727AdE2A78AFbcb686b0eb915574f#contract) | +| `EnvelopeBatcher` | [`0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1`](https://sepolia.explorer.zksync.io/address/0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1#contract) | +| `EnvelopeApprovalPaymaster` | [`0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268`](https://sepolia.explorer.zksync.io/address/0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268#contract) | ## Three deposit paths From 1d58c43286df7bc72677e570a1faf87f25834b8d Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 14 May 2026 12:16:53 -0400 Subject: [PATCH 31/31] feat(envelope): add makeCustomDepositFrom for operator-orchestrated deposits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing makeCustomDeposit pulls tokens from msg.sender, which means an operator submitting the deposit tx (e.g. via paymaster Mode B) can only fund deposits from the operator's own balance — even when the user has approved the vault. makeCustomDepositFrom takes a `_from` parameter and pulls via standard transferFrom against `_from`'s allowance, so the operator can submit while the user funds. Native ETH (contractType 0) is rejected: there is no allowance model for native ETH, so an operator cannot pull from a third party. ETH deposits must continue to use makeCustomDeposit directly from the funder. The authorization model is the standard ERC-20 / 721 / 1155 allowance semantic — granting allowance to the vault is consent for any caller to invoke transferFrom up to that allowance. Same trust model as DEX routers, etc. Combined with paymaster Mode A (sponsoring approve) + Mode B (sponsoring makeCustomDepositFrom), this gives the canonical Path C UX: user signs one EIP-712 grant, sends one tx (approve), operator handles the rest. 13 tests covering all four token contract types, allowance/balance failures, ECO gating, ETH rejection, dual-zero auth-field rejection, and a regression that the original makeCustomDeposit semantics are unchanged. Full repo: 979/979 pass (was 966). Redeployed on ZkSync Sepolia: EnvelopeVault 0xed414522b1Fbe08EEfd156f912a57CF345A55735 EnvelopeApprovalPaymaster 0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD EnvelopeBatcher (unchanged source) 0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1 --- src/envelope/V4/EnvelopeVault.sol | 92 ++++++- src/envelope/doc/EnvelopeApprovalPaymaster.md | 17 +- src/envelope/doc/EnvelopeVault.md | 26 +- src/envelope/doc/README.md | 10 +- test/envelope/MakeCustomDepositFrom.t.sol | 224 ++++++++++++++++++ 5 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 test/envelope/MakeCustomDepositFrom.t.sol diff --git a/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index 10c4685d..0a5b9d71 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -1,9 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -// Modified by Nodle (2026-05-12) — see src/envelope/doc/PeanutV4.md ("Vendoring patches -// applied at import") and the git history of this file for the full patch set. The -// upstream source is peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license -// text is bundled at src/envelope/V4/LICENSE-GPL. +// Modified by Nodle (2026-05-12, extended 2026-05-14) — see src/envelope/doc/EnvelopeVault.md +// ("Vendoring patches applied at import" and "Operator-orchestrated deposits") and the git +// history of this file for the full patch set. The upstream source is +// peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license text is bundled at +// src/envelope/V4/LICENSE-GPL. pragma solidity ^0.8.26; ////////////////////////////////////////////////////////////////////////////////////// @@ -349,6 +350,59 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { ); } + /** + * Operator-orchestrated deposit. Pulls tokens from `_from` (who must have approved + * the vault) and credits the deposit to `_onBehalfOf` as the senderAddress. Used so + * an operator can submit the deposit tx (e.g. via a paymaster) without holding the + * user's tokens. + * + * Native ETH (contractType 0) is intentionally not supported: ETH has no allowance + * model, so an operator cannot pull ETH from a third party. For ETH deposits, the + * funder must call `makeCustomDeposit` directly. + * + * Authorization model: relies on the standard ERC-20/721/1155 allowance — `_from` + * granting allowance to the vault is, by ERC-20 convention, consent for any caller + * to invoke transferFrom up to the allowance. The same threat model already applies + * to every existing transferFrom-based pattern (DEXes, routers, etc.). + * + * Same parameters as `makeCustomDeposit` minus the EIP-3009 args (3009 already + * supports operator-orchestrated pulls via its own signature). + */ + function makeCustomDepositFrom( + address _from, + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address _pubKey20, + address _onBehalfOf, + bool _withMFA, + address _recipient, + uint40 _reclaimableAfter + ) public nonReentrant returns (uint256) { + require(_from != address(0), "FROM MUST BE NONZERO"); + + _amount = _pullTokensFromViaApproval( + _from, + _tokenAddress, + _contractType, + _amount, + _tokenId + ); + + return _storeDeposit( + _tokenAddress, + _contractType, + _amount, + _tokenId, + _pubKey20, + _onBehalfOf, + _withMFA, + _recipient, + _reclaimableAfter + ); + } + function _storeDeposit( address _tokenAddress, uint8 _contractType, @@ -442,6 +496,36 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { return _amount; } + /** + * Same as _pullTokensViaApproval but pulls from `_from` instead of msg.sender. + * Backs `makeCustomDepositFrom` for operator-orchestrated deposits. + * ETH (contractType 0) is rejected: native ETH cannot be transferFrom-pulled. + */ + function _pullTokensFromViaApproval( + address _from, + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId + ) internal returns (uint256) { + require(_contractType >= 1 && _contractType < 5, "INVALID CONTRACT TYPE FOR FROM-DEPOSIT"); + + if (_contractType == 1) { + require(_tokenAddress != ecoAddress, "ECO DEPOSITS MUST USE _contractType 4"); + IERC20(_tokenAddress).safeTransferFrom(_from, address(this), _amount); + } else if (_contractType == 2) { + require(_amount == 1, "AMOUNT MUST BE 1 FOR ERC721"); + IERC721(_tokenAddress).safeTransferFrom(_from, address(this), _tokenId, "Internal transfer"); + } else if (_contractType == 3) { + IERC1155(_tokenAddress).safeTransferFrom(_from, address(this), _tokenId, _amount, "Internal transfer"); + } else if (_contractType == 4) { + IERC20(_tokenAddress).safeTransferFrom(_from, address(this), _amount); + _amount *= IL2ECO(_tokenAddress).linearInflationMultiplier(); + } + + return _amount; + } + /** * Pulls the tokens via EIP-3009 according to the encoded data * Also validates that _onBehalfOf is the unpacked _from. diff --git a/src/envelope/doc/EnvelopeApprovalPaymaster.md b/src/envelope/doc/EnvelopeApprovalPaymaster.md index 677e25f7..298a9ee1 100644 --- a/src/envelope/doc/EnvelopeApprovalPaymaster.md +++ b/src/envelope/doc/EnvelopeApprovalPaymaster.md @@ -9,17 +9,28 @@ Sponsors gas in **two modes**, both funded from one ETH pool and bounded by the | Mode | Caller | Auth | What gets sponsored | |---|---|---|---| | **A — User approval** | regular user | EIP-712 grant signed off-chain by `operatorSigner` (single-use nonce, deadline) + selector + spender checks | `token.approve(envelopeVault, ...)` / `token.setApprovalForAll(envelopeVault, true)` for ERC-20 / 721 / 1155 — the user-side step in Path C | -| **B — Operator direct call** | operator EOA on the `isOperator` allowlist | target must be on the `isAllowedTarget` allowlist; no grant required | Anything the operator wants to call on an allowlisted target — typically `vault.makeCustomDeposit`, `vault.withdrawDeposit`, etc. | +| **B — Operator direct call** | operator EOA on the `isOperator` allowlist | target must be on the `isAllowedTarget` allowlist; no grant required | Anything the operator wants to call on an allowlisted target — typically `vault.makeCustomDepositFrom(user, ...)` (operator submits, user funds via prior approval), `vault.withdrawDeposit`, etc. | Mode B is the "single point we top up" pattern: instead of funding the operator's hot wallet directly, fund the paymaster and let the operator submit txs gaslessly. Bounded daily spend (QuotaControl), bounded per-tx spend (`maxEthPerTx`), and rotation just means flipping `isOperator` on a new EOA — no balance migration. +### Combined Mode A + Mode B flow (canonical Path C) + +``` +1. Operator backend signs an EIP-712 EnvelopeApprovalGrant for the user +2. User submits: token.approve(vault, amount) ← Mode A sponsors gas +3. Operator submits: vault.makeCustomDepositFrom(user, ..., onBehalfOf: user, ...) + ← Mode B sponsors gas +``` + +The user signs the EIP-712 grant once and sends one tx (the `approve`); the operator handles the deposit on their behalf. Both txs are gasless from the user's perspective. `makeCustomDepositFrom` was added on the vault specifically to let Mode B pull the user's tokens via the standard ERC-20 allowance — see `src/envelope/doc/EnvelopeVault.md#operator-orchestrated-deposits`. + ## Deployment scope - **Authorization model** — signed grants from the operator. No on-chain user whitelist; the backend gates per request. - **No token allowlist** — the operator's grant is the only auth surface. Defense-in-depth comes from a hard per-tx ETH cap and a global daily quota. - **Operator-driven UX** — the user never sees the EIP-712 grant; only the operator's backend does. -Deployed on ZkSync Sepolia at [`0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268`](https://sepolia.explorer.zksync.io/address/0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268#contract). +Deployed on ZkSync Sepolia at [`0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD`](https://sepolia.explorer.zksync.io/address/0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD#contract). ## Inheritance @@ -279,7 +290,7 @@ import { Wallet } from "zksync-ethers"; import { ethers } from "ethers"; import { randomBytes, hexlify } from "ethers"; -const PAYMASTER = "0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268"; +const PAYMASTER = "0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD"; const CHAIN_ID = 300; const operatorWallet = new Wallet(process.env.OPERATOR_PK!); diff --git a/src/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md index b7b79999..50acfe0a 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -69,7 +69,8 @@ All deposit functions are `payable` (ETH path uses `msg.value`) and `nonReentran | `makeMFADeposit(...)` | Same shape, but `withMFA=true` | | `makeSelflessDeposit(..., onBehalfOf)` | Deposit credited to `onBehalfOf` (reclaim rights go to them, not msg.sender) — used by batcher | | `makeSelflessMFADeposit(..., onBehalfOf)` | Selfless + MFA | -| `makeCustomDeposit(token, contractType, amount, tokenId, pubKey20, onBehalfOf, withMFA, recipient, reclaimableAfter, isGasless3009, args3009)` | All knobs exposed — the canonical entry point | +| `makeCustomDeposit(token, contractType, amount, tokenId, pubKey20, onBehalfOf, withMFA, recipient, reclaimableAfter, isGasless3009, args3009)` | All knobs exposed — the canonical entry point. Pulls funds from `msg.sender`. | +| `makeCustomDepositFrom(from, token, contractType, amount, tokenId, pubKey20, onBehalfOf, withMFA, recipient, reclaimableAfter)` | **Operator-orchestrated**: pulls funds from `from` (who must have approved the vault), credits `onBehalfOf` as the senderAddress. ETH not supported (no allowance for native). Added 2026-05-14 to support paymaster Mode B + arbitrary ERC-20s. | | `makeDepositWithAuthorization(token, from, amount, pubKey20, nonce, validAfter, validBefore, v, r, s)` | EIP-3009 path for USDC-style tokens — no pre-approval needed | The minimalistic deposit functions (`makeDeposit`, `makeMFADeposit`, `makeSelflessDeposit`, `makeSelflessMFADeposit`) are marked `@deprecated` upstream but kept for ABI compatibility; new integrations should call `makeCustomDeposit`. @@ -110,6 +111,22 @@ All withdraws set `claimed = true` BEFORE the asset transfer (CEI). `nonReentran For ERC-20, the depositor must approve the vault first (Path C). The `EnvelopeApprovalPaymaster` exists to sponsor that approval tx. +## Operator-orchestrated deposits + +`makeCustomDepositFrom` lets a third party (e.g. an operator EOA backing a paymaster Mode B flow) submit the deposit transaction while the funds come from a different wallet — typically the end user. + +``` +1. User submits: token.approve(vault, amount) ← gasless via paymaster Mode A +2. Operator submits: vault.makeCustomDepositFrom( + _from: user, // tokens come from here + _onBehalfOf: user, // credited as senderAddress (so user can reclaim) + ...) ← gasless via paymaster Mode B +``` + +The vault calls `transferFrom(_from, vault, amount)`. Authorization rests on the standard ERC-20 / ERC-721 / ERC-1155 allowance — granting allowance to the vault is, by ERC-20 convention, consent for any caller to invoke `transferFrom` up to that allowance. This is the same trust model already in use across the ecosystem (Uniswap routers, etc.) so it adds no new threat surface beyond what the user assumes when they call `approve`. + +ETH (`contractType == 0`) is intentionally rejected: native ETH has no allowance model, so there is no way for an operator to pull ETH from a third party. ETH deposits must use `makeCustomDeposit` directly from the funder. + ## Receiver hooks (S1 hardening) The vault implements `IERC721Receiver` + `IERC1155Receiver` because withdrawing NFTs goes through `safeTransferFrom` and the **recipient** may be a contract that needs the receiver-check; for the vault itself, the only legitimate calls to its own receiver hooks are when the vault itself is the operator (i.e. during withdraw). Direct deposits via `safeTransferFrom(user → vault, ...)` from outside this contract are explicitly rejected: @@ -163,12 +180,15 @@ Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with arra | ZkSync | Explicit `override(IERC165)` on `supportsInterface` | | Modern | Named imports throughout | | Modern | Pragma pinned to `0.8.26` | +| Feature | `makeCustomDepositFrom(_from, ...)` — operator-orchestrated deposits pulled from a third-party allowance (added 2026-05-14) | ## Test coverage | Suite | File | |---|---| | Vendored upstream tests | `test/envelope/EnvelopeVault.t.sol`, `Deposit.t.sol`, `SigWithdraw.t.sol`, `SenderWithdraw.t.sol`, `MFA.t.sol`, `RecipientBound.t.sol`, `Integration.t.sol`, `EnvelopeGasless.t.sol` | -| Hardening (S1–S4 + T1–T4) | `test/envelope/EnvelopeHardening.t.sol` | +| Hardening (S1–S4 + T1–T4 + T5) | `test/envelope/EnvelopeHardening.t.sol` | +| Edge cases | `test/envelope/EnvelopeEdgeCases.t.sol` | +| `makeCustomDepositFrom` | `test/envelope/MakeCustomDepositFrom.t.sol` | -71 tests pass. +103 tests pass. diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 9f96d26c..26383755 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -45,9 +45,9 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s | | Address | |---|---| -| `EnvelopeVault` | [`0x37dbCC12784727AdE2A78AFbcb686b0eb915574f`](https://sepolia.explorer.zksync.io/address/0x37dbCC12784727AdE2A78AFbcb686b0eb915574f#contract) | +| `EnvelopeVault` | [`0xed414522b1Fbe08EEfd156f912a57CF345A55735`](https://sepolia.explorer.zksync.io/address/0xed414522b1Fbe08EEfd156f912a57CF345A55735#contract) | | `EnvelopeBatcher` | [`0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1`](https://sepolia.explorer.zksync.io/address/0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1#contract) | -| `EnvelopeApprovalPaymaster` | [`0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268`](https://sepolia.explorer.zksync.io/address/0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268#contract) | +| `EnvelopeApprovalPaymaster` | [`0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD`](https://sepolia.explorer.zksync.io/address/0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD#contract) | ## Three deposit paths @@ -57,7 +57,7 @@ The vault itself supports three ways a sender can fund a link: |---|---|---|---| | **A** — ETH | `msg.value` directly | n/a | no | | **B** — EIP-2612 / EIP-3009 token | `makeDepositWithAuthorization` (EIP-3009) | embedded in signature | no | -| **C** — anything else (ERC-20 w/o permit, ERC-721, ERC-1155) | `makeCustomDeposit` after user calls `token.approve` / `setApprovalForAll` | separate approval tx | **yes** — see [EnvelopeApprovalPaymaster](./EnvelopeApprovalPaymaster.md) | +| **C** — anything else (ERC-20 w/o permit, ERC-721, ERC-1155) | `makeCustomDepositFrom(user, ...)` (operator-submitted) after user calls `token.approve` / `setApprovalForAll` | separate approval tx | **yes** — both legs are sponsored by [EnvelopeApprovalPaymaster](./EnvelopeApprovalPaymaster.md) (Mode A for the approve, Mode B for the deposit) | ## Deploy @@ -72,8 +72,8 @@ Both are Hardhat-zksync scripts. See each spec for env vars. | Suite | Tests | |---|---| -| Envelope core (`test/envelope/`) | **90** (56 vendored + 11 hardening + 23 edge cases) | +| Envelope core (`test/envelope/`) | **103** (56 vendored + 11 hardening + 23 edge cases + 13 `makeCustomDepositFrom`) | | `EnvelopeApprovalPaymaster` (`test/paymasters/EnvelopeApprovalPaymaster.t.sol`) | **27** (19 Mode A + 7 Mode B + 1 EIP-1271 contract signer) | | Other paymasters (unchanged) | 102 | | Rest of repo | 747 | -| **Total** | **966** | +| **Total** | **979** | diff --git a/test/envelope/MakeCustomDepositFrom.t.sol b/test/envelope/MakeCustomDepositFrom.t.sol new file mode 100644 index 00000000..5d16aec2 --- /dev/null +++ b/test/envelope/MakeCustomDepositFrom.t.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.26; + +// Tests for makeCustomDepositFrom — operator-orchestrated deposits where the +// caller (operator) is not the funder. Token pull comes from `_from` via the +// standard transferFrom allowance path. Used in the Mode B paymaster flow. + +import {Test} from "forge-std/Test.sol"; +import {EnvelopeVault} from "../../src/envelope/V4/EnvelopeVault.sol"; +import {ERC20Mock} from "./mocks/ERC20Mock.sol"; +import {ERC721Mock} from "./mocks/ERC721Mock.sol"; +import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; +import {L2ECOMock} from "./mocks/L2ECOMock.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + +contract MakeCustomDepositFromTest is Test, ERC721Holder, ERC1155Holder { + EnvelopeVault public vault; + ERC20Mock public erc20; + ERC721Mock public erc721; + ERC1155Mock public erc1155; + L2ECOMock public eco; + + address constant OPERATOR = address(0x000000000000000000000000000000000000f0F0); + address constant USER = address(0x000000000000000000000000000000000000a11c); + address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + + function setUp() public { + eco = new L2ECOMock(1); + vault = new EnvelopeVault(address(eco), address(0)); + erc20 = new ERC20Mock(); + erc721 = new ERC721Mock(); + erc1155 = new ERC1155Mock(); + } + + // ─── Happy paths ────────────────────────────────────────────────────── + + function test_ERC20_pullsFromUser_creditsOnBehalfOf() public { + erc20.mint(USER, 100); + vm.prank(USER); + erc20.approve(address(vault), 100); + + vm.prank(OPERATOR); + uint256 idx = vault.makeCustomDepositFrom( + USER, // _from — tokens come from here + address(erc20), // _tokenAddress + 1, // _contractType (ERC20) + 100, // _amount + 0, // _tokenId + PUBKEY20, // _pubKey20 + USER, // _onBehalfOf — credited as senderAddress + false, // _withMFA + address(0), // _recipient + 0 // _reclaimableAfter + ); + + assertEq(erc20.balanceOf(USER), 0, "user balance drained"); + assertEq(erc20.balanceOf(address(vault)), 100, "vault holds the tokens"); + assertEq(erc20.balanceOf(OPERATOR), 0, "operator never touched the tokens"); + + EnvelopeVault.Deposit memory d = vault.getDeposit(idx); + assertEq(d.amount, 100); + assertEq(d.senderAddress, USER, "senderAddress reflects _onBehalfOf, not msg.sender"); + assertEq(d.pubKey20, PUBKEY20); + } + + function test_ERC20_canReclaimViaWithdrawDepositSender() public { + erc20.mint(USER, 50); + vm.prank(USER); + erc20.approve(address(vault), 50); + + vm.prank(OPERATOR); + uint256 idx = vault.makeCustomDepositFrom( + USER, address(erc20), 1, 50, 0, PUBKEY20, USER, false, address(0), 0 + ); + + // User reclaims using the senderAddress credential — operator can't reclaim. + vm.prank(USER); + bool ok = vault.withdrawDepositSender(idx); + assertTrue(ok); + assertEq(erc20.balanceOf(USER), 50, "user reclaimed"); + } + + function test_ERC721_pullsFromUser() public { + erc721.mint(USER, 7); + vm.prank(USER); + erc721.approve(address(vault), 7); + + vm.prank(OPERATOR); + vault.makeCustomDepositFrom( + USER, address(erc721), 2, 1, 7, PUBKEY20, USER, false, address(0), 0 + ); + + assertEq(erc721.ownerOf(7), address(vault)); + } + + function test_ERC1155_pullsFromUser() public { + erc1155.mint(USER, 1, 500, ""); + vm.prank(USER); + erc1155.setApprovalForAll(address(vault), true); + + vm.prank(OPERATOR); + vault.makeCustomDepositFrom( + USER, address(erc1155), 3, 200, 1, PUBKEY20, USER, false, address(0), 0 + ); + + assertEq(erc1155.balanceOf(USER, 1), 300); + assertEq(erc1155.balanceOf(address(vault), 1), 200); + } + + function test_L2ECO_pullsFromUserAndScalesByMultiplier() public { + eco.setMultiplier(3); + eco.mint(USER, 1_000); + vm.prank(USER); + eco.approve(address(vault), 1_000); + + vm.prank(OPERATOR); + uint256 idx = vault.makeCustomDepositFrom( + USER, address(eco), 4, 1_000, 0, PUBKEY20, USER, false, address(0), 0 + ); + + // contractType==4 stores amount * multiplier; recipient gets back amount/multiplier on withdraw. + EnvelopeVault.Deposit memory d = vault.getDeposit(idx); + assertEq(d.amount, 3_000, "stored amount scaled by multiplier"); + assertEq(eco.balanceOf(address(vault)), 1_000, "vault holds the underlying transferred amount"); + } + + // ─── Reverts ────────────────────────────────────────────────────────── + + function test_RevertWhen_FromIsZero() public { + vm.prank(OPERATOR); + vm.expectRevert(bytes("FROM MUST BE NONZERO")); + vault.makeCustomDepositFrom( + address(0), address(erc20), 1, 1, 0, PUBKEY20, USER, false, address(0), 0 + ); + } + + function test_RevertWhen_NoAllowance() public { + erc20.mint(USER, 100); + // No approve call. + + vm.prank(OPERATOR); + vm.expectRevert(); // ERC20InsufficientAllowance from OZ v5 + vault.makeCustomDepositFrom( + USER, address(erc20), 1, 100, 0, PUBKEY20, USER, false, address(0), 0 + ); + } + + function test_RevertWhen_InsufficientBalance() public { + erc20.mint(USER, 10); + vm.prank(USER); + erc20.approve(address(vault), 100); + + vm.prank(OPERATOR); + vm.expectRevert(); // ERC20InsufficientBalance from OZ v5 + vault.makeCustomDepositFrom( + USER, address(erc20), 1, 100, 0, PUBKEY20, USER, false, address(0), 0 + ); + } + + function test_RevertWhen_ETHContractType() public { + vm.prank(OPERATOR); + vm.expectRevert(bytes("INVALID CONTRACT TYPE FOR FROM-DEPOSIT")); + vault.makeCustomDepositFrom( + USER, address(0), 0, 1 ether, 0, PUBKEY20, USER, false, address(0), 0 + ); + } + + function test_RevertWhen_InvalidContractType() public { + vm.prank(OPERATOR); + vm.expectRevert(bytes("INVALID CONTRACT TYPE FOR FROM-DEPOSIT")); + vault.makeCustomDepositFrom( + USER, address(erc20), 5, 1, 0, PUBKEY20, USER, false, address(0), 0 + ); + } + + function test_RevertWhen_ECOAsContractType1() public { + eco.mint(USER, 100); + vm.prank(USER); + eco.approve(address(vault), 100); + + vm.prank(OPERATOR); + vm.expectRevert(bytes("ECO DEPOSITS MUST USE _contractType 4")); + vault.makeCustomDepositFrom( + USER, address(eco), 1, 100, 0, PUBKEY20, USER, false, address(0), 0 + ); + } + + function test_RevertWhen_NoAuthorizationFields() public { + erc20.mint(USER, 100); + vm.prank(USER); + erc20.approve(address(vault), 100); + + vm.prank(OPERATOR); + vm.expectRevert(bytes("DEPOSIT MUST HAVE AUTH")); + vault.makeCustomDepositFrom( + USER, address(erc20), 1, 100, 0, + address(0), // no pubKey20 + USER, + false, + address(0), // no recipient + 0 + ); + } + + // ─── Regression: original makeCustomDeposit semantics unchanged ──────── + + function test_OriginalMakeCustomDepositStillPullsFromMsgSender() public { + erc20.mint(OPERATOR, 100); + vm.prank(OPERATOR); + erc20.approve(address(vault), 100); + + vm.prank(OPERATOR); + vault.makeCustomDeposit( + address(erc20), 1, 100, 0, PUBKEY20, + USER, // _onBehalfOf + false, address(0), 0, + false, "" // 3009 disabled + ); + + assertEq(erc20.balanceOf(OPERATOR), 0, "old function still pulls from msg.sender"); + assertEq(erc20.balanceOf(address(vault)), 100); + } +}