From 34180c02ae9559aba9ba24c680d0a8fdd839bf5d Mon Sep 17 00:00:00 2001 From: douglasacost Date: Tue, 12 May 2026 16:01:31 -0400 Subject: [PATCH 01/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] =?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/49] 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/49] 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/49] =?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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] =?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/49] 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/49] 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/49] 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/49] 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/49] 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/49] =?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/49] =?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/49] =?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/49] =?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/49] =?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/49] 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/49] 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/49] 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); + } +} From b9a3937dbc868b8f3c7d3e27a3eefdc6d2682559 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 18 May 2026 10:06:04 -0400 Subject: [PATCH 32/49] chore(spellcheck): whitelist 'funder' (used in EnvelopeVault docs + tests) --- .cspell.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index e29af002..853567fc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -122,6 +122,7 @@ "defi", "MAGICVALUE", "unhashed", - "Hashbinary" + "Hashbinary", + "funder" ] } From 1b55826f18668a4d1bfc7104d3a361d582f22905 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 18 May 2026 12:49:51 -0400 Subject: [PATCH 33/49] revert: drop EnvelopeApprovalPaymaster + makeCustomDepositFrom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator-relayed deposit flow has been retired: - The operator/controller submits its own txs and pays its own gas; no paymaster needed for that path. - The user pays for their own approve and their own deposit. So both pieces of the operator-orchestration stack come out: EnvelopeApprovalPaymaster - src/paymasters/EnvelopeApprovalPaymaster.sol (deleted) - test/paymasters/EnvelopeApprovalPaymaster.t.sol (deleted, was 27 tests) - hardhat-deploy/DeployEnvelopePaymaster.ts (deleted) - src/envelope/doc/EnvelopeApprovalPaymaster.md (deleted) - On-chain (Sepolia): paymaster 0xbA6a646B drained (0.00121 ETH → deployer), no longer referenced in .env-test. makeCustomDepositFrom (and its _pullTokensFromViaApproval helper) - Removed from src/envelope/V4/EnvelopeVault.sol; vault modification notice reverted to its pre-2026-05-14 form. - test/envelope/MakeCustomDepositFrom.t.sol deleted (was 13 tests). - The deployed vault 0xed414522 still has this function as inert dead code; no on-chain redeploy bundled with this commit. Knock-on cleanup: - .cspell.json: drop 'funder' (only use was in the removed code) - src/envelope/doc/README.md: drop paymaster row, simplify Path C, refresh test counts (979 → 939) - src/envelope/doc/EnvelopeVault.md: remove makeCustomDepositFrom row, drop the Operator-orchestrated deposits section, refresh test counts Full repo: 939/939 tests pass. cspell: 0/238 issues. hardhat compile: clean. --- .cspell.json | 3 +- hardhat-deploy/DeployEnvelopePaymaster.ts | 183 ------ src/envelope/V4/EnvelopeVault.sol | 92 +-- src/envelope/doc/EnvelopeApprovalPaymaster.md | 350 ----------- src/envelope/doc/EnvelopeVault.md | 23 +- src/envelope/doc/README.md | 32 +- src/paymasters/EnvelopeApprovalPaymaster.sol | 234 ------- test/envelope/MakeCustomDepositFrom.t.sol | 224 ------- .../EnvelopeApprovalPaymaster.t.sol | 590 ------------------ 9 files changed, 21 insertions(+), 1710 deletions(-) delete mode 100644 hardhat-deploy/DeployEnvelopePaymaster.ts delete mode 100644 src/envelope/doc/EnvelopeApprovalPaymaster.md delete mode 100644 src/paymasters/EnvelopeApprovalPaymaster.sol delete mode 100644 test/envelope/MakeCustomDepositFrom.t.sol delete mode 100644 test/paymasters/EnvelopeApprovalPaymaster.t.sol diff --git a/.cspell.json b/.cspell.json index 853567fc..e29af002 100644 --- a/.cspell.json +++ b/.cspell.json @@ -122,7 +122,6 @@ "defi", "MAGICVALUE", "unhashed", - "Hashbinary", - "funder" + "Hashbinary" ] } diff --git a/hardhat-deploy/DeployEnvelopePaymaster.ts b/hardhat-deploy/DeployEnvelopePaymaster.ts deleted file mode 100644 index 88a99722..00000000 --- a/hardhat-deploy/DeployEnvelopePaymaster.ts +++ /dev/null @@ -1,183 +0,0 @@ -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). - * - ENVELOPE_VAULT: Address of the deployed 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 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. - * 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. - * - 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: ENVELOPE_VAULT (so operator can call the vault directly). - * - * 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.ENVELOPE_VAULT; - if (!envelopeVault || envelopeVault === ZERO) { - throw new Error("ENVELOPE_VAULT env var is required (the deployed Envelope 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.ENVELOPE_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; - - 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); - 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("Mode B operators: ", initialOperators.length > 0 ? initialOperators : "(none — seed later)"); - console.log("Mode B targets: ", initialTargets); - 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}`); - } - - // 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); - 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/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index 0a5b9d71..7532beaf 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -1,10 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -// 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. +// Modified by Nodle (2026-05-12) — see src/envelope/doc/EnvelopeVault.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. pragma solidity ^0.8.26; ////////////////////////////////////////////////////////////////////////////////////// @@ -350,59 +349,6 @@ 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, @@ -496,36 +442,6 @@ 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 deleted file mode 100644 index 298a9ee1..00000000 --- a/src/envelope/doc/EnvelopeApprovalPaymaster.md +++ /dev/null @@ -1,350 +0,0 @@ -# EnvelopeApprovalPaymaster — Path-C gas sponsor - -`src/paymasters/EnvelopeApprovalPaymaster.sol` - -## Purpose - -Sponsors gas in **two modes**, both funded from one ETH pool and bounded by the same per-tx cap + daily QuotaControl: - -| 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.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 [`0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD`](https://sepolia.explorer.zksync.io/address/0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD#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; - -// 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: -- `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` — 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] -B. paymasterInput flow == IPaymasterFlow.general [WrongFlow] -C. Grant: - - paymasterInput length >= 4 [InvalidPaymasterInput] - - block.timestamp <= deadline [GrantExpired] - - !isNonceUsed[nonce] [NonceAlreadyUsed] - - 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] - - 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 -// Mode A — rotate the EIP-712 grant signer -function setOperatorSigner(address newSigner) external onlyRole(DEFAULT_ADMIN_ROLE); - -// 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)`, `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 - -```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(); // 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: - -```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 - -### 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` (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` | -| Operator-signer key compromise | Bounded by `maxEthPerTx` per tx AND quota per day. Admin rotates via `setOperatorSigner` | - -### 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) - -| 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 = "0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD"; -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 ENVELOPE_VAULT -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` — **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 -- **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/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md index 50acfe0a..92a860cb 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -70,7 +70,6 @@ All deposit functions are `payable` (ETH path uses `msg.value`) and `nonReentran | `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. 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`. @@ -109,23 +108,7 @@ All withdraws set `claimed = true` BEFORE the asset transfer (CEI). `nonReentran | 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. - -## 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. +For ERC-20, the depositor must approve the vault first (Path C). The user pays for that approve themselves; the deposit tx is then submitted by the depositor calling `makeCustomDeposit` directly. ## Receiver hooks (S1 hardening) @@ -180,7 +163,6 @@ 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 @@ -189,6 +171,5 @@ Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with arra | 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 + T5) | `test/envelope/EnvelopeHardening.t.sol` | | Edge cases | `test/envelope/EnvelopeEdgeCases.t.sol` | -| `makeCustomDepositFrom` | `test/envelope/MakeCustomDepositFrom.t.sol` | -103 tests pass. +90 tests pass. diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 26383755..33a4ee54 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -1,9 +1,8 @@ # 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 / -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. +contracts. Senders deposit assets (ETH / ERC-20 / ERC-721 / ERC-1155) against a +per-link public key; recipients claim with the matching private key. ## Layout @@ -11,7 +10,6 @@ sponsors the user-side approval txs so the UX is gasless from the holder's POV. |---|---|---| | `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): @@ -28,7 +26,6 @@ This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) doesn't apply u |---|---|---| | `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 | | `test/envelope/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained | | All other repo files | unchanged | Whatever they were | @@ -37,8 +34,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. `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. +- **Source files** carry the Envelope brand (`EnvelopeVault.sol`, `EnvelopeBatcher.sol`); the audit lineage to upstream `peanutprotocol/peanut-contracts@main` is preserved via the `// Modified by Nodle` top-of-file notice, the `// @author Squirrel Labs` attribution, the bundled `LICENSE-GPL`, and the git rename history. +- **Contract symbols** (the names visible on the explorer / in the SDK / in the EIP-712 domain) use the Envelope brand: `EnvelopeVault`, `EnvelopeBatcher`. 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) @@ -47,33 +44,32 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s |---|---| | `EnvelopeVault` | [`0xed414522b1Fbe08EEfd156f912a57CF345A55735`](https://sepolia.explorer.zksync.io/address/0xed414522b1Fbe08EEfd156f912a57CF345A55735#contract) | | `EnvelopeBatcher` | [`0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1`](https://sepolia.explorer.zksync.io/address/0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1#contract) | -| `EnvelopeApprovalPaymaster` | [`0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD`](https://sepolia.explorer.zksync.io/address/0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD#contract) | ## 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) | `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) | +| Path | Trigger | Approval | +|---|---|---| +| **A** — ETH | `msg.value` directly into `makeDeposit` / `makeCustomDeposit` | n/a | +| **B** — EIP-3009 token (USDC-style) | `makeDepositWithAuthorization` | embedded in signature | +| **C** — anything else (ERC-20, ERC-721, ERC-1155) | `makeCustomDeposit` after `token.approve` / `setApprovalForAll` | separate approval tx | + +User pays for both the approve and the deposit themselves. ## Deploy | Script | Purpose | |---|---| | `hardhat-deploy/DeployEnvelope.ts` | vault + batcher | -| `hardhat-deploy/DeployEnvelopePaymaster.ts` | paymaster | -Both are Hardhat-zksync scripts. See each spec for env vars. +Hardhat-zksync script. See the vault spec for env vars. ## Test coverage | Suite | Tests | |---|---| -| 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) | +| Envelope core (`test/envelope/`) | **90** (56 vendored + 11 hardening + 23 edge cases) | | Other paymasters (unchanged) | 102 | | Rest of repo | 747 | -| **Total** | **979** | +| **Total** | **939** | diff --git a/src/paymasters/EnvelopeApprovalPaymaster.sol b/src/paymasters/EnvelopeApprovalPaymaster.sol deleted file mode 100644 index 1d66eb83..00000000 --- a/src/paymasters/EnvelopeApprovalPaymaster.sol +++ /dev/null @@ -1,234 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause-Clear -pragma solidity ^0.8.26; - -import { - IPaymaster, - 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 {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; -import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "./BasePaymaster.sol"; -import {QuotaControl} from "../QuotaControl.sol"; - -/// @title Envelope Approval Paymaster -/// @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, 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 - - bytes32 public constant EIP712_DOMAIN_TYPEHASH = - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); - bytes32 public constant GRANT_TYPEHASH = - keccak256("EnvelopeApprovalGrant(address user,uint256 deadline,bytes32 nonce)"); - - bytes32 public immutable DOMAIN_SEPARATOR; - 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(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(); - error NonceAlreadyUsed(); - error InvalidGrantSignature(); - error UnsupportedSelector(); - error SpenderNotEnvelope(); - error TargetNotAllowed(); - error PerTxLimitExceeded(); - error InsufficientPaymasterBalance(); - error ZeroAddress(); - error Unused(); - - /// @param admin DEFAULT_ADMIN_ROLE - /// @param withdrawer WITHDRAWER_ROLE - /// @param operatorSigner_ EOA or contract whose ECDSA signatures the paymaster will accept as grants - /// @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 envelope_, - uint256 maxEthPerTx_, - uint256 initialQuota, - uint256 initialPeriod - ) BasePaymaster(admin, withdrawer) QuotaControl(initialQuota, initialPeriod, admin) { - if (admin == address(0) || envelope_ == address(0) || operatorSigner_ == address(0)) revert ZeroAddress(); - - envelopeVault = envelope_; - operatorSigner = operatorSigner_; - maxEthPerTx = maxEthPerTx_; - - DOMAIN_SEPARATOR = keccak256( - abi.encode( - EIP712_DOMAIN_TYPEHASH, - keccak256(bytes("EnvelopeApprovalPaymaster")), - keccak256(bytes("1")), - block.chainid, - address(this) - ) - ); - } - - function validateAndPayForPaymasterTransaction(bytes32, bytes32, Transaction calldata transaction) - external - payable - override - returns (bytes4 magic, bytes memory) - { - _mustBeBootloader(); - _requireGeneralFlow(transaction.paymasterInput); - - address from = address(uint160(transaction.from)); - address to = address(uint160(transaction.to)); - uint256 requiredETH = transaction.gasLimit * transaction.maxFeePerGas; - if (requiredETH > maxEthPerTx) revert PerTxLimitExceeded(); - - 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; - } - - /// @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"); - } - if (bytes4(paymasterInput[0:4]) != IPaymasterFlow.general.selector) revert WrongFlow(); - } - - /// @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(); - - bytes32 structHash = keccak256(abi.encode(GRANT_TYPEHASH, user, deadline, nonce)); - bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); - // 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; - } - - /// @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(); - address spender; - // Both target selectors have an `address` as their first argument. - assembly { - spender := calldataload(add(data.offset, 0x04)) - } - if (spender != envelopeVault) revert SpenderNotEnvelope(); - } - - /// @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); - (bool ok,) = BOOTLOADER_FORMAL_ADDRESS.call{value: requiredETH}(""); - if (!ok) revert InsufficientPaymasterBalance(); - } - - /// @dev Unused — full validation lives in `validateAndPayForPaymasterTransaction`. - /// Required because BasePaymaster declares this hook abstract. - function _validateAndPayGeneralFlow(address, address, uint256) internal pure override { - revert Unused(); - } - - /// @dev Unused — only the `general` flow is supported. - function _validateAndPayApprovalBasedFlow(address, address, address, uint256, bytes memory, uint256) - internal - pure - override - { - revert PaymasterFlowNotSupported(); - } - - // ── Admin ────────────────────────────────────────────────────────────── - - function setOperatorSigner(address newSigner) external onlyRole(DEFAULT_ADMIN_ROLE) { - if (newSigner == address(0)) revert ZeroAddress(); - 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/test/envelope/MakeCustomDepositFrom.t.sol b/test/envelope/MakeCustomDepositFrom.t.sol deleted file mode 100644 index 5d16aec2..00000000 --- a/test/envelope/MakeCustomDepositFrom.t.sol +++ /dev/null @@ -1,224 +0,0 @@ -// 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); - } -} diff --git a/test/paymasters/EnvelopeApprovalPaymaster.t.sol b/test/paymasters/EnvelopeApprovalPaymaster.t.sol deleted file mode 100644 index ce79ce0c..00000000 --- a/test/paymasters/EnvelopeApprovalPaymaster.t.sol +++ /dev/null @@ -1,590 +0,0 @@ -// 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 {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 {SampleWallet} from "../envelope/mocks/SampleSCW.sol"; - -/// @dev Bootloader address — paymaster validation must be called from this address. -address constant BOOTLOADER = address(uint160(0x8001)); - -contract EnvelopeApprovalPaymasterTest is Test { - using AccessControlUtils for Vm; - - EnvelopeApprovalPaymaster paymaster; - - address admin = address(0xA1); - address withdrawer = address(0xA2); - address envelope = address(0xBEEF); - address sponsoredToken = address(0xCAFE); - - uint256 operatorPk = uint256(keccak256("operator-signer")); - address operator; - - 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; - - function setUp() public { - operator = vm.addr(operatorPk); - user = vm.addr(userPk); - - paymaster = new EnvelopeApprovalPaymaster( - admin, withdrawer, operator, envelope, MAX_ETH_PER_TX, QUOTA, PERIOD - ); - vm.deal(address(paymaster), 10 ether); - } - - // ── 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(envelope, 1000); - - uint256 gasLimit = 100_000; - uint256 gasPrice = 1 gwei; - uint256 expectedPay = gasLimit * gasPrice; - - uint256 balBefore = address(paymaster).balance; - uint256 bootBefore = BOOTLOADER.balance; - _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"); - 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(envelope, true); - - _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)); - } - - // ── 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(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei); - - vm.expectRevert(BasePaymaster.AccessRestrictedToBootloader.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(sponsoredToken, _approveCall(envelope, 1), wrongFlowInput, 100_000, 1 gwei); - - vm.prank(BOOTLOADER); - vm.expectRevert(EnvelopeApprovalPaymaster.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(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei); - - vm.prank(BOOTLOADER); - vm.expectRevert(EnvelopeApprovalPaymaster.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(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei)); - - vm.prank(BOOTLOADER); - vm.expectRevert(EnvelopeApprovalPaymaster.NonceAlreadyUsed.selector); - paymaster.validateAndPayForPaymasterTransaction( - bytes32(0), bytes32(0), - _txTo(sponsoredToken, _approveCall(envelope, 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(EnvelopeApprovalPaymaster.InvalidGrantSignature.selector); - paymaster.validateAndPayForPaymasterTransaction( - bytes32(0), bytes32(0), - _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, 100_000, 1 gwei) - ); - } - - function test_revertsOnSignatureForDifferentUser() public { - 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(EnvelopeApprovalPaymaster.InvalidGrantSignature.selector); - paymaster.validateAndPayForPaymasterTransaction( - bytes32(0), bytes32(0), - _txTo(sponsoredToken, _approveCall(envelope, 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, envelope, uint256(1)); - - vm.prank(BOOTLOADER); - vm.expectRevert(EnvelopeApprovalPaymaster.UnsupportedSelector.selector); - paymaster.validateAndPayForPaymasterTransaction( - bytes32(0), bytes32(0), - _txTo(sponsoredToken, data, pmInput, 100_000, 1 gwei) - ); - } - - 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); - // Approve attacker instead of envelope - bytes memory data = _approveCall(address(0xBAD), 1000); - - vm.prank(BOOTLOADER); - vm.expectRevert(EnvelopeApprovalPaymaster.SpenderNotEnvelope.selector); - paymaster.validateAndPayForPaymasterTransaction( - bytes32(0), bytes32(0), - _txTo(sponsoredToken, data, pmInput, 100_000, 1 gwei) - ); - } - - function test_revertsOnPerTxLimitExceeded() public { - 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); - - // 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(EnvelopeApprovalPaymaster.PerTxLimitExceeded.selector); - paymaster.validateAndPayForPaymasterTransaction( - bytes32(0), bytes32(0), - _txTo(sponsoredToken, _approveCall(envelope, 1), pmInput, gasLimit, gasPrice) - ); - } - - function test_revertsOnExceededQuota() public { - // 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; - 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) - ); - - // 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); - tight.validateAndPayForPaymasterTransaction( - bytes32(0), bytes32(0), - _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); - 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(EnvelopeApprovalPaymaster.InsufficientPaymasterBalance.selector); - paymaster.validateAndPayForPaymasterTransaction( - bytes32(0), bytes32(0), - _txTo(sponsoredToken, _approveCall(envelope, 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(sponsoredToken, _approveCall(envelope, 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(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); - } - - // ── Admin ────────────────────────────────────────────────────────────── - - function test_adminCanRotateOperatorSigner() public { - address newSigner = address(0x99); - vm.prank(admin); - paymaster.setOperatorSigner(newSigner); - 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); - 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); - } - - // ── 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. - - 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"); - } -} From ff861c41fccff2ca9b683aa2871d3276d088470a Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 18 May 2026 13:04:59 -0400 Subject: [PATCH 34/49] docs(envelope): refresh Sepolia vault address after redeploy Vault redeployed cleanly without makeCustomDepositFrom in the bytecode. Old: 0xed414522b1Fbe08EEfd156f912a57CF345A55735 (had inert dead code) New: 0x5cf96a5db415801E52a63f216AEE601FAB6B8b11 (chain == source) Batcher unchanged. Paymaster gone (removed in 1b55826). --- src/envelope/doc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 33a4ee54..25000163 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -42,7 +42,7 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s | | Address | |---|---| -| `EnvelopeVault` | [`0xed414522b1Fbe08EEfd156f912a57CF345A55735`](https://sepolia.explorer.zksync.io/address/0xed414522b1Fbe08EEfd156f912a57CF345A55735#contract) | +| `EnvelopeVault` | [`0x5cf96a5db415801E52a63f216AEE601FAB6B8b11`](https://sepolia.explorer.zksync.io/address/0x5cf96a5db415801E52a63f216AEE601FAB6B8b11#contract) | | `EnvelopeBatcher` | [`0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1`](https://sepolia.explorer.zksync.io/address/0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1#contract) | ## Three deposit paths From 6f5a7ed460751d356e21412233a3685376b3ca89 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 19 May 2026 16:17:52 +1200 Subject: [PATCH 35/49] refactor(envelope): remove ECO logic, custom errors, consistent naming - Remove contractType 4 (ECO) and all IL2ECO/ecoAddress references - Delete IL2ECO.sol and L2ECOMock.sol - Convert all require() strings to custom errors - Remove 'DEPOSIT MUST HAVE AUTH' check from _storeDeposit - Rename MFA_AUTHORIZER to mfaAuthorizer (immutable, lowercase) - Constructor now takes single arg: address _mfaAuthorizer - Update all test files to match new interface --- src/envelope/V4/EnvelopeVault.sol | 128 +++++++++++--------------- src/envelope/util/IL2ECO.sol | 8 -- test/envelope/Deposit.t.sol | 2 +- test/envelope/EnvelopeBatcher.t.sol | 2 +- test/envelope/EnvelopeEdgeCases.t.sol | 67 +++----------- test/envelope/EnvelopeGasless.t.sol | 28 +++--- test/envelope/EnvelopeHardening.t.sol | 127 ++----------------------- test/envelope/EnvelopeVault.t.sol | 42 +-------- test/envelope/Integration.t.sol | 2 +- test/envelope/MFA.t.sol | 8 +- test/envelope/RecipientBound.t.sol | 6 +- test/envelope/SenderWithdraw.t.sol | 22 ++--- test/envelope/SigWithdraw.t.sol | 8 +- test/envelope/mocks/L2ECOMock.sol | 27 ------ 14 files changed, 115 insertions(+), 362 deletions(-) delete mode 100644 src/envelope/util/IL2ECO.sol delete mode 100644 test/envelope/mocks/L2ECOMock.sol diff --git a/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index 7532beaf..4920020d 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -46,18 +46,39 @@ import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Re import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; -import {IL2ECO} from "../util/IL2ECO.sol"; import {IEIP3009} from "../util/IEIP3009.sol"; contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { using SafeERC20 for IERC20; + // ── Custom Errors ──────────────────────────────────────────────────────────── + + error InvalidContractType(); + error WrongEthAmount(); + error Erc721AmountMustBeOne(); + error DepositIndexOutOfBounds(); + error DepositAlreadyClaimed(); + error RequiresMfaAuthorization(); + error WrongMfaSignature(); + error WrongSignature(); + error WrongRecipient(); + error NotTheRecipient(); + error NotTheSender(); + error TooEarlyToReclaim(); + error InvalidGaslessReclaimSignature(); + error EthTransferFailed(); + error DirectTransfersNotAllowed(); + error ContractTypeMustBeOneFor3009(); + error Wrong3009OnBehalfOf(); + + // ── Data Structures ────────────────────────────────────────────────────────── + 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 + uint8 contractType; // (1 byte) 0 for eth, 1 for erc20, 2 for erc721, 3 for erc1155 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 @@ -83,7 +104,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { /// @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; + address public immutable mfaAuthorizer; struct EIP712Domain { string name; @@ -99,7 +120,6 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { } Deposit[] public deposits; // array of deposits - address public immutable ecoAddress; // address of the ECO token (set at deploy, never changes) // events event DepositEvent( @@ -110,12 +130,10 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { ); event MessageEvent(string message); - /// @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) { + constructor(address _mfaAuthorizer) { emit MessageEvent("Hello World, have a nutty day!"); - ecoAddress = _ecoAddress; - MFA_AUTHORIZER = _mfaAuthorizer; + mfaAuthorizer = _mfaAuthorizer; DOMAIN_SEPARATOR = hash( EIP712Domain({name: "Envelope", version: "4.4", chainId: block.chainid, verifyingContract: address(this)}) ); @@ -151,7 +169,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { 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"); + if (!valid) revert InvalidGaslessReclaimSignature(); } /** @@ -291,7 +309,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { * 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 _contractType uint8 for the type of contract being sent. 0 for eth, 1 for erc20, 2 for erc721, 3 for erc1155 * @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 @@ -319,7 +337,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { bytes calldata _args3009 ) public payable nonReentrant returns (uint256) { if (_isGasless3009) { - require(_contractType == 1, "_contractType HAS TO BE 1 FOR 3009"); + if (_contractType != 1) revert ContractTypeMustBeOneFor3009(); _amount = _pullTokensVia3009Encoded( _tokenAddress, _amount, @@ -360,11 +378,6 @@ contract EnvelopeVault 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({ @@ -399,52 +412,28 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { uint256 _amount, uint256 _tokenId ) internal returns (uint256) { - // check that the contract type is valid - require(_contractType < 5, "INVALID CONTRACT TYPE"); + if (_contractType > 3) revert InvalidContractType(); - // handle deposit types if (_contractType == 0) { - require(_amount == msg.value, "WRONG ETH AMOUNT"); + if (_amount != msg.value) revert WrongEthAmount(); } 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"); - + if (_amount != 1) revert Erc721AmountMustBeOne(); 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 - // 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; } /** - * Pulls the tokens via EIP-3009 according to the encoded data - * Also validates that _onBehalfOf is the unpacked _from. + * Pulls the tokens via EIP-3009 according to the encoded data. + * Also validates that _onBehalfOf is the unpacked _from. */ function _pullTokensVia3009Encoded( address _tokenAddress, @@ -464,7 +453,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { (_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"); + if (_from != _onBehalfOf) revert Wrong3009OnBehalfOf(); return _pullTokensVia3009(_tokenAddress, _from, _amount, _pubKey20, _nonce, _validAfter, _validBefore, _v, _r, _s); } @@ -532,10 +521,6 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { 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, @@ -570,7 +555,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { override returns (bytes4) { - require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + if (_operator != address(this)) revert DirectTransfersNotAllowed(); return this.onERC721Received.selector; } @@ -582,7 +567,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { uint256, /* _value */ bytes calldata /* _data */ ) external view override returns (bytes4) { - require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + if (_operator != address(this)) revert DirectTransfersNotAllowed(); return this.onERC1155Received.selector; } @@ -594,7 +579,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { uint256[] calldata, /* _values */ bytes calldata /* _data */ ) external view override returns (bytes4) { - require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + if (_operator != address(this)) revert DirectTransfersNotAllowed(); return this.onERC1155BatchReceived.selector; } @@ -639,7 +624,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { ) ); address authorizationSigner = getSigner(digest, _MFASignature); - require(authorizationSigner == MFA_AUTHORIZER, "WRONG MFA SIGNATURE"); + if (authorizationSigner != mfaAuthorizer) revert WrongMfaSignature(); return _withdrawDeposit( _index, @@ -652,7 +637,6 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { /** * @notice Function to withdraw tokens. Must be called by the recipient. - * This is useful for * @return bool true if successful */ function withdrawDepositAsRecipient( @@ -660,7 +644,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { address _recipientAddress, bytes memory _signature ) external nonReentrant returns (bool) { - require(_recipientAddress == msg.sender, "NOT THE RECIPIENT"); + if (_recipientAddress != msg.sender) revert NotTheRecipient(); return _withdrawDeposit( _index, @@ -690,9 +674,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { 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"); + if (_index >= deposits.length) revert DepositIndexOutOfBounds(); Deposit memory _deposit = deposits[_index]; - require(_deposit.claimed == false, "DEPOSIT ALREADY WITHDRAWN"); + if (_deposit.claimed) revert DepositAlreadyClaimed(); // check that the signer is the same as the one stored in the deposit. // Signature may be empty for address-bound deposits. @@ -713,9 +697,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { ); 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"); + if (_deposit.requiresMFA && !_authorized) revert RequiresMfaAuthorization(); + if (_deposit.pubKey20 != address(0) && depositSigner != _deposit.pubKey20) revert WrongSignature(); + if (_deposit.recipient != address(0) && _recipientAddress != _deposit.recipient) revert WrongRecipient(); // emit the withdraw event emit WithdrawEvent(_index, _deposit.contractType, _deposit.amount, _recipientAddress); @@ -727,7 +711,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { if (_deposit.contractType == 0) { /// handle eth deposits (bool success,) = _recipientAddress.call{value: _deposit.amount}(""); - require(success, "Transfer failed"); + if (!success) revert EthTransferFailed(); } else if (_deposit.contractType == 1) { /// handle erc20 deposits IERC20 token = IERC20(_deposit.tokenAddress); @@ -740,31 +724,27 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { /// 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 - uint256 scaledAmount = _deposit.amount / IL2ECO(_deposit.tokenAddress).linearInflationMultiplier(); - IERC20(_deposit.tokenAddress).safeTransfer(_recipientAddress, scaledAmount); } return true; } /** - * @notice Function to allow a sender to withdraw their deposit after 24 hours + * @notice Function to allow a sender to withdraw their deposit * @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"); + if (_index >= deposits.length) revert DepositIndexOutOfBounds(); Deposit memory _deposit = deposits[_index]; - require(_deposit.claimed == false, "DEPOSIT ALREADY WITHDRAWN"); + if (_deposit.claimed) revert DepositAlreadyClaimed(); // check that the sender is the one who made the deposit - require(_deposit.senderAddress == _senderAddress, "NOT THE SENDER"); + if (_deposit.senderAddress != _senderAddress) revert NotTheSender(); // check timestamp for address-bound links if (_deposit.recipient != address(0)) { - require(block.timestamp > _deposit.reclaimableAfter, "TOO EARLY TO RECLAIM"); + if (block.timestamp <= _deposit.reclaimableAfter) revert TooEarlyToReclaim(); } // emit the withdraw event @@ -776,7 +756,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { if (_deposit.contractType == 0) { /// handle eth deposits (bool success,) = payable(_deposit.senderAddress).call{value: _deposit.amount}(""); - require(success, "FAILED TO WITHDRAW ETH TO SENDER"); + if (!success) revert EthTransferFailed(); } else if (_deposit.contractType == 1) { /// handle erc20 deposits IERC20 token = IERC20(_deposit.tokenAddress); @@ -789,10 +769,6 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { /// 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 - uint256 scaledAmount = _deposit.amount / IL2ECO(_deposit.tokenAddress).linearInflationMultiplier(); - IERC20(_deposit.tokenAddress).safeTransfer(_deposit.senderAddress, scaledAmount); } return true; diff --git a/src/envelope/util/IL2ECO.sol b/src/envelope/util/IL2ECO.sol deleted file mode 100644 index cdb3dd24..00000000 --- a/src/envelope/util/IL2ECO.sol +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.26; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -interface IL2ECO is IERC20 { - function linearInflationMultiplier() external view returns (uint256); -} diff --git a/test/envelope/Deposit.t.sol b/test/envelope/Deposit.t.sol index 1edf8e42..37033075 100644 --- a/test/envelope/Deposit.t.sol +++ b/test/envelope/Deposit.t.sol @@ -25,7 +25,7 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/EnvelopeBatcher.t.sol b/test/envelope/EnvelopeBatcher.t.sol index bf9f856b..9f21b890 100644 --- a/test/envelope/EnvelopeBatcher.t.sol +++ b/test/envelope/EnvelopeBatcher.t.sol @@ -19,7 +19,7 @@ contract EnvelopeBatcherTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { batcher = new EnvelopeBatcher(); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index fc2b8fd2..7198e854 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -11,7 +11,6 @@ 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"; -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"; @@ -64,7 +63,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function setUp() public { LINK_PUBKEY20 = vm.addr(LINK_PRIV); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); batcher = new EnvelopeBatcher(); erc20 = new ERC20Mock(); erc721 = new ERC721Mock(); @@ -99,14 +98,14 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // ── EnvelopeVault deposit input validation ────────────────────────────────── function test_RevertWhen_DepositInvalidContractType() public { - // _pullTokensViaApproval rejects contractType >= 5. - vm.expectRevert("INVALID CONTRACT TYPE"); + // _pullTokensViaApproval rejects contractType > 3. + vm.expectRevert(EnvelopeVault.InvalidContractType.selector); 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"); + vm.expectRevert(EnvelopeVault.WrongEthAmount.selector); vault.makeDeposit{value: 100}(address(0), 0, 50, 0, LINK_PUBKEY20); } @@ -114,24 +113,15 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // contractType==2 requires _amount == 1. erc721.mint(address(this), 1); erc721.approve(address(vault), 1); - vm.expectRevert("AMOUNT MUST BE 1 FOR ERC721"); + vm.expectRevert(EnvelopeVault.Erc721AmountMustBeOne.selector); vault.makeDeposit(address(erc721), 2, 2, 1, LINK_PUBKEY20); } - function test_RevertWhen_DepositEcoTokenViaPlainErc20() public { - // Deploying with _ecoAddress = testToken forces contractType==4 for that token. - 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); - } - // ── EnvelopeVault withdraw input validation ───────────────────────────────── function test_RevertWhen_WithdrawIndexOutOfBounds() public { bytes memory sig = _signWithdrawal(99, ALICE, LINK_PRIV); - vm.expectRevert("DEPOSIT INDEX DOES NOT EXIST"); + vm.expectRevert(EnvelopeVault.DepositIndexOutOfBounds.selector); vault.withdrawDeposit(99, ALICE, sig); } @@ -140,7 +130,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); vault.withdrawDeposit(idx, ALICE, sig); - vm.expectRevert("DEPOSIT ALREADY WITHDRAWN"); + vm.expectRevert(EnvelopeVault.DepositAlreadyClaimed.selector); vault.withdrawDeposit(idx, ALICE, sig); } @@ -150,7 +140,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { uint256 wrongKey = uint256(keccak256("wrong-signer")); bytes memory sig = _signWithdrawal(idx, ALICE, wrongKey); - vm.expectRevert("WRONG SIGNATURE"); + vm.expectRevert(EnvelopeVault.WrongSignature.selector); vault.withdrawDeposit(idx, ALICE, sig); } @@ -174,7 +164,7 @@ contract EnvelopeEdgeCasesTest 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"); + vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); vault.withdrawDepositAsRecipient(idx, ALICE, sig); } @@ -186,7 +176,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // 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"); + vm.expectRevert(EnvelopeVault.WrongRecipient.selector); vault.withdrawDeposit(idx, BOB, sig); } @@ -195,7 +185,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { 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"); + vm.expectRevert(EnvelopeVault.TooEarlyToReclaim.selector); vault.withdrawDepositSender(idx); vm.warp(reclaimAfter + 1); @@ -205,16 +195,16 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function test_RevertWhen_SenderReclaimNotTheSender() public { uint256 idx = _depositEth(1 ether); vm.prank(ALICE); - vm.expectRevert("NOT THE SENDER"); + vm.expectRevert(EnvelopeVault.NotTheSender.selector); vault.withdrawDepositSender(idx); } function test_RevertWhen_MFADepositWithoutMFASignature() public { - // vault is deployed with MFA_AUTHORIZER == address(0), so MFA-flagged + // vault is deployed with mfaAuthorizer == address(0), so MFA-flagged // deposits can never be withdrawn via withdrawDeposit (REQUIRES AUTHORIZATION). 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"); + vm.expectRevert(EnvelopeVault.RequiresMfaAuthorization.selector); vault.withdrawDeposit(idx, ALICE, sig); } @@ -285,8 +275,6 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { } // 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); @@ -332,31 +320,4 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { assertEq(ids.length, 0); assertEq(vault.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(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 - // 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(vault)); - eco.transfer(address(0xdead), 50); - - bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); - 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 949bb2d3..6e185b2c 100644 --- a/test/envelope/EnvelopeGasless.t.sol +++ b/test/envelope/EnvelopeGasless.t.sol @@ -27,7 +27,7 @@ contract EnvelopeVaultGaslessTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); } function testMakeDepositERC20WithAuthorization() public { @@ -88,7 +88,7 @@ contract EnvelopeVaultGaslessTest is Test { uint256 depositIndex, address depositorAddress, bytes32 privateKey, - string memory expectRevert + bytes4 expectedError ) internal { bytes32 digest = _calculateDigest(depositIndex); (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(privateKey), digest); @@ -96,8 +96,8 @@ contract EnvelopeVaultGaslessTest is Test { EnvelopeVault.GaslessReclaim memory reclaimRequest = EnvelopeVault.GaslessReclaim(depositIndex); - if (bytes(expectRevert).length > 0) { - vm.expectRevert(bytes(expectRevert)); + if (expectedError != bytes4(0)) { + vm.expectRevert(expectedError); } vault.withdrawDepositSenderGasless(reclaimRequest, depositorAddress, signature); @@ -109,27 +109,27 @@ contract EnvelopeVaultGaslessTest is Test { uint256 depositIndex2 = _makeDeposit(SAMPLE_ADDRESS); // Test a successful withdrawal of the second deposit - _withdrawDepositSenderGaslessEOA(depositIndex2, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, ""); + _withdrawDepositSenderGaslessEOA(depositIndex2, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, bytes4(0)); // depositIndex2 has already been withdrawn - _withdrawDepositSenderGaslessEOA(depositIndex2, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, "DEPOSIT ALREADY WITHDRAWN"); + _withdrawDepositSenderGaslessEOA(depositIndex2, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, EnvelopeVault.DepositAlreadyClaimed.selector); // Correct depositor address, but wrong private key. // Private key and the provided address don't match. - _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS, SAMPLE_PRIVKEY_2, "INVALID SIGNATURE"); + _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS, SAMPLE_PRIVKEY_2, EnvelopeVault.InvalidGaslessReclaimSignature.selector); // Provided address and private key do match, but they are wrong. - _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS_2, SAMPLE_PRIVKEY_2, "NOT THE SENDER"); + _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS_2, SAMPLE_PRIVKEY_2, EnvelopeVault.NotTheSender.selector); // 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"); + _withdrawDepositSenderGaslessEOA(depositIndex3, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, EnvelopeVault.NotTheSender.selector); // Withdraw both - _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, ""); - _withdrawDepositSenderGaslessEOA(depositIndex3, SAMPLE_ADDRESS_2, SAMPLE_PRIVKEY_2, ""); + _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, bytes4(0)); + _withdrawDepositSenderGaslessEOA(depositIndex3, SAMPLE_ADDRESS_2, SAMPLE_PRIVKEY_2, bytes4(0)); } // Test that smart contract wallets are able to withdraw gaslessly too @@ -143,13 +143,13 @@ contract EnvelopeVaultGaslessTest is Test { EnvelopeVault.GaslessReclaim memory reclaimRequest = EnvelopeVault.GaslessReclaim(depositIndex); // Submit a wrong signature - vm.expectRevert("INVALID SIGNATURE"); + vm.expectRevert(EnvelopeVault.InvalidGaslessReclaimSignature.selector); vault.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"); + _withdrawDepositSenderGaslessEOA(depositIndex, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, EnvelopeVault.NotTheSender.selector); // Withdraw! vault.withdrawDepositSenderGasless( @@ -161,7 +161,7 @@ contract EnvelopeVaultGaslessTest is Test { } /** - * Test that we can use makeCustomisableDeposit to deposit gaslessly + * Test that we can use makeCustomDeposit to deposit gaslessly */ function testGaslessViaMakeCustomisableDeposit() public { testToken.mint(SAMPLE_ADDRESS, 1000); diff --git a/test/envelope/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index 241f9929..609fc656 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -4,16 +4,13 @@ pragma solidity 0.8.26; // 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) -// T4 — _storeDeposit rejects deposits with no withdrawal authority (fix for S4) -// T5 — _withdrawDeposit L2ECO branch sends to recipient, not sender (upstream bug fix) +// T2 — mfaAuthorizer is now a per-deploy constructor arg (fix for S3 hardcoded key) 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"; @@ -26,7 +23,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); function setUp() public { - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); } @@ -40,13 +37,13 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { function test_T1_directERC721TransferReverts() public { erc721.mint(address(this), 42); - vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); + vm.expectRevert(EnvelopeVault.DirectTransfersNotAllowed.selector); erc721.safeTransferFrom(address(this), address(vault), 42); } function test_T1_directERC1155TransferReverts() public { erc1155.mint(address(this), 7, 1, ""); - vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); + vm.expectRevert(EnvelopeVault.DirectTransfersNotAllowed.selector); erc1155.safeTransferFrom(address(this), address(vault), 7, 1, ""); } @@ -57,20 +54,20 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { amounts[0] = 1; amounts[1] = 1; erc1155.mint(address(this), 1, 1, ""); erc1155.mint(address(this), 2, 1, ""); - vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); + vm.expectRevert(EnvelopeVault.DirectTransfersNotAllowed.selector); erc1155.safeBatchTransferFrom(address(this), address(vault), ids, amounts, ""); } // ── T2 ───────────────────────────────────────────────────────────────── - // MFA_AUTHORIZER is now per-deploy. Prove a freshly-deployed EnvelopeVault + // mfaAuthorizer 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.vault.mfa-test-signer")); address mfaSigner = vm.addr(mfaPrivKey); - EnvelopeVault nodleVault = new EnvelopeVault(address(0), mfaSigner); - assertEq(nodleVault.MFA_AUTHORIZER(), mfaSigner, "constructor arg ignored"); + EnvelopeVault nodleVault = new EnvelopeVault(mfaSigner); + assertEq(nodleVault.mfaAuthorizer(), mfaSigner, "constructor arg ignored"); // make an MFA-gated deposit, then craft both signatures with our test keys. uint256 depositPrivKey = uint256(keccak256("nodle.vault.deposit-key")); @@ -96,7 +93,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { (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) + // MFA signature (signed by configured mfaAuthorizer) bytes32 mfaHash = MessageHashUtilsLite.toEthSignedMessageHash( keccak256( abi.encodePacked( @@ -129,112 +126,6 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { vm.expectRevert(); vault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); } - - // ── 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"); - vault.makeDeposit{value: 1 wei}(address(0), 0, 1, 0, address(0)); - } - - function test_T4_dualZeroCustomDepositRejected() public { - vm.expectRevert("DEPOSIT MUST HAVE AUTH"); - 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 = vault.makeDeposit{value: 1 wei}(address(0), 0, 1, 0, PUBKEY20); - assertEq(idx, 0); - } - - function test_T4_recipientOnlyAccepted() public { - uint256 idx = vault.makeCustomDeposit{value: 1 wei}( - address(0), 0, 1, 0, address(0), address(this), false, ALICE, uint40(0), false, "" - ); - 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(vault), 100); - - vm.prank(sender); - 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(vault)), 100, "vault should hold raw tokens"); - assertEq(eco.balanceOf(sender), 0, "sender's tokens should be in the vault"); - 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( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - idx, - recipient, - vault.ANYONE_WITHDRAWAL_MODE() - ) - ) - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(depositPrivKey, digest); - bytes memory sig = abi.encodePacked(r, s, v); - 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(vault)), 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(vault), 50); - vm.prank(sender); - uint256 idx = vault.makeDeposit(address(eco), 4, 50, 0, pubKey20); - - assertEq(eco.balanceOf(sender), 0); - - vm.prank(sender); - vault.withdrawDepositSender(idx); - - assertEq(eco.balanceOf(sender), 50, "sender reclaim should return the tokens"); - assertEq(eco.balanceOf(address(vault)), 0); - } } /// @dev Local copy of OZ's MessageHashUtils.toEthSignedMessageHash to avoid pulling diff --git a/test/envelope/EnvelopeVault.t.sol b/test/envelope/EnvelopeVault.t.sol index 717d4e90..94887961 100644 --- a/test/envelope/EnvelopeVault.t.sol +++ b/test/envelope/EnvelopeVault.t.sol @@ -30,17 +30,15 @@ contract EnvelopeVaultTest is Test { testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); // Mint tokens for test accounts testToken.mint(address(this), 1000); testToken721.mint(address(this), 1); - // testToken1155.mint(address(this), 1, 1000, ""); // Approve EnvelopeVault to spend tokens testToken.approve(address(vault), 1000); testToken721.setApprovalForAll(address(vault), true); - // testToken1155.setApprovalForAll(address(vault), true); } function testContractCreation() public { @@ -50,7 +48,6 @@ contract EnvelopeVaultTest is Test { function testMakeDepositERC20() public { uint256 amount = 100; - // Moved minting and approval to the setup function uint256 depositIndex = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); assertEq(depositIndex, 0, "Deposit failed"); @@ -63,61 +60,28 @@ contract EnvelopeVaultTest is Test { // Make a deposit on behalf of 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"); + // Deposit was made on behalf of other address, so we can't withdraw + vm.expectRevert(EnvelopeVault.NotTheSender.selector); vault.withdrawDepositSender(depositIndex); vm.prank(SAMPLE_ADDRESS); // selfless deposit's owner can reclaim 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 vaultECO = new EnvelopeVault(address(testToken), address(0)); - - // approve tokens to be spent by the new vault instance - testToken.approve(address(vault), 1000); - - // Test!!!!!!!! - vm.expectRevert("ECO DEPOSITS MUST USE _contractType 4"); - 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 = vault.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); assertEq(depositIndex, 0, "Deposit failed"); assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); } - // function testMakeDepositERC1155() public { - // uint256 tokenId = 1; - // uint256 amount = 100; - - // // Moved minting and approval to the setup function - // uint256 depositIndex = vault.makeDeposit( - // address(testToken1155), - // 3, - // amount, - // tokenId, - // PUBKEY20 - // ); - - // assertEq(depositIndex, 0, "Deposit failed"); - // assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); - // } - // test sender withdrawal function testSenderTimeWithdraw() public { uint256 amount = 1000; assertEq(testToken.balanceOf(address(vault)), 0, "Contract balance mismatch"); - // Moved minting and approval to the setup function uint256 depositIndex = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); assertEq(depositIndex, 0, "Deposit failed"); diff --git a/test/envelope/Integration.t.sol b/test/envelope/Integration.t.sol index 985cda1c..8d0fa4bf 100644 --- a/test/envelope/Integration.t.sol +++ b/test/envelope/Integration.t.sol @@ -25,7 +25,7 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/MFA.t.sol b/test/envelope/MFA.t.sol index e1da7ff0..4b97009e 100644 --- a/test/envelope/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -16,7 +16,7 @@ contract EnvelopeVaultMFATest is Test { address public constant LEGACY_MFA_AUTHORIZER = 0x3B14D43Bf521EF7FD9600533bEB73B6e9178DE7C; function setUp() public { - vault = new EnvelopeVault(address(0), LEGACY_MFA_AUTHORIZER); + vault = new EnvelopeVault(LEGACY_MFA_AUTHORIZER); } function testMFADeposit() public { @@ -44,11 +44,11 @@ contract EnvelopeVaultMFATest is Test { bytes memory signature = abi.encodePacked(r, s, v); // Withdrawing without authorization, so should fail - vm.expectRevert("REQUIRES AUTHORIZATION"); + vm.expectRevert(EnvelopeVault.RequiresMfaAuthorization.selector); vault.withdrawDeposit(depositIndex, address(this), signature); // Withdrawing with incorrect authorization signature - vm.expectRevert("WRONG MFA SIGNATURE"); + vm.expectRevert(EnvelopeVault.WrongMfaSignature.selector); vault.withdrawMFADeposit(depositIndex, address(this), signature, signature); // Authorization is correct! Withdrawal has to be successful! @@ -57,4 +57,4 @@ contract EnvelopeVaultMFATest is Test { } receive () payable external {} -} \ No newline at end of file +} diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index d49c9514..2d0c77f4 100644 --- a/test/envelope/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -22,7 +22,7 @@ contract RecipientBoundTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); testToken.mint(address(this), 1000); testToken.approve(address(vault), 1000); } @@ -45,7 +45,7 @@ contract RecipientBoundTest is Test { 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"); + vm.expectRevert(EnvelopeVault.WrongRecipient.selector); vault.withdrawDeposit(depositIndex, address(this), bytes("")); vault.withdrawDeposit(depositIndex, SAMPLE_ADDRESS, bytes("")); @@ -72,7 +72,7 @@ contract RecipientBoundTest is Test { require(testToken.balanceOf(address(this)) == 0, "TOKEN WAS NOT CHARGED!"); // Try to reclaim, but it's too early - vm.expectRevert("TOO EARLY TO RECLAIM"); + vm.expectRevert(EnvelopeVault.TooEarlyToReclaim.selector); vault.withdrawDepositSender(depositIndex); vm.warp(block.timestamp + 11); // advance past reclaimableAfter diff --git a/test/envelope/SenderWithdraw.t.sol b/test/envelope/SenderWithdraw.t.sol index a289ed3c..ec8f63a3 100644 --- a/test/envelope/SenderWithdraw.t.sol +++ b/test/envelope/SenderWithdraw.t.sol @@ -19,7 +19,7 @@ contract TestSenderWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(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"); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); testToken = new ERC20Mock(); // contractType 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"); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); testToken = new ERC721Mock(); // contractType 2 // Mint token for test @@ -106,23 +106,19 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { bytes32 public constant PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; uint256 _depositIdx; - uint256 _tokenId = 1; // tokenId used for ERC1155 - uint256 _tokenAmount = 100; // amount of ERC1155 tokens + uint256 _tokenId = 1; - // apparently not possible to fuzz test in setUp() function? function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(0)); - testToken = new ERC1155Mock(); // contractType 3 - - // Mint tokens for test - testToken.mint(address(this), _tokenId, _tokenAmount, ""); + vault = new EnvelopeVault(address(0)); + testToken = new ERC1155Mock(); - // Approve the contract to spend the tokens + // Mint tokens + testToken.mint(address(this), _tokenId, 100, ""); testToken.setApprovalForAll(address(vault), true); // Make a deposit - _depositIdx = vault.makeDeposit(address(testToken), 3, _tokenAmount, _tokenId, PUBKEY20); + _depositIdx = vault.makeDeposit(address(testToken), 3, 100, _tokenId, PUBKEY20); } function testSenderWithdrawErc1155() public { diff --git a/test/envelope/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol index ba551091..03bee7f6 100644 --- a/test/envelope/SigWithdraw.t.sol +++ b/test/envelope/SigWithdraw.t.sol @@ -24,7 +24,7 @@ contract TestSigWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(0)); + vault = new EnvelopeVault(address(0)); } // test sender withdrawal of ETH @@ -33,7 +33,7 @@ contract TestSigWithdrawEther is Test { uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); // Can't use withdrawDepositAsRecipient - vm.expectRevert("NOT THE RECIPIENT"); + vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureAnybody); // Anybody can withdraw @@ -45,11 +45,11 @@ contract TestSigWithdrawEther is Test { uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); // Can't use pure withdrawDeposit - vm.expectRevert("WRONG SIGNATURE"); + vm.expectRevert(EnvelopeVault.WrongSignature.selector); vault.withdrawDeposit(depositIdx, _recipientAddress, signatureRecipient); // Only the recipient is able to withdraw via withdrawDepositAsRecipient - vm.expectRevert("NOT THE RECIPIENT"); + vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); vm.prank(_recipientAddress); // Withdraw! diff --git a/test/envelope/mocks/L2ECOMock.sol b/test/envelope/mocks/L2ECOMock.sol deleted file mode 100644 index d920e767..00000000 --- a/test/envelope/mocks/L2ECOMock.sol +++ /dev/null @@ -1,27 +0,0 @@ -// 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 EnvelopeVault'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 24263a6ead14262bde5506a7eee130f6a13a7623 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 19 May 2026 16:48:57 +1200 Subject: [PATCH 36/49] feat(envelope): add Ownable2Step + backend-signed fee model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inherit Ownable2Step; owner passed in constructor alongside mfaAuthorizer - withdrawMFADeposit now accepts serviceFee + gasAbsorptionFee params - Both fee amounts are included in MFA signature (tamper-proof, backend-determined) - Fees deducted from deposit amount at MFA withdrawal, accumulated per-token - withdrawFees(address token) onlyOwner (pull pattern) - No on-chain percentage/cap config — backend has full fee discretion - New errors: FeeExceedsDepositAmount, NoFeesToWithdraw - New events: FeeCollected, FeesWithdrawn - MFA test rewritten to use vm.sign (old hardcoded sig incompatible with new payload) --- src/envelope/V4/EnvelopeVault.sol | 64 ++++++++-- test/envelope/Deposit.t.sol | 2 +- test/envelope/EnvelopeBatcher.t.sol | 2 +- test/envelope/EnvelopeEdgeCases.t.sol | 2 +- test/envelope/EnvelopeGasless.t.sol | 2 +- test/envelope/EnvelopeHardening.t.sol | 16 ++- test/envelope/EnvelopeVault.t.sol | 2 +- test/envelope/Integration.t.sol | 2 +- test/envelope/MFA.t.sol | 170 ++++++++++++++++++++++---- test/envelope/RecipientBound.t.sol | 2 +- test/envelope/SenderWithdraw.t.sol | 8 +- test/envelope/SigWithdraw.t.sol | 2 +- 12 files changed, 228 insertions(+), 46 deletions(-) diff --git a/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index 4920020d..19b80404 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -46,9 +46,10 @@ import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Re import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {IEIP3009} from "../util/IEIP3009.sol"; -contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { +contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ownable2Step { using SafeERC20 for IERC20; // ── Custom Errors ──────────────────────────────────────────────────────────── @@ -70,6 +71,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { error DirectTransfersNotAllowed(); error ContractTypeMustBeOneFor3009(); error Wrong3009OnBehalfOf(); + error FeeExceedsDepositAmount(); + error NoFeesToWithdraw(); // ── Data Structures ────────────────────────────────────────────────────────── @@ -121,6 +124,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { Deposit[] public deposits; // array of deposits + /// @notice Accumulated fees per token address (address(0) for ETH). + mapping(address => uint256) public accumulatedFees; + // events event DepositEvent( uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _senderAddress @@ -128,10 +134,15 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { event WithdrawEvent( uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _recipientAddress ); + event FeeCollected( + uint256 indexed _index, address indexed tokenAddress, uint256 serviceFee, uint256 gasAbsorptionFee + ); + event FeesWithdrawn(address indexed tokenAddress, uint256 amount); event MessageEvent(string message); /// @param _mfaAuthorizer address authorized to sign MFA withdraw approvals (use address(0) to disable MFA). - constructor(address _mfaAuthorizer) { + /// @param _owner initial owner of the contract (receives accumulated fees). + constructor(address _mfaAuthorizer, address _owner) Ownable(_owner) { emit MessageEvent("Hello World, have a nutty day!"); mfaAuthorizer = _mfaAuthorizer; DOMAIN_SEPARATOR = hash( @@ -602,16 +613,24 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { } /** - * @notice Function to withdraw tokens with MFA. - * @return bool true if successful + * @notice Function to withdraw tokens with MFA. Fees are backend-signed flat amounts + * denominated in the deposit's token. + * @param _index deposit index + * @param _recipientAddress address to receive the deposit (minus fees) + * @param _signature withdrawal signature from the deposit's pubKey20 + * @param _MFASignature backend signature authorizing this withdrawal and specifying fees + * @param _serviceFee flat fee (in deposit token units) for the MFA service + * @param _gasAbsorptionFee flat fee (in deposit token units) to cover gasless claim; 0 if not absorbing */ function withdrawMFADeposit( uint256 _index, address _recipientAddress, bytes memory _signature, - bytes memory _MFASignature + bytes memory _MFASignature, + uint256 _serviceFee, + uint256 _gasAbsorptionFee ) external nonReentrant returns (bool) { - // Verify the MFA signature + // Verify the MFA signature (includes fee amounts to prevent tampering) bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( @@ -619,13 +638,25 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { block.chainid, address(this), _index, - _recipientAddress + _recipientAddress, + _serviceFee, + _gasAbsorptionFee ) ) ); address authorizationSigner = getSigner(digest, _MFASignature); if (authorizationSigner != mfaAuthorizer) revert WrongMfaSignature(); + // Deduct fees from deposit amount before withdrawal + uint256 totalFee = _serviceFee + _gasAbsorptionFee; + if (totalFee > 0) { + Deposit storage dep = deposits[_index]; + if (totalFee > dep.amount) revert FeeExceedsDepositAmount(); + dep.amount -= totalFee; + accumulatedFees[dep.tokenAddress] += totalFee; + emit FeeCollected(_index, dep.tokenAddress, _serviceFee, _gasAbsorptionFee); + } + return _withdrawDeposit( _index, _recipientAddress, @@ -853,5 +884,24 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard { return _deposits; } + /** + * @notice Withdraw accumulated fees for a given token. Only callable by owner. + * @param _tokenAddress token to withdraw fees for (address(0) for ETH) + */ + function withdrawFees(address _tokenAddress) external onlyOwner nonReentrant { + uint256 amount = accumulatedFees[_tokenAddress]; + if (amount == 0) revert NoFeesToWithdraw(); + accumulatedFees[_tokenAddress] = 0; + + if (_tokenAddress == address(0)) { + (bool success,) = msg.sender.call{value: amount}(""); + if (!success) revert EthTransferFailed(); + } else { + IERC20(_tokenAddress).safeTransfer(msg.sender, amount); + } + + emit FeesWithdrawn(_tokenAddress, amount); + } + // and that's all! Have a nutty day! } diff --git a/test/envelope/Deposit.t.sol b/test/envelope/Deposit.t.sol index 37033075..5d4a9a8a 100644 --- a/test/envelope/Deposit.t.sol +++ b/test/envelope/Deposit.t.sol @@ -25,7 +25,7 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/EnvelopeBatcher.t.sol b/test/envelope/EnvelopeBatcher.t.sol index 9f21b890..4fbdc1e5 100644 --- a/test/envelope/EnvelopeBatcher.t.sol +++ b/test/envelope/EnvelopeBatcher.t.sol @@ -19,7 +19,7 @@ contract EnvelopeBatcherTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { batcher = new EnvelopeBatcher(); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index 7198e854..ea2bfd5b 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -63,7 +63,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function setUp() public { LINK_PUBKEY20 = vm.addr(LINK_PRIV); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); batcher = new EnvelopeBatcher(); erc20 = new ERC20Mock(); erc721 = new ERC721Mock(); diff --git a/test/envelope/EnvelopeGasless.t.sol b/test/envelope/EnvelopeGasless.t.sol index 6e185b2c..de77721b 100644 --- a/test/envelope/EnvelopeGasless.t.sol +++ b/test/envelope/EnvelopeGasless.t.sol @@ -27,7 +27,7 @@ contract EnvelopeVaultGaslessTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); } function testMakeDepositERC20WithAuthorization() public { diff --git a/test/envelope/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index 609fc656..af885d5b 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -23,7 +23,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); function setUp() public { - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); } @@ -66,7 +66,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { uint256 mfaPrivKey = uint256(keccak256("nodle.vault.mfa-test-signer")); address mfaSigner = vm.addr(mfaPrivKey); - EnvelopeVault nodleVault = new EnvelopeVault(mfaSigner); + EnvelopeVault nodleVault = new EnvelopeVault(mfaSigner, address(this)); assertEq(nodleVault.mfaAuthorizer(), mfaSigner, "constructor arg ignored"); // make an MFA-gated deposit, then craft both signatures with our test keys. @@ -93,7 +93,9 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { (uint8 wv, bytes32 wr, bytes32 ws) = vm.sign(depositPrivKey, wdHash); bytes memory wdSig = abi.encodePacked(wr, ws, wv); - // MFA signature (signed by configured mfaAuthorizer) + // MFA signature (signed by configured mfaAuthorizer, includes fee amounts) + uint256 serviceFee = 0; + uint256 gasAbsorptionFee = 0; bytes32 mfaHash = MessageHashUtilsLite.toEthSignedMessageHash( keccak256( abi.encodePacked( @@ -101,14 +103,16 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { block.chainid, address(nodleVault), idx, - address(this) + address(this), + serviceFee, + gasAbsorptionFee ) ) ); (uint8 mv, bytes32 mr, bytes32 ms) = vm.sign(mfaPrivKey, mfaHash); bytes memory mfaSig = abi.encodePacked(mr, ms, mv); - nodleVault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); + nodleVault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, serviceFee, gasAbsorptionFee); } function test_T2_zeroMfaAuthorizerRejectsAllMfaWithdrawals() public { @@ -124,7 +128,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { bytes memory wdSig = hex"00"; bytes memory mfaSig = hex"00"; vm.expectRevert(); - vault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig); + vault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, 0, 0); } } diff --git a/test/envelope/EnvelopeVault.t.sol b/test/envelope/EnvelopeVault.t.sol index 94887961..8c483af6 100644 --- a/test/envelope/EnvelopeVault.t.sol +++ b/test/envelope/EnvelopeVault.t.sol @@ -30,7 +30,7 @@ contract EnvelopeVaultTest is Test { testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); // Mint tokens for test accounts testToken.mint(address(this), 1000); diff --git a/test/envelope/Integration.t.sol b/test/envelope/Integration.t.sol index 8d0fa4bf..e3bcde91 100644 --- a/test/envelope/Integration.t.sol +++ b/test/envelope/Integration.t.sol @@ -25,7 +25,7 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/MFA.t.sol b/test/envelope/MFA.t.sol index 4b97009e..03a64cd5 100644 --- a/test/envelope/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -11,50 +11,178 @@ contract EnvelopeVaultMFATest 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; + // MFA authorizer key pair for testing + uint256 public constant MFA_PRIVKEY = uint256(keccak256("nodle.vault.mfa-authorizer")); + address public MFA_AUTHORIZER; function setUp() public { - vault = new EnvelopeVault(LEGACY_MFA_AUTHORIZER); + MFA_AUTHORIZER = vm.addr(MFA_PRIVKEY); + vault = new EnvelopeVault(MFA_AUTHORIZER, address(this)); + } + + function _signMfa(uint256 depositIndex, address recipient, uint256 serviceFee, uint256 gasAbsorptionFee) + internal + view + returns (bytes memory) + { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + depositIndex, + recipient, + serviceFee, + gasAbsorptionFee + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIVKEY, digest); + return abi.encodePacked(r, s, v); } function testMFADeposit() public { - uint256 depositIndex = vault.makeSelflessMFADeposit{value: 1}( - 0x0000000000000000000000000000000000000000, - 0, - 1, - 0, - SAMPLE_ADDRESS, - 0x0000000000000000000000000000000000001234); + uint256 depositIndex = vault.makeSelflessMFADeposit{value: 1 ether}( + address(0), + 0, + 1 ether, + 0, + SAMPLE_ADDRESS, + address(0x1234) + ); - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + // Build withdrawal signature + bytes32 wdDigest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( vault.ENVELOPE_SALT(), block.chainid, address(vault), depositIndex, - address(this), // recipient + address(this), vault.ANYONE_WITHDRAWAL_MODE() ) ) ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), digest); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); bytes memory signature = abi.encodePacked(r, s, v); - // Withdrawing without authorization, so should fail + // Withdrawing without authorization should fail vm.expectRevert(EnvelopeVault.RequiresMfaAuthorization.selector); vault.withdrawDeposit(depositIndex, address(this), signature); - // Withdrawing with incorrect authorization signature + // Withdrawing with incorrect MFA signature should fail vm.expectRevert(EnvelopeVault.WrongMfaSignature.selector); - vault.withdrawMFADeposit(depositIndex, address(this), signature, signature); + vault.withdrawMFADeposit(depositIndex, address(this), signature, signature, 0, 0); + + // Correct MFA authorization with zero fees + bytes memory mfaSig = _signMfa(depositIndex, address(this), 0, 0); + vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, 0, 0); + } + + function testMFADepositWithFees() public { + uint256 depositAmount = 1 ether; + uint256 serviceFee = 0.01 ether; + uint256 gasAbsorptionFee = 0.005 ether; + + uint256 depositIndex = vault.makeSelflessMFADeposit{value: depositAmount}( + address(0), + 0, + depositAmount, + 0, + SAMPLE_ADDRESS, + address(0x1234) + ); + + // Build withdrawal signature + bytes32 wdDigest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + depositIndex, + address(this), + vault.ANYONE_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); + bytes memory signature = abi.encodePacked(r, s, v); + + // MFA signature with fees + bytes memory mfaSig = _signMfa(depositIndex, address(this), serviceFee, gasAbsorptionFee); + + uint256 balBefore = address(this).balance; + vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, serviceFee, gasAbsorptionFee); + uint256 balAfter = address(this).balance; + + // Recipient gets deposit minus fees + assertEq(balAfter - balBefore, depositAmount - serviceFee - gasAbsorptionFee); + + // Fees accumulated in contract + assertEq(vault.accumulatedFees(address(0)), serviceFee + gasAbsorptionFee); + } + + function testWithdrawFeesOnlyOwner() public { + // Make deposit + withdraw with fees to accumulate some + uint256 depositAmount = 1 ether; + uint256 serviceFee = 0.01 ether; + + uint256 depositIndex = vault.makeSelflessMFADeposit{value: depositAmount}( + address(0), 0, depositAmount, 0, SAMPLE_ADDRESS, address(0x1234) + ); + + bytes32 wdDigest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + vault.ENVELOPE_SALT(), block.chainid, address(vault), + depositIndex, address(this), vault.ANYONE_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); + bytes memory signature = abi.encodePacked(r, s, v); + bytes memory mfaSig = _signMfa(depositIndex, address(this), serviceFee, 0); + vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, serviceFee, 0); + + // Non-owner cannot withdraw fees + vm.prank(address(0xdead)); + vm.expectRevert(); + vault.withdrawFees(address(0)); + + // Owner can withdraw + uint256 balBefore = address(this).balance; + vault.withdrawFees(address(0)); + assertEq(address(this).balance - balBefore, serviceFee); + assertEq(vault.accumulatedFees(address(0)), 0); + } + + function test_RevertIf_FeeExceedsDeposit() public { + uint256 depositAmount = 0.01 ether; + + uint256 depositIndex = vault.makeSelflessMFADeposit{value: depositAmount}( + address(0), 0, depositAmount, 0, SAMPLE_ADDRESS, address(0x1234) + ); + + bytes32 wdDigest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + vault.ENVELOPE_SALT(), block.chainid, address(vault), + depositIndex, address(this), vault.ANYONE_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); + bytes memory signature = abi.encodePacked(r, s, v); - // Authorization is correct! Withdrawal has to be successful! - bytes memory authorization = hex"41caae599d693a31ea45aab95c8d166e9709cb450f1c76a2b06306ee61cb28b37ed0cad0d47d055580ce204ac9973b671a0970d02f9ee6572a9234f3130707321c"; - vault.withdrawMFADeposit(depositIndex, address(this), signature, authorization); + // Fee exceeds deposit + uint256 bigFee = 1 ether; + bytes memory mfaSig = _signMfa(depositIndex, address(this), bigFee, 0); + vm.expectRevert(EnvelopeVault.FeeExceedsDepositAmount.selector); + vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, bigFee, 0); } - receive () payable external {} + receive() payable external {} } diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index 2d0c77f4..411f8301 100644 --- a/test/envelope/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -22,7 +22,7 @@ contract RecipientBoundTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); testToken.mint(address(this), 1000); testToken.approve(address(vault), 1000); } diff --git a/test/envelope/SenderWithdraw.t.sol b/test/envelope/SenderWithdraw.t.sol index ec8f63a3..5d980c65 100644 --- a/test/envelope/SenderWithdraw.t.sol +++ b/test/envelope/SenderWithdraw.t.sol @@ -19,7 +19,7 @@ contract TestSenderWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); } 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"); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); testToken = new ERC20Mock(); // contractType 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"); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); testToken = new ERC721Mock(); // contractType 2 // Mint token for test @@ -110,7 +110,7 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); testToken = new ERC1155Mock(); // Mint tokens diff --git a/test/envelope/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol index 03bee7f6..58429cbd 100644 --- a/test/envelope/SigWithdraw.t.sol +++ b/test/envelope/SigWithdraw.t.sol @@ -24,7 +24,7 @@ contract TestSigWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0)); + vault = new EnvelopeVault(address(0), address(this)); } // test sender withdrawal of ETH From 1fbd6a2102e5b21cfed8b4a095d2e055c0a418b1 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 19 May 2026 17:15:51 +1200 Subject: [PATCH 37/49] feat(envelope): replace IEIP3009 gasless with sponsored claim/reclaim via IPaymaster - Remove all EIP-3009 based gasless deposit and withdrawal code - Add IPaymaster interface for treasury/paymaster validation - Add withdrawMFADepositSponsored: sponsored claim with serviceFee + gasAbsorptionFee - Add withdrawDepositSenderSponsored: sponsored reclaim via EIP-712 sender auth - gasAbsorptionFee goes directly to treasury, serviceFee accumulates for owner - Delete IEIP3009.sol, EIP3009 mocks, and EnvelopeGasless tests - Add Sponsored.t.sol with 6 tests covering happy path and reverts - Update EnvelopeBatcher, RecipientBound, EdgeCases, ERC20Mock for new signatures --- src/envelope/V4/EnvelopeBatcher.sol | 4 +- src/envelope/V4/EnvelopeVault.sol | 816 +++++++----------- src/envelope/util/IEIP3009.sol | 65 -- src/envelope/util/IPaymaster.sol | 14 + test/envelope/EnvelopeEdgeCases.t.sol | 4 +- test/envelope/EnvelopeGasless.t.sol | 214 ----- test/envelope/RecipientBound.t.sol | 8 +- test/envelope/Sponsored.t.sol | 279 ++++++ test/envelope/mocks/EIP3009Implementation.sol | 42 - test/envelope/mocks/EIP3009Internals.sol | 101 --- test/envelope/mocks/ERC20Mock.sol | 5 +- 11 files changed, 597 insertions(+), 955 deletions(-) delete mode 100644 src/envelope/util/IEIP3009.sol create mode 100644 src/envelope/util/IPaymaster.sol delete mode 100644 test/envelope/EnvelopeGasless.t.sol create mode 100644 test/envelope/Sponsored.t.sol delete mode 100644 test/envelope/mocks/EIP3009Implementation.sol delete mode 100644 test/envelope/mocks/EIP3009Internals.sol diff --git a/src/envelope/V4/EnvelopeBatcher.sol b/src/envelope/V4/EnvelopeBatcher.sol index 7097dd76..56a794cb 100644 --- a/src/envelope/V4/EnvelopeBatcher.sol +++ b/src/envelope/V4/EnvelopeBatcher.sol @@ -177,9 +177,7 @@ contract EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { msg.sender, // deposit owner _withMFAs[i], address(0), // not recipient-bound - uint40(0), - false, // not EIP-3009 - "" // not EIP-3009 + uint40(0) ); } return depositIndexes; diff --git a/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index 19b80404..0d290144 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -47,7 +47,7 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; -import {IEIP3009} from "../util/IEIP3009.sol"; +import {IPaymaster} from "../util/IPaymaster.sol"; contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ownable2Step { using SafeERC20 for IERC20; @@ -69,8 +69,6 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow error InvalidGaslessReclaimSignature(); error EthTransferFailed(); error DirectTransfersNotAllowed(); - error ContractTypeMustBeOneFor3009(); - error Wrong3009OnBehalfOf(); error FeeExceedsDepositAmount(); error NoFeesToWithdraw(); @@ -176,28 +174,24 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow 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); if (!valid) revert InvalidGaslessReclaimSignature(); } /** * @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 + * @dev ERC165 interface detection */ function supportsInterface(bytes4 _interfaceId) external pure override(IERC165) 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 - */ + // ══════════════════════════════════════════════════════════════════════════════ + // Deposit Functions + // ══════════════════════════════════════════════════════════════════════════════ + function makeDeposit( address _tokenAddress, uint8 _contractType, @@ -205,29 +199,10 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow 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 - ); + _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); + return _storeDeposit(_tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, false, address(0), 0); } - /* - * Makes a minimalistic with MFA (requires an external authorisation to withdraw). - * @deprecated makeCustomDeposit should be used for everything - */ function makeMFADeposit( address _tokenAddress, uint8 _contractType, @@ -235,29 +210,10 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow 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 - ); + _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); + return _storeDeposit(_tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, true, address(0), 0); } - /* - * 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, @@ -266,29 +222,10 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow 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 - ); + _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); + return _storeDeposit(_tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, true, address(0), 0); } - /* - * Minimalistic function to make a deposit and delegate ownership. - * @deprecated makeCustomDeposit should be used for everything - */ function makeSelflessDeposit( address _tokenAddress, uint8 _contractType, @@ -297,41 +234,23 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow 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 - ); + _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); + return _storeDeposit(_tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, false, address(0), 0); } /** - * The big main function that supports ALL possible scenarios of depositing. - * @dev For token deposits, allowance must be set before calling this function + * @notice The main function that supports all scenarios of depositing. * @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 - * @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 _contractType 0 for eth, 1 for erc20, 2 for erc721, 3 for erc1155 + * @param _amount amount of tokens being sent + * @param _tokenId 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 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 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). + * @param _recipient if not 0x00, only _recipient will be able to withdraw + * @param _reclaimableAfter if _recipient is set, the sender can reclaim only after this timestamp * @return uint256 index of the deposit - */ + */ function makeCustomDeposit( address _tokenAddress, uint8 _contractType, @@ -340,297 +259,269 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address _pubKey20, address _onBehalfOf, bool _withMFA, - // arguments for address-bound deposits address _recipient, - uint40 _reclaimableAfter, - // arguments for 3009 - bool _isGasless3009, - bytes calldata _args3009 + uint40 _reclaimableAfter ) public payable nonReentrant returns (uint256) { - if (_isGasless3009) { - if (_contractType != 1) revert ContractTypeMustBeOneFor3009(); - _amount = _pullTokensVia3009Encoded( - _tokenAddress, - _amount, - _pubKey20, - _onBehalfOf, - _args3009 - ); - } else { - _amount = _pullTokensViaApproval( - _tokenAddress, - _contractType, - _amount, - _tokenId - ); - } - + _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); return _storeDeposit( - _tokenAddress, - _contractType, - _amount, - _tokenId, - _pubKey20, - _onBehalfOf, - _withMFA, - _recipient, - _reclaimableAfter + _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 - }) - ); + // ══════════════════════════════════════════════════════════════════════════════ + // Withdrawal Functions + // ══════════════════════════════════════════════════════════════════════════════ - // emit the deposit event - emit DepositEvent(deposits.length - 1, _contractType, _amount, _onBehalfOf); + /** + * @notice Withdraw tokens. Can be called by anyone with a valid signature. + */ + function withdrawDeposit( + uint256 _index, + address _recipientAddress, + bytes memory _signature + ) external nonReentrant returns (bool) { + return _withdrawDeposit(_index, _recipientAddress, ANYONE_WITHDRAWAL_MODE, _signature, false); + } - // return id of new deposit - return deposits.length - 1; + /** + * @notice Withdraw tokens with MFA. Fees are backend-signed flat amounts. + * @param _index deposit index + * @param _recipientAddress address to receive the deposit (minus fees) + * @param _signature withdrawal signature from the deposit's pubKey20 + * @param _MFASignature backend signature authorizing this withdrawal and specifying fees + * @param _serviceFee flat fee for the MFA service + * @param _gasAbsorptionFee flat fee to cover gasless claim; 0 if not absorbing + */ + function withdrawMFADeposit( + uint256 _index, + address _recipientAddress, + bytes memory _signature, + bytes memory _MFASignature, + uint256 _serviceFee, + uint256 _gasAbsorptionFee + ) external nonReentrant returns (bool) { + _verifyMfaSignature(_index, _recipientAddress, _serviceFee, _gasAbsorptionFee, _MFASignature); + _collectFees(_index, _serviceFee, _gasAbsorptionFee); + return _withdrawDeposit(_index, _recipientAddress, ANYONE_WITHDRAWAL_MODE, _signature, true); } /** - * 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. + * @notice Sponsored MFA withdrawal. A paymaster submits on behalf of the recipient. + * The gasAbsorptionFee is sent to the treasury instead of accumulating. + * @param _index deposit index + * @param _recipientAddress address to receive the deposit (minus fees) + * @param _signature withdrawal signature from the deposit's pubKey20 + * @param _MFASignature backend signature authorizing this withdrawal and specifying fees + * @param _serviceFee flat fee for the MFA service (accumulated for owner) + * @param _gasAbsorptionFee flat fee sent to treasury to reimburse gas + * @param _treasury paymaster address that submitted the tx and receives gasAbsorptionFee */ - function _pullTokensViaApproval( - address _tokenAddress, - uint8 _contractType, - uint256 _amount, - uint256 _tokenId - ) internal returns (uint256) { - if (_contractType > 3) revert InvalidContractType(); + function withdrawMFADepositSponsored( + uint256 _index, + address _recipientAddress, + bytes memory _signature, + bytes memory _MFASignature, + uint256 _serviceFee, + uint256 _gasAbsorptionFee, + address _treasury + ) external nonReentrant returns (bool) { + _verifyMfaSignature(_index, _recipientAddress, _serviceFee, _gasAbsorptionFee, _MFASignature); - if (_contractType == 0) { - if (_amount != msg.value) revert WrongEthAmount(); - } else if (_contractType == 1) { - IERC20 token = IERC20(_tokenAddress); - token.safeTransferFrom(msg.sender, address(this), _amount); - } else if (_contractType == 2) { - if (_amount != 1) revert Erc721AmountMustBeOne(); - IERC721 token = IERC721(_tokenAddress); - token.safeTransferFrom(msg.sender, address(this), _tokenId, "Internal transfer"); - } else if (_contractType == 3) { - IERC1155 token = IERC1155(_tokenAddress); - token.safeTransferFrom(msg.sender, address(this), _tokenId, _amount, "Internal transfer"); + // Treasury validates the sponsorship + IPaymaster(_treasury).validateSponsoredOperation(msg.sender, _gasAbsorptionFee); + + // Deduct fees: serviceFee → accumulated, gasAbsorptionFee → treasury + uint256 totalFee = _serviceFee + _gasAbsorptionFee; + if (totalFee > 0) { + Deposit storage dep = deposits[_index]; + if (totalFee > dep.amount) revert FeeExceedsDepositAmount(); + dep.amount -= totalFee; + + if (_serviceFee > 0) { + accumulatedFees[dep.tokenAddress] += _serviceFee; + } + if (_gasAbsorptionFee > 0) { + _transferFeeToTreasury(dep.tokenAddress, _gasAbsorptionFee, _treasury); + } + emit FeeCollected(_index, dep.tokenAddress, _serviceFee, _gasAbsorptionFee); } - return _amount; + return _withdrawDeposit(_index, _recipientAddress, ANYONE_WITHDRAWAL_MODE, _signature, true); } /** - * Pulls the tokens via EIP-3009 according to the encoded data. - * Also validates that _onBehalfOf is the unpacked _from. + * @notice Withdraw tokens. Must be called by the recipient. */ - 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)); - - if (_from != _onBehalfOf) revert Wrong3009OnBehalfOf(); - return _pullTokensVia3009(_tokenAddress, _from, _amount, _pubKey20, _nonce, _validAfter, _validBefore, _v, _r, _s); + function withdrawDepositAsRecipient( + uint256 _index, + address _recipientAddress, + bytes memory _signature + ) external nonReentrant returns (bool) { + if (_recipientAddress != msg.sender) revert NotTheRecipient(); + return _withdrawDeposit(_index, _recipientAddress, RECIPIENT_WITHDRAWAL_MODE, _signature, false); } + // ══════════════════════════════════════════════════════════════════════════════ + // Sender Reclaim Functions + // ══════════════════════════════════════════════════════════════════════════════ + /** - * Performs a EIP-3009 transfer for tokens like USDC. - * Reverts if the transfer failed. - * Returns the amount of actually deposited tokens. + * @notice Sender reclaims their deposit directly. */ - 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; + function withdrawDepositSender(uint256 _index) external nonReentrant returns (bool) { + return _withdrawDepositSender(_index, msg.sender); } /** - * @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 + * @notice Sponsored sender reclaim. A paymaster submits on behalf of the sender. + * Sender authorizes via EIP-712 signature. MFA authorizer signs the gas fee. + * @param _reclaim EIP-712 signed reclaim request + * @param _signer the sender address (must match deposit's senderAddress) + * @param _signature EIP-712 signature from the sender authorizing reclaim + * @param _MFASignature backend signature specifying the gas absorption fee + * @param _gasAbsorptionFee flat fee sent to treasury to reimburse gas + * @param _treasury paymaster address that receives gasAbsorptionFee */ - 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) { - _pullTokensVia3009( - _tokenAddress, - _from, - _amount, - _pubKey20, - _nonce, - _validAfter, - _validBefore, - _v, - _r, - _s - ); + function withdrawDepositSenderSponsored( + GaslessReclaim calldata _reclaim, + address _signer, + bytes calldata _signature, + bytes calldata _MFASignature, + uint256 _gasAbsorptionFee, + address _treasury + ) external nonReentrant returns (bool) { + // Verify sender authorized this reclaim + verifyGaslessReclaim(_reclaim, _signer, _signature); - return _storeDeposit( - _tokenAddress, - 1, // contractType is always 1 here (ERC20) - _amount, - 0, // it's always ERC20, so tokenId doesn't matter - _pubKey20, - _from, - false, // no MFA - address(0), // no restrictions on the recipient - 0 // no restrictions on the recipient + // Verify MFA signature covers the fee for this sponsored reclaim + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + ENVELOPE_SALT, + block.chainid, + address(this), + _reclaim.depositIndex, + _signer, + _gasAbsorptionFee + ) + ) ); + address authorizationSigner = getSigner(digest, _MFASignature); + if (authorizationSigner != mfaAuthorizer) revert WrongMfaSignature(); + + // Treasury validates + IPaymaster(_treasury).validateSponsoredOperation(msg.sender, _gasAbsorptionFee); + + // Deduct gas fee from deposit and send to treasury + if (_gasAbsorptionFee > 0) { + Deposit storage dep = deposits[_reclaim.depositIndex]; + if (_gasAbsorptionFee > dep.amount) revert FeeExceedsDepositAmount(); + dep.amount -= _gasAbsorptionFee; + _transferFeeToTreasury(dep.tokenAddress, _gasAbsorptionFee, _treasury); + emit FeeCollected(_reclaim.depositIndex, dep.tokenAddress, 0, _gasAbsorptionFee); + } + + return _withdrawDepositSender(_reclaim.depositIndex, _signer); } - /// @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) + // ══════════════════════════════════════════════════════════════════════════════ + // Token Receiver Hooks + // ══════════════════════════════════════════════════════════════════════════════ + + function onERC721Received(address _operator, address, uint256, bytes calldata) + external view override returns (bytes4) { if (_operator != address(this)) revert DirectTransfersNotAllowed(); return this.onERC721Received.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) { + function onERC1155Received(address _operator, address, uint256, uint256, bytes calldata) + external view override returns (bytes4) + { if (_operator != address(this)) revert DirectTransfersNotAllowed(); return this.onERC1155Received.selector; } - /// @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 view override returns (bytes4) { + function onERC1155BatchReceived(address _operator, address, uint256[] calldata, uint256[] calldata, bytes calldata) + external view override returns (bytes4) + { if (_operator != address(this)) revert DirectTransfersNotAllowed(); return this.onERC1155BatchReceived.selector; } + // ══════════════════════════════════════════════════════════════════════════════ + // Owner Functions + // ══════════════════════════════════════════════════════════════════════════════ + /** - * @notice Function to withdraw tokens. Can be called by anyone. - * @return bool true if successful + * @notice Withdraw accumulated fees for a given token. Only callable by owner. + * @param _tokenAddress token to withdraw fees for (address(0) for ETH) */ - function withdrawDeposit( - uint256 _index, - address _recipientAddress, - bytes memory _signature - ) external nonReentrant returns (bool) { - return _withdrawDeposit( - _index, - _recipientAddress, - ANYONE_WITHDRAWAL_MODE, - _signature, - false - ); + function withdrawFees(address _tokenAddress) external onlyOwner nonReentrant { + uint256 amount = accumulatedFees[_tokenAddress]; + if (amount == 0) revert NoFeesToWithdraw(); + accumulatedFees[_tokenAddress] = 0; + + if (_tokenAddress == address(0)) { + (bool success,) = msg.sender.call{value: amount}(""); + if (!success) revert EthTransferFailed(); + } else { + IERC20(_tokenAddress).safeTransfer(msg.sender, amount); + } + + emit FeesWithdrawn(_tokenAddress, amount); } - /** - * @notice Function to withdraw tokens with MFA. Fees are backend-signed flat amounts - * denominated in the deposit's token. - * @param _index deposit index - * @param _recipientAddress address to receive the deposit (minus fees) - * @param _signature withdrawal signature from the deposit's pubKey20 - * @param _MFASignature backend signature authorizing this withdrawal and specifying fees - * @param _serviceFee flat fee (in deposit token units) for the MFA service - * @param _gasAbsorptionFee flat fee (in deposit token units) to cover gasless claim; 0 if not absorbing - */ - function withdrawMFADeposit( + // ══════════════════════════════════════════════════════════════════════════════ + // View Functions + // ══════════════════════════════════════════════════════════════════════════════ + + function getSigner(bytes32 messageHash, bytes memory signature) public pure returns (address) { + return ECDSA.recover(messageHash, signature); + } + + function getDepositCount() external view returns (uint256) { + return deposits.length; + } + + function getDeposit(uint256 _index) external view returns (Deposit memory) { + return deposits[_index]; + } + + function getAllDeposits() external view returns (Deposit[] memory) { + return 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; + for (uint256 i = 0; i < deposits.length; i++) { + if (deposits[i].senderAddress == _address) { + _deposits[count] = deposits[i]; + count++; + } + } + return _deposits; + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Internal Functions + // ══════════════════════════════════════════════════════════════════════════════ + + function _verifyMfaSignature( uint256 _index, address _recipientAddress, - bytes memory _signature, - bytes memory _MFASignature, uint256 _serviceFee, - uint256 _gasAbsorptionFee - ) external nonReentrant returns (bool) { - // Verify the MFA signature (includes fee amounts to prevent tampering) + uint256 _gasAbsorptionFee, + bytes memory _MFASignature + ) internal view { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( @@ -646,8 +537,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ); address authorizationSigner = getSigner(digest, _MFASignature); if (authorizationSigner != mfaAuthorizer) revert WrongMfaSignature(); + } - // Deduct fees from deposit amount before withdrawal + function _collectFees(uint256 _index, uint256 _serviceFee, uint256 _gasAbsorptionFee) internal { uint256 totalFee = _serviceFee + _gasAbsorptionFee; if (totalFee > 0) { Deposit storage dep = deposits[_index]; @@ -656,47 +548,69 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow accumulatedFees[dep.tokenAddress] += totalFee; emit FeeCollected(_index, dep.tokenAddress, _serviceFee, _gasAbsorptionFee); } + } - return _withdrawDeposit( - _index, - _recipientAddress, - ANYONE_WITHDRAWAL_MODE, - _signature, - true + function _transferFeeToTreasury(address _tokenAddress, uint256 _amount, address _treasury) internal { + if (_tokenAddress == address(0)) { + (bool success,) = _treasury.call{value: _amount}(""); + if (!success) revert EthTransferFailed(); + } else { + IERC20(_tokenAddress).safeTransfer(_treasury, _amount); + } + } + + function _storeDeposit( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address _pubKey20, + address _onBehalfOf, + bool _requiresMFA, + address _recipient, + uint40 _reclaimableAfter + ) internal returns (uint256) { + 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 DepositEvent(deposits.length - 1, _contractType, _amount, _onBehalfOf); + return deposits.length - 1; } - /** - * @notice Function to withdraw tokens. Must be called by the recipient. - * @return bool true if successful - */ - function withdrawDepositAsRecipient( - uint256 _index, - address _recipientAddress, - bytes memory _signature - ) external nonReentrant returns (bool) { - if (_recipientAddress != msg.sender) revert NotTheRecipient(); + function _pullTokensViaApproval( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId + ) internal returns (uint256) { + if (_contractType > 3) revert InvalidContractType(); - return _withdrawDeposit( - _index, - _recipientAddress, - RECIPIENT_WITHDRAWAL_MODE, - _signature, - false - ); + if (_contractType == 0) { + if (_amount != msg.value) revert WrongEthAmount(); + } else if (_contractType == 1) { + IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), _amount); + } else if (_contractType == 2) { + if (_amount != 1) revert Erc721AmountMustBeOne(); + IERC721(_tokenAddress).safeTransferFrom(msg.sender, address(this), _tokenId, "Internal transfer"); + } else if (_contractType == 3) { + IERC1155(_tokenAddress).safeTransferFrom(msg.sender, address(this), _tokenId, _amount, "Internal transfer"); + } + + return _amount; } - /** - * @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, @@ -704,16 +618,12 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes memory _signature, bool _authorized ) internal returns (bool) { - // check that the deposit exists and that it isn't already withdrawn if (_index >= deposits.length) revert DepositIndexOutOfBounds(); Deposit memory _deposit = deposits[_index]; if (_deposit.claimed) revert DepositAlreadyClaimed(); - - // 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( @@ -732,176 +642,46 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow if (_deposit.pubKey20 != address(0) && depositSigner != _deposit.pubKey20) revert WrongSignature(); if (_deposit.recipient != address(0) && _recipientAddress != _deposit.recipient) revert WrongRecipient(); - // 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}(""); if (!success) revert EthTransferFailed(); } else if (_deposit.contractType == 1) { - /// handle erc20 deposits - IERC20 token = IERC20(_deposit.tokenAddress); - token.safeTransfer(_recipientAddress, _deposit.amount); + IERC20(_deposit.tokenAddress).safeTransfer(_recipientAddress, _deposit.amount); } else if (_deposit.contractType == 2) { - /// handle erc721 deposits - IERC721 token = IERC721(_deposit.tokenAddress); - token.safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId); + IERC721(_deposit.tokenAddress).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, ""); + IERC1155(_deposit.tokenAddress).safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId, _deposit.amount, ""); } return true; } - /** - * @notice Function to allow a sender to withdraw their deposit - * @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 if (_index >= deposits.length) revert DepositIndexOutOfBounds(); Deposit memory _deposit = deposits[_index]; if (_deposit.claimed) revert DepositAlreadyClaimed(); - // check that the sender is the one who made the deposit if (_deposit.senderAddress != _senderAddress) revert NotTheSender(); - // check timestamp for address-bound links if (_deposit.recipient != address(0)) { if (block.timestamp <= _deposit.reclaimableAfter) revert TooEarlyToReclaim(); } - // 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}(""); if (!success) revert EthTransferFailed(); } else if (_deposit.contractType == 1) { - /// handle erc20 deposits - IERC20 token = IERC20(_deposit.tokenAddress); - token.safeTransfer(_deposit.senderAddress, _deposit.amount); + IERC20(_deposit.tokenAddress).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); + IERC721(_deposit.tokenAddress).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, ""); + IERC1155(_deposit.tokenAddress).safeTransferFrom(address(this), _deposit.senderAddress, _deposit.tokenId, _deposit.amount, ""); } 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; - } - - /** - * @notice Withdraw accumulated fees for a given token. Only callable by owner. - * @param _tokenAddress token to withdraw fees for (address(0) for ETH) - */ - function withdrawFees(address _tokenAddress) external onlyOwner nonReentrant { - uint256 amount = accumulatedFees[_tokenAddress]; - if (amount == 0) revert NoFeesToWithdraw(); - accumulatedFees[_tokenAddress] = 0; - - if (_tokenAddress == address(0)) { - (bool success,) = msg.sender.call{value: amount}(""); - if (!success) revert EthTransferFailed(); - } else { - IERC20(_tokenAddress).safeTransfer(msg.sender, amount); - } - - emit FeesWithdrawn(_tokenAddress, amount); - } - - // and that's all! Have a nutty day! } diff --git a/src/envelope/util/IEIP3009.sol b/src/envelope/util/IEIP3009.sol deleted file mode 100644 index dd3d362a..00000000 --- a/src/envelope/util/IEIP3009.sol +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.26; - -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/envelope/util/IPaymaster.sol b/src/envelope/util/IPaymaster.sol new file mode 100644 index 00000000..9666e141 --- /dev/null +++ b/src/envelope/util/IPaymaster.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +/// @title IPaymaster +/// @notice Interface that a paymaster/treasury must implement to sponsor gasless +/// claim and reclaim operations on EnvelopeVault. +interface IPaymaster { + /// @notice Called by EnvelopeVault before a sponsored operation proceeds. + /// @dev The treasury should validate that `operator` is authorized to perform + /// sponsored operations and track/consume any quota. Revert to deny. + /// @param operator The address submitting the sponsored transaction (msg.sender to vault). + /// @param fee The gas absorption fee being paid to this treasury. + function validateSponsoredOperation(address operator, uint256 fee) external; +} diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index ea2bfd5b..173ca881 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -171,7 +171,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function test_RevertWhen_RecipientBoundClaimedByOtherAddress() public { // Address-bound deposit: recipient = ALICE. uint256 idx = vault.makeCustomDeposit{value: 1 ether}( - address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, 0, false, "" + address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, 0 ); // Even with a valid pubKey signature, the contract-stored recipient blocks // anyone else from being the named recipient on withdrawal. @@ -183,7 +183,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function test_RecipientBoundSenderCannotReclaimBeforeDeadline() public { uint40 reclaimAfter = uint40(block.timestamp + 1 days); uint256 idx = vault.makeCustomDeposit{value: 1 ether}( - address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, reclaimAfter, false, "" + address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, reclaimAfter ); vm.expectRevert(EnvelopeVault.TooEarlyToReclaim.selector); vault.withdrawDepositSender(idx); diff --git a/test/envelope/EnvelopeGasless.t.sol b/test/envelope/EnvelopeGasless.t.sol deleted file mode 100644 index de77721b..00000000 --- a/test/envelope/EnvelopeGasless.t.sol +++ /dev/null @@ -1,214 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.0; - -import "forge-std/Test.sol"; -import "../../src/envelope/V4/EnvelopeVault.sol"; -import "./mocks/ERC20Mock.sol"; -import "./mocks/SampleSCW.sol"; - -contract EnvelopeVaultGaslessTest is Test { - EnvelopeVault public vault; - 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(); - vault = new EnvelopeVault(address(0), address(this)); - } - - function testMakeDepositERC20WithAuthorization() 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 & vault depositor address - address(vault), // 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 = vault.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(vault.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(vault), amount); - vm.prank(depositor); - depositIndex = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); - } - - function _calculateDigest(uint256 depositIndex) internal view returns (bytes32 digest) { - bytes32 hashedReclaimRequest = keccak256(abi.encode(vault.GASLESS_RECLAIM_TYPEHASH(), depositIndex)); - // Prepare data for the withdrawal - digest = keccak256(abi.encodePacked("\x19\x01", vault.DOMAIN_SEPARATOR(), hashedReclaimRequest)); - } - - function _withdrawDepositSenderGaslessEOA( - uint256 depositIndex, - address depositorAddress, - bytes32 privateKey, - bytes4 expectedError - ) internal { - bytes32 digest = _calculateDigest(depositIndex); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(privateKey), digest); - bytes memory signature = abi.encodePacked(r, s, v); - - EnvelopeVault.GaslessReclaim memory reclaimRequest = EnvelopeVault.GaslessReclaim(depositIndex); - - if (expectedError != bytes4(0)) { - vm.expectRevert(expectedError); - } - - vault.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, bytes4(0)); - - // depositIndex2 has already been withdrawn - _withdrawDepositSenderGaslessEOA(depositIndex2, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, EnvelopeVault.DepositAlreadyClaimed.selector); - - // Correct depositor address, but wrong private key. - // Private key and the provided address don't match. - _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS, SAMPLE_PRIVKEY_2, EnvelopeVault.InvalidGaslessReclaimSignature.selector); - - // Provided address and private key do match, but they are wrong. - _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS_2, SAMPLE_PRIVKEY_2, EnvelopeVault.NotTheSender.selector); - - // 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, EnvelopeVault.NotTheSender.selector); - - // Withdraw both - _withdrawDepositSenderGaslessEOA(depositIndex1, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, bytes4(0)); - _withdrawDepositSenderGaslessEOA(depositIndex3, SAMPLE_ADDRESS_2, SAMPLE_PRIVKEY_2, bytes4(0)); - } - - // Test that smart contract wallets are able to withdraw gaslessly too - function testWithdrawDepositSenderGaslessSCW() public { - // Make a deposit - SampleWallet scwallet = new SampleWallet(); - uint256 depositIndex = _makeDeposit(address(scwallet)); - - bytes32 digest = _calculateDigest(depositIndex); - - EnvelopeVault.GaslessReclaim memory reclaimRequest = EnvelopeVault.GaslessReclaim(depositIndex); - - // Submit a wrong signature - vm.expectRevert(EnvelopeVault.InvalidGaslessReclaimSignature.selector); - vault.withdrawDepositSenderGasless( - reclaimRequest, address(scwallet), bytes("LOL THIS IS DEFINITELY NOT THE SIGNATURE") - ); - - // Try to withdraw with an EOA - _withdrawDepositSenderGaslessEOA(depositIndex, SAMPLE_ADDRESS, SAMPLE_PRIVKEY, EnvelopeVault.NotTheSender.selector); - - // Withdraw! - vault.withdrawDepositSenderGasless( - reclaimRequest, - address(scwallet), - // In our sample SCW the digest will be the right signature - abi.encodePacked(digest) - ); - } - - /** - * Test that we can use makeCustomDeposit 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 & vault depositor address - address(vault), // 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 = vault.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(vault.getDepositCount(), 1, "Deposit count mismatch"); - } -} diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index 411f8301..a2e4e90c 100644 --- a/test/envelope/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -37,9 +37,7 @@ contract RecipientBoundTest is Test { 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 + 0 // no timelock for reclaiming ); require(testToken.balanceOf(address(this)) == 0, "TOKEN WAS NOT CHARGED!"); require(testToken.balanceOf(SAMPLE_ADDRESS) == 0, "SAMPLE_ADDRESS MUST NOT HAVE TOKENS AT START!"); @@ -65,9 +63,7 @@ contract RecipientBoundTest is Test { 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 + uint40(block.timestamp + 10) // the sender will be able to reclaim in 10 seconds ); require(testToken.balanceOf(address(this)) == 0, "TOKEN WAS NOT CHARGED!"); diff --git a/test/envelope/Sponsored.t.sol b/test/envelope/Sponsored.t.sol new file mode 100644 index 00000000..5141852a --- /dev/null +++ b/test/envelope/Sponsored.t.sol @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; +import "../../src/envelope/util/IPaymaster.sol"; + +contract MockPaymaster is IPaymaster { + bool public shouldRevert; + uint256 public lastFee; + address public lastOperator; + + function validateSponsoredOperation(address operator, uint256 fee) external { + if (shouldRevert) revert("paymaster: denied"); + lastOperator = operator; + lastFee = fee; + } + + function setRevert(bool _shouldRevert) external { + shouldRevert = _shouldRevert; + } + + receive() external payable {} +} + +contract EnvelopeVaultSponsoredTest is Test { + EnvelopeVault public vault; + MockPaymaster public paymaster; + + // Link keypair + uint256 public constant LINK_PRIVKEY = uint256(keccak256("link-key")); + address public LINK_PUBKEY; + + // MFA authorizer + uint256 public constant MFA_PRIVKEY = uint256(keccak256("nodle.vault.mfa-authorizer")); + address public MFA_AUTHORIZER; + + // Sender (depositor) keypair for sponsored reclaim + uint256 public constant SENDER_PRIVKEY = uint256(keccak256("sender-key")); + address public SENDER; + + // Operator (relayer) who submits the tx + address public constant OPERATOR = address(0xBEEF); + + function setUp() public { + LINK_PUBKEY = vm.addr(LINK_PRIVKEY); + MFA_AUTHORIZER = vm.addr(MFA_PRIVKEY); + SENDER = vm.addr(SENDER_PRIVKEY); + + vault = new EnvelopeVault(MFA_AUTHORIZER, address(this)); + paymaster = new MockPaymaster(); + + vm.deal(SENDER, 10 ether); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Helpers + // ═══════════════════════════════════════════════════════════════════════════ + + function _makeDeposit(uint256 amount, bool withMFA) internal returns (uint256) { + return vault.makeCustomDeposit{value: amount}( + address(0), 0, amount, 0, LINK_PUBKEY, SENDER, withMFA, address(0), 0 + ); + } + + function _signWithdrawal(uint256 depositIndex, address recipient) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + depositIndex, + recipient, + vault.ANYONE_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _signMfa(uint256 depositIndex, address recipient, uint256 serviceFee, uint256 gasAbsorptionFee) + internal view returns (bytes memory) + { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + depositIndex, + recipient, + serviceFee, + gasAbsorptionFee + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _signGaslessReclaim(uint256 depositIndex) internal view returns (bytes memory) { + bytes32 structHash = keccak256( + abi.encode( + vault.GASLESS_RECLAIM_TYPEHASH(), + depositIndex + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", vault.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SENDER_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _signMfaForReclaim(uint256 depositIndex, address signer, uint256 gasAbsorptionFee) + internal view returns (bytes memory) + { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + depositIndex, + signer, + gasAbsorptionFee + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // withdrawMFADepositSponsored tests + // ═══════════════════════════════════════════════════════════════════════════ + + function test_WithdrawMFADepositSponsored() public { + uint256 depositAmount = 1 ether; + uint256 serviceFee = 0.01 ether; + uint256 gasAbsorptionFee = 0.005 ether; + + vm.prank(SENDER); + uint256 idx = _makeDeposit(depositAmount, true); + + bytes memory linkSig = _signWithdrawal(idx, address(this)); + bytes memory mfaSig = _signMfa(idx, address(this), serviceFee, gasAbsorptionFee); + + uint256 balBefore = address(this).balance; + vm.prank(OPERATOR); + vault.withdrawMFADepositSponsored( + idx, address(this), linkSig, mfaSig, serviceFee, gasAbsorptionFee, address(paymaster) + ); + uint256 balAfter = address(this).balance; + + // Recipient gets deposit minus both fees + assertEq(balAfter - balBefore, depositAmount - serviceFee - gasAbsorptionFee); + + // Service fee accumulated + assertEq(vault.accumulatedFees(address(0)), serviceFee); + + // Gas absorption fee sent to paymaster + assertEq(address(paymaster).balance, gasAbsorptionFee); + + // Paymaster was called with correct args + assertEq(paymaster.lastOperator(), OPERATOR); + assertEq(paymaster.lastFee(), gasAbsorptionFee); + } + + function test_RevertIf_SponsoredClaimPaymasterDenies() public { + vm.prank(SENDER); + uint256 idx = _makeDeposit(1 ether, true); + + bytes memory linkSig = _signWithdrawal(idx, address(this)); + bytes memory mfaSig = _signMfa(idx, address(this), 0, 0.01 ether); + + paymaster.setRevert(true); + + vm.prank(OPERATOR); + vm.expectRevert("paymaster: denied"); + vault.withdrawMFADepositSponsored( + idx, address(this), linkSig, mfaSig, 0, 0.01 ether, address(paymaster) + ); + } + + function test_RevertIf_SponsoredClaimFeeExceedsDeposit() public { + vm.prank(SENDER); + uint256 idx = _makeDeposit(1 ether, true); + + uint256 bigFee = 2 ether; + bytes memory linkSig = _signWithdrawal(idx, address(this)); + bytes memory mfaSig = _signMfa(idx, address(this), bigFee, 0); + + vm.prank(OPERATOR); + vm.expectRevert(EnvelopeVault.FeeExceedsDepositAmount.selector); + vault.withdrawMFADepositSponsored( + idx, address(this), linkSig, mfaSig, bigFee, 0, address(paymaster) + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // withdrawDepositSenderSponsored tests + // ═══════════════════════════════════════════════════════════════════════════ + + function test_WithdrawDepositSenderSponsored() public { + uint256 depositAmount = 1 ether; + uint256 gasAbsorptionFee = 0.01 ether; + + vm.prank(SENDER); + uint256 idx = _makeDeposit(depositAmount, false); + + EnvelopeVault.GaslessReclaim memory reclaim = EnvelopeVault.GaslessReclaim({ + depositIndex: idx + }); + + bytes memory senderSig = _signGaslessReclaim(idx); + bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, gasAbsorptionFee); + + uint256 senderBalBefore = SENDER.balance; + + vm.prank(OPERATOR); + vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, gasAbsorptionFee, address(paymaster)); + + // Sender gets deposit minus gas fee + uint256 senderBalAfter = SENDER.balance; + assertEq(senderBalAfter - senderBalBefore, depositAmount - gasAbsorptionFee); + + // Gas fee sent to paymaster + assertEq(address(paymaster).balance, gasAbsorptionFee); + + // Paymaster validated + assertEq(paymaster.lastOperator(), OPERATOR); + assertEq(paymaster.lastFee(), gasAbsorptionFee); + } + + function test_RevertIf_SponsoredReclaimWrongSender() public { + vm.prank(SENDER); + uint256 idx = _makeDeposit(1 ether, false); + + EnvelopeVault.GaslessReclaim memory reclaim = EnvelopeVault.GaslessReclaim({ + depositIndex: idx + }); + + // Sign with a different key (not the depositor) + uint256 wrongKey = uint256(keccak256("wrong-key")); + address wrongSigner = vm.addr(wrongKey); + + bytes32 structHash = keccak256(abi.encode(vault.GASLESS_RECLAIM_TYPEHASH(), idx)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", vault.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongKey, digest); + bytes memory wrongSig = abi.encodePacked(r, s, v); + + bytes memory mfaSig = _signMfaForReclaim(idx, wrongSigner, 0); + + vm.prank(OPERATOR); + vm.expectRevert(EnvelopeVault.NotTheSender.selector); + vault.withdrawDepositSenderSponsored(reclaim, wrongSigner, wrongSig, mfaSig, 0, address(paymaster)); + } + + function test_RevertIf_SponsoredReclaimPaymasterDenies() public { + vm.prank(SENDER); + uint256 idx = _makeDeposit(1 ether, false); + + EnvelopeVault.GaslessReclaim memory reclaim = EnvelopeVault.GaslessReclaim({ + depositIndex: idx + }); + + bytes memory senderSig = _signGaslessReclaim(idx); + bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, 0.01 ether); + + paymaster.setRevert(true); + + vm.prank(OPERATOR); + vm.expectRevert("paymaster: denied"); + vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, 0.01 ether, address(paymaster)); + } + + receive() external payable {} +} diff --git a/test/envelope/mocks/EIP3009Implementation.sol b/test/envelope/mocks/EIP3009Implementation.sol deleted file mode 100644 index 4165a392..00000000 --- a/test/envelope/mocks/EIP3009Implementation.sol +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-License-Identifier: MIT -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/envelope/util/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/test/envelope/mocks/EIP3009Internals.sol b/test/envelope/mocks/EIP3009Internals.sol deleted file mode 100644 index 9eda8ab9..00000000 --- a/test/envelope/mocks/EIP3009Internals.sol +++ /dev/null @@ -1,101 +0,0 @@ -/** - * SPDX-License-Identifier: MIT - * - * Copyright (c) 2018-2020 CENTRE SECZ - */ - -pragma solidity 0.8.26; - -import {EIP712Domain} from "./EIP712Domain.sol"; -import {EIP712} from "./EIP712.sol"; -import {IEIP3009} from "../../../src/envelope/util/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/test/envelope/mocks/ERC20Mock.sol b/test/envelope/mocks/ERC20Mock.sol index 8e08306f..6ba97364 100644 --- a/test/envelope/mocks/ERC20Mock.sol +++ b/test/envelope/mocks/ERC20Mock.sol @@ -2,11 +2,8 @@ pragma solidity 0.8.26; 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 { +contract ERC20Mock is ERC20 { constructor() ERC20("ERC20Mock", "20MOCK") { this; } From 2da99085b86c0d6a9537d8d9695fe8023336f51a Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 19 May 2026 17:27:41 +1200 Subject: [PATCH 38/49] feat(envelope): add deadline to MFA signatures - Backend-signed MFA messages now include a deadline (block.timestamp) - deadline=0 means no expiry (signature never expires) - Add MfaSignatureExpired error, checked before signature verification - Deadline included in both withdrawMFADeposit/Sponsored and withdrawDepositSenderSponsored MFA hash preimages - Add 4 deadline-specific tests (valid deadline, expired claim, expired reclaim, zero=no-expiry) --- src/envelope/V4/EnvelopeVault.sol | 27 +++++-- test/envelope/EnvelopeHardening.t.sol | 8 +- test/envelope/MFA.t.sol | 27 +++---- test/envelope/Sponsored.t.sol | 108 ++++++++++++++++++++++---- 4 files changed, 131 insertions(+), 39 deletions(-) diff --git a/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index 0d290144..f0c6a7e5 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -71,6 +71,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow error DirectTransfersNotAllowed(); error FeeExceedsDepositAmount(); error NoFeesToWithdraw(); + error MfaSignatureExpired(); // ── Data Structures ────────────────────────────────────────────────────────── @@ -299,9 +300,10 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes memory _signature, bytes memory _MFASignature, uint256 _serviceFee, - uint256 _gasAbsorptionFee + uint256 _gasAbsorptionFee, + uint256 _deadline ) external nonReentrant returns (bool) { - _verifyMfaSignature(_index, _recipientAddress, _serviceFee, _gasAbsorptionFee, _MFASignature); + _verifyMfaSignature(_index, _recipientAddress, _serviceFee, _gasAbsorptionFee, _deadline, _MFASignature); _collectFees(_index, _serviceFee, _gasAbsorptionFee); return _withdrawDeposit(_index, _recipientAddress, ANYONE_WITHDRAWAL_MODE, _signature, true); } @@ -324,9 +326,10 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes memory _MFASignature, uint256 _serviceFee, uint256 _gasAbsorptionFee, - address _treasury + address _treasury, + uint256 _deadline ) external nonReentrant returns (bool) { - _verifyMfaSignature(_index, _recipientAddress, _serviceFee, _gasAbsorptionFee, _MFASignature); + _verifyMfaSignature(_index, _recipientAddress, _serviceFee, _gasAbsorptionFee, _deadline, _MFASignature); // Treasury validates the sponsorship IPaymaster(_treasury).validateSponsoredOperation(msg.sender, _gasAbsorptionFee); @@ -389,11 +392,15 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes calldata _signature, bytes calldata _MFASignature, uint256 _gasAbsorptionFee, - address _treasury + address _treasury, + uint256 _deadline ) external nonReentrant returns (bool) { // Verify sender authorized this reclaim verifyGaslessReclaim(_reclaim, _signer, _signature); + // Check deadline (0 = no expiry) + if (_deadline != 0 && block.timestamp > _deadline) revert MfaSignatureExpired(); + // Verify MFA signature covers the fee for this sponsored reclaim bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( @@ -403,7 +410,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address(this), _reclaim.depositIndex, _signer, - _gasAbsorptionFee + _gasAbsorptionFee, + _deadline ) ) ); @@ -520,8 +528,12 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address _recipientAddress, uint256 _serviceFee, uint256 _gasAbsorptionFee, + uint256 _deadline, bytes memory _MFASignature ) internal view { + // deadline == 0 means no expiry + if (_deadline != 0 && block.timestamp > _deadline) revert MfaSignatureExpired(); + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( @@ -531,7 +543,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow _index, _recipientAddress, _serviceFee, - _gasAbsorptionFee + _gasAbsorptionFee, + _deadline ) ) ); diff --git a/test/envelope/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index af885d5b..e5cd65c9 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -96,6 +96,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { // MFA signature (signed by configured mfaAuthorizer, includes fee amounts) uint256 serviceFee = 0; uint256 gasAbsorptionFee = 0; + uint256 deadline = 0; // no expiry bytes32 mfaHash = MessageHashUtilsLite.toEthSignedMessageHash( keccak256( abi.encodePacked( @@ -105,14 +106,15 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { idx, address(this), serviceFee, - gasAbsorptionFee + gasAbsorptionFee, + deadline ) ) ); (uint8 mv, bytes32 mr, bytes32 ms) = vm.sign(mfaPrivKey, mfaHash); bytes memory mfaSig = abi.encodePacked(mr, ms, mv); - nodleVault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, serviceFee, gasAbsorptionFee); + nodleVault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, serviceFee, gasAbsorptionFee, 0); } function test_T2_zeroMfaAuthorizerRejectsAllMfaWithdrawals() public { @@ -128,7 +130,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { bytes memory wdSig = hex"00"; bytes memory mfaSig = hex"00"; vm.expectRevert(); - vault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, 0, 0); + vault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, 0, 0, 0); } } diff --git a/test/envelope/MFA.t.sol b/test/envelope/MFA.t.sol index 03a64cd5..d13b6de9 100644 --- a/test/envelope/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -20,7 +20,7 @@ contract EnvelopeVaultMFATest is Test { vault = new EnvelopeVault(MFA_AUTHORIZER, address(this)); } - function _signMfa(uint256 depositIndex, address recipient, uint256 serviceFee, uint256 gasAbsorptionFee) + function _signMfa(uint256 depositIndex, address recipient, uint256 serviceFee, uint256 gasAbsorptionFee, uint256 deadline) internal view returns (bytes memory) @@ -34,7 +34,8 @@ contract EnvelopeVaultMFATest is Test { depositIndex, recipient, serviceFee, - gasAbsorptionFee + gasAbsorptionFee, + deadline ) ) ); @@ -74,11 +75,11 @@ contract EnvelopeVaultMFATest is Test { // Withdrawing with incorrect MFA signature should fail vm.expectRevert(EnvelopeVault.WrongMfaSignature.selector); - vault.withdrawMFADeposit(depositIndex, address(this), signature, signature, 0, 0); + vault.withdrawMFADeposit(depositIndex, address(this), signature, signature, 0, 0, 0); - // Correct MFA authorization with zero fees - bytes memory mfaSig = _signMfa(depositIndex, address(this), 0, 0); - vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, 0, 0); + // Correct MFA authorization with zero fees and no deadline + bytes memory mfaSig = _signMfa(depositIndex, address(this), 0, 0, 0); + vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, 0, 0, 0); } function testMFADepositWithFees() public { @@ -111,11 +112,11 @@ contract EnvelopeVaultMFATest is Test { (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); bytes memory signature = abi.encodePacked(r, s, v); - // MFA signature with fees - bytes memory mfaSig = _signMfa(depositIndex, address(this), serviceFee, gasAbsorptionFee); + // MFA signature with fees and no deadline + bytes memory mfaSig = _signMfa(depositIndex, address(this), serviceFee, gasAbsorptionFee, 0); uint256 balBefore = address(this).balance; - vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, serviceFee, gasAbsorptionFee); + vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, serviceFee, gasAbsorptionFee, 0); uint256 balAfter = address(this).balance; // Recipient gets deposit minus fees @@ -144,8 +145,8 @@ contract EnvelopeVaultMFATest is Test { ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); bytes memory signature = abi.encodePacked(r, s, v); - bytes memory mfaSig = _signMfa(depositIndex, address(this), serviceFee, 0); - vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, serviceFee, 0); + bytes memory mfaSig = _signMfa(depositIndex, address(this), serviceFee, 0, 0); + vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, serviceFee, 0, 0); // Non-owner cannot withdraw fees vm.prank(address(0xdead)); @@ -179,9 +180,9 @@ contract EnvelopeVaultMFATest is Test { // Fee exceeds deposit uint256 bigFee = 1 ether; - bytes memory mfaSig = _signMfa(depositIndex, address(this), bigFee, 0); + bytes memory mfaSig = _signMfa(depositIndex, address(this), bigFee, 0, 0); vm.expectRevert(EnvelopeVault.FeeExceedsDepositAmount.selector); - vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, bigFee, 0); + vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, bigFee, 0, 0); } receive() payable external {} diff --git a/test/envelope/Sponsored.t.sol b/test/envelope/Sponsored.t.sol index 5141852a..a15c0448 100644 --- a/test/envelope/Sponsored.t.sol +++ b/test/envelope/Sponsored.t.sol @@ -80,7 +80,7 @@ contract EnvelopeVaultSponsoredTest is Test { return abi.encodePacked(r, s, v); } - function _signMfa(uint256 depositIndex, address recipient, uint256 serviceFee, uint256 gasAbsorptionFee) + function _signMfa(uint256 depositIndex, address recipient, uint256 serviceFee, uint256 gasAbsorptionFee, uint256 deadline) internal view returns (bytes memory) { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( @@ -92,7 +92,8 @@ contract EnvelopeVaultSponsoredTest is Test { depositIndex, recipient, serviceFee, - gasAbsorptionFee + gasAbsorptionFee, + deadline ) ) ); @@ -112,7 +113,7 @@ contract EnvelopeVaultSponsoredTest is Test { return abi.encodePacked(r, s, v); } - function _signMfaForReclaim(uint256 depositIndex, address signer, uint256 gasAbsorptionFee) + function _signMfaForReclaim(uint256 depositIndex, address signer, uint256 gasAbsorptionFee, uint256 deadline) internal view returns (bytes memory) { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( @@ -123,7 +124,8 @@ contract EnvelopeVaultSponsoredTest is Test { address(vault), depositIndex, signer, - gasAbsorptionFee + gasAbsorptionFee, + deadline ) ) ); @@ -144,12 +146,12 @@ contract EnvelopeVaultSponsoredTest is Test { uint256 idx = _makeDeposit(depositAmount, true); bytes memory linkSig = _signWithdrawal(idx, address(this)); - bytes memory mfaSig = _signMfa(idx, address(this), serviceFee, gasAbsorptionFee); + bytes memory mfaSig = _signMfa(idx, address(this), serviceFee, gasAbsorptionFee, 0); uint256 balBefore = address(this).balance; vm.prank(OPERATOR); vault.withdrawMFADepositSponsored( - idx, address(this), linkSig, mfaSig, serviceFee, gasAbsorptionFee, address(paymaster) + idx, address(this), linkSig, mfaSig, serviceFee, gasAbsorptionFee, address(paymaster), 0 ); uint256 balAfter = address(this).balance; @@ -172,14 +174,14 @@ contract EnvelopeVaultSponsoredTest is Test { uint256 idx = _makeDeposit(1 ether, true); bytes memory linkSig = _signWithdrawal(idx, address(this)); - bytes memory mfaSig = _signMfa(idx, address(this), 0, 0.01 ether); + bytes memory mfaSig = _signMfa(idx, address(this), 0, 0.01 ether, 0); paymaster.setRevert(true); vm.prank(OPERATOR); vm.expectRevert("paymaster: denied"); vault.withdrawMFADepositSponsored( - idx, address(this), linkSig, mfaSig, 0, 0.01 ether, address(paymaster) + idx, address(this), linkSig, mfaSig, 0, 0.01 ether, address(paymaster), 0 ); } @@ -189,12 +191,12 @@ contract EnvelopeVaultSponsoredTest is Test { uint256 bigFee = 2 ether; bytes memory linkSig = _signWithdrawal(idx, address(this)); - bytes memory mfaSig = _signMfa(idx, address(this), bigFee, 0); + bytes memory mfaSig = _signMfa(idx, address(this), bigFee, 0, 0); vm.prank(OPERATOR); vm.expectRevert(EnvelopeVault.FeeExceedsDepositAmount.selector); vault.withdrawMFADepositSponsored( - idx, address(this), linkSig, mfaSig, bigFee, 0, address(paymaster) + idx, address(this), linkSig, mfaSig, bigFee, 0, address(paymaster), 0 ); } @@ -214,12 +216,12 @@ contract EnvelopeVaultSponsoredTest is Test { }); bytes memory senderSig = _signGaslessReclaim(idx); - bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, gasAbsorptionFee); + bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, gasAbsorptionFee, 0); uint256 senderBalBefore = SENDER.balance; vm.prank(OPERATOR); - vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, gasAbsorptionFee, address(paymaster)); + vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, gasAbsorptionFee, address(paymaster), 0); // Sender gets deposit minus gas fee uint256 senderBalAfter = SENDER.balance; @@ -250,11 +252,11 @@ contract EnvelopeVaultSponsoredTest is Test { (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongKey, digest); bytes memory wrongSig = abi.encodePacked(r, s, v); - bytes memory mfaSig = _signMfaForReclaim(idx, wrongSigner, 0); + bytes memory mfaSig = _signMfaForReclaim(idx, wrongSigner, 0, 0); vm.prank(OPERATOR); vm.expectRevert(EnvelopeVault.NotTheSender.selector); - vault.withdrawDepositSenderSponsored(reclaim, wrongSigner, wrongSig, mfaSig, 0, address(paymaster)); + vault.withdrawDepositSenderSponsored(reclaim, wrongSigner, wrongSig, mfaSig, 0, address(paymaster), 0); } function test_RevertIf_SponsoredReclaimPaymasterDenies() public { @@ -266,13 +268,87 @@ contract EnvelopeVaultSponsoredTest is Test { }); bytes memory senderSig = _signGaslessReclaim(idx); - bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, 0.01 ether); + bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, 0.01 ether, 0); paymaster.setRevert(true); vm.prank(OPERATOR); vm.expectRevert("paymaster: denied"); - vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, 0.01 ether, address(paymaster)); + vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, 0.01 ether, address(paymaster), 0); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Deadline tests + // ═══════════════════════════════════════════════════════════════════════════ + + function test_WithdrawMFADepositSponsoredWithDeadline() public { + vm.prank(SENDER); + uint256 idx = _makeDeposit(1 ether, true); + + uint256 deadline = block.timestamp + 1 hours; + bytes memory linkSig = _signWithdrawal(idx, address(this)); + bytes memory mfaSig = _signMfa(idx, address(this), 0, 0.01 ether, deadline); + + // Should succeed before deadline + vm.prank(OPERATOR); + vault.withdrawMFADepositSponsored( + idx, address(this), linkSig, mfaSig, 0, 0.01 ether, address(paymaster), deadline + ); + } + + function test_RevertIf_MFASignatureExpiredSponsored() public { + vm.prank(SENDER); + uint256 idx = _makeDeposit(1 ether, true); + + uint256 deadline = block.timestamp + 1 hours; + bytes memory linkSig = _signWithdrawal(idx, address(this)); + bytes memory mfaSig = _signMfa(idx, address(this), 0, 0.01 ether, deadline); + + // Warp past deadline + vm.warp(deadline + 1); + + vm.prank(OPERATOR); + vm.expectRevert(EnvelopeVault.MfaSignatureExpired.selector); + vault.withdrawMFADepositSponsored( + idx, address(this), linkSig, mfaSig, 0, 0.01 ether, address(paymaster), deadline + ); + } + + function test_RevertIf_MFASignatureExpiredSponsoredReclaim() public { + vm.prank(SENDER); + uint256 idx = _makeDeposit(1 ether, false); + + EnvelopeVault.GaslessReclaim memory reclaim = EnvelopeVault.GaslessReclaim({ + depositIndex: idx + }); + + uint256 deadline = block.timestamp + 30 minutes; + bytes memory senderSig = _signGaslessReclaim(idx); + bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, 0.01 ether, deadline); + + // Warp past deadline + vm.warp(deadline + 1); + + vm.prank(OPERATOR); + vm.expectRevert(EnvelopeVault.MfaSignatureExpired.selector); + vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, 0.01 ether, address(paymaster), deadline); + } + + function test_ZeroDeadlineMeansNoExpiry() public { + vm.prank(SENDER); + uint256 idx = _makeDeposit(1 ether, true); + + bytes memory linkSig = _signWithdrawal(idx, address(this)); + // deadline = 0 means never expires + bytes memory mfaSig = _signMfa(idx, address(this), 0, 0, 0); + + // Warp far into the future + vm.warp(block.timestamp + 365 days); + + vm.prank(OPERATOR); + vault.withdrawMFADepositSponsored( + idx, address(this), linkSig, mfaSig, 0, 0, address(paymaster), 0 + ); } receive() external payable {} From 927783ad96eda1e31c27a147b0b0989c68648546 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 19 May 2026 23:39:11 +1200 Subject: [PATCH 39/49] feat(envelope): move gasless flow to paymaster --- hardhat-deploy/DeployEnvelope.ts | 51 +- src/envelope/V4/EnvelopeVault.sol | 495 ++++++++++-------- src/envelope/doc/EnvelopePaymaster.md | 47 ++ src/envelope/doc/EnvelopeVault.md | 204 +++----- src/envelope/doc/README.md | 91 ++-- .../util/IEnvelopeGaslessValidator.sol | 8 + src/envelope/util/IPaymaster.sol | 14 - src/paymasters/BasePaymaster.sol | 19 +- src/paymasters/EnvelopePaymaster.sol | 47 ++ src/paymasters/WhitelistPaymaster.sol | 6 +- test/envelope/Deposit.t.sol | 2 +- test/envelope/EnvelopeBatcher.t.sol | 36 +- test/envelope/EnvelopeEdgeCases.t.sol | 2 +- test/envelope/EnvelopeHardening.t.sol | 35 +- test/envelope/EnvelopeVault.t.sol | 2 +- test/envelope/Gasless.t.sol | 243 +++++++++ test/envelope/Integration.t.sol | 2 +- test/envelope/MFA.t.sol | 169 ++---- test/envelope/RecipientBound.t.sol | 8 +- test/envelope/SenderWithdraw.t.sol | 8 +- test/envelope/SigWithdraw.t.sol | 2 +- test/envelope/Sponsored.t.sol | 355 ------------- test/paymasters/BasePaymaster.t.sol | 12 +- test/paymasters/BondTreasuryPaymaster.t.sol | 2 +- test/paymasters/EnvelopePaymaster.t.sol | 203 +++++++ test/paymasters/WhitelistPaymaster.t.sol | 2 +- 26 files changed, 1105 insertions(+), 960 deletions(-) create mode 100644 src/envelope/doc/EnvelopePaymaster.md create mode 100644 src/envelope/util/IEnvelopeGaslessValidator.sol delete mode 100644 src/envelope/util/IPaymaster.sol create mode 100644 src/paymasters/EnvelopePaymaster.sol create mode 100644 test/envelope/Gasless.t.sol delete mode 100644 test/envelope/Sponsored.t.sol create mode 100644 test/paymasters/EnvelopePaymaster.t.sol diff --git a/hardhat-deploy/DeployEnvelope.ts b/hardhat-deploy/DeployEnvelope.ts index 7fd63abe..7869e9c0 100644 --- a/hardhat-deploy/DeployEnvelope.ts +++ b/hardhat-deploy/DeployEnvelope.ts @@ -15,13 +15,16 @@ dotenv.config({ path: ".env-test" }); * - DEPLOYER_PRIVATE_KEY: Private key for deployment. * * Optional environment variables: - * - 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. * - 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. + * Set to your backend signer for production MFA/fee authorizations. + * - ENVELOPE_OWNER: Owner/fee withdrawer. Defaults to deployer. + * - ENVELOPE_FEE_TOKEN: ERC20 token used for service/gasless fees (e.g. NODL). + * Defaults to 0x0 (non-zero fee authorizations disabled). * - ENVELOPE_DEPLOY_BATCHER: "true"|"false". Default "true". Deploys EnvelopeBatcher. + * - ENVELOPE_DEPLOY_PAYMASTER: "true"|"false". Default "false". Deploys EnvelopePaymaster. + * - ENVELOPE_PAYMASTER_ADMIN: Admin for EnvelopePaymaster. Defaults to deployer. + * - ENVELOPE_PAYMASTER_WITHDRAWER: ETH withdrawer for EnvelopePaymaster. Defaults to deployer. * * Usage: * yarn hardhat deploy-zksync \ @@ -31,9 +34,13 @@ dotenv.config({ path: ".env-test" }); module.exports = async function (hre: HardhatRuntimeEnvironment) { const ZERO = "0x0000000000000000000000000000000000000000"; - const ecoToken = process.env.ENVELOPE_ECO_TOKEN ?? ZERO; const mfaAuthorizer = process.env.ENVELOPE_MFA_AUTHORIZER ?? ZERO; + const envelopeOwner = process.env.ENVELOPE_OWNER ?? wallet.address; + const feeToken = process.env.ENVELOPE_FEE_TOKEN ?? ZERO; const deployBatcher = (process.env.ENVELOPE_DEPLOY_BATCHER ?? "true").toLowerCase() === "true"; + const deployPaymaster = (process.env.ENVELOPE_DEPLOY_PAYMASTER ?? "false").toLowerCase() === "true"; + const paymasterAdmin = process.env.ENVELOPE_PAYMASTER_ADMIN ?? wallet.address; + const paymasterWithdrawer = process.env.ENVELOPE_PAYMASTER_WITHDRAWER ?? wallet.address; const rpcUrl = hre.network.config.url!; const provider = new Provider(rpcUrl); @@ -43,13 +50,15 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log("=== Deploying Envelope 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("Owner: ", envelopeOwner); + console.log("Fee Token: ", feeToken); console.log("Deploy Batcher: ", deployBatcher); + console.log("Deploy Paymaster:", deployPaymaster); console.log(""); // 1. Vault — required. - const vault = await deployContract(deployer, "EnvelopeVault", [ecoToken, mfaAuthorizer]); + const vault = await deployContract(deployer, "EnvelopeVault", [mfaAuthorizer, envelopeOwner, feeToken]); const vaultAddr = await vault.getAddress(); // 2. Batcher — optional. @@ -59,10 +68,22 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { batcherAddr = await batcher.getAddress(); } + // 3. Paymaster — optional. Must be funded with ETH after deployment. + let paymasterAddr: string | undefined; + if (deployPaymaster) { + const envelopePaymaster = await deployContract(deployer, "EnvelopePaymaster", [ + paymasterAdmin, + paymasterWithdrawer, + vaultAddr, + ]); + paymasterAddr = await envelopePaymaster.getAddress(); + } + console.log(""); console.log("=== Deployment Complete ==="); console.log("EnvelopeVault: ", vaultAddr); if (batcherAddr) console.log("EnvelopeBatcher: ", batcherAddr); + if (paymasterAddr) console.log("EnvelopePaymaster: ", paymasterAddr); console.log(""); // Verification @@ -72,7 +93,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { await hre.run("verify:verify", { address: vaultAddr, contract: "src/envelope/V4/EnvelopeVault.sol:EnvelopeVault", - constructorArguments: [ecoToken, mfaAuthorizer], + constructorArguments: [mfaAuthorizer, envelopeOwner, feeToken], }); } catch (e: any) { console.log("Verification failed or already verified:", e.message); @@ -91,10 +112,24 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { } } + if (paymasterAddr) { + try { + console.log("Verifying EnvelopePaymaster..."); + await hre.run("verify:verify", { + address: paymasterAddr, + contract: "src/paymasters/EnvelopePaymaster.sol:EnvelopePaymaster", + constructorArguments: [paymasterAdmin, paymasterWithdrawer, vaultAddr], + }); + } catch (e: any) { + console.log("Verification failed or already verified:", e.message); + } + } + console.log(""); console.log("=== Add these to .env-test: ==="); console.log(`ENVELOPE_VAULT=${vaultAddr}`); if (batcherAddr) console.log(`ENVELOPE_BATCHER=${batcherAddr}`); + if (paymasterAddr) console.log(`ENVELOPE_PAYMASTER=${paymasterAddr}`); if (mfaAuthorizer === ZERO) { console.log(""); diff --git a/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index f0c6a7e5..c463287a 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -45,9 +45,7 @@ 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 {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; -import {IPaymaster} from "../util/IPaymaster.sol"; contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ownable2Step { using SafeERC20 for IERC20; @@ -66,12 +64,13 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow error NotTheRecipient(); error NotTheSender(); error TooEarlyToReclaim(); - error InvalidGaslessReclaimSignature(); error EthTransferFailed(); error DirectTransfersNotAllowed(); - error FeeExceedsDepositAmount(); error NoFeesToWithdraw(); error MfaSignatureExpired(); + error FeeAuthorizationExpired(); + error WrongFeeAuthorizationSignature(); + error FeeTokenNotConfigured(); // ── Data Structures ────────────────────────────────────────────────────────── @@ -90,14 +89,39 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ///// 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) + uint256 serviceFee; // backend-authorized service fee collected at deposit time + uint256 gaslessFee; // prepaid gas sponsorship fee collected at deposit time + } // 8 storage slots (32 byte each) + + /// @notice Full deposit intent covered by a backend fee authorization. + struct DepositRequest { + address tokenAddress; + uint8 contractType; + uint256 amount; + uint256 tokenId; + address pubKey20; + address onBehalfOf; + bool withMFA; + address recipient; + uint40 reclaimableAfter; + } + + /// @notice Backend-signed fee bundle collected when a deposit is created. + /// @dev deadline == 0 means no expiry. Non-zero fees require `signature` from `mfaAuthorizer`. + struct FeeAuthorization { + uint256 serviceFee; + uint256 gaslessFee; + uint256 deadline; + bytes signature; + } // 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 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 + 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 @@ -115,15 +139,12 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address verifyingContract; } - bytes32 public constant GASLESS_RECLAIM_TYPEHASH = keccak256("GaslessReclaim(uint256 depositIndex)"); - - struct GaslessReclaim { - uint256 depositIndex; - } - Deposit[] public deposits; // array of deposits - /// @notice Accumulated fees per token address (address(0) for ETH). + /// @notice ERC-20 token used for Envelope service and gasless sponsorship fees (for example NODL). + IERC20 public immutable feeToken; + + /// @notice Accumulated fees per token address (address(0) for ETH; feeToken for deposit-time fees). mapping(address => uint256) public accumulatedFees; // events @@ -133,17 +154,17 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow event WithdrawEvent( uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _recipientAddress ); - event FeeCollected( - uint256 indexed _index, address indexed tokenAddress, uint256 serviceFee, uint256 gasAbsorptionFee - ); + event FeeCollected(uint256 indexed _index, address indexed tokenAddress, uint256 serviceFee, uint256 gaslessFee); event FeesWithdrawn(address indexed tokenAddress, uint256 amount); event MessageEvent(string message); - /// @param _mfaAuthorizer address authorized to sign MFA withdraw approvals (use address(0) to disable MFA). + /// @param _mfaAuthorizer address authorized to sign backend fee and MFA approvals (use address(0) to disable). /// @param _owner initial owner of the contract (receives accumulated fees). - constructor(address _mfaAuthorizer, address _owner) Ownable(_owner) { + /// @param _feeToken ERC-20 token used for fees; address(0) disables non-zero fee authorizations. + constructor(address _mfaAuthorizer, address _owner, address _feeToken) Ownable(_owner) { emit MessageEvent("Hello World, have a nutty day!"); mfaAuthorizer = _mfaAuthorizer; + feeToken = IERC20(_feeToken); DOMAIN_SEPARATOR = hash( EIP712Domain({name: "Envelope", version: "4.4", chainId: block.chainid, verifyingContract: address(this)}) ); @@ -161,25 +182,6 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ); } - 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 - { - bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, hash(reclaim))); - bool valid = SignatureChecker.isValidSignatureNow(signer, digest, signature); - if (!valid) revert InvalidGaslessReclaimSignature(); - } - /** * @notice supportsInterface function * @dev ERC165 interface detection @@ -201,7 +203,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address _pubKey20 ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); - return _storeDeposit(_tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, false, address(0), 0); + return _storeDeposit( + _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, false, address(0), 0, 0, 0 + ); } function makeMFADeposit( @@ -212,7 +216,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address _pubKey20 ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); - return _storeDeposit(_tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, true, address(0), 0); + return _storeDeposit( + _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, true, address(0), 0, 0, 0 + ); } function makeSelflessMFADeposit( @@ -224,7 +230,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address _onBehalfOf ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); - return _storeDeposit(_tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, true, address(0), 0); + return _storeDeposit( + _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, true, address(0), 0, 0, 0 + ); } function makeSelflessDeposit( @@ -236,7 +244,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address _onBehalfOf ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); - return _storeDeposit(_tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, false, address(0), 0); + return _storeDeposit( + _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, false, address(0), 0, 0, 0 + ); } /** @@ -265,8 +275,51 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); return _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, - _pubKey20, _onBehalfOf, _withMFA, _recipient, _reclaimableAfter + _tokenAddress, + _contractType, + _amount, + _tokenId, + _pubKey20, + _onBehalfOf, + _withMFA, + _recipient, + _reclaimableAfter, + 0, + 0 + ); + } + + /** + * @notice Create a deposit and collect backend-authorized service/gasless fees up front. + * @dev Non-zero fees are paid in `feeToken` by msg.sender. `gaslessFee > 0` marks the deposit + * as eligible for EnvelopePaymaster-sponsored claim or sender reclaim. The fee authorization + * is signed by `mfaAuthorizer` and includes the full deposit intent plus a deadline. + */ + function makeCustomDepositWithFees(DepositRequest calldata _request, FeeAuthorization calldata _feeAuthorization) + public + payable + nonReentrant + returns (uint256) + { + _verifyFeeAuthorization(_request, _feeAuthorization); + uint256 amount = + _pullTokensViaApproval(_request.tokenAddress, _request.contractType, _request.amount, _request.tokenId); + + uint256 index = deposits.length; + _collectDepositFees(index, msg.sender, _feeAuthorization.serviceFee, _feeAuthorization.gaslessFee); + + return _storeDeposit( + _request.tokenAddress, + _request.contractType, + amount, + _request.tokenId, + _request.pubKey20, + _request.onBehalfOf, + _request.withMFA, + _request.recipient, + _request.reclaimableAfter, + _feeAuthorization.serviceFee, + _feeAuthorization.gaslessFee ); } @@ -277,90 +330,41 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow /** * @notice Withdraw tokens. Can be called by anyone with a valid signature. */ - function withdrawDeposit( - uint256 _index, - address _recipientAddress, - bytes memory _signature - ) external nonReentrant returns (bool) { + function withdrawDeposit(uint256 _index, address _recipientAddress, bytes memory _signature) + external + nonReentrant + returns (bool) + { return _withdrawDeposit(_index, _recipientAddress, ANYONE_WITHDRAWAL_MODE, _signature, false); } /** - * @notice Withdraw tokens with MFA. Fees are backend-signed flat amounts. + * @notice Withdraw tokens with backend MFA approval. * @param _index deposit index - * @param _recipientAddress address to receive the deposit (minus fees) + * @param _recipientAddress address to receive the full deposit amount * @param _signature withdrawal signature from the deposit's pubKey20 - * @param _MFASignature backend signature authorizing this withdrawal and specifying fees - * @param _serviceFee flat fee for the MFA service - * @param _gasAbsorptionFee flat fee to cover gasless claim; 0 if not absorbing + * @param _MFASignature backend signature authorizing this withdrawal + * @param _deadline backend-provided signature deadline; 0 means no expiry */ function withdrawMFADeposit( uint256 _index, address _recipientAddress, bytes memory _signature, bytes memory _MFASignature, - uint256 _serviceFee, - uint256 _gasAbsorptionFee, uint256 _deadline ) external nonReentrant returns (bool) { - _verifyMfaSignature(_index, _recipientAddress, _serviceFee, _gasAbsorptionFee, _deadline, _MFASignature); - _collectFees(_index, _serviceFee, _gasAbsorptionFee); - return _withdrawDeposit(_index, _recipientAddress, ANYONE_WITHDRAWAL_MODE, _signature, true); - } - - /** - * @notice Sponsored MFA withdrawal. A paymaster submits on behalf of the recipient. - * The gasAbsorptionFee is sent to the treasury instead of accumulating. - * @param _index deposit index - * @param _recipientAddress address to receive the deposit (minus fees) - * @param _signature withdrawal signature from the deposit's pubKey20 - * @param _MFASignature backend signature authorizing this withdrawal and specifying fees - * @param _serviceFee flat fee for the MFA service (accumulated for owner) - * @param _gasAbsorptionFee flat fee sent to treasury to reimburse gas - * @param _treasury paymaster address that submitted the tx and receives gasAbsorptionFee - */ - function withdrawMFADepositSponsored( - uint256 _index, - address _recipientAddress, - bytes memory _signature, - bytes memory _MFASignature, - uint256 _serviceFee, - uint256 _gasAbsorptionFee, - address _treasury, - uint256 _deadline - ) external nonReentrant returns (bool) { - _verifyMfaSignature(_index, _recipientAddress, _serviceFee, _gasAbsorptionFee, _deadline, _MFASignature); - - // Treasury validates the sponsorship - IPaymaster(_treasury).validateSponsoredOperation(msg.sender, _gasAbsorptionFee); - - // Deduct fees: serviceFee → accumulated, gasAbsorptionFee → treasury - uint256 totalFee = _serviceFee + _gasAbsorptionFee; - if (totalFee > 0) { - Deposit storage dep = deposits[_index]; - if (totalFee > dep.amount) revert FeeExceedsDepositAmount(); - dep.amount -= totalFee; - - if (_serviceFee > 0) { - accumulatedFees[dep.tokenAddress] += _serviceFee; - } - if (_gasAbsorptionFee > 0) { - _transferFeeToTreasury(dep.tokenAddress, _gasAbsorptionFee, _treasury); - } - emit FeeCollected(_index, dep.tokenAddress, _serviceFee, _gasAbsorptionFee); - } - + _verifyMfaSignature(_index, _recipientAddress, _deadline, _MFASignature); return _withdrawDeposit(_index, _recipientAddress, ANYONE_WITHDRAWAL_MODE, _signature, true); } /** * @notice Withdraw tokens. Must be called by the recipient. */ - function withdrawDepositAsRecipient( - uint256 _index, - address _recipientAddress, - bytes memory _signature - ) external nonReentrant returns (bool) { + function withdrawDepositAsRecipient(uint256 _index, address _recipientAddress, bytes memory _signature) + external + nonReentrant + returns (bool) + { if (_recipientAddress != msg.sender) revert NotTheRecipient(); return _withdrawDeposit(_index, _recipientAddress, RECIPIENT_WITHDRAWAL_MODE, _signature, false); } @@ -376,83 +380,35 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow return _withdrawDepositSender(_index, msg.sender); } - /** - * @notice Sponsored sender reclaim. A paymaster submits on behalf of the sender. - * Sender authorizes via EIP-712 signature. MFA authorizer signs the gas fee. - * @param _reclaim EIP-712 signed reclaim request - * @param _signer the sender address (must match deposit's senderAddress) - * @param _signature EIP-712 signature from the sender authorizing reclaim - * @param _MFASignature backend signature specifying the gas absorption fee - * @param _gasAbsorptionFee flat fee sent to treasury to reimburse gas - * @param _treasury paymaster address that receives gasAbsorptionFee - */ - function withdrawDepositSenderSponsored( - GaslessReclaim calldata _reclaim, - address _signer, - bytes calldata _signature, - bytes calldata _MFASignature, - uint256 _gasAbsorptionFee, - address _treasury, - uint256 _deadline - ) external nonReentrant returns (bool) { - // Verify sender authorized this reclaim - verifyGaslessReclaim(_reclaim, _signer, _signature); - - // Check deadline (0 = no expiry) - if (_deadline != 0 && block.timestamp > _deadline) revert MfaSignatureExpired(); - - // Verify MFA signature covers the fee for this sponsored reclaim - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - ENVELOPE_SALT, - block.chainid, - address(this), - _reclaim.depositIndex, - _signer, - _gasAbsorptionFee, - _deadline - ) - ) - ); - address authorizationSigner = getSigner(digest, _MFASignature); - if (authorizationSigner != mfaAuthorizer) revert WrongMfaSignature(); - - // Treasury validates - IPaymaster(_treasury).validateSponsoredOperation(msg.sender, _gasAbsorptionFee); - - // Deduct gas fee from deposit and send to treasury - if (_gasAbsorptionFee > 0) { - Deposit storage dep = deposits[_reclaim.depositIndex]; - if (_gasAbsorptionFee > dep.amount) revert FeeExceedsDepositAmount(); - dep.amount -= _gasAbsorptionFee; - _transferFeeToTreasury(dep.tokenAddress, _gasAbsorptionFee, _treasury); - emit FeeCollected(_reclaim.depositIndex, dep.tokenAddress, 0, _gasAbsorptionFee); - } - - return _withdrawDepositSender(_reclaim.depositIndex, _signer); - } - // ══════════════════════════════════════════════════════════════════════════════ // Token Receiver Hooks // ══════════════════════════════════════════════════════════════════════════════ function onERC721Received(address _operator, address, uint256, bytes calldata) - external view override returns (bytes4) + external + view + override + returns (bytes4) { if (_operator != address(this)) revert DirectTransfersNotAllowed(); return this.onERC721Received.selector; } function onERC1155Received(address _operator, address, uint256, uint256, bytes calldata) - external view override returns (bytes4) + external + view + override + returns (bytes4) { if (_operator != address(this)) revert DirectTransfersNotAllowed(); return this.onERC1155Received.selector; } function onERC1155BatchReceived(address _operator, address, uint256[] calldata, uint256[] calldata, bytes calldata) - external view override returns (bytes4) + external + view + override + returns (bytes4) { if (_operator != address(this)) revert DirectTransfersNotAllowed(); return this.onERC1155BatchReceived.selector; @@ -489,6 +445,41 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow return ECDSA.recover(messageHash, signature); } + /// @notice Returns whether `caller` can use an EnvelopePaymaster for the encoded vault call. + /// @dev Intended for ZkSync paymaster validation. Re-checks claim/reclaim preconditions so the + /// paymaster only pays for prepaid gasless deposits that should execute successfully. + function isValidGaslessOperation(address caller, bytes calldata callData) external view returns (bool) { + if (callData.length < 4) return false; + + bytes4 selector = bytes4(callData[0:4]); + + if (selector == this.withdrawDeposit.selector) { + (uint256 index, address recipient, bytes memory signature) = + abi.decode(callData[4:], (uint256, address, bytes)); + return _isValidGaslessClaim(caller, index, recipient, ANYONE_WITHDRAWAL_MODE, signature, false); + } + + if (selector == this.withdrawDepositAsRecipient.selector) { + (uint256 index, address recipient, bytes memory signature) = + abi.decode(callData[4:], (uint256, address, bytes)); + return _isValidGaslessClaim(caller, index, recipient, RECIPIENT_WITHDRAWAL_MODE, signature, false); + } + + if (selector == this.withdrawMFADeposit.selector) { + (uint256 index, address recipient, bytes memory signature, bytes memory mfaSignature, uint256 deadline) = + abi.decode(callData[4:], (uint256, address, bytes, bytes, uint256)); + if (!_isMfaSignatureValid(index, recipient, deadline, mfaSignature)) return false; + return _isValidGaslessClaim(caller, index, recipient, ANYONE_WITHDRAWAL_MODE, signature, true); + } + + if (selector == this.withdrawDepositSender.selector) { + (uint256 index) = abi.decode(callData[4:], (uint256)); + return _isValidGaslessReclaim(caller, index); + } + + return false; + } + function getDepositCount() external view returns (uint256) { return deposits.length; } @@ -526,8 +517,6 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow function _verifyMfaSignature( uint256 _index, address _recipientAddress, - uint256 _serviceFee, - uint256 _gasAbsorptionFee, uint256 _deadline, bytes memory _MFASignature ) internal view { @@ -536,39 +525,72 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( - abi.encodePacked( - ENVELOPE_SALT, - block.chainid, - address(this), - _index, - _recipientAddress, - _serviceFee, - _gasAbsorptionFee, - _deadline - ) + abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _deadline) ) ); address authorizationSigner = getSigner(digest, _MFASignature); if (authorizationSigner != mfaAuthorizer) revert WrongMfaSignature(); } - function _collectFees(uint256 _index, uint256 _serviceFee, uint256 _gasAbsorptionFee) internal { - uint256 totalFee = _serviceFee + _gasAbsorptionFee; - if (totalFee > 0) { - Deposit storage dep = deposits[_index]; - if (totalFee > dep.amount) revert FeeExceedsDepositAmount(); - dep.amount -= totalFee; - accumulatedFees[dep.tokenAddress] += totalFee; - emit FeeCollected(_index, dep.tokenAddress, _serviceFee, _gasAbsorptionFee); + function _isMfaSignatureValid(uint256 _index, address _recipientAddress, uint256 _deadline, bytes memory _signature) + internal + view + returns (bool) + { + if (_deadline != 0 && block.timestamp > _deadline) return false; + + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _deadline) + ) + ); + return _recoverSigner(digest, _signature) == mfaAuthorizer; + } + + function _verifyFeeAuthorization(DepositRequest calldata _request, FeeAuthorization calldata _feeAuthorization) + internal + view + { + uint256 totalFee = _feeAuthorization.serviceFee + _feeAuthorization.gaslessFee; + if (totalFee == 0) return; + if (address(feeToken) == address(0)) revert FeeTokenNotConfigured(); + if (_feeAuthorization.deadline != 0 && block.timestamp > _feeAuthorization.deadline) { + revert FeeAuthorizationExpired(); } + + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encode( + ENVELOPE_SALT, + block.chainid, + address(this), + msg.sender, + _request.tokenAddress, + _request.contractType, + _request.amount, + _request.tokenId, + _request.pubKey20, + _request.onBehalfOf, + _request.withMFA, + _request.recipient, + _request.reclaimableAfter, + _feeAuthorization.serviceFee, + _feeAuthorization.gaslessFee, + _feeAuthorization.deadline + ) + ) + ); + address authorizationSigner = getSigner(digest, _feeAuthorization.signature); + if (authorizationSigner != mfaAuthorizer) revert WrongFeeAuthorizationSignature(); } - function _transferFeeToTreasury(address _tokenAddress, uint256 _amount, address _treasury) internal { - if (_tokenAddress == address(0)) { - (bool success,) = _treasury.call{value: _amount}(""); - if (!success) revert EthTransferFailed(); - } else { - IERC20(_tokenAddress).safeTransfer(_treasury, _amount); + function _collectDepositFees(uint256 _index, address _feePayer, uint256 _serviceFee, uint256 _gaslessFee) internal { + uint256 totalFee = _serviceFee + _gaslessFee; + if (totalFee > 0) { + address tokenAddress = address(feeToken); + feeToken.safeTransferFrom(_feePayer, address(this), totalFee); + accumulatedFees[tokenAddress] += totalFee; + emit FeeCollected(_index, tokenAddress, _serviceFee, _gaslessFee); } } @@ -581,7 +603,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address _onBehalfOf, bool _requiresMFA, address _recipient, - uint40 _reclaimableAfter + uint40 _reclaimableAfter, + uint256 _serviceFee, + uint256 _gaslessFee ) internal returns (uint256) { deposits.push( Deposit({ @@ -595,19 +619,73 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow timestamp: uint40(block.timestamp), requiresMFA: _requiresMFA, recipient: _recipient, - reclaimableAfter: _reclaimableAfter + reclaimableAfter: _reclaimableAfter, + serviceFee: _serviceFee, + gaslessFee: _gaslessFee }) ); emit DepositEvent(deposits.length - 1, _contractType, _amount, _onBehalfOf); return deposits.length - 1; } - function _pullTokensViaApproval( - address _tokenAddress, - uint8 _contractType, - uint256 _amount, - uint256 _tokenId - ) internal returns (uint256) { + function _isValidGaslessClaim( + address _caller, + uint256 _index, + address _recipientAddress, + bytes32 _extraData, + bytes memory _signature, + bool _authorized + ) internal view returns (bool) { + if (_caller != _recipientAddress) return false; + if (_index >= deposits.length) return false; + if (deposits[_index].gaslessFee == 0) return false; + return _isValidWithdrawal(_index, _recipientAddress, _extraData, _signature, _authorized); + } + + function _isValidWithdrawal( + uint256 _index, + address _recipientAddress, + bytes32 _extraData, + bytes memory _signature, + bool _authorized + ) internal view returns (bool) { + Deposit memory deposit = deposits[_index]; + if (deposit.claimed) return false; + if (deposit.requiresMFA && !_authorized) return false; + if (deposit.recipient != address(0) && _recipientAddress != deposit.recipient) return false; + + if (deposit.pubKey20 != address(0)) { + bytes32 recipientAddressHash = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _extraData) + ) + ); + if (_recoverSigner(recipientAddressHash, _signature) != deposit.pubKey20) return false; + } + + return true; + } + + function _isValidGaslessReclaim(address _caller, uint256 _index) internal view returns (bool) { + if (_index >= deposits.length) return false; + Deposit memory deposit = deposits[_index]; + if (deposit.gaslessFee == 0) return false; + if (deposit.claimed) return false; + if (deposit.senderAddress != _caller) return false; + if (deposit.recipient != address(0) && block.timestamp <= deposit.reclaimableAfter) return false; + return true; + } + + function _recoverSigner(bytes32 _digest, bytes memory _signature) internal pure returns (address) { + (address recovered, ECDSA.RecoverError error,) = ECDSA.tryRecover(_digest, _signature); + if (error != ECDSA.RecoverError.NoError) return address(0); + return recovered; + } + + function _pullTokensViaApproval(address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId) + internal + returns (uint256) + { if (_contractType > 3) revert InvalidContractType(); if (_contractType == 0) { @@ -639,14 +717,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow if (_signature.length > 0) { bytes32 _recipientAddressHash = MessageHashUtils.toEthSignedMessageHash( keccak256( - abi.encodePacked( - ENVELOPE_SALT, - block.chainid, - address(this), - _index, - _recipientAddress, - _extraData - ) + abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _extraData) ) ); depositSigner = getSigner(_recipientAddressHash, _signature); @@ -666,7 +737,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } else if (_deposit.contractType == 2) { IERC721(_deposit.tokenAddress).safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId); } else if (_deposit.contractType == 3) { - IERC1155(_deposit.tokenAddress).safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId, _deposit.amount, ""); + IERC1155(_deposit.tokenAddress) + .safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId, _deposit.amount, ""); } return true; @@ -692,7 +764,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } else if (_deposit.contractType == 2) { IERC721(_deposit.tokenAddress).safeTransferFrom(address(this), _deposit.senderAddress, _deposit.tokenId); } else if (_deposit.contractType == 3) { - IERC1155(_deposit.tokenAddress).safeTransferFrom(address(this), _deposit.senderAddress, _deposit.tokenId, _deposit.amount, ""); + IERC1155(_deposit.tokenAddress) + .safeTransferFrom(address(this), _deposit.senderAddress, _deposit.tokenId, _deposit.amount, ""); } return true; diff --git a/src/envelope/doc/EnvelopePaymaster.md b/src/envelope/doc/EnvelopePaymaster.md new file mode 100644 index 00000000..1ea994e1 --- /dev/null +++ b/src/envelope/doc/EnvelopePaymaster.md @@ -0,0 +1,47 @@ +# EnvelopePaymaster + +`src/paymasters/EnvelopePaymaster.sol` + +## Purpose + +`EnvelopePaymaster` is the ZkSync paymaster for prepaid EnvelopeVault gasless operations. It pays ETH for claims and sender reclaims only when the target `EnvelopeVault` says the operation is valid and prepaid. + +## Constructor + +```solidity +constructor(address admin, address withdrawer, address envelopeVault) +``` + +| Param | Purpose | +|---|---| +| `admin` | Default admin for `BasePaymaster` roles. | +| `withdrawer` | Address allowed to withdraw excess ETH from the paymaster. | +| `envelopeVault` | The only vault destination this paymaster will sponsor. | + +## Validation Flow + +The paymaster supports ZkSync general flow only. + +1. ZkSync bootloader calls `validateAndPayForPaymasterTransaction` on `BasePaymaster`. +2. `BasePaymaster` forwards `from`, `to`, `requiredETH`, and `transaction.data` to `_validateAndPayGeneralFlow`. +3. `EnvelopePaymaster` requires `to == envelopeVault`. +4. It calls `EnvelopeVault.isValidGaslessOperation(from, transaction.data)`. +5. It verifies it has enough ETH for `requiredETH`. +6. `BasePaymaster` pays the bootloader. + +The paymaster does not keep per-gift state and does not price fees. Fee pricing and eligibility are recorded in `EnvelopeVault` at deposit creation. + +## Sponsored Selectors + +The paymaster delegates selector checks to the vault. The currently accepted operations are: + +- `withdrawDeposit` +- `withdrawMFADeposit` +- `withdrawDepositAsRecipient` +- `withdrawDepositSender` + +Approval-based paymaster flow is explicitly rejected. + +## Funding + +The paymaster must be funded with ETH on ZkSync. The vault collects `feeToken` compensation at deposit creation; moving that accumulated fee value back into paymaster ETH funding is an operational/backend treasury process, not a claim-time on-chain transfer. diff --git a/src/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md index 92a860cb..34749af1 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -1,175 +1,123 @@ -# EnvelopeVault — link-based asset vault +# EnvelopeVault `src/envelope/V4/EnvelopeVault.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. +`EnvelopeVault` is a link-based asset vault for ETH, ERC-20, ERC-721, and ERC-1155 gifts. A sender deposits an asset against a per-link `pubKey20`; the recipient claims by presenting a signature from the matching private key. The vault supports open links, address-bound links, optional backend MFA, sender reclaim, deposit-time service fees, and prepaid gasless claim/reclaim eligibility for ZkSync paymasters. ## Constructor ```solidity -constructor(address _ecoAddress, address _mfaAuthorizer) +constructor(address mfaAuthorizer, address owner, address feeToken) ``` -| 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 | +| Param | Purpose | +|---|---| +| `mfaAuthorizer` | Backend signer for MFA claim approvals and deposit-time fee authorizations. `address(0)` disables non-zero fee authorizations and makes MFA withdrawals fail. | +| `owner` | Owns the vault and can withdraw accumulated fees. | +| `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. | -Both stored `immutable`. The MFA authorizer was promoted from a hardcoded constant in upstream to per-deploy config during vendoring. +The constructor also sets the EIP-712 domain separator used by the vault-side validation helpers. -The constructor also computes and stores `DOMAIN_SEPARATOR` for the gasless-reclaim EIP-712 signature flow. +## Deposit Model -## Storage +All deposits store a `Deposit` record: ```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 + address pubKey20; + uint256 amount; + address tokenAddress; + uint8 contractType; // 0=ETH, 1=ERC20, 2=ERC721, 3=ERC1155 + bool claimed; + bool requiresMFA; + uint40 timestamp; + uint256 tokenId; + address senderAddress; + address recipient; + uint40 reclaimableAfter; + uint256 serviceFee; // feeToken amount collected at deposit creation + uint256 gaslessFee; // feeToken amount prepaid for paymaster sponsorship +} ``` -## Constants - -| Name | Value | Purpose | -|---|---|---| -| `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 | - -## Deposit functions +`serviceFee` and `gaslessFee` are not deducted from the gift amount. They are separate `feeToken` transfers from the depositor to the vault and are accounted in `accumulatedFees[address(feeToken)]`. -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. +## Main Deposit Functions -| Function | Use case | +| Function | Flow | |---|---| -| `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. Pulls funds from `msg.sender`. | -| `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"); -``` +| `makeDeposit(token, type, amount, tokenId, pubKey20)` | Basic open link. No MFA, no fees, no gasless sponsorship. | +| `makeMFADeposit(...)` | Basic open link that requires backend MFA at claim time. No deposit-time fees unless using `makeCustomDepositWithFees`. | +| `makeSelflessDeposit(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | +| `makeSelflessMFADeposit(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | +| `makeCustomDeposit(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | +| `makeCustomDepositWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0`. | -so the dual-zero footgun is impossible. +`FeeAuthorization` covers the full deposit intent, the fee payer (`msg.sender`), the two fee amounts, and a backend-selected deadline. `deadline == 0` means no expiry. If either fee is non-zero, the signature must recover to `mfaAuthorizer`. -## Withdraw functions +## Withdraw And Claim Functions -| Function | Caller | Auth | +| Function | Caller | Authorization | |---|---|---| -| `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)` | +| `withdrawDeposit(index, recipient, signature)` | Anyone, or a recipient using a paymaster | Link key signs `(salt, chainId, vault, index, recipient, ANYONE_WITHDRAWAL_MODE)`. | +| `withdrawMFADeposit(index, recipient, signature, mfaSignature, deadline)` | Anyone, or a recipient using a paymaster | Link signature plus backend MFA signature over `(salt, chainId, vault, index, recipient, deadline)`. | +| `withdrawDepositAsRecipient(index, recipient, signature)` | Must be `recipient` | Link key signs using `RECIPIENT_WITHDRAWAL_MODE`. | +| `withdrawDepositSender(index)` | Original `senderAddress` | Sender reclaim. If the deposit is recipient-bound, `block.timestamp` must be greater than `reclaimableAfter`. | -All withdraws set `claimed = true` BEFORE the asset transfer (CEI). `nonReentrant` adds belt-and-suspenders. +All withdrawal paths set `claimed = true` before transferring assets. Claim-time fee collection was intentionally removed: fees are now collected when the envelope is created. -## Asset paths +## Gasless Paymaster Flow -`contractType` determines how assets flow: +Gasless operation is handled by ZkSync paymasters, not by an internal vault callback. The vault is only the source of truth for whether a paymaster should sponsor a call. -| 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` | +1. Sender creates a deposit through `makeCustomDepositWithFees` with `gaslessFee > 0`. +2. The vault collects the gasless sponsorship fee immediately in `feeToken` and records it on the deposit. +3. A receiver submits a ZkSync transaction to `withdrawDeposit`, `withdrawMFADeposit`, or `withdrawDepositAsRecipient` using `EnvelopePaymaster`. +4. ZkSync calls the paymaster before execution. The paymaster checks the transaction targets this vault and calls `isValidGaslessOperation(from, transaction.data)`. +5. The vault re-checks the deposit state, gasless fee, recipient/sender identity, signatures, MFA deadline, and reclaim delay. +6. If validation passes, the paymaster pays ETH to the bootloader. The vault function then executes normally. -For ERC-20, the depositor must approve the vault first (Path C). The user pays for that approve themselves; the deposit tx is then submitted by the depositor calling `makeCustomDeposit` directly. +Sender reclaim can also be gasless: the sender submits `withdrawDepositSender(index)` through the paymaster. This is allowed only for deposits with `gaslessFee > 0` and the same reclaim timing rules as the regular reclaim path. -## 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: +## Paymaster Validation Helper ```solidity -require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); +function isValidGaslessOperation(address caller, bytes calldata callData) external view returns (bool); ``` -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. +This function is intended for paymaster validation. It accepts only these selectors: -## EIP-3009 path +- `withdrawDeposit` +- `withdrawMFADeposit` +- `withdrawDepositAsRecipient` +- `withdrawDepositSender` -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. +For claim calls, `caller` must be the recipient. For reclaim calls, `caller` must be the stored sender. The helper returns false for non-prepaid deposits, claimed deposits, unsupported selectors, wrong callers, invalid signatures, expired MFA approvals, or early reclaims. -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. +## Fees -## Events +| Fee | Collected | Meaning | +|---|---|---| +| `serviceFee` | Deposit creation | Paid backend service fee for optional security/MFA/compliance checks. | +| `gaslessFee` | Deposit creation | Prepaid compensation for paymaster-sponsored claim or reclaim. | -```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!") -``` +Both fees are backend-priced off-chain and backend-signed on-chain. The vault does not encode pricing policy; it enforces the signed amounts and deadline. The owner withdraws accumulated fees through `withdrawFees(token)`. -## Views +## Removed EIP-3009 Path -```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); -``` +The previous EIP-3009 deposit and gasless reclaim paths were removed. ERC-20 deposits now use standard allowance-based transfers, and ZkSync gasless UX is provided by the paymaster flow above. -Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with array length. Indexing services should listen to events instead. +## Events -## Vendoring patches applied at import +```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 FeeCollected(uint256 indexed index, address indexed tokenAddress, uint256 serviceFee, uint256 gaslessFee); +event FeesWithdrawn(address indexed tokenAddress, uint256 amount); +``` -| | 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/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 + T5) | `test/envelope/EnvelopeHardening.t.sol` | -| Edge cases | `test/envelope/EnvelopeEdgeCases.t.sol` | +## Test Coverage -90 tests pass. +Core coverage lives in `test/envelope/`. Gasless fee and vault-side paymaster eligibility tests live in `test/envelope/Gasless.t.sol`; ZkSync paymaster validation tests live in `test/paymasters/EnvelopePaymaster.t.sol`. diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 25000163..5aee93e5 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -1,75 +1,90 @@ # Envelope contracts -The Envelope flow on Nodle is built on top of the vendored **Peanut Protocol V4.4** -contracts. Senders deposit assets (ETH / ERC-20 / ERC-721 / ERC-1155) against a -per-link public key; recipients claim with the matching private key. +The Envelope flow on Nodle is built on top of modified Peanut Protocol V4.4 contracts. Senders deposit assets against a per-link public key; recipients claim with the matching private key. Nodle-specific additions include address-bound links, backend MFA, deposit-time service fees, and ZkSync paymaster support for prepaid gasless claims and reclaims. ## Layout | Contract | Source | Spec | |---|---|---| -| `EnvelopeVault` (vault) | `src/envelope/V4/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | -| `EnvelopeBatcher` (batched deposits) | `src/envelope/V4/EnvelopeBatcher.sol` | [EnvelopeBatcher.md](./EnvelopeBatcher.md) | +| `EnvelopeVault` | `src/envelope/V4/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | +| `EnvelopeBatcher` | `src/envelope/V4/EnvelopeBatcher.sol` | [EnvelopeBatcher.md](./EnvelopeBatcher.md) | +| `EnvelopePaymaster` | `src/paymasters/EnvelopePaymaster.sol` | [EnvelopePaymaster.md](./EnvelopePaymaster.md) | -Interfaces (vendored, unmodified): +Interfaces: | Interface | Source | Used by | |---|---|---| -| `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`) | +| `IEnvelopeGaslessValidator` | `src/envelope/util/IEnvelopeGaslessValidator.sol` | `EnvelopePaymaster` queries `EnvelopeVault.isValidGaslessOperation` before sponsoring gas. | ## License notice -This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) doesn't apply uniformly here. +This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) does not apply uniformly here. | Files | License | Notes | |---|---|---| -| `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 | -| `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 | +| `src/envelope/V4/EnvelopeVault.sol`, `EnvelopeBatcher.sol` | **GPL-3.0-or-later** | Modified copies of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/V4/LICENSE-GPL`. Each file carries a top-of-file modification notice per GPL §5(a). | +| `src/envelope/util/IEnvelopeGaslessValidator.sol` | **GPL-3.0-or-later** | Minimal interface for the GPL vault validation surface. | +| `test/envelope/**/*.t.sol` | **GPL-3.0-or-later** | Test files that import GPL-licensed contracts are relicensed for compatibility. | +| `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). +The GPL is "viral" only across `import` boundaries; non-importing files in the same repository remain under their own licenses under the OSI's "mere aggregation" interpretation. ## Naming convention -- **Source files** carry the Envelope brand (`EnvelopeVault.sol`, `EnvelopeBatcher.sol`); the audit lineage to upstream `peanutprotocol/peanut-contracts@main` is preserved via the `// Modified by Nodle` top-of-file notice, the `// @author Squirrel Labs` attribution, the bundled `LICENSE-GPL`, and the git rename history. -- **Contract symbols** (the names visible on the explorer / in the SDK / in the EIP-712 domain) use the Envelope brand: `EnvelopeVault`, `EnvelopeBatcher`. 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. +- **Source files** carry the Envelope brand (`EnvelopeVault.sol`, `EnvelopeBatcher.sol`); upstream audit lineage is preserved via the `// Modified by Nodle` notice, `// @author Squirrel Labs` attribution, bundled `LICENSE-GPL`, and git history. +- **Contract symbols** use the Envelope brand: `EnvelopeVault`, `EnvelopeBatcher`, `EnvelopePaymaster`. +- **On-chain hashed constants** keep upstream-compatible values where changing them would alter signature digests. -## Deployed on ZkSync Sepolia (chain 300) +## Main flows -| | Address | -|---|---| -| `EnvelopeVault` | [`0x5cf96a5db415801E52a63f216AEE601FAB6B8b11`](https://sepolia.explorer.zksync.io/address/0x5cf96a5db415801E52a63f216AEE601FAB6B8b11#contract) | -| `EnvelopeBatcher` | [`0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1`](https://sepolia.explorer.zksync.io/address/0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1#contract) | +| Flow | Entry point | Summary | +|---|---|---| +| Basic deposit | `EnvelopeVault.makeDeposit` / `makeCustomDeposit` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | +| Paid or gasless-ready deposit | `EnvelopeVault.makeCustomDepositWithFees` | Sender supplies a backend-signed `FeeAuthorization`; the vault collects `serviceFee` and/or `gaslessFee` in `feeToken` at deposit creation. | +| Open claim | `EnvelopeVault.withdrawDeposit` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | +| MFA claim | `EnvelopeVault.withdrawMFADeposit` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | +| Recipient-bound claim | `EnvelopeVault.withdrawDepositAsRecipient` | Only the bound recipient can submit the transaction. | +| Sender reclaim | `EnvelopeVault.withdrawDepositSender` | Original sender reclaims unclaimed deposits; recipient-bound deposits also enforce `reclaimableAfter`. | +| Gasless validation | `EnvelopeVault.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid claim/reclaim calldata before the paymaster pays gas. | -## Three deposit paths +## ZkSync gasless model -The vault itself supports three ways a sender can fund a link: +Gasless operations are paymaster-native: -| Path | Trigger | Approval | -|---|---|---| -| **A** — ETH | `msg.value` directly into `makeDeposit` / `makeCustomDeposit` | n/a | -| **B** — EIP-3009 token (USDC-style) | `makeDepositWithAuthorization` | embedded in signature | -| **C** — anything else (ERC-20, ERC-721, ERC-1155) | `makeCustomDeposit` after `token.approve` / `setApprovalForAll` | separate approval tx | +1. Backend prices optional `serviceFee` and `gaslessFee` off-chain and signs the full deposit intent. +2. Sender creates the envelope with `makeCustomDepositWithFees` and prepays those fees in `feeToken`. +3. A recipient or sender submits a supported claim/reclaim call through `EnvelopePaymaster`. +4. Before execution, the paymaster checks the destination and calls `isValidGaslessOperation` on the vault. +5. If the vault approves and the paymaster has enough ETH, the paymaster pays the ZkSync bootloader and the vault call executes normally. -User pays for both the approve and the deposit themselves. +The vault no longer contains an internal paymaster callback, and the EIP-3009 gasless deposit/reclaim path has been removed. ## Deploy | Script | Purpose | |---|---| -| `hardhat-deploy/DeployEnvelope.ts` | vault + batcher | +| `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeVault`, `EnvelopeBatcher`, and optionally `EnvelopePaymaster`. | -Hardhat-zksync script. See the vault spec for env vars. +Important environment variables: + +| Variable | Purpose | +|---|---| +| `ENVELOPE_MFA_AUTHORIZER` | Required backend signer for MFA and fee authorizations. | +| `ENVELOPE_OWNER` | Optional vault owner; defaults to deployer. | +| `ENVELOPE_FEE_TOKEN` | Optional fee token; defaults to zero address for fee-disabled deployments. | +| `ENVELOPE_DEPLOY_PAYMASTER` | Set to `true` to deploy `EnvelopePaymaster`. | +| `ENVELOPE_PAYMASTER_ADMIN` | Optional paymaster admin; defaults to deployer. | +| `ENVELOPE_PAYMASTER_WITHDRAWER` | Optional paymaster ETH withdrawer; defaults to deployer. | ## Test coverage -| Suite | Tests | +Relevant suites: + +| Suite | Focus | |---|---| -| Envelope core (`test/envelope/`) | **90** (56 vendored + 11 hardening + 23 edge cases) | -| Other paymasters (unchanged) | 102 | -| Rest of repo | 747 | -| **Total** | **939** | +| `test/envelope/` | Vault deposits, claims, MFA, recipient binding, reclaim, fee collection, and gasless eligibility. | +| `test/paymasters/EnvelopePaymaster.t.sol` | ZkSync paymaster validation and rejection paths for Envelope gasless operations. | +| `test/paymasters/` | Shared base, whitelist, bond treasury, and Envelope paymaster behavior. | + +Latest focused validation: `forge test --match-path 'test/{envelope/*,paymasters/*}'` passed 194 tests across 18 suites. diff --git a/src/envelope/util/IEnvelopeGaslessValidator.sol b/src/envelope/util/IEnvelopeGaslessValidator.sol new file mode 100644 index 00000000..8e619849 --- /dev/null +++ b/src/envelope/util/IEnvelopeGaslessValidator.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +/// @notice Minimal EnvelopeVault view used by ZkSync paymasters during validation. +interface IEnvelopeGaslessValidator { + /// @notice Returns true when `caller` may use a paymaster for the encoded vault call. + function isValidGaslessOperation(address caller, bytes calldata callData) external view returns (bool); +} diff --git a/src/envelope/util/IPaymaster.sol b/src/envelope/util/IPaymaster.sol deleted file mode 100644 index 9666e141..00000000 --- a/src/envelope/util/IPaymaster.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.26; - -/// @title IPaymaster -/// @notice Interface that a paymaster/treasury must implement to sponsor gasless -/// claim and reclaim operations on EnvelopeVault. -interface IPaymaster { - /// @notice Called by EnvelopeVault before a sponsored operation proceeds. - /// @dev The treasury should validate that `operator` is authorized to perform - /// sponsored operations and track/consume any quota. Revert to deny. - /// @param operator The address submitting the sponsored transaction (msg.sender to vault). - /// @param fee The gas absorption fee being paid to this treasury. - function validateSponsoredOperation(address operator, uint256 fee) external; -} diff --git a/src/paymasters/BasePaymaster.sol b/src/paymasters/BasePaymaster.sol index b35c13bf..68079f69 100644 --- a/src/paymasters/BasePaymaster.sol +++ b/src/paymasters/BasePaymaster.sol @@ -42,7 +42,12 @@ abstract contract BasePaymaster is IPaymaster, AccessControl { bytes32, /*_txHash*/ bytes32, /*_suggestedSignedHash*/ Transaction calldata transaction - ) external payable virtual returns (bytes4 magic, bytes memory context) { + ) + external + payable + virtual + returns (bytes4 magic, bytes memory context) + { _mustBeBootloader(); // By default we consider the transaction as accepted. @@ -61,7 +66,7 @@ abstract contract BasePaymaster is IPaymaster, AccessControl { address userAddress = address(uint160(transaction.from)); if (paymasterInputSelector == IPaymasterFlow.general.selector) { - _validateAndPayGeneralFlow(userAddress, destAddress, requiredETH); + _validateAndPayGeneralFlow(userAddress, destAddress, requiredETH, transaction.data); } else if (paymasterInputSelector == IPaymasterFlow.approvalBased.selector) { (address token, uint256 minimalAllowance, bytes memory data) = abi.decode(transaction.paymasterInput[4:], (address, uint256, bytes)); @@ -87,7 +92,10 @@ abstract contract BasePaymaster is IPaymaster, AccessControl { bytes32, /*_suggestedSignedHash*/ ExecutionResult, /*_txResult*/ uint256 /*_maxRefundedGas*/ - ) external payable { + ) + external + payable + { _mustBeBootloader(); // Refunds are not supported yet. @@ -111,7 +119,10 @@ abstract contract BasePaymaster is IPaymaster, AccessControl { } } - function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) internal virtual; + /// @dev Subclasses should validate `from`, `to`, and decoded `transactionData` before ETH is paid. + function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH, bytes memory transactionData) + internal + virtual; /// @dev Subclasses implementing this flow MUST verify that the token allowance /// from the user to this paymaster is at least `tokenAmount` before calling diff --git a/src/paymasters/EnvelopePaymaster.sol b/src/paymasters/EnvelopePaymaster.sol new file mode 100644 index 00000000..a22b5c41 --- /dev/null +++ b/src/paymasters/EnvelopePaymaster.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {BasePaymaster} from "./BasePaymaster.sol"; +import {IEnvelopeGaslessValidator} from "../envelope/util/IEnvelopeGaslessValidator.sol"; + +/// @notice ZkSync paymaster that sponsors prepaid gasless EnvelopeVault claims and reclaims. +/// @dev The EnvelopeVault remains the source of truth for whether a call is valid and prepaid. +/// This paymaster only accepts general-flow transactions targeting that vault. +contract EnvelopePaymaster is BasePaymaster { + IEnvelopeGaslessValidator public immutable envelopeVault; + + error DestinationIsNotEnvelopeVault(); + error EnvelopeGaslessOperationNotApproved(); + error PaymasterBalanceTooLow(); + + constructor(address admin, address withdrawer, address envelopeVault_) BasePaymaster(admin, withdrawer) { + envelopeVault = IEnvelopeGaslessValidator(envelopeVault_); + } + + function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH, bytes memory transactionData) + internal + view + override + { + if (to != address(envelopeVault)) revert DestinationIsNotEnvelopeVault(); + + bool approved; + try envelopeVault.isValidGaslessOperation(from, transactionData) returns (bool valid) { + approved = valid; + } catch { + approved = false; + } + if (!approved) revert EnvelopeGaslessOperationNotApproved(); + + if (address(this).balance < requiredETH) revert PaymasterBalanceTooLow(); + } + + function _validateAndPayApprovalBasedFlow(address, address, address, uint256, bytes memory, uint256) + internal + pure + override + { + revert PaymasterFlowNotSupported(); + } +} diff --git a/src/paymasters/WhitelistPaymaster.sol b/src/paymasters/WhitelistPaymaster.sol index a2405954..a9d9c45d 100644 --- a/src/paymasters/WhitelistPaymaster.sol +++ b/src/paymasters/WhitelistPaymaster.sol @@ -64,7 +64,11 @@ contract WhitelistPaymaster is BasePaymaster { emit WhitelistedUsersRemoved(users); } - function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) internal view override { + function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH, bytes memory) + internal + view + override + { if (!isWhitelistedContract[to]) { revert DestIsNotWhitelisted(); } diff --git a/test/envelope/Deposit.t.sol b/test/envelope/Deposit.t.sol index 5d4a9a8a..be726403 100644 --- a/test/envelope/Deposit.t.sol +++ b/test/envelope/Deposit.t.sol @@ -25,7 +25,7 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/EnvelopeBatcher.t.sol b/test/envelope/EnvelopeBatcher.t.sol index 4fbdc1e5..fd0bf0e6 100644 --- a/test/envelope/EnvelopeBatcher.t.sol +++ b/test/envelope/EnvelopeBatcher.t.sol @@ -19,7 +19,7 @@ contract EnvelopeBatcherTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { batcher = new EnvelopeBatcher(); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); @@ -176,18 +176,13 @@ contract EnvelopeBatcherTest is Test, ERC1155Holder, ERC721Holder { amounts[1] = 20; amounts[2] = 30; amounts[3] = 40; - - uint256[] memory depositIndices = batcher.batchMakeDepositRaffle{value: 100}( - address(vault), - address(testToken), - 0, - amounts, - PUBKEY20 - ); - - for(uint256 i = 0; i < amounts.length; i++) { + + uint256[] memory depositIndices = + batcher.batchMakeDepositRaffle{value: 100}(address(vault), address(testToken), 0, amounts, PUBKEY20); + + for (uint256 i = 0; i < amounts.length; i++) { EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); - assert(deposit.amount == amounts[i]); // main assertion + assert(deposit.amount == amounts[i]); // main assertion // a few sanity checks assert(deposit.contractType == 0); @@ -207,18 +202,13 @@ contract EnvelopeBatcherTest is Test, ERC1155Holder, ERC721Holder { testToken.mint(address(this), 100); testToken.approve(address(batcher), 100); - - uint256[] memory depositIndices = batcher.batchMakeDepositRaffle( - address(vault), - address(testToken), - 1, - amounts, - PUBKEY20 - ); - - for(uint256 i = 0; i < amounts.length; i++) { + + uint256[] memory depositIndices = + batcher.batchMakeDepositRaffle(address(vault), address(testToken), 1, amounts, PUBKEY20); + + for (uint256 i = 0; i < amounts.length; i++) { EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); - assert(deposit.amount == amounts[i]); // main assertion + assert(deposit.amount == amounts[i]); // main assertion // a few sanity checks assert(deposit.contractType == 1); diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index 173ca881..6c455675 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -63,7 +63,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function setUp() public { LINK_PUBKEY20 = vm.addr(LINK_PRIV); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); batcher = new EnvelopeBatcher(); erc20 = new ERC20Mock(); erc721 = new ERC721Mock(); diff --git a/test/envelope/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index e5cd65c9..0ec7fa60 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -23,7 +23,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); function setUp() public { - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); } @@ -50,8 +50,10 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { 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; + 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(EnvelopeVault.DirectTransfersNotAllowed.selector); @@ -66,16 +68,14 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { uint256 mfaPrivKey = uint256(keccak256("nodle.vault.mfa-test-signer")); address mfaSigner = vm.addr(mfaPrivKey); - EnvelopeVault nodleVault = new EnvelopeVault(mfaSigner, address(this)); + EnvelopeVault nodleVault = new EnvelopeVault(mfaSigner, address(this), address(0)); assertEq(nodleVault.mfaAuthorizer(), mfaSigner, "constructor arg ignored"); // make an MFA-gated deposit, then craft both signatures with our test keys. uint256 depositPrivKey = uint256(keccak256("nodle.vault.deposit-key")); address depositSigner = vm.addr(depositPrivKey); - uint256 idx = nodleVault.makeSelflessMFADeposit{value: 1 wei}( - address(0), 0, 1, 0, depositSigner, address(this) - ); + uint256 idx = nodleVault.makeSelflessMFADeposit{value: 1 wei}(address(0), 0, 1, 0, depositSigner, address(this)); // withdrawal signature (signed by deposit pubkey) bytes32 wdHash = MessageHashUtilsLite.toEthSignedMessageHash( @@ -93,28 +93,19 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { (uint8 wv, bytes32 wr, bytes32 ws) = vm.sign(depositPrivKey, wdHash); bytes memory wdSig = abi.encodePacked(wr, ws, wv); - // MFA signature (signed by configured mfaAuthorizer, includes fee amounts) - uint256 serviceFee = 0; - uint256 gasAbsorptionFee = 0; + // MFA signature (signed by configured mfaAuthorizer, includes deadline) uint256 deadline = 0; // no expiry bytes32 mfaHash = MessageHashUtilsLite.toEthSignedMessageHash( keccak256( abi.encodePacked( - nodleVault.ENVELOPE_SALT(), - block.chainid, - address(nodleVault), - idx, - address(this), - serviceFee, - gasAbsorptionFee, - deadline + nodleVault.ENVELOPE_SALT(), block.chainid, address(nodleVault), idx, address(this), deadline ) ) ); (uint8 mv, bytes32 mr, bytes32 ms) = vm.sign(mfaPrivKey, mfaHash); bytes memory mfaSig = abi.encodePacked(mr, ms, mv); - nodleVault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, serviceFee, gasAbsorptionFee, 0); + nodleVault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, deadline); } function test_T2_zeroMfaAuthorizerRejectsAllMfaWithdrawals() public { @@ -122,15 +113,13 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { uint256 depositPrivKey = uint256(keccak256("dep")); address depositSigner = vm.addr(depositPrivKey); - uint256 idx = vault.makeSelflessMFADeposit{value: 1 wei}( - address(0), 0, 1, 0, depositSigner, address(this) - ); + uint256 idx = vault.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(); - vault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, 0, 0, 0); + vault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, 0); } } diff --git a/test/envelope/EnvelopeVault.t.sol b/test/envelope/EnvelopeVault.t.sol index 8c483af6..1dda3597 100644 --- a/test/envelope/EnvelopeVault.t.sol +++ b/test/envelope/EnvelopeVault.t.sol @@ -30,7 +30,7 @@ contract EnvelopeVaultTest is Test { testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); // Mint tokens for test accounts testToken.mint(address(this), 1000); diff --git a/test/envelope/Gasless.t.sol b/test/envelope/Gasless.t.sol new file mode 100644 index 00000000..c9c20e93 --- /dev/null +++ b/test/envelope/Gasless.t.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/envelope/V4/EnvelopeVault.sol"; +import "./mocks/ERC20Mock.sol"; + +contract EnvelopeVaultGaslessTest is Test { + EnvelopeVault public vault; + ERC20Mock public feeToken; + + uint256 public constant LINK_PRIVKEY = uint256(keccak256("link-key")); + address public LINK_PUBKEY; + + uint256 public constant BACKEND_PRIVKEY = uint256(keccak256("nodle.vault.backend-authorizer")); + address public BACKEND_AUTHORIZER; + + address public constant SENDER = address(0xA11CE); + address public constant RECIPIENT = address(0xB0B); + + function setUp() public { + LINK_PUBKEY = vm.addr(LINK_PRIVKEY); + BACKEND_AUTHORIZER = vm.addr(BACKEND_PRIVKEY); + + feeToken = new ERC20Mock(); + vault = new EnvelopeVault(BACKEND_AUTHORIZER, address(this), address(feeToken)); + + vm.deal(SENDER, 10 ether); + feeToken.mint(SENDER, 1_000 ether); + + vm.prank(SENDER); + feeToken.approve(address(vault), type(uint256).max); + } + + function _request(uint256 amount, bool withMFA, address recipient, uint40 reclaimableAfter) + internal + view + returns (EnvelopeVault.DepositRequest memory) + { + return EnvelopeVault.DepositRequest({ + tokenAddress: address(0), + contractType: 0, + amount: amount, + tokenId: 0, + pubKey20: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: withMFA, + recipient: recipient, + reclaimableAfter: reclaimableAfter + }); + } + + function _signFeeAuthorization( + EnvelopeVault.DepositRequest memory request, + address feePayer, + uint256 serviceFee, + uint256 gaslessFee, + uint256 deadline + ) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encode( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + feePayer, + request.tokenAddress, + request.contractType, + request.amount, + request.tokenId, + request.pubKey20, + request.onBehalfOf, + request.withMFA, + request.recipient, + request.reclaimableAfter, + serviceFee, + gaslessFee, + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _feeAuthorization( + EnvelopeVault.DepositRequest memory request, + uint256 serviceFee, + uint256 gaslessFee, + uint256 deadline + ) internal view returns (EnvelopeVault.FeeAuthorization memory) { + return EnvelopeVault.FeeAuthorization({ + serviceFee: serviceFee, + gaslessFee: gaslessFee, + deadline: deadline, + signature: _signFeeAuthorization(request, SENDER, serviceFee, gaslessFee, deadline) + }); + } + + function _signWithdrawal(uint256 depositIndex, address recipient, bytes32 mode) + internal + view + returns (bytes memory) + { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked(vault.ENVELOPE_SALT(), block.chainid, address(vault), depositIndex, recipient, mode) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _signMfa(uint256 depositIndex, address recipient, uint256 deadline) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + vault.ENVELOPE_SALT(), block.chainid, address(vault), depositIndex, recipient, deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _makeGaslessDeposit(uint256 amount, bool withMFA, address recipient, uint40 reclaimableAfter) + internal + returns (uint256) + { + EnvelopeVault.DepositRequest memory request = _request(amount, withMFA, recipient, reclaimableAfter); + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0.01 ether, 0.02 ether, 0); + + vm.prank(SENDER); + return vault.makeCustomDepositWithFees{value: amount}(request, authorization); + } + + function test_MakeCustomDepositWithFeesCollectsFeesAtDeposit() public { + uint256 amount = 1 ether; + uint256 serviceFee = 0.01 ether; + uint256 gaslessFee = 0.02 ether; + EnvelopeVault.DepositRequest memory request = _request(amount, true, address(0), 0); + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, serviceFee, gaslessFee, 0); + + vm.prank(SENDER); + uint256 index = vault.makeCustomDepositWithFees{value: amount}(request, authorization); + + EnvelopeVault.Deposit memory deposit = vault.getDeposit(index); + assertEq(deposit.amount, amount); + assertEq(deposit.serviceFee, serviceFee); + assertEq(deposit.gaslessFee, gaslessFee); + assertEq(feeToken.balanceOf(address(vault)), serviceFee + gaslessFee); + assertEq(vault.accumulatedFees(address(feeToken)), serviceFee + gaslessFee); + } + + function test_RevertIf_FeeTokenNotConfigured() public { + EnvelopeVault vaultWithoutFeeToken = new EnvelopeVault(BACKEND_AUTHORIZER, address(this), address(0)); + EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, 0); + + vm.prank(SENDER); + vm.expectRevert(EnvelopeVault.FeeTokenNotConfigured.selector); + vaultWithoutFeeToken.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + } + + function test_RevertIf_FeeAuthorizationExpired() public { + EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + uint256 deadline = block.timestamp + 1 hours; + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, deadline); + + vm.warp(deadline + 1); + + vm.prank(SENDER); + vm.expectRevert(EnvelopeVault.FeeAuthorizationExpired.selector); + vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + } + + function test_RevertIf_WrongFeeAuthorizationSignature() public { + EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, 0); + authorization.gaslessFee = 0.02 ether; + + vm.prank(SENDER); + vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); + vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + } + + function test_IsValidGaslessClaim() public { + uint256 index = _makeGaslessDeposit(1 ether, false, address(0), 0); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + + assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); + assertFalse(vault.isValidGaslessOperation(SENDER, callData)); + } + + function test_IsValidGaslessMfaClaim() public { + uint256 index = _makeGaslessDeposit(1 ether, true, address(0), 0); + uint256 deadline = block.timestamp + 1 hours; + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); + bytes memory mfaSig = _signMfa(index, RECIPIENT, deadline); + bytes memory callData = + abi.encodeCall(EnvelopeVault.withdrawMFADeposit, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); + + assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); + + vm.warp(deadline + 1); + assertFalse(vault.isValidGaslessOperation(RECIPIENT, callData)); + } + + function test_IsValidGaslessRecipientBoundClaim() public { + uint256 index = _makeGaslessDeposit(1 ether, false, RECIPIENT, uint40(block.timestamp + 1 days)); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + + assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); + assertFalse(vault.isValidGaslessOperation(address(0xCAFE), callData)); + } + + function test_IsValidGaslessReclaimAfterDelay() public { + uint40 reclaimableAfter = uint40(block.timestamp + 1 days); + uint256 index = _makeGaslessDeposit(1 ether, false, RECIPIENT, reclaimableAfter); + bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDepositSender, (index)); + + assertFalse(vault.isValidGaslessOperation(SENDER, callData)); + + vm.warp(reclaimableAfter + 1); + assertTrue(vault.isValidGaslessOperation(SENDER, callData)); + assertFalse(vault.isValidGaslessOperation(RECIPIENT, callData)); + } + + function test_ZeroGaslessFeeDoesNotApprovePaymaster() public { + EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0.01 ether, 0, 0); + + vm.prank(SENDER); + uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + + assertFalse(vault.isValidGaslessOperation(RECIPIENT, callData)); + } +} diff --git a/test/envelope/Integration.t.sol b/test/envelope/Integration.t.sol index e3bcde91..c45cee9a 100644 --- a/test/envelope/Integration.t.sol +++ b/test/envelope/Integration.t.sol @@ -25,7 +25,7 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/MFA.t.sol b/test/envelope/MFA.t.sol index d13b6de9..223cafbe 100644 --- a/test/envelope/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -7,35 +7,22 @@ import "../../src/envelope/V4/EnvelopeVault.sol"; contract EnvelopeVaultMFATest is Test { EnvelopeVault public vault; - // a dummy private/public keypair to test withdrawals address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); bytes32 public constant SAMPLE_PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; - // MFA authorizer key pair for testing uint256 public constant MFA_PRIVKEY = uint256(keccak256("nodle.vault.mfa-authorizer")); address public MFA_AUTHORIZER; function setUp() public { MFA_AUTHORIZER = vm.addr(MFA_PRIVKEY); - vault = new EnvelopeVault(MFA_AUTHORIZER, address(this)); + vault = new EnvelopeVault(MFA_AUTHORIZER, address(this), address(0)); } - function _signMfa(uint256 depositIndex, address recipient, uint256 serviceFee, uint256 gasAbsorptionFee, uint256 deadline) - internal - view - returns (bytes memory) - { + function _signMfa(uint256 depositIndex, address recipient, uint256 deadline) internal view returns (bytes memory) { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - depositIndex, - recipient, - serviceFee, - gasAbsorptionFee, - deadline + vault.ENVELOPE_SALT(), block.chainid, address(vault), depositIndex, recipient, deadline ) ) ); @@ -43,147 +30,63 @@ contract EnvelopeVaultMFATest is Test { return abi.encodePacked(r, s, v); } - function testMFADeposit() public { - uint256 depositIndex = vault.makeSelflessMFADeposit{value: 1 ether}( - address(0), - 0, - 1 ether, - 0, - SAMPLE_ADDRESS, - address(0x1234) - ); - - // Build withdrawal signature - bytes32 wdDigest = MessageHashUtils.toEthSignedMessageHash( + function _signWithdrawal(uint256 depositIndex, address recipient) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( vault.ENVELOPE_SALT(), block.chainid, address(vault), depositIndex, - address(this), + recipient, vault.ANYONE_WITHDRAWAL_MODE() ) ) ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); - bytes memory signature = abi.encodePacked(r, s, v); - - // Withdrawing without authorization should fail - vm.expectRevert(EnvelopeVault.RequiresMfaAuthorization.selector); - vault.withdrawDeposit(depositIndex, address(this), signature); - - // Withdrawing with incorrect MFA signature should fail - vm.expectRevert(EnvelopeVault.WrongMfaSignature.selector); - vault.withdrawMFADeposit(depositIndex, address(this), signature, signature, 0, 0, 0); - - // Correct MFA authorization with zero fees and no deadline - bytes memory mfaSig = _signMfa(depositIndex, address(this), 0, 0, 0); - vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, 0, 0, 0); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), digest); + return abi.encodePacked(r, s, v); } - function testMFADepositWithFees() public { - uint256 depositAmount = 1 ether; - uint256 serviceFee = 0.01 ether; - uint256 gasAbsorptionFee = 0.005 ether; - - uint256 depositIndex = vault.makeSelflessMFADeposit{value: depositAmount}( - address(0), - 0, - depositAmount, - 0, - SAMPLE_ADDRESS, - address(0x1234) - ); - - // Build withdrawal signature - bytes32 wdDigest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - depositIndex, - address(this), - vault.ANYONE_WITHDRAWAL_MODE() - ) - ) - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); - bytes memory signature = abi.encodePacked(r, s, v); + function testMFADeposit() public { + uint256 depositIndex = + vault.makeSelflessMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, SAMPLE_ADDRESS, address(0x1234)); - // MFA signature with fees and no deadline - bytes memory mfaSig = _signMfa(depositIndex, address(this), serviceFee, gasAbsorptionFee, 0); + bytes memory withdrawalSig = _signWithdrawal(depositIndex, address(this)); - uint256 balBefore = address(this).balance; - vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, serviceFee, gasAbsorptionFee, 0); - uint256 balAfter = address(this).balance; + vm.expectRevert(EnvelopeVault.RequiresMfaAuthorization.selector); + vault.withdrawDeposit(depositIndex, address(this), withdrawalSig); - // Recipient gets deposit minus fees - assertEq(balAfter - balBefore, depositAmount - serviceFee - gasAbsorptionFee); + vm.expectRevert(EnvelopeVault.WrongMfaSignature.selector); + vault.withdrawMFADeposit(depositIndex, address(this), withdrawalSig, withdrawalSig, 0); - // Fees accumulated in contract - assertEq(vault.accumulatedFees(address(0)), serviceFee + gasAbsorptionFee); + bytes memory mfaSig = _signMfa(depositIndex, address(this), 0); + vault.withdrawMFADeposit(depositIndex, address(this), withdrawalSig, mfaSig, 0); } - function testWithdrawFeesOnlyOwner() public { - // Make deposit + withdraw with fees to accumulate some - uint256 depositAmount = 1 ether; - uint256 serviceFee = 0.01 ether; + function testMFADepositWithDeadline() public { + uint256 depositIndex = + vault.makeSelflessMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, SAMPLE_ADDRESS, address(0x1234)); - uint256 depositIndex = vault.makeSelflessMFADeposit{value: depositAmount}( - address(0), 0, depositAmount, 0, SAMPLE_ADDRESS, address(0x1234) - ); + uint256 deadline = block.timestamp + 1 hours; + bytes memory withdrawalSig = _signWithdrawal(depositIndex, address(this)); + bytes memory mfaSig = _signMfa(depositIndex, address(this), deadline); - bytes32 wdDigest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), block.chainid, address(vault), - depositIndex, address(this), vault.ANYONE_WITHDRAWAL_MODE() - ) - ) - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); - bytes memory signature = abi.encodePacked(r, s, v); - bytes memory mfaSig = _signMfa(depositIndex, address(this), serviceFee, 0, 0); - vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, serviceFee, 0, 0); - - // Non-owner cannot withdraw fees - vm.prank(address(0xdead)); - vm.expectRevert(); - vault.withdrawFees(address(0)); - - // Owner can withdraw - uint256 balBefore = address(this).balance; - vault.withdrawFees(address(0)); - assertEq(address(this).balance - balBefore, serviceFee); - assertEq(vault.accumulatedFees(address(0)), 0); + vault.withdrawMFADeposit(depositIndex, address(this), withdrawalSig, mfaSig, deadline); } - function test_RevertIf_FeeExceedsDeposit() public { - uint256 depositAmount = 0.01 ether; + function test_RevertIf_MfaSignatureExpired() public { + uint256 depositIndex = + vault.makeSelflessMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, SAMPLE_ADDRESS, address(0x1234)); - uint256 depositIndex = vault.makeSelflessMFADeposit{value: depositAmount}( - address(0), 0, depositAmount, 0, SAMPLE_ADDRESS, address(0x1234) - ); + uint256 deadline = block.timestamp + 1 hours; + bytes memory withdrawalSig = _signWithdrawal(depositIndex, address(this)); + bytes memory mfaSig = _signMfa(depositIndex, address(this), deadline); - bytes32 wdDigest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), block.chainid, address(vault), - depositIndex, address(this), vault.ANYONE_WITHDRAWAL_MODE() - ) - ) - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), wdDigest); - bytes memory signature = abi.encodePacked(r, s, v); - - // Fee exceeds deposit - uint256 bigFee = 1 ether; - bytes memory mfaSig = _signMfa(depositIndex, address(this), bigFee, 0, 0); - vm.expectRevert(EnvelopeVault.FeeExceedsDepositAmount.selector); - vault.withdrawMFADeposit(depositIndex, address(this), signature, mfaSig, bigFee, 0, 0); + vm.warp(deadline + 1); + + vm.expectRevert(EnvelopeVault.MfaSignatureExpired.selector); + vault.withdrawMFADeposit(depositIndex, address(this), withdrawalSig, mfaSig, deadline); } - receive() payable external {} + receive() external payable {} } diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index a2e4e90c..e1170912 100644 --- a/test/envelope/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -22,7 +22,7 @@ contract RecipientBoundTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); testToken.mint(address(this), 1000); testToken.approve(address(vault), 1000); } @@ -48,12 +48,12 @@ contract RecipientBoundTest is Test { vault.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 { + function testRecipientBoundReclaim() public { uint256 depositIndex = vault.makeCustomDeposit( address(testToken), 1, // contract type - erc 20 @@ -74,5 +74,5 @@ contract RecipientBoundTest is Test { vm.warp(block.timestamp + 11); // advance past reclaimableAfter 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 5d980c65..bb884548 100644 --- a/test/envelope/SenderWithdraw.t.sol +++ b/test/envelope/SenderWithdraw.t.sol @@ -19,7 +19,7 @@ contract TestSenderWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), 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"); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); testToken = new ERC20Mock(); // contractType 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"); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); testToken = new ERC721Mock(); // contractType 2 // Mint token for test @@ -110,7 +110,7 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); testToken = new ERC1155Mock(); // Mint tokens diff --git a/test/envelope/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol index 58429cbd..77cd7266 100644 --- a/test/envelope/SigWithdraw.t.sol +++ b/test/envelope/SigWithdraw.t.sol @@ -24,7 +24,7 @@ contract TestSigWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(this)); + vault = new EnvelopeVault(address(0), address(this), address(0)); } // test sender withdrawal of ETH diff --git a/test/envelope/Sponsored.t.sol b/test/envelope/Sponsored.t.sol deleted file mode 100644 index a15c0448..00000000 --- a/test/envelope/Sponsored.t.sol +++ /dev/null @@ -1,355 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.0; - -import "forge-std/Test.sol"; -import "../../src/envelope/V4/EnvelopeVault.sol"; -import "../../src/envelope/util/IPaymaster.sol"; - -contract MockPaymaster is IPaymaster { - bool public shouldRevert; - uint256 public lastFee; - address public lastOperator; - - function validateSponsoredOperation(address operator, uint256 fee) external { - if (shouldRevert) revert("paymaster: denied"); - lastOperator = operator; - lastFee = fee; - } - - function setRevert(bool _shouldRevert) external { - shouldRevert = _shouldRevert; - } - - receive() external payable {} -} - -contract EnvelopeVaultSponsoredTest is Test { - EnvelopeVault public vault; - MockPaymaster public paymaster; - - // Link keypair - uint256 public constant LINK_PRIVKEY = uint256(keccak256("link-key")); - address public LINK_PUBKEY; - - // MFA authorizer - uint256 public constant MFA_PRIVKEY = uint256(keccak256("nodle.vault.mfa-authorizer")); - address public MFA_AUTHORIZER; - - // Sender (depositor) keypair for sponsored reclaim - uint256 public constant SENDER_PRIVKEY = uint256(keccak256("sender-key")); - address public SENDER; - - // Operator (relayer) who submits the tx - address public constant OPERATOR = address(0xBEEF); - - function setUp() public { - LINK_PUBKEY = vm.addr(LINK_PRIVKEY); - MFA_AUTHORIZER = vm.addr(MFA_PRIVKEY); - SENDER = vm.addr(SENDER_PRIVKEY); - - vault = new EnvelopeVault(MFA_AUTHORIZER, address(this)); - paymaster = new MockPaymaster(); - - vm.deal(SENDER, 10 ether); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Helpers - // ═══════════════════════════════════════════════════════════════════════════ - - function _makeDeposit(uint256 amount, bool withMFA) internal returns (uint256) { - return vault.makeCustomDeposit{value: amount}( - address(0), 0, amount, 0, LINK_PUBKEY, SENDER, withMFA, address(0), 0 - ); - } - - function _signWithdrawal(uint256 depositIndex, address recipient) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - depositIndex, - recipient, - vault.ANYONE_WITHDRAWAL_MODE() - ) - ) - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); - return abi.encodePacked(r, s, v); - } - - function _signMfa(uint256 depositIndex, address recipient, uint256 serviceFee, uint256 gasAbsorptionFee, uint256 deadline) - internal view returns (bytes memory) - { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - depositIndex, - recipient, - serviceFee, - gasAbsorptionFee, - deadline - ) - ) - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIVKEY, digest); - return abi.encodePacked(r, s, v); - } - - function _signGaslessReclaim(uint256 depositIndex) internal view returns (bytes memory) { - bytes32 structHash = keccak256( - abi.encode( - vault.GASLESS_RECLAIM_TYPEHASH(), - depositIndex - ) - ); - bytes32 digest = keccak256(abi.encodePacked("\x19\x01", vault.DOMAIN_SEPARATOR(), structHash)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(SENDER_PRIVKEY, digest); - return abi.encodePacked(r, s, v); - } - - function _signMfaForReclaim(uint256 depositIndex, address signer, uint256 gasAbsorptionFee, uint256 deadline) - internal view returns (bytes memory) - { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - depositIndex, - signer, - gasAbsorptionFee, - deadline - ) - ) - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIVKEY, digest); - return abi.encodePacked(r, s, v); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // withdrawMFADepositSponsored tests - // ═══════════════════════════════════════════════════════════════════════════ - - function test_WithdrawMFADepositSponsored() public { - uint256 depositAmount = 1 ether; - uint256 serviceFee = 0.01 ether; - uint256 gasAbsorptionFee = 0.005 ether; - - vm.prank(SENDER); - uint256 idx = _makeDeposit(depositAmount, true); - - bytes memory linkSig = _signWithdrawal(idx, address(this)); - bytes memory mfaSig = _signMfa(idx, address(this), serviceFee, gasAbsorptionFee, 0); - - uint256 balBefore = address(this).balance; - vm.prank(OPERATOR); - vault.withdrawMFADepositSponsored( - idx, address(this), linkSig, mfaSig, serviceFee, gasAbsorptionFee, address(paymaster), 0 - ); - uint256 balAfter = address(this).balance; - - // Recipient gets deposit minus both fees - assertEq(balAfter - balBefore, depositAmount - serviceFee - gasAbsorptionFee); - - // Service fee accumulated - assertEq(vault.accumulatedFees(address(0)), serviceFee); - - // Gas absorption fee sent to paymaster - assertEq(address(paymaster).balance, gasAbsorptionFee); - - // Paymaster was called with correct args - assertEq(paymaster.lastOperator(), OPERATOR); - assertEq(paymaster.lastFee(), gasAbsorptionFee); - } - - function test_RevertIf_SponsoredClaimPaymasterDenies() public { - vm.prank(SENDER); - uint256 idx = _makeDeposit(1 ether, true); - - bytes memory linkSig = _signWithdrawal(idx, address(this)); - bytes memory mfaSig = _signMfa(idx, address(this), 0, 0.01 ether, 0); - - paymaster.setRevert(true); - - vm.prank(OPERATOR); - vm.expectRevert("paymaster: denied"); - vault.withdrawMFADepositSponsored( - idx, address(this), linkSig, mfaSig, 0, 0.01 ether, address(paymaster), 0 - ); - } - - function test_RevertIf_SponsoredClaimFeeExceedsDeposit() public { - vm.prank(SENDER); - uint256 idx = _makeDeposit(1 ether, true); - - uint256 bigFee = 2 ether; - bytes memory linkSig = _signWithdrawal(idx, address(this)); - bytes memory mfaSig = _signMfa(idx, address(this), bigFee, 0, 0); - - vm.prank(OPERATOR); - vm.expectRevert(EnvelopeVault.FeeExceedsDepositAmount.selector); - vault.withdrawMFADepositSponsored( - idx, address(this), linkSig, mfaSig, bigFee, 0, address(paymaster), 0 - ); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // withdrawDepositSenderSponsored tests - // ═══════════════════════════════════════════════════════════════════════════ - - function test_WithdrawDepositSenderSponsored() public { - uint256 depositAmount = 1 ether; - uint256 gasAbsorptionFee = 0.01 ether; - - vm.prank(SENDER); - uint256 idx = _makeDeposit(depositAmount, false); - - EnvelopeVault.GaslessReclaim memory reclaim = EnvelopeVault.GaslessReclaim({ - depositIndex: idx - }); - - bytes memory senderSig = _signGaslessReclaim(idx); - bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, gasAbsorptionFee, 0); - - uint256 senderBalBefore = SENDER.balance; - - vm.prank(OPERATOR); - vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, gasAbsorptionFee, address(paymaster), 0); - - // Sender gets deposit minus gas fee - uint256 senderBalAfter = SENDER.balance; - assertEq(senderBalAfter - senderBalBefore, depositAmount - gasAbsorptionFee); - - // Gas fee sent to paymaster - assertEq(address(paymaster).balance, gasAbsorptionFee); - - // Paymaster validated - assertEq(paymaster.lastOperator(), OPERATOR); - assertEq(paymaster.lastFee(), gasAbsorptionFee); - } - - function test_RevertIf_SponsoredReclaimWrongSender() public { - vm.prank(SENDER); - uint256 idx = _makeDeposit(1 ether, false); - - EnvelopeVault.GaslessReclaim memory reclaim = EnvelopeVault.GaslessReclaim({ - depositIndex: idx - }); - - // Sign with a different key (not the depositor) - uint256 wrongKey = uint256(keccak256("wrong-key")); - address wrongSigner = vm.addr(wrongKey); - - bytes32 structHash = keccak256(abi.encode(vault.GASLESS_RECLAIM_TYPEHASH(), idx)); - bytes32 digest = keccak256(abi.encodePacked("\x19\x01", vault.DOMAIN_SEPARATOR(), structHash)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongKey, digest); - bytes memory wrongSig = abi.encodePacked(r, s, v); - - bytes memory mfaSig = _signMfaForReclaim(idx, wrongSigner, 0, 0); - - vm.prank(OPERATOR); - vm.expectRevert(EnvelopeVault.NotTheSender.selector); - vault.withdrawDepositSenderSponsored(reclaim, wrongSigner, wrongSig, mfaSig, 0, address(paymaster), 0); - } - - function test_RevertIf_SponsoredReclaimPaymasterDenies() public { - vm.prank(SENDER); - uint256 idx = _makeDeposit(1 ether, false); - - EnvelopeVault.GaslessReclaim memory reclaim = EnvelopeVault.GaslessReclaim({ - depositIndex: idx - }); - - bytes memory senderSig = _signGaslessReclaim(idx); - bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, 0.01 ether, 0); - - paymaster.setRevert(true); - - vm.prank(OPERATOR); - vm.expectRevert("paymaster: denied"); - vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, 0.01 ether, address(paymaster), 0); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Deadline tests - // ═══════════════════════════════════════════════════════════════════════════ - - function test_WithdrawMFADepositSponsoredWithDeadline() public { - vm.prank(SENDER); - uint256 idx = _makeDeposit(1 ether, true); - - uint256 deadline = block.timestamp + 1 hours; - bytes memory linkSig = _signWithdrawal(idx, address(this)); - bytes memory mfaSig = _signMfa(idx, address(this), 0, 0.01 ether, deadline); - - // Should succeed before deadline - vm.prank(OPERATOR); - vault.withdrawMFADepositSponsored( - idx, address(this), linkSig, mfaSig, 0, 0.01 ether, address(paymaster), deadline - ); - } - - function test_RevertIf_MFASignatureExpiredSponsored() public { - vm.prank(SENDER); - uint256 idx = _makeDeposit(1 ether, true); - - uint256 deadline = block.timestamp + 1 hours; - bytes memory linkSig = _signWithdrawal(idx, address(this)); - bytes memory mfaSig = _signMfa(idx, address(this), 0, 0.01 ether, deadline); - - // Warp past deadline - vm.warp(deadline + 1); - - vm.prank(OPERATOR); - vm.expectRevert(EnvelopeVault.MfaSignatureExpired.selector); - vault.withdrawMFADepositSponsored( - idx, address(this), linkSig, mfaSig, 0, 0.01 ether, address(paymaster), deadline - ); - } - - function test_RevertIf_MFASignatureExpiredSponsoredReclaim() public { - vm.prank(SENDER); - uint256 idx = _makeDeposit(1 ether, false); - - EnvelopeVault.GaslessReclaim memory reclaim = EnvelopeVault.GaslessReclaim({ - depositIndex: idx - }); - - uint256 deadline = block.timestamp + 30 minutes; - bytes memory senderSig = _signGaslessReclaim(idx); - bytes memory mfaSig = _signMfaForReclaim(idx, SENDER, 0.01 ether, deadline); - - // Warp past deadline - vm.warp(deadline + 1); - - vm.prank(OPERATOR); - vm.expectRevert(EnvelopeVault.MfaSignatureExpired.selector); - vault.withdrawDepositSenderSponsored(reclaim, SENDER, senderSig, mfaSig, 0.01 ether, address(paymaster), deadline); - } - - function test_ZeroDeadlineMeansNoExpiry() public { - vm.prank(SENDER); - uint256 idx = _makeDeposit(1 ether, true); - - bytes memory linkSig = _signWithdrawal(idx, address(this)); - // deadline = 0 means never expires - bytes memory mfaSig = _signMfa(idx, address(this), 0, 0, 0); - - // Warp far into the future - vm.warp(block.timestamp + 365 days); - - vm.prank(OPERATOR); - vault.withdrawMFADepositSponsored( - idx, address(this), linkSig, mfaSig, 0, 0, address(paymaster), 0 - ); - } - - receive() external payable {} -} diff --git a/test/paymasters/BasePaymaster.t.sol b/test/paymasters/BasePaymaster.t.sol index 55c53a9c..9fe1119f 100644 --- a/test/paymasters/BasePaymaster.t.sol +++ b/test/paymasters/BasePaymaster.t.sol @@ -3,10 +3,7 @@ pragma solidity ^0.8.20; import {Test, console} from "forge-std/Test.sol"; -import { - BasePaymaster, - BOOTLOADER_FORMAL_ADDRESS -} from "../../src/paymasters/BasePaymaster.sol"; +import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "../../src/paymasters/BasePaymaster.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 {ExecutionResult} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymaster.sol"; @@ -16,7 +13,7 @@ contract MockPaymaster is BasePaymaster { constructor(address admin, address withdrawer) BasePaymaster(admin, withdrawer) {} - function _validateAndPayGeneralFlow(address, address, uint256) internal override { + function _validateAndPayGeneralFlow(address, address, uint256, bytes memory) internal override { emit MockPaymasterCalled(); } @@ -105,8 +102,9 @@ contract BasePaymasterTest is Test { } function test_RevertIf_notCalledByBootloader() public { - Transaction memory txn = - _buildTransaction(charlie, alice, 100_000, 1 gwei, abi.encodeWithSelector(IPaymasterFlow.general.selector, "")); + Transaction memory txn = _buildTransaction( + charlie, alice, 100_000, 1 gwei, abi.encodeWithSelector(IPaymasterFlow.general.selector, "") + ); vm.prank(charlie); vm.expectRevert(BasePaymaster.AccessRestrictedToBootloader.selector); diff --git a/test/paymasters/BondTreasuryPaymaster.t.sol b/test/paymasters/BondTreasuryPaymaster.t.sol index d689ec6b..1347d568 100644 --- a/test/paymasters/BondTreasuryPaymaster.t.sol +++ b/test/paymasters/BondTreasuryPaymaster.t.sol @@ -65,7 +65,7 @@ contract MockBondTreasuryPaymaster is BondTreasuryPaymaster { {} function mock_validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) public view { - _validateAndPayGeneralFlow(from, to, requiredETH); + _validateAndPayGeneralFlow(from, to, requiredETH, ""); } function mock_validateAndPayApprovalBasedFlow( diff --git a/test/paymasters/EnvelopePaymaster.t.sol b/test/paymasters/EnvelopePaymaster.t.sol new file mode 100644 index 00000000..959ccd5e --- /dev/null +++ b/test/paymasters/EnvelopePaymaster.t.sol @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.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 {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "../../src/paymasters/BasePaymaster.sol"; +import {EnvelopePaymaster} from "../../src/paymasters/EnvelopePaymaster.sol"; +import {EnvelopeVault} from "../../src/envelope/V4/EnvelopeVault.sol"; +import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract EnvelopePaymasterTest is Test { + EnvelopeVault public vault; + EnvelopePaymaster public paymaster; + ERC20Mock public feeToken; + + address public constant ADMIN = address(0xA11CE); + address public constant WITHDRAWER = address(0xB0B); + address public constant SENDER = address(0xCAFE); + address public constant RECIPIENT = address(0xD00D); + + uint256 public constant LINK_PRIVKEY = uint256(keccak256("link-key")); + address public LINK_PUBKEY; + + uint256 public constant BACKEND_PRIVKEY = uint256(keccak256("nodle.vault.backend-authorizer")); + address public BACKEND_AUTHORIZER; + + function setUp() public { + LINK_PUBKEY = vm.addr(LINK_PRIVKEY); + BACKEND_AUTHORIZER = vm.addr(BACKEND_PRIVKEY); + + feeToken = new ERC20Mock(); + vault = new EnvelopeVault(BACKEND_AUTHORIZER, address(this), address(feeToken)); + paymaster = new EnvelopePaymaster(ADMIN, WITHDRAWER, address(vault)); + + vm.deal(SENDER, 10 ether); + vm.deal(address(paymaster), 1 ether); + feeToken.mint(SENDER, 1_000 ether); + + vm.prank(SENDER); + feeToken.approve(address(vault), type(uint256).max); + } + + function _request(uint256 amount) internal view returns (EnvelopeVault.DepositRequest memory) { + return EnvelopeVault.DepositRequest({ + tokenAddress: address(0), + contractType: 0, + amount: amount, + tokenId: 0, + pubKey20: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + } + + function _signFeeAuthorization( + EnvelopeVault.DepositRequest memory request, + uint256 serviceFee, + uint256 gaslessFee, + uint256 deadline + ) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encode( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + SENDER, + request.tokenAddress, + request.contractType, + request.amount, + request.tokenId, + request.pubKey20, + request.onBehalfOf, + request.withMFA, + request.recipient, + request.reclaimableAfter, + serviceFee, + gaslessFee, + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _makeGaslessDeposit(uint256 amount) internal returns (uint256) { + EnvelopeVault.DepositRequest memory request = _request(amount); + EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0.01 ether, + deadline: 0, + signature: _signFeeAuthorization(request, 0, 0.01 ether, 0) + }); + + vm.prank(SENDER); + return vault.makeCustomDepositWithFees{value: amount}(request, authorization); + } + + function _signWithdrawal(uint256 depositIndex, address recipient) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + depositIndex, + recipient, + vault.ANYONE_WITHDRAWAL_MODE() + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _buildTransaction(address from, address to, bytes memory data, uint256 gasLimit, uint256 maxFeePerGas) + internal + pure + returns (Transaction memory) + { + Transaction memory txn; + txn.from = uint256(uint160(from)); + txn.to = uint256(uint160(to)); + txn.gasLimit = gasLimit; + txn.maxFeePerGas = maxFeePerGas; + txn.data = data; + txn.paymasterInput = abi.encodeWithSelector(IPaymasterFlow.general.selector, ""); + return txn; + } + + function test_ValidateAndPayForGaslessEnvelopeClaim() public { + uint256 index = _makeGaslessDeposit(1 ether); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); + bytes memory data = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + + uint256 gasLimit = 100_000; + uint256 maxFeePerGas = 1 gwei; + uint256 requiredETH = gasLimit * maxFeePerGas; + Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), data, gasLimit, maxFeePerGas); + + uint256 bootloaderBalBefore = BOOTLOADER_FORMAL_ADDRESS.balance; + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + (bytes4 magic,) = paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + + assertEq(magic, paymaster.validateAndPayForPaymasterTransaction.selector); + assertEq(BOOTLOADER_FORMAL_ADDRESS.balance, bootloaderBalBefore + requiredETH); + } + + function test_RevertIf_DestinationIsNotEnvelopeVault() public { + uint256 index = _makeGaslessDeposit(1 ether); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); + bytes memory data = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + Transaction memory txn = _buildTransaction(RECIPIENT, address(feeToken), data, 100_000, 1 gwei); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(EnvelopePaymaster.DestinationIsNotEnvelopeVault.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_EnvelopeOperationNotApproved() public { + vm.prank(SENDER); + uint256 index = vault.makeDeposit{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY); + + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); + bytes memory data = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), data, 100_000, 1 gwei); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(EnvelopePaymaster.EnvelopeGaslessOperationNotApproved.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_PaymasterBalanceTooLow() public { + uint256 index = _makeGaslessDeposit(1 ether); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); + bytes memory data = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), data, 2 ether, 1); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(EnvelopePaymaster.PaymasterBalanceTooLow.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_ApprovalBasedFlow() public { + Transaction memory txn; + txn.from = uint256(uint160(RECIPIENT)); + txn.to = uint256(uint160(address(vault))); + txn.gasLimit = 100_000; + txn.maxFeePerGas = 1 gwei; + txn.paymasterInput = abi.encodeWithSelector(IPaymasterFlow.approvalBased.selector, address(feeToken), 1, ""); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(BasePaymaster.PaymasterFlowNotSupported.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } +} diff --git a/test/paymasters/WhitelistPaymaster.t.sol b/test/paymasters/WhitelistPaymaster.t.sol index d7a32fca..78563192 100644 --- a/test/paymasters/WhitelistPaymaster.t.sol +++ b/test/paymasters/WhitelistPaymaster.t.sol @@ -12,7 +12,7 @@ contract MockWhitelistPaymaster is WhitelistPaymaster { constructor(address admin, address withdrawer) WhitelistPaymaster(admin, withdrawer) {} function mock_validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) public view { - _validateAndPayGeneralFlow(from, to, requiredETH); + _validateAndPayGeneralFlow(from, to, requiredETH, ""); } function mock_validateAndPayApprovalBasedFlow( From 17ccc38e049236884f73540afd75b9e1ea281d72 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 20 May 2026 00:17:50 +1200 Subject: [PATCH 40/49] feat(envelope): move batching into vault --- .solhintignore | 1 - hardhat-deploy/DeployEnvelope.ts | 37 +-- src/envelope/V4/EnvelopeBatcher.sol | 249 ---------------- src/envelope/V4/EnvelopeVault.sol | 266 ++++++++++++++++- src/envelope/doc/EnvelopeBatcher.md | 92 ------ src/envelope/doc/EnvelopeVault.md | 12 + src/envelope/doc/README.md | 13 +- test/envelope/EnvelopeBatcher.t.sol | 220 -------------- test/envelope/EnvelopeBatching.t.sol | 400 ++++++++++++++++++++++++++ test/envelope/EnvelopeEdgeCases.t.sol | 73 ++--- 10 files changed, 716 insertions(+), 647 deletions(-) delete mode 100644 src/envelope/V4/EnvelopeBatcher.sol delete mode 100644 src/envelope/doc/EnvelopeBatcher.md delete mode 100644 test/envelope/EnvelopeBatcher.t.sol create mode 100644 test/envelope/EnvelopeBatching.t.sol diff --git a/.solhintignore b/.solhintignore index f139a58c..2b0b0f55 100644 --- a/.solhintignore +++ b/.solhintignore @@ -6,4 +6,3 @@ # Our own code (EnvelopeApprovalPaymaster, anything authored in this repo) # is NOT in this list and remains lint-clean. src/envelope/V4/EnvelopeVault.sol -src/envelope/V4/EnvelopeBatcher.sol diff --git a/hardhat-deploy/DeployEnvelope.ts b/hardhat-deploy/DeployEnvelope.ts index 7869e9c0..c360a709 100644 --- a/hardhat-deploy/DeployEnvelope.ts +++ b/hardhat-deploy/DeployEnvelope.ts @@ -21,7 +21,6 @@ dotenv.config({ path: ".env-test" }); * - ENVELOPE_OWNER: Owner/fee withdrawer. Defaults to deployer. * - ENVELOPE_FEE_TOKEN: ERC20 token used for service/gasless fees (e.g. NODL). * Defaults to 0x0 (non-zero fee authorizations disabled). - * - ENVELOPE_DEPLOY_BATCHER: "true"|"false". Default "true". Deploys EnvelopeBatcher. * - ENVELOPE_DEPLOY_PAYMASTER: "true"|"false". Default "false". Deploys EnvelopePaymaster. * - ENVELOPE_PAYMASTER_ADMIN: Admin for EnvelopePaymaster. Defaults to deployer. * - ENVELOPE_PAYMASTER_WITHDRAWER: ETH withdrawer for EnvelopePaymaster. Defaults to deployer. @@ -34,26 +33,24 @@ dotenv.config({ path: ".env-test" }); module.exports = async function (hre: HardhatRuntimeEnvironment) { const ZERO = "0x0000000000000000000000000000000000000000"; + 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 mfaAuthorizer = process.env.ENVELOPE_MFA_AUTHORIZER ?? ZERO; const envelopeOwner = process.env.ENVELOPE_OWNER ?? wallet.address; const feeToken = process.env.ENVELOPE_FEE_TOKEN ?? ZERO; - const deployBatcher = (process.env.ENVELOPE_DEPLOY_BATCHER ?? "true").toLowerCase() === "true"; const deployPaymaster = (process.env.ENVELOPE_DEPLOY_PAYMASTER ?? "false").toLowerCase() === "true"; const paymasterAdmin = process.env.ENVELOPE_PAYMASTER_ADMIN ?? wallet.address; const paymasterWithdrawer = process.env.ENVELOPE_PAYMASTER_WITHDRAWER ?? wallet.address; - 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); - console.log("=== Deploying Envelope on ZkSync ==="); console.log("Network: ", hre.network.name); console.log("Deployer: ", wallet.address); console.log("MFA Authorizer: ", mfaAuthorizer); console.log("Owner: ", envelopeOwner); console.log("Fee Token: ", feeToken); - console.log("Deploy Batcher: ", deployBatcher); console.log("Deploy Paymaster:", deployPaymaster); console.log(""); @@ -61,14 +58,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { const vault = await deployContract(deployer, "EnvelopeVault", [mfaAuthorizer, envelopeOwner, feeToken]); const vaultAddr = await vault.getAddress(); - // 2. Batcher — optional. - let batcherAddr: string | undefined; - if (deployBatcher) { - const batcher = await deployContract(deployer, "EnvelopeBatcher", []); - batcherAddr = await batcher.getAddress(); - } - - // 3. Paymaster — optional. Must be funded with ETH after deployment. + // 2. Paymaster — optional. Must be funded with ETH after deployment. let paymasterAddr: string | undefined; if (deployPaymaster) { const envelopePaymaster = await deployContract(deployer, "EnvelopePaymaster", [ @@ -82,7 +72,6 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); console.log("=== Deployment Complete ==="); console.log("EnvelopeVault: ", vaultAddr); - if (batcherAddr) console.log("EnvelopeBatcher: ", batcherAddr); if (paymasterAddr) console.log("EnvelopePaymaster: ", paymasterAddr); console.log(""); @@ -99,19 +88,6 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log("Verification failed or already verified:", e.message); } - if (batcherAddr) { - try { - console.log("Verifying EnvelopeBatcher..."); - await hre.run("verify:verify", { - address: batcherAddr, - contract: "src/envelope/V4/EnvelopeBatcher.sol:EnvelopeBatcher", - constructorArguments: [], - }); - } catch (e: any) { - console.log("Verification failed or already verified:", e.message); - } - } - if (paymasterAddr) { try { console.log("Verifying EnvelopePaymaster..."); @@ -128,7 +104,6 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); console.log("=== Add these to .env-test: ==="); console.log(`ENVELOPE_VAULT=${vaultAddr}`); - if (batcherAddr) console.log(`ENVELOPE_BATCHER=${batcherAddr}`); if (paymasterAddr) console.log(`ENVELOPE_PAYMASTER=${paymasterAddr}`); if (mfaAuthorizer === ZERO) { diff --git a/src/envelope/V4/EnvelopeBatcher.sol b/src/envelope/V4/EnvelopeBatcher.sol deleted file mode 100644 index 56a794cb..00000000 --- a/src/envelope/V4/EnvelopeBatcher.sol +++ /dev/null @@ -1,249 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// -// 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/vault-contracts@main; the full GNU GPL v3 license text is bundled -// at src/envelope/V4/LICENSE-GPL. -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 {EnvelopeVault} from "./EnvelopeVault.sol"; - -/// @title Peanut Batcher V4.4 -/// @notice Stateless helper that pulls tokens from msg.sender then forwards N deposits -/// 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 EnvelopeBatcher is IERC721Receiver, IERC1155Receiver { - using SafeERC20 for IERC20; - - 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); - } - } - - function supportsInterface(bytes4 _interfaceId) external pure override(IERC165) returns (bool) { - return _interfaceId == type(IERC165).interfaceId || _interfaceId == type(IERC721Receiver).interfaceId - || _interfaceId == type(IERC1155Receiver).interfaceId; - } - - /// @notice ERC-721 receiver hook. Self-only — unsolicited transfers revert (S1). - function onERC721Received(address _operator, address, uint256, bytes calldata) - external - view - override - returns (bytes4) - { - require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); - return this.onERC721Received.selector; - } - - /// @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) - { - require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); - return this.onERC1155Received.selector; - } - - /// @notice ERC-1155 batch receiver hook. Self-only — unsolicited transfers revert (S1). - function onERC1155BatchReceived( - address _operator, - 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 _vaultAddress, - address _tokenAddress, - uint8 _contractType, - uint256 _amount, - uint256 _tokenId, - address[] calldata _pubKeys20 - ) external payable returns (uint256[] memory) { - EnvelopeVault vault = EnvelopeVault(_vaultAddress); - 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(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(vault), true); - } - - uint256[] memory depositIndexes = new uint256[](_pubKeys20.length); - for (uint256 i = 0; i < _pubKeys20.length; i++) { - depositIndexes[i] = vault.makeSelflessDeposit{value: etherAmount}( - _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender - ); - } - return depositIndexes; - } - - /// @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 _vaultAddress, - address _tokenAddress, - uint8 _contractType, - uint256 _amount, - uint256 _tokenId, - address[] calldata _pubKeys20 - ) external payable { - 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 - // 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++) { - vault.makeSelflessDeposit{value: etherPerCall}( - _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender - ); - } - } - - function batchMakeDepositArbitrary( - address _vaultAddress, - 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 - && _withMFAs.length == _pubKeys20.length, - "PARAMETERS LENGTH MISMATCH" - ); - EnvelopeVault vault = EnvelopeVault(_vaultAddress); - - 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], _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(_vaultAddress, true); - } - - depositIndexes[i] = vault.makeCustomDeposit{value: etherAmount}( - _tokenAddresses[i], - _contractTypes[i], - _amounts[i], - _tokenIds[i], - _pubKeys20[i], - msg.sender, // deposit owner - _withMFAs[i], - address(0), // not recipient-bound - uint40(0) - ); - } - return depositIndexes; - } - - function batchMakeDepositRaffle( - 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 vault = EnvelopeVault(_vaultAddress); - - if (_contractType == 1) { - _setAllowanceIfZero(_tokenAddress, _vaultAddress); - 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] = vault.makeSelflessDeposit{value: etherAmount}( - _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender - ); - } - return depositIndexes; - } - - function batchMakeDepositRaffleMFA( - 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 vault = EnvelopeVault(_vaultAddress); - - if (_contractType == 1) { - _setAllowanceIfZero(_tokenAddress, _vaultAddress); - 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] = vault.makeSelflessMFADeposit{value: etherAmount}( - _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender - ); - } - return depositIndexes; - } -} diff --git a/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index c463287a..8763c7e5 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -71,6 +71,11 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow error FeeAuthorizationExpired(); error WrongFeeAuthorizationSignature(); error FeeTokenNotConfigured(); + error ParametersLengthMismatch(); + error InvalidTotalEtherSent(); + error EthNotAcceptedForNonEthDeposit(); + error Erc721BatchNotSupported(); + error UnsupportedRaffleContractType(); // ── Data Structures ────────────────────────────────────────────────────────── @@ -323,6 +328,171 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ); } + /// @notice Create many same-shape deposits in one transaction. + /// @dev The caller remains the recorded sender for every deposit and keeps reclaim rights. + /// ERC-721 is intentionally excluded here because each NFT needs a distinct tokenId. + function makeBatchDeposit( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address[] calldata _pubKeys20 + ) external payable nonReentrant returns (uint256[] memory) { + uint256 totalAmount = _amount * _pubKeys20.length; + _pullUniformBatchAssets(msg.sender, _tokenAddress, _contractType, totalAmount, _tokenId); + + uint256[] memory depositIndexes = new uint256[](_pubKeys20.length); + for (uint256 i = 0; i < _pubKeys20.length; ++i) { + depositIndexes[i] = _storeDeposit( + _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender, false, address(0), 0, 0, 0 + ); + } + return depositIndexes; + } + + /// @notice Same as makeBatchDeposit, but avoids allocating and returning the indexes array. + function makeBatchDepositNoReturn( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address[] calldata _pubKeys20 + ) external payable nonReentrant { + uint256 totalAmount = _amount * _pubKeys20.length; + _pullUniformBatchAssets(msg.sender, _tokenAddress, _contractType, totalAmount, _tokenId); + + for (uint256 i = 0; i < _pubKeys20.length; ++i) { + _storeDeposit( + _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender, false, address(0), 0, 0, 0 + ); + } + } + + /// @notice Create a heterogeneous batch of no-fee deposits. + /// @dev Supports ETH, ERC-20, ERC-721, and ERC-1155. Recipient binding is intentionally + /// left to makeBatchCustomDepositWithFees via DepositRequest[]. + function makeBatchCustomDeposit( + address[] calldata _tokenAddresses, + uint8[] calldata _contractTypes, + uint256[] calldata _amounts, + uint256[] calldata _tokenIds, + address[] calldata _pubKeys20, + bool[] calldata _withMFAs + ) external payable nonReentrant returns (uint256[] memory) { + _validateBatchArrayLengths( + _tokenAddresses.length, + _contractTypes.length, + _amounts.length, + _tokenIds.length, + _pubKeys20.length, + _withMFAs.length + ); + + uint256 expectedEther; + for (uint256 i = 0; i < _amounts.length; ++i) { + if (_contractTypes[i] > 3) revert InvalidContractType(); + if (_contractTypes[i] == 0) expectedEther += _amounts[i]; + } + if (msg.value != expectedEther) revert InvalidTotalEtherSent(); + + uint256[] memory depositIndexes = new uint256[](_amounts.length); + for (uint256 i = 0; i < _amounts.length; ++i) { + uint256 amount = _pullTokensViaApprovalFrom( + msg.sender, + _tokenAddresses[i], + _contractTypes[i], + _amounts[i], + _tokenIds[i], + _contractTypes[i] == 0 ? _amounts[i] : 0 + ); + depositIndexes[i] = _storeDeposit( + _tokenAddresses[i], + _contractTypes[i], + amount, + _tokenIds[i], + _pubKeys20[i], + msg.sender, + _withMFAs[i], + address(0), + 0, + 0, + 0 + ); + } + return depositIndexes; + } + + /// @notice Create a heterogeneous batch of deposits with backend-authorized fees. + /// @dev Fee authorizations are signed for the real caller because batching is vault-native. + function makeBatchCustomDepositWithFees( + DepositRequest[] calldata _requests, + FeeAuthorization[] calldata _feeAuthorizations + ) external payable nonReentrant returns (uint256[] memory) { + if (_requests.length != _feeAuthorizations.length) { + revert ParametersLengthMismatch(); + } + + uint256 expectedEther; + for (uint256 i = 0; i < _requests.length; ++i) { + if (_requests[i].contractType > 3) revert InvalidContractType(); + if (_requests[i].contractType == 0) expectedEther += _requests[i].amount; + _verifyFeeAuthorization(_requests[i], _feeAuthorizations[i]); + } + if (msg.value != expectedEther) revert InvalidTotalEtherSent(); + + uint256[] memory depositIndexes = new uint256[](_requests.length); + for (uint256 i = 0; i < _requests.length; ++i) { + DepositRequest calldata request = _requests[i]; + FeeAuthorization calldata feeAuthorization = _feeAuthorizations[i]; + uint256 amount = _pullTokensViaApprovalFrom( + msg.sender, + request.tokenAddress, + request.contractType, + request.amount, + request.tokenId, + request.contractType == 0 ? request.amount : 0 + ); + + uint256 index = deposits.length; + _collectDepositFees(index, msg.sender, feeAuthorization.serviceFee, feeAuthorization.gaslessFee); + depositIndexes[i] = _storeDeposit( + request.tokenAddress, + request.contractType, + amount, + request.tokenId, + request.pubKey20, + request.onBehalfOf, + request.withMFA, + request.recipient, + request.reclaimableAfter, + feeAuthorization.serviceFee, + feeAuthorization.gaslessFee + ); + } + + return depositIndexes; + } + + /// @notice Create raffle-style ETH or ERC-20 deposits sharing one pubKey20 and different amounts. + function makeBatchDepositRaffle( + address _tokenAddress, + uint8 _contractType, + uint256[] calldata _amounts, + address _pubKey20 + ) external payable nonReentrant returns (uint256[] memory) { + return _makeBatchDepositRaffle(_tokenAddress, _contractType, _amounts, _pubKey20, false); + } + + /// @notice Create MFA-gated raffle-style ETH or ERC-20 deposits sharing one pubKey20. + function makeBatchMFADepositRaffle( + address _tokenAddress, + uint8 _contractType, + uint256[] calldata _amounts, + address _pubKey20 + ) external payable nonReentrant returns (uint256[] memory) { + return _makeBatchDepositRaffle(_tokenAddress, _contractType, _amounts, _pubKey20, true); + } + // ══════════════════════════════════════════════════════════════════════════════ // Withdrawal Functions // ══════════════════════════════════════════════════════════════════════════════ @@ -494,14 +664,14 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow function getAllDepositsForAddress(address _address) external view returns (Deposit[] memory) { uint256 count = 0; - for (uint256 i = 0; i < deposits.length; i++) { + for (uint256 i = 0; i < deposits.length; ++i) { if (deposits[i].senderAddress == _address) { count++; } } Deposit[] memory _deposits = new Deposit[](count); count = 0; - for (uint256 i = 0; i < deposits.length; i++) { + for (uint256 i = 0; i < deposits.length; ++i) { if (deposits[i].senderAddress == _address) { _deposits[count] = deposits[i]; count++; @@ -682,21 +852,105 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow return recovered; } + function _validateBatchArrayLengths( + uint256 _tokenAddressesLength, + uint256 _contractTypesLength, + uint256 _amountsLength, + uint256 _tokenIdsLength, + uint256 _pubKeys20Length, + uint256 _withMFAsLength + ) internal pure { + if ( + _tokenAddressesLength != _pubKeys20Length || _contractTypesLength != _pubKeys20Length + || _amountsLength != _pubKeys20Length || _tokenIdsLength != _pubKeys20Length + || _withMFAsLength != _pubKeys20Length + ) revert ParametersLengthMismatch(); + } + + function _pullUniformBatchAssets( + address _from, + address _tokenAddress, + uint8 _contractType, + uint256 _totalAmount, + uint256 _tokenId + ) internal { + if (_contractType == 0) { + if (msg.value != _totalAmount) revert InvalidTotalEtherSent(); + return; + } + if (msg.value != 0) revert EthNotAcceptedForNonEthDeposit(); + + if (_contractType == 1) { + if (_totalAmount > 0) IERC20(_tokenAddress).safeTransferFrom(_from, address(this), _totalAmount); + } else if (_contractType == 2) { + revert Erc721BatchNotSupported(); + } else if (_contractType == 3) { + if (_totalAmount > 0) { + IERC1155(_tokenAddress).safeTransferFrom(_from, address(this), _tokenId, _totalAmount, ""); + } + } else { + revert InvalidContractType(); + } + } + + function _makeBatchDepositRaffle( + address _tokenAddress, + uint8 _contractType, + uint256[] calldata _amounts, + address _pubKey20, + bool _requiresMFA + ) internal returns (uint256[] memory) { + if (_contractType != 0 && _contractType != 1) { + revert UnsupportedRaffleContractType(); + } + + uint256 totalAmount; + for (uint256 i = 0; i < _amounts.length; ++i) { + totalAmount += _amounts[i]; + } + + if (_contractType == 0) { + if (msg.value != totalAmount) revert InvalidTotalEtherSent(); + } else { + if (msg.value != 0) revert EthNotAcceptedForNonEthDeposit(); + if (totalAmount > 0) IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); + } + + uint256[] memory depositIndexes = new uint256[](_amounts.length); + for (uint256 i = 0; i < _amounts.length; ++i) { + depositIndexes[i] = _storeDeposit( + _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender, _requiresMFA, address(0), 0, 0, 0 + ); + } + return depositIndexes; + } + function _pullTokensViaApproval(address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId) internal returns (uint256) { + return _pullTokensViaApprovalFrom(msg.sender, _tokenAddress, _contractType, _amount, _tokenId, msg.value); + } + + function _pullTokensViaApprovalFrom( + address _from, + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + uint256 _ethAmount + ) internal returns (uint256) { if (_contractType > 3) revert InvalidContractType(); if (_contractType == 0) { - if (_amount != msg.value) revert WrongEthAmount(); + if (_amount != _ethAmount) revert WrongEthAmount(); } else if (_contractType == 1) { - IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), _amount); + IERC20(_tokenAddress).safeTransferFrom(_from, address(this), _amount); } else if (_contractType == 2) { if (_amount != 1) revert Erc721AmountMustBeOne(); - IERC721(_tokenAddress).safeTransferFrom(msg.sender, address(this), _tokenId, "Internal transfer"); + IERC721(_tokenAddress).safeTransferFrom(_from, address(this), _tokenId, "Internal transfer"); } else if (_contractType == 3) { - IERC1155(_tokenAddress).safeTransferFrom(msg.sender, address(this), _tokenId, _amount, "Internal transfer"); + IERC1155(_tokenAddress).safeTransferFrom(_from, address(this), _tokenId, _amount, "Internal transfer"); } return _amount; diff --git a/src/envelope/doc/EnvelopeBatcher.md b/src/envelope/doc/EnvelopeBatcher.md deleted file mode 100644 index 75995839..00000000 --- a/src/envelope/doc/EnvelopeBatcher.md +++ /dev/null @@ -1,92 +0,0 @@ -# EnvelopeBatcher — N-deposits-in-one-tx helper - -`src/envelope/V4/EnvelopeBatcher.sol` - -## Purpose - -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 vault` storage var was dropped during hardening). - -## Constructor - -```solidity -constructor() // no args -``` - -## Public entry points - -| Function | Use case | -|---|---| -| `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 `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 - -```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(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. - -## `_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. (`EnvelopeVault public vault` was removed during hardening — see ZkSync notes.) - -## Events / errors - -None of its own. Inner deposits emit `EnvelopeVault.DepositEvent`. - -## Vendoring patches - -| | Patch | -|---|---| -| OZ v5 | `safeApprove` → `forceApprove` | -| 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 | -| Modern | Pragma pinned to `0.8.26` | -| Add | `_withMFAs.length` check in `batchMakeDepositArbitrary` (upstream was missing) | - -## Test coverage - -`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) -- multiple batches in a row -- not-approved revert paths for all three asset types diff --git a/src/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md index 34749af1..55866ef5 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -54,9 +54,21 @@ struct Deposit { | `makeSelflessMFADeposit(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | | `makeCustomDeposit(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | | `makeCustomDepositWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0`. | +| `makeBatchDeposit(...)` | Creates many same-shape no-fee deposits in one transaction. ETH, ERC-20, and ERC-1155 are supported; ERC-721 uses the heterogeneous batch path. | +| `makeBatchDepositNoReturn(...)` | Same as `makeBatchDeposit` but skips allocating/returning the deposit indexes array. | +| `makeBatchCustomDeposit(...)` | Creates a heterogeneous no-fee batch and supports ETH, ERC-20, ERC-721, and ERC-1155. | +| `makeBatchCustomDepositWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `DepositRequest` and `FeeAuthorization` structs as the single-deposit flow. | +| `makeBatchDepositRaffle(...)` | Creates ETH or ERC-20 raffle-style deposits with different amounts and one shared `pubKey20`. | +| `makeBatchMFADepositRaffle(...)` | Same as raffle batching, but every deposit requires MFA at claim time. | `FeeAuthorization` covers the full deposit intent, the fee payer (`msg.sender`), the two fee amounts, and a backend-selected deadline. `deadline == 0` means no expiry. If either fee is non-zero, the signature must recover to `mfaAuthorizer`. +## Vault-Native Batching + +Batching is implemented directly in `EnvelopeVault` rather than a separate companion contract. This keeps the real sender as `msg.sender`, so reclaim rights and backend fee signatures use the same identity as single deposits. It also removes the extra custody hop where a batcher temporarily holds tokens before forwarding them to the vault. + +The batching functions share the same storage and events as single deposits. Same-shape batches aggregate ERC-20/ERC-1155 pulls for efficiency; heterogeneous batches pull each asset separately and can include ERC-721 token IDs. Batched fee authorizations are signed for the caller of the vault, not an intermediate contract. + ## Withdraw And Claim Functions | Function | Caller | Authorization | diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 5aee93e5..0d445319 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -7,7 +7,6 @@ The Envelope flow on Nodle is built on top of modified Peanut Protocol V4.4 cont | Contract | Source | Spec | |---|---|---| | `EnvelopeVault` | `src/envelope/V4/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | -| `EnvelopeBatcher` | `src/envelope/V4/EnvelopeBatcher.sol` | [EnvelopeBatcher.md](./EnvelopeBatcher.md) | | `EnvelopePaymaster` | `src/paymasters/EnvelopePaymaster.sol` | [EnvelopePaymaster.md](./EnvelopePaymaster.md) | Interfaces: @@ -22,7 +21,7 @@ This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) does not apply | Files | License | Notes | |---|---|---| -| `src/envelope/V4/EnvelopeVault.sol`, `EnvelopeBatcher.sol` | **GPL-3.0-or-later** | Modified copies of upstream Peanut Protocol V4.4. Full GPL v3 text is 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` | **GPL-3.0-or-later** | Modified copy of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/V4/LICENSE-GPL`. The file carries a top-of-file modification notice per GPL §5(a). | | `src/envelope/util/IEnvelopeGaslessValidator.sol` | **GPL-3.0-or-later** | Minimal interface for the GPL vault validation surface. | | `test/envelope/**/*.t.sol` | **GPL-3.0-or-later** | Test files that import GPL-licensed contracts are relicensed for compatibility. | | `test/envelope/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained. | @@ -32,8 +31,8 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s ## Naming convention -- **Source files** carry the Envelope brand (`EnvelopeVault.sol`, `EnvelopeBatcher.sol`); upstream audit lineage is preserved via the `// Modified by Nodle` notice, `// @author Squirrel Labs` attribution, bundled `LICENSE-GPL`, and git history. -- **Contract symbols** use the Envelope brand: `EnvelopeVault`, `EnvelopeBatcher`, `EnvelopePaymaster`. +- **Source files** carry the Envelope brand (`EnvelopeVault.sol`); upstream audit lineage is preserved via the `// Modified by Nodle` notice, `// @author Squirrel Labs` attribution, bundled `LICENSE-GPL`, and git history. +- **Contract symbols** use the Envelope brand: `EnvelopeVault`, `EnvelopePaymaster`. - **On-chain hashed constants** keep upstream-compatible values where changing them would alter signature digests. ## Main flows @@ -42,6 +41,7 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s |---|---|---| | Basic deposit | `EnvelopeVault.makeDeposit` / `makeCustomDeposit` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | | Paid or gasless-ready deposit | `EnvelopeVault.makeCustomDepositWithFees` | Sender supplies a backend-signed `FeeAuthorization`; the vault collects `serviceFee` and/or `gaslessFee` in `feeToken` at deposit creation. | +| Batch deposit | `EnvelopeVault.makeBatchDeposit` / `makeBatchCustomDeposit` / `makeBatchCustomDepositWithFees` | Sender creates many deposits in one transaction without a separate batcher contract. Fee signatures are signed for the actual caller. | | Open claim | `EnvelopeVault.withdrawDeposit` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | | MFA claim | `EnvelopeVault.withdrawMFADeposit` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | | Recipient-bound claim | `EnvelopeVault.withdrawDepositAsRecipient` | Only the bound recipient can submit the transaction. | @@ -64,7 +64,7 @@ The vault no longer contains an internal paymaster callback, and the EIP-3009 ga | Script | Purpose | |---|---| -| `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeVault`, `EnvelopeBatcher`, and optionally `EnvelopePaymaster`. | +| `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeVault` and optionally `EnvelopePaymaster`. | Important environment variables: @@ -84,7 +84,8 @@ Relevant suites: | Suite | Focus | |---|---| | `test/envelope/` | Vault deposits, claims, MFA, recipient binding, reclaim, fee collection, and gasless eligibility. | +| `test/envelope/EnvelopeBatching.t.sol` | Vault-native batching, raffle batches, ERC-721 heterogeneous batches, and batched fee authorizations. | | `test/paymasters/EnvelopePaymaster.t.sol` | ZkSync paymaster validation and rejection paths for Envelope gasless operations. | | `test/paymasters/` | Shared base, whitelist, bond treasury, and Envelope paymaster behavior. | -Latest focused validation: `forge test --match-path 'test/{envelope/*,paymasters/*}'` passed 194 tests across 18 suites. +Latest focused validation: `forge test --match-path 'test/{envelope/*,paymasters/*}'` passed 200 tests across 18 suites. diff --git a/test/envelope/EnvelopeBatcher.t.sol b/test/envelope/EnvelopeBatcher.t.sol deleted file mode 100644 index fd0bf0e6..00000000 --- a/test/envelope/EnvelopeBatcher.t.sol +++ /dev/null @@ -1,220 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.19; - -import "forge-std/Test.sol"; -import "../../src/envelope/V4/EnvelopeBatcher.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"; - -contract EnvelopeBatcherTest is Test, ERC1155Holder, ERC721Holder { - EnvelopeBatcher public batcher; - EnvelopeVault public vault; - ERC20Mock public testToken; - ERC721Mock public testToken721; - ERC1155Mock public testToken1155; - address public PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); - - function setUp() public { - batcher = new EnvelopeBatcher(); - vault = new EnvelopeVault(address(0), address(this), 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(vault), 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(vault), 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(vault), 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 EnvelopeVault contract to spend the tokens - testToken1155.setApprovalForAll(address(batcher), true); - } - // make the batch deposit - uint256[] memory depositIndexes = - batcher.batchMakeDeposit(address(vault), address(testToken1155), 3, 1, 1, pubKeys20); - // check that the correct number of deposits were made - assertEq(depositIndexes.length, numDeposits); - } - - // Test failure case where EnvelopeVault 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(vault), address(testToken), 1, amount, 0, pubKeys20); - } - - // 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); - 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(vault), address(testToken721), 2, 1, numDeposits, pubKeys20); - } - - // 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); - 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(vault), 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(vault), 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(vault), address(testToken), 0, amounts, PUBKEY20); - - for (uint256 i = 0; i < amounts.length; i++) { - EnvelopeVault.Deposit memory deposit = vault.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(vault), address(testToken), 1, amounts, PUBKEY20); - - for (uint256 i = 0; i < amounts.length; i++) { - EnvelopeVault.Deposit memory deposit = vault.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/envelope/EnvelopeBatching.t.sol b/test/envelope/EnvelopeBatching.t.sol new file mode 100644 index 00000000..784d17e0 --- /dev/null +++ b/test/envelope/EnvelopeBatching.t.sol @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {EnvelopeVault} from "../../src/envelope/V4/EnvelopeVault.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"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { + EnvelopeVault public vault; + EnvelopeVault public feeVault; + ERC20Mock public testToken; + ERC20Mock public feeToken; + ERC721Mock public testToken721; + ERC1155Mock public testToken1155; + + address public constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + uint256 public constant LINK_PRIVKEY = uint256(keccak256("batch-link-key")); + uint256 public constant BACKEND_PRIVKEY = uint256(keccak256("batch-backend-authorizer")); + address public constant RECIPIENT = address(0xB0B); + address public linkPubKey; + address public backendAuthorizer; + + function setUp() public { + linkPubKey = vm.addr(LINK_PRIVKEY); + backendAuthorizer = vm.addr(BACKEND_PRIVKEY); + + vault = new EnvelopeVault(address(0), address(this), address(0)); + testToken = new ERC20Mock(); + feeToken = new ERC20Mock(); + feeVault = new EnvelopeVault(backendAuthorizer, address(this), address(feeToken)); + testToken721 = new ERC721Mock(); + testToken1155 = new ERC1155Mock(); + } + + receive() external payable {} + + function testMakeBatchDepositEth() public { + uint256 amount = 100; + uint256 numDeposits = 10; + address[] memory pubKeys20 = _pubKeys(numDeposits, PUBKEY20); + + uint256[] memory depositIndexes = + vault.makeBatchDeposit{value: amount * numDeposits}(address(0), 0, amount, 0, pubKeys20); + + assertEq(depositIndexes.length, numDeposits); + assertEq(vault.getDepositCount(), numDeposits); + for (uint256 i = 0; i < numDeposits; ++i) { + EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndexes[i]); + assertEq(deposit.amount, amount); + assertEq(deposit.senderAddress, address(this)); + } + } + + function testMakeBatchDepositERC20() public { + uint256 amount = 100; + uint256 numDeposits = 10; + address[] memory pubKeys20 = _pubKeys(numDeposits, PUBKEY20); + + testToken.mint(address(this), amount * numDeposits); + testToken.approve(address(vault), amount * numDeposits); + + uint256[] memory depositIndexes = vault.makeBatchDeposit(address(testToken), 1, amount, 0, pubKeys20); + + assertEq(depositIndexes.length, numDeposits); + assertEq(testToken.balanceOf(address(vault)), amount * numDeposits); + } + + function test_RevertIf_MakeBatchDepositERC721SameShape() public { + address[] memory pubKeys20 = _pubKeys(2, PUBKEY20); + + vm.expectRevert(EnvelopeVault.Erc721BatchNotSupported.selector); + vault.makeBatchDeposit(address(testToken721), 2, 1, 1, pubKeys20); + } + + function testMakeBatchDepositERC1155() public { + uint256 numDeposits = 10; + address[] memory pubKeys20 = _pubKeys(numDeposits, PUBKEY20); + + testToken1155.mint(address(this), 1, numDeposits, ""); + testToken1155.setApprovalForAll(address(vault), true); + + uint256[] memory depositIndexes = vault.makeBatchDeposit(address(testToken1155), 3, 1, 1, pubKeys20); + + assertEq(depositIndexes.length, numDeposits); + assertEq(testToken1155.balanceOf(address(vault), 1), numDeposits); + } + + function test_RevertIf_BatchERC20DepositNotApproved() public { + uint256 amount = 100; + uint256 numDeposits = 10; + address[] memory pubKeys20 = _pubKeys(numDeposits, PUBKEY20); + testToken.mint(address(this), amount * numDeposits); + + vm.expectRevert(); + vault.makeBatchDeposit(address(testToken), 1, amount, 0, pubKeys20); + } + + function test_RevertIf_BatchERC1155DepositNotApproved() public { + uint256 numDeposits = 10; + address[] memory pubKeys20 = _pubKeys(numDeposits, PUBKEY20); + testToken1155.mint(address(this), 1, numDeposits, ""); + + vm.expectRevert(); + vault.makeBatchDeposit(address(testToken1155), 3, 1, 1, pubKeys20); + } + + function testMultipleBatchERC20DepositsInRow() public { + uint256 amount = 100; + uint256 numDeposits = 10; + uint256 numberOfBatches = 3; + address[] memory pubKeys20 = _pubKeys(numDeposits, PUBKEY20); + + for (uint256 batch = 0; batch < numberOfBatches; ++batch) { + testToken.mint(address(this), amount * numDeposits); + testToken.approve(address(vault), amount * numDeposits); + + uint256[] memory depositIndexes = vault.makeBatchDeposit(address(testToken), 1, amount, 0, pubKeys20); + + assertEq(depositIndexes.length, numDeposits); + } + } + + function testMakeBatchCustomDepositSupportsERC721Deposit() public { + uint256 tokenId = 42; + address[] memory tokenAddresses = new address[](1); + uint8[] memory contractTypes = new uint8[](1); + uint256[] memory amounts = new uint256[](1); + uint256[] memory tokenIds = new uint256[](1); + address[] memory pubKeys20 = new address[](1); + bool[] memory withMFAs = new bool[](1); + + tokenAddresses[0] = address(testToken721); + contractTypes[0] = 2; + amounts[0] = 1; + tokenIds[0] = tokenId; + pubKeys20[0] = PUBKEY20; + + testToken721.mint(address(this), tokenId); + testToken721.approve(address(vault), tokenId); + + uint256[] memory depositIndexes = + vault.makeBatchCustomDeposit(tokenAddresses, contractTypes, amounts, tokenIds, pubKeys20, withMFAs); + + EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndexes[0]); + assertEq(testToken721.ownerOf(tokenId), address(vault)); + assertEq(deposit.contractType, 2); + assertEq(deposit.tokenId, tokenId); + assertEq(deposit.senderAddress, address(this)); + } + + function testMakeBatchCustomDepositWithFeesCollectsFeesAtDeposit() public { + EnvelopeVault.DepositRequest[] memory requests = new EnvelopeVault.DepositRequest[](2); + EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](2); + + requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); + requests[1] = _request(address(0), 0, 2 ether, 0, true, RECIPIENT, uint40(block.timestamp + 1 days)); + authorizations[0] = _authorization(feeVault, requests[0], address(this), 0.01 ether, 0.02 ether, 0); + authorizations[1] = _authorization(feeVault, requests[1], address(this), 0.03 ether, 0.04 ether, 0); + + feeToken.mint(address(this), 0.1 ether); + feeToken.approve(address(feeVault), 0.1 ether); + + uint256[] memory depositIndexes = + feeVault.makeBatchCustomDepositWithFees{value: 3 ether}(requests, authorizations); + + EnvelopeVault.Deposit memory firstDeposit = feeVault.getDeposit(depositIndexes[0]); + EnvelopeVault.Deposit memory secondDeposit = feeVault.getDeposit(depositIndexes[1]); + + assertEq(depositIndexes.length, 2); + assertEq(firstDeposit.senderAddress, address(this)); + assertEq(firstDeposit.serviceFee, 0.01 ether); + assertEq(firstDeposit.gaslessFee, 0.02 ether); + assertEq(secondDeposit.requiresMFA, true); + assertEq(secondDeposit.recipient, RECIPIENT); + assertEq(feeToken.balanceOf(address(feeVault)), 0.1 ether); + assertEq(feeVault.accumulatedFees(address(feeToken)), 0.1 ether); + + bytes memory withdrawalSig = + _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.ANYONE_WITHDRAWAL_MODE()); + bytes memory callData = + abi.encodeCall(EnvelopeVault.withdrawDeposit, (depositIndexes[0], RECIPIENT, withdrawalSig)); + assertTrue(feeVault.isValidGaslessOperation(RECIPIENT, callData)); + } + + function testMakeBatchCustomDepositWithFeesSupportsERC721AndERC1155() public { + uint256 tokenId = 77; + uint256 erc1155Id = 9; + EnvelopeVault.DepositRequest[] memory requests = new EnvelopeVault.DepositRequest[](2); + EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](2); + + requests[0] = _request(address(testToken721), 2, 1, tokenId, false, address(0), 0); + requests[1] = _request(address(testToken1155), 3, 5, erc1155Id, false, address(0), 0); + authorizations[0] = _authorization(feeVault, requests[0], address(this), 1, 2, 0); + authorizations[1] = _authorization(feeVault, requests[1], address(this), 3, 4, 0); + + testToken721.mint(address(this), tokenId); + testToken721.approve(address(feeVault), tokenId); + testToken1155.mint(address(this), erc1155Id, 5, ""); + testToken1155.setApprovalForAll(address(feeVault), true); + feeToken.mint(address(this), 10); + feeToken.approve(address(feeVault), 10); + + uint256[] memory depositIndexes = feeVault.makeBatchCustomDepositWithFees(requests, authorizations); + + EnvelopeVault.Deposit memory nftDeposit = feeVault.getDeposit(depositIndexes[0]); + EnvelopeVault.Deposit memory multiTokenDeposit = feeVault.getDeposit(depositIndexes[1]); + + assertEq(testToken721.ownerOf(tokenId), address(feeVault)); + assertEq(testToken1155.balanceOf(address(feeVault), erc1155Id), 5); + assertEq(nftDeposit.contractType, 2); + assertEq(nftDeposit.tokenId, tokenId); + assertEq(multiTokenDeposit.contractType, 3); + assertEq(multiTokenDeposit.amount, 5); + assertEq(feeToken.balanceOf(address(feeVault)), 10); + } + + function test_RevertIf_BatchFeeAuthorizationIsSignedForDifferentPayer() public { + EnvelopeVault.DepositRequest[] memory requests = new EnvelopeVault.DepositRequest[](1); + EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](1); + + requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); + authorizations[0] = _authorization(feeVault, requests[0], address(0xBAD), 0.01 ether, 0.02 ether, 0); + + feeToken.mint(address(this), 0.03 ether); + feeToken.approve(address(feeVault), 0.03 ether); + + vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); + feeVault.makeBatchCustomDepositWithFees{value: 1 ether}(requests, authorizations); + } + + function testMakeBatchDepositRaffleEth() public { + uint256[] memory amounts = new uint256[](4); + amounts[0] = 10; + amounts[1] = 20; + amounts[2] = 30; + amounts[3] = 40; + + uint256[] memory depositIndices = vault.makeBatchDepositRaffle{value: 100}(address(0), 0, amounts, PUBKEY20); + + for (uint256 i = 0; i < amounts.length; ++i) { + EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); + assertEq(deposit.amount, amounts[i]); + assertEq(deposit.contractType, 0); + assertEq(deposit.pubKey20, PUBKEY20); + assertEq(deposit.senderAddress, address(this)); + } + } + + function testMakeBatchDepositRaffleERC20() 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(vault), 100); + + uint256[] memory depositIndices = vault.makeBatchDepositRaffle(address(testToken), 1, amounts, PUBKEY20); + + for (uint256 i = 0; i < amounts.length; ++i) { + EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); + assertEq(deposit.amount, amounts[i]); + assertEq(deposit.contractType, 1); + assertEq(deposit.pubKey20, PUBKEY20); + assertEq(deposit.senderAddress, address(this)); + } + } + + function testMakeBatchMFADepositRaffle() public { + uint256[] memory amounts = new uint256[](2); + amounts[0] = 10; + amounts[1] = 20; + + uint256[] memory depositIndices = vault.makeBatchMFADepositRaffle{value: 30}(address(0), 0, amounts, PUBKEY20); + + for (uint256 i = 0; i < amounts.length; ++i) { + EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); + assertTrue(deposit.requiresMFA); + assertEq(deposit.senderAddress, address(this)); + } + } + + function testMakeBatchDepositNoReturnEth() public { + address[] memory pubKeys20 = _pubKeys(3, PUBKEY20); + + vault.makeBatchDepositNoReturn{value: 3 ether}(address(0), 0, 1 ether, 0, pubKeys20); + + assertEq(vault.getDepositCount(), 3); + } + + function testBatchZeroLengthDepositsIsNoop() public { + address[] memory pubKeys20 = new address[](0); + uint256[] memory ids = vault.makeBatchDeposit(address(0), 0, 0, 0, pubKeys20); + + assertEq(ids.length, 0); + assertEq(vault.getDepositCount(), 0); + } + + function _pubKeys(uint256 count, address pubKey20) internal pure returns (address[] memory) { + address[] memory pubKeys20 = new address[](count); + for (uint256 i = 0; i < count; ++i) { + pubKeys20[i] = pubKey20; + } + return pubKeys20; + } + + function _request( + address tokenAddress, + uint8 contractType, + uint256 amount, + uint256 tokenId, + bool withMFA, + address recipient, + uint40 reclaimableAfter + ) internal view returns (EnvelopeVault.DepositRequest memory) { + return EnvelopeVault.DepositRequest({ + tokenAddress: tokenAddress, + contractType: contractType, + amount: amount, + tokenId: tokenId, + pubKey20: linkPubKey, + onBehalfOf: address(this), + withMFA: withMFA, + recipient: recipient, + reclaimableAfter: reclaimableAfter + }); + } + + function _authorization( + EnvelopeVault targetVault, + EnvelopeVault.DepositRequest memory request, + address feePayer, + uint256 serviceFee, + uint256 gaslessFee, + uint256 deadline + ) internal view returns (EnvelopeVault.FeeAuthorization memory) { + return EnvelopeVault.FeeAuthorization({ + serviceFee: serviceFee, + gaslessFee: gaslessFee, + deadline: deadline, + signature: _signFeeAuthorization(targetVault, request, feePayer, serviceFee, gaslessFee, deadline) + }); + } + + function _signFeeAuthorization( + EnvelopeVault targetVault, + EnvelopeVault.DepositRequest memory request, + address feePayer, + uint256 serviceFee, + uint256 gaslessFee, + uint256 deadline + ) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encode( + targetVault.ENVELOPE_SALT(), + block.chainid, + address(targetVault), + feePayer, + request.tokenAddress, + request.contractType, + request.amount, + request.tokenId, + request.pubKey20, + request.onBehalfOf, + request.withMFA, + request.recipient, + request.reclaimableAfter, + serviceFee, + gaslessFee, + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _signWithdrawal(EnvelopeVault targetVault, uint256 depositIndex, address recipient, bytes32 mode) + internal + view + returns (bytes memory) + { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked( + targetVault.ENVELOPE_SALT(), block.chainid, address(targetVault), depositIndex, recipient, mode + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } +} diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index 6c455675..4199d349 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -1,13 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; -// Edge-case coverage for EnvelopeVault / EnvelopeBatcher — gates the vendored happy-path -// tests don't exercise directly. Names follow the repo's test_RevertWhen_* / test_* +// Edge-case coverage for EnvelopeVault behavior the 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 {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"; @@ -49,7 +48,6 @@ contract ReentrantToken is ERC20Mock { contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { EnvelopeVault public vault; - EnvelopeBatcher public batcher; ERC20Mock public erc20; ERC721Mock public erc721; ERC1155Mock public erc1155; @@ -64,7 +62,6 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function setUp() public { LINK_PUBKEY20 = vm.addr(LINK_PRIV); vault = new EnvelopeVault(address(0), address(this), address(0)); - batcher = new EnvelopeBatcher(); erc20 = new ERC20Mock(); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); @@ -78,12 +75,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - idx, - recipient, - vault.ANYONE_WITHDRAWAL_MODE() + vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, recipient, vault.ANYONE_WITHDRAWAL_MODE() ) ) ); @@ -150,12 +142,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - idx, - ALICE, - vault.RECIPIENT_WITHDRAWAL_MODE() + vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, ALICE, vault.RECIPIENT_WITHDRAWAL_MODE() ) ) ); @@ -251,13 +238,15 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { assertTrue(evil.attempted(), "reentrancy attempt should have run"); } - // ── EnvelopeBatcher input validation ─────────────────────────────────── + // ── Vault-native batch 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(vault), address(0), 0, 1 ether, 0, pubKeys); + for (uint256 i = 0; i < 3; i++) { + pubKeys[i] = LINK_PUBKEY20; + } + vm.expectRevert(EnvelopeVault.InvalidTotalEtherSent.selector); + vault.makeBatchDeposit{value: 1 ether}(address(0), 0, 1 ether, 0, pubKeys); // expected 3 * 1 ether, sent 1 ether } @@ -270,53 +259,53 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { address[] memory pks = new address[](2); bool[] memory mfa = new bool[](3); // wrong length - vm.expectRevert("PARAMETERS LENGTH MISMATCH"); - batcher.batchMakeDepositArbitrary(address(vault), tokens, types, amounts, ids, pks, mfa); + vm.expectRevert(EnvelopeVault.ParametersLengthMismatch.selector); + vault.makeBatchCustomDeposit(tokens, types, amounts, ids, pks, mfa); } - // batchMakeDepositNoReturn — ETH path must require exact total, non-ETH path must reject msg.value. + // makeBatchDepositNoReturn — ETH path must require exact total, non-ETH path must reject msg.value. function test_BatchNoReturnEth_HappyPath() public { address[] memory pubKeys = new address[](3); - for (uint256 i = 0; i < 3; i++) pubKeys[i] = LINK_PUBKEY20; + for (uint256 i = 0; i < 3; i++) { + pubKeys[i] = LINK_PUBKEY20; + } - batcher.batchMakeDepositNoReturn{value: 3 ether}( - address(vault), address(0), 0, 1 ether, 0, pubKeys - ); + vault.makeBatchDepositNoReturn{value: 3 ether}(address(0), 0, 1 ether, 0, pubKeys); assertEq(vault.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(vault), address(0), 0, 1 ether, 0, pubKeys - ); + for (uint256 i = 0; i < 3; i++) { + pubKeys[i] = LINK_PUBKEY20; + } + vm.expectRevert(EnvelopeVault.InvalidTotalEtherSent.selector); + vault.makeBatchDepositNoReturn{value: 1 ether}(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); + erc20.approve(address(vault), 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(vault), address(erc20), 1, 100, 0, pubKeys - ); + for (uint256 i = 0; i < 2; i++) { + pubKeys[i] = LINK_PUBKEY20; + } + vm.expectRevert(EnvelopeVault.EthNotAcceptedForNonEthDeposit.selector); + vault.makeBatchDepositNoReturn{value: 1 wei}(address(erc20), 1, 100, 0, pubKeys); } 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(vault), address(erc721), 2, amounts, LINK_PUBKEY20); + vm.expectRevert(EnvelopeVault.UnsupportedRaffleContractType.selector); + vault.makeBatchDepositRaffle(address(erc721), 2, amounts, LINK_PUBKEY20); } function test_BatchZeroLengthDepositsIsNoop() public { address[] memory pubKeys = new address[](0); - uint256[] memory ids = batcher.batchMakeDeposit(address(vault), address(0), 0, 0, 0, pubKeys); + uint256[] memory ids = vault.makeBatchDeposit(address(0), 0, 0, 0, pubKeys); assertEq(ids.length, 0); assertEq(vault.getDepositCount(), 0); } From 77ce0bd87ff0b08acdd94456f04e765a35e63d07 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 20 May 2026 16:27:07 +1200 Subject: [PATCH 41/49] feat(envelope): support sponsored gasless claims --- src/envelope/V4/EnvelopeVault.sol | 85 ++++++-- src/envelope/doc/EnvelopePaymaster.md | 16 +- src/envelope/doc/EnvelopeVault.md | 267 ++++++++++++++++++++---- src/envelope/doc/README.md | 84 ++++---- src/paymasters/EnvelopePaymaster.sol | 4 +- test/envelope/EnvelopeBatching.t.sol | 52 ++++- test/envelope/Gasless.t.sol | 115 +++++++++- test/paymasters/EnvelopePaymaster.t.sol | 12 ++ 8 files changed, 523 insertions(+), 112 deletions(-) diff --git a/src/envelope/V4/EnvelopeVault.sol b/src/envelope/V4/EnvelopeVault.sol index 8763c7e5..3477d981 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/V4/EnvelopeVault.sol @@ -87,6 +87,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint8 contractType; // (1 byte) 0 for eth, 1 for erc20, 2 for erc721, 3 for erc1155 bool claimed; // (1 byte) has this deposit been claimed bool requiresMFA; // (1 byte) is additional auth (MFA) required? + bool gaslessSponsored; // (1 byte) can the paymaster sponsor this deposit without a prepaid gasless fee? uint40 timestamp; // ( 5 bytes) timestamp of the deposit ///// uint256 tokenId; // (32 bytes) id of the token being sent (if erc721 or erc1155) @@ -112,10 +113,12 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } /// @notice Backend-signed fee bundle collected when a deposit is created. - /// @dev deadline == 0 means no expiry. Non-zero fees require `signature` from `mfaAuthorizer`. + /// @dev deadline == 0 means no expiry. Non-zero fees or sponsored gasless eligibility require + /// `signature` from `mfaAuthorizer`. Zero-fee authorizations with a non-empty signature are verified too. struct FeeAuthorization { uint256 serviceFee; uint256 gaslessFee; + bool gaslessSponsored; uint256 deadline; bytes signature; } @@ -209,7 +212,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); return _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, false, address(0), 0, 0, 0 + _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, false, address(0), 0, 0, 0, false ); } @@ -222,7 +225,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); return _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, true, address(0), 0, 0, 0 + _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, true, address(0), 0, 0, 0, false ); } @@ -236,7 +239,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); return _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, true, address(0), 0, 0, 0 + _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, true, address(0), 0, 0, 0, false ); } @@ -250,7 +253,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); return _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, false, address(0), 0, 0, 0 + _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, false, address(0), 0, 0, 0, false ); } @@ -290,15 +293,17 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow _recipient, _reclaimableAfter, 0, - 0 + 0, + false ); } /** * @notice Create a deposit and collect backend-authorized service/gasless fees up front. - * @dev Non-zero fees are paid in `feeToken` by msg.sender. `gaslessFee > 0` marks the deposit - * as eligible for EnvelopePaymaster-sponsored claim or sender reclaim. The fee authorization - * is signed by `mfaAuthorizer` and includes the full deposit intent plus a deadline. + * @dev Non-zero fees are paid in `feeToken` by msg.sender. `gaslessFee > 0` or + * `gaslessSponsored == true` marks the deposit as eligible for EnvelopePaymaster-sponsored + * claim or sender reclaim. The fee authorization is signed by `mfaAuthorizer` and includes + * the full deposit intent plus a deadline. */ function makeCustomDepositWithFees(DepositRequest calldata _request, FeeAuthorization calldata _feeAuthorization) public @@ -324,7 +329,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow _request.recipient, _request.reclaimableAfter, _feeAuthorization.serviceFee, - _feeAuthorization.gaslessFee + _feeAuthorization.gaslessFee, + _feeAuthorization.gaslessSponsored ); } @@ -344,7 +350,18 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256[] memory depositIndexes = new uint256[](_pubKeys20.length); for (uint256 i = 0; i < _pubKeys20.length; ++i) { depositIndexes[i] = _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender, false, address(0), 0, 0, 0 + _tokenAddress, + _contractType, + _amount, + _tokenId, + _pubKeys20[i], + msg.sender, + false, + address(0), + 0, + 0, + 0, + false ); } return depositIndexes; @@ -363,7 +380,18 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow for (uint256 i = 0; i < _pubKeys20.length; ++i) { _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKeys20[i], msg.sender, false, address(0), 0, 0, 0 + _tokenAddress, + _contractType, + _amount, + _tokenId, + _pubKeys20[i], + msg.sender, + false, + address(0), + 0, + 0, + 0, + false ); } } @@ -416,7 +444,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address(0), 0, 0, - 0 + 0, + false ); } return depositIndexes; @@ -466,7 +495,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow request.recipient, request.reclaimableAfter, feeAuthorization.serviceFee, - feeAuthorization.gaslessFee + feeAuthorization.gaslessFee, + feeAuthorization.gaslessSponsored ); } @@ -722,8 +752,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow view { uint256 totalFee = _feeAuthorization.serviceFee + _feeAuthorization.gaslessFee; - if (totalFee == 0) return; - if (address(feeToken) == address(0)) revert FeeTokenNotConfigured(); + if (totalFee == 0 && !_feeAuthorization.gaslessSponsored && _feeAuthorization.signature.length == 0) return; + if (totalFee > 0 && address(feeToken) == address(0)) revert FeeTokenNotConfigured(); if (_feeAuthorization.deadline != 0 && block.timestamp > _feeAuthorization.deadline) { revert FeeAuthorizationExpired(); } @@ -746,6 +776,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow _request.reclaimableAfter, _feeAuthorization.serviceFee, _feeAuthorization.gaslessFee, + _feeAuthorization.gaslessSponsored, _feeAuthorization.deadline ) ) @@ -775,7 +806,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address _recipient, uint40 _reclaimableAfter, uint256 _serviceFee, - uint256 _gaslessFee + uint256 _gaslessFee, + bool _gaslessSponsored ) internal returns (uint256) { deposits.push( Deposit({ @@ -788,6 +820,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow senderAddress: _onBehalfOf, timestamp: uint40(block.timestamp), requiresMFA: _requiresMFA, + gaslessSponsored: _gaslessSponsored, recipient: _recipient, reclaimableAfter: _reclaimableAfter, serviceFee: _serviceFee, @@ -808,7 +841,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ) internal view returns (bool) { if (_caller != _recipientAddress) return false; if (_index >= deposits.length) return false; - if (deposits[_index].gaslessFee == 0) return false; + Deposit storage deposit = deposits[_index]; + if (deposit.gaslessFee == 0 && !deposit.gaslessSponsored) return false; return _isValidWithdrawal(_index, _recipientAddress, _extraData, _signature, _authorized); } @@ -839,7 +873,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow function _isValidGaslessReclaim(address _caller, uint256 _index) internal view returns (bool) { if (_index >= deposits.length) return false; Deposit memory deposit = deposits[_index]; - if (deposit.gaslessFee == 0) return false; + if (deposit.gaslessFee == 0 && !deposit.gaslessSponsored) return false; if (deposit.claimed) return false; if (deposit.senderAddress != _caller) return false; if (deposit.recipient != address(0) && block.timestamp <= deposit.reclaimableAfter) return false; @@ -919,7 +953,18 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256[] memory depositIndexes = new uint256[](_amounts.length); for (uint256 i = 0; i < _amounts.length; ++i) { depositIndexes[i] = _storeDeposit( - _tokenAddress, _contractType, _amounts[i], 0, _pubKey20, msg.sender, _requiresMFA, address(0), 0, 0, 0 + _tokenAddress, + _contractType, + _amounts[i], + 0, + _pubKey20, + msg.sender, + _requiresMFA, + address(0), + 0, + 0, + 0, + false ); } return depositIndexes; diff --git a/src/envelope/doc/EnvelopePaymaster.md b/src/envelope/doc/EnvelopePaymaster.md index 1ea994e1..fead0d9c 100644 --- a/src/envelope/doc/EnvelopePaymaster.md +++ b/src/envelope/doc/EnvelopePaymaster.md @@ -4,7 +4,7 @@ ## Purpose -`EnvelopePaymaster` is the ZkSync paymaster for prepaid EnvelopeVault gasless operations. It pays ETH for claims and sender reclaims only when the target `EnvelopeVault` says the operation is valid and prepaid. +`EnvelopePaymaster` is the ZkSync paymaster for EnvelopeVault gasless operations. It pays ETH for claims and sender reclaims only when the target `EnvelopeVault` says the operation is valid and either prepaid (`gaslessFee > 0`) or backend-sponsored (`gaslessSponsored == true`). ## Constructor @@ -12,11 +12,11 @@ constructor(address admin, address withdrawer, address envelopeVault) ``` -| Param | Purpose | -|---|---| -| `admin` | Default admin for `BasePaymaster` roles. | -| `withdrawer` | Address allowed to withdraw excess ETH from the paymaster. | -| `envelopeVault` | The only vault destination this paymaster will sponsor. | +| Param | Purpose | +| --------------- | ---------------------------------------------------------- | +| `admin` | Default admin for `BasePaymaster` roles. | +| `withdrawer` | Address allowed to withdraw excess ETH from the paymaster. | +| `envelopeVault` | The only vault destination this paymaster will sponsor. | ## Validation Flow @@ -29,7 +29,7 @@ The paymaster supports ZkSync general flow only. 5. It verifies it has enough ETH for `requiredETH`. 6. `BasePaymaster` pays the bootloader. -The paymaster does not keep per-gift state and does not price fees. Fee pricing and eligibility are recorded in `EnvelopeVault` at deposit creation. +The paymaster does not keep per-gift state and does not price fees. Fee pricing, prepaid gasless amounts, and backend-sponsored eligibility are recorded in `EnvelopeVault` at deposit creation. ## Sponsored Selectors @@ -44,4 +44,4 @@ Approval-based paymaster flow is explicitly rejected. ## Funding -The paymaster must be funded with ETH on ZkSync. The vault collects `feeToken` compensation at deposit creation; moving that accumulated fee value back into paymaster ETH funding is an operational/backend treasury process, not a claim-time on-chain transfer. +The paymaster must be funded with ETH on ZkSync. The vault collects `feeToken` compensation at deposit creation when `gaslessFee > 0`; moving that accumulated fee value back into paymaster ETH funding is an operational/backend treasury process, not a claim-time on-chain transfer. When `gaslessSponsored == true`, no sender-side gasless fee is collected and the backend/paymaster operator funds the claim gas budget directly. diff --git a/src/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md index 55866ef5..37b749a4 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -4,7 +4,186 @@ ## Purpose -`EnvelopeVault` is a link-based asset vault for ETH, ERC-20, ERC-721, and ERC-1155 gifts. A sender deposits an asset against a per-link `pubKey20`; the recipient claims by presenting a signature from the matching private key. The vault supports open links, address-bound links, optional backend MFA, sender reclaim, deposit-time service fees, and prepaid gasless claim/reclaim eligibility for ZkSync paymasters. +`EnvelopeVault` is a link-based asset vault for ETH, ERC-20, ERC-721, and ERC-1155 gifts. A sender deposits an asset against a per-link `pubKey20`; the recipient claims by presenting a signature from the matching private key. The vault supports open links, address-bound links, optional backend MFA, sender reclaim, deposit-time service fees, and prepaid or backend-sponsored gasless claim/reclaim eligibility for ZkSync paymasters. + +## Actors And Architecture + +The core product actors are: + +| Actor | Role | +| ----- | ---- | +| Sender / Nina | Creates the gift link and funds the envelope. In the app, Nina signs one ZkSync smart-account batch instead of separate approval and deposit transactions. | +| Backend / Atlas | Prices the service and gasless portions, signs `FeeAuthorization`, and optionally signs MFA approvals at claim time. Atlas can also sponsor gasless eligibility with `gaslessSponsored=true`. | +| Receiver / Remy | Opens the link and claims the gift. Remy may be explicitly recipient-bound, or the link may be open to whoever has the link key. | +| App Wallet | A ZkSync smart account controlled by the app. It batches approvals plus the vault call into one user confirmation. | +| EnvelopeVault | Custodies gifts, validates backend fee authorization, stores gasless eligibility, and executes claims/reclaims. | +| EnvelopePaymaster | Pays ZkSync claim/reclaim gas only when `EnvelopeVault.isValidGaslessOperation` approves the calldata. | + +```mermaid +flowchart LR + Sender[Sender / Nina] --> AppWallet[App Wallet\nZkSync smart account] + AppWallet -->|approve gift token if needed| GiftToken[Gift token\nETH / ERC20 / ERC721 / ERC1155] + AppWallet -->|approve fee token if needed| FeeToken[NODL fee token] + AppWallet -->|deposit call| Vault[EnvelopeVault] + Backend[Backend / Atlas\nMFA + fee signer] -->|FeeAuthorization| AppWallet + Backend -->|MFA signature| Receiver[Receiver / Remy] + Receiver -->|claim tx| Vault + Receiver -->|gasless claim tx| Paymaster[EnvelopePaymaster] + Paymaster -->|validate calldata| Vault + Paymaster -->|pays ETH gas| Bootloader[ZkSync bootloader] + Vault -->|transfers gift| Receiver + Vault -->|records fees| FeeAccounting[accumulatedFees] +``` + +## Backend Fee Decision + +The vault does not price fees. Atlas chooses the service fee, gasless fee, and whether the backend sponsors claim gas, then signs the complete deposit intent for the app wallet address that will call the vault. + +```mermaid +flowchart TD + Request[App sends gift request\nasset, amount, MFA, recipient binding, gasless preference] --> Classify{Needs MFA?} + Classify -->|yes| Service[Set serviceFee for MFA/backend checks] + Classify -->|no| NoService[serviceFee can be zero] + Service --> Gasless{Gasless claim requested?} + NoService --> Gasless + Gasless -->|no| NoGasless[gaslessFee=0\ngaslessSponsored=false] + Gasless -->|sender pays| Prepaid[gaslessFee in NODL\ngaslessSponsored=false] + Gasless -->|promotion / sponsored first envelope| Sponsored[gaslessFee=0\ngaslessSponsored=true] + Prepaid --> Sign[Sign FeeAuthorization\nfor the app wallet address] + Sponsored --> Sign + NoGasless --> Sign + Sign --> UI[Return quote, deadline, signature] +``` + +`gaslessFee > 0` means the sender prepaid the paymaster budget in NODL. `gaslessSponsored == true` means Atlas approved paymaster eligibility without collecting a gasless fee from the sender. Either condition allows the paymaster path, but only the non-zero fee path transfers NODL into the vault. + +## App Wallet Batch UX + +For best UX, the app wallet should use ZkSync native account abstraction and present a single confirmation that internally executes the required approvals plus the vault call. This is especially important because the deployed L2 NODL token does not implement `ERC20Permit`. + +Recommended sender flow: + +1. Derive or load Nina's app-wallet smart-account address. +2. Build the `DepositRequest` using `onBehalfOf = appWalletAddress` when the app wallet should own sender reclaim rights. +3. Ask Atlas for a `FeeAuthorization` signed for `feePayer = appWalletAddress`. +4. Query current allowances and approvals. +5. Build an AA batch containing only the missing approvals plus `makeCustomDepositWithFees`. +6. Show Nina one clear confirmation: gift asset, recipient-binding status, MFA status, NODL service fee, gasless fee or sponsorship, and reclaim policy. +7. Submit one ZkSync smart-account transaction. + +Pseudo-call plan: + +```solidity +Call[] memory calls; + +if (giftIsERC20 && giftAllowance < giftAmount) { + calls.push(Call({ + to: giftToken, + value: 0, + data: abi.encodeCall(IERC20.approve, (address(vault), giftAmount)) + })); +} + +if (feeAuthorization.serviceFee + feeAuthorization.gaslessFee > 0 && nodlAllowance < totalFee) { + calls.push(Call({ + to: address(feeToken), + value: 0, + data: abi.encodeCall(IERC20.approve, (address(vault), totalFee)) + })); +} + +calls.push(Call({ + to: address(vault), + value: request.contractType == 0 ? request.amount : 0, + data: abi.encodeCall(EnvelopeVault.makeCustomDepositWithFees, (request, feeAuthorization)) +})); + +appWallet.executeBatch(calls, paymasterParams); +``` + +For ERC-721, use `approve(vault, tokenId)` before the vault call if the vault is not already approved. For ERC-1155, use `setApprovalForAll(vault, true)` only when needed; for one-shot UX, the app may add `setApprovalForAll(vault, false)` after the deposit call in the same batch to avoid leaving standing approval. + +## Main Sequences + +### No MFA, No Gasless P2P Gift + +```mermaid +sequenceDiagram + participant Nina as Sender / Nina + participant Wallet as App Wallet + participant Vault as EnvelopeVault + participant Remy as Receiver / Remy + + Nina->>Wallet: Create gift and link key + Wallet->>Vault: makeCustomDeposit or makeDeposit + Vault-->>Wallet: Deposit index stored, no fees, no MFA + Nina-->>Remy: Share link out of band + Remy->>Remy: Link key signs claim for Remy's address + Remy->>Vault: withdrawDeposit(index, Remy, linkSignature) + Vault-->>Remy: Transfer full gift amount +``` + +If Remy is recipient-bound, the sender uses `makeCustomDeposit(..., recipient=Remy, reclaimableAfter=...)`, and Remy can call either `withdrawDeposit` with Remy as recipient or the stricter `withdrawDepositAsRecipient` path. The paymaster will only sponsor recipient-bound claims when the caller is the bound recipient and gasless eligibility exists. + +### MFA Without Gasless Claim + +```mermaid +sequenceDiagram + participant Nina as Sender / Nina + participant Wallet as App Wallet + participant Atlas as Backend / Atlas + participant FeeToken as NODL Fee Token + participant Vault as EnvelopeVault + participant Remy as Receiver / Remy + + Nina->>Wallet: Configure MFA gift + Wallet->>Atlas: Request fee quote and authorization + Atlas-->>Wallet: FeeAuthorization(serviceFee, gaslessFee=0, gaslessSponsored=false) + Wallet->>FeeToken: approve(vault, serviceFee) inside AA batch, if needed + Wallet->>Vault: makeCustomDepositWithFees(request.withMFA=true, authorization) + Vault->>FeeToken: transferFrom(app wallet, vault, serviceFee) + Vault-->>Wallet: Deposit index stored, requiresMFA=true + Nina-->>Remy: Share link + Remy->>Atlas: Complete MFA challenge + Atlas-->>Remy: MFA signature for (vault, index, Remy, deadline) + Remy->>Vault: withdrawMFADeposit(index, Remy, linkSignature, mfaSignature, deadline) + Vault-->>Remy: Transfer full gift amount +``` + +In this flow Remy pays the claim transaction gas. The service fee is collected at deposit creation and does not reduce the gift amount. + +### MFA With Gasless Claim + +```mermaid +sequenceDiagram + participant Nina as Sender / Nina + participant Wallet as App Wallet + participant Atlas as Backend / Atlas + participant FeeToken as NODL Fee Token + participant Vault as EnvelopeVault + participant Remy as Receiver / Remy + participant Paymaster as EnvelopePaymaster + participant Bootloader as ZkSync Bootloader + + Nina->>Wallet: Configure MFA gift with gasless claim + Wallet->>Atlas: Request fee quote and gasless authorization + Atlas-->>Wallet: FeeAuthorization(serviceFee, gaslessFee or gaslessSponsored=true) + Wallet->>FeeToken: approve(vault, serviceFee + gaslessFee) inside AA batch, if needed + Wallet->>Vault: makeCustomDepositWithFees(request.withMFA=true, authorization) + Vault->>FeeToken: transferFrom(app wallet, vault, serviceFee + gaslessFee) when totalFee > 0 + Vault-->>Wallet: Deposit stored with gasless eligibility + Nina-->>Remy: Share link + Remy->>Atlas: Complete MFA challenge + Atlas-->>Remy: MFA signature + Remy->>Paymaster: Submit withdrawMFADeposit through ZkSync paymaster + Paymaster->>Vault: isValidGaslessOperation(Remy, calldata) + Vault-->>Paymaster: true if recipient, signatures, MFA, and eligibility are valid + Paymaster->>Bootloader: Pay required ETH gas + Bootloader->>Vault: Execute withdrawMFADeposit + Vault-->>Remy: Transfer full gift amount +``` + +Gasless eligibility is independent of the gift amount. The paymaster must still be funded with ETH; NODL fee collection is an accounting and treasury process, not an on-chain claim-time swap. ## Constructor @@ -12,11 +191,11 @@ constructor(address mfaAuthorizer, address owner, address feeToken) ``` -| Param | Purpose | -|---|---| +| Param | Purpose | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mfaAuthorizer` | Backend signer for MFA claim approvals and deposit-time fee authorizations. `address(0)` disables non-zero fee authorizations and makes MFA withdrawals fail. | -| `owner` | Owns the vault and can withdraw accumulated fees. | -| `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. | +| `owner` | Owns the vault and can withdraw accumulated fees. | +| `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. | The constructor also sets the EIP-712 domain separator used by the vault-side validation helpers. @@ -32,6 +211,7 @@ struct Deposit { uint8 contractType; // 0=ETH, 1=ERC20, 2=ERC721, 3=ERC1155 bool claimed; bool requiresMFA; + bool gaslessSponsored; // backend approved paymaster eligibility without gaslessFee uint40 timestamp; uint256 tokenId; address senderAddress; @@ -42,26 +222,36 @@ struct Deposit { } ``` -`serviceFee` and `gaslessFee` are not deducted from the gift amount. They are separate `feeToken` transfers from the depositor to the vault and are accounted in `accumulatedFees[address(feeToken)]`. +`serviceFee` and `gaslessFee` are not deducted from the gift amount. They are separate `feeToken` transfers from the depositor to the vault and are accounted in `accumulatedFees[address(feeToken)]`. `gaslessSponsored` records backend-approved paymaster eligibility when Atlas pays the gas budget operationally instead of collecting a sender-side `gaslessFee`. ## Main Deposit Functions -| Function | Flow | -|---|---| -| `makeDeposit(token, type, amount, tokenId, pubKey20)` | Basic open link. No MFA, no fees, no gasless sponsorship. | -| `makeMFADeposit(...)` | Basic open link that requires backend MFA at claim time. No deposit-time fees unless using `makeCustomDepositWithFees`. | -| `makeSelflessDeposit(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | -| `makeSelflessMFADeposit(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | -| `makeCustomDeposit(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | -| `makeCustomDepositWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0`. | -| `makeBatchDeposit(...)` | Creates many same-shape no-fee deposits in one transaction. ETH, ERC-20, and ERC-1155 are supported; ERC-721 uses the heterogeneous batch path. | -| `makeBatchDepositNoReturn(...)` | Same as `makeBatchDeposit` but skips allocating/returning the deposit indexes array. | -| `makeBatchCustomDeposit(...)` | Creates a heterogeneous no-fee batch and supports ETH, ERC-20, ERC-721, and ERC-1155. | -| `makeBatchCustomDepositWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `DepositRequest` and `FeeAuthorization` structs as the single-deposit flow. | -| `makeBatchDepositRaffle(...)` | Creates ETH or ERC-20 raffle-style deposits with different amounts and one shared `pubKey20`. | -| `makeBatchMFADepositRaffle(...)` | Same as raffle batching, but every deposit requires MFA at claim time. | - -`FeeAuthorization` covers the full deposit intent, the fee payer (`msg.sender`), the two fee amounts, and a backend-selected deadline. `deadline == 0` means no expiry. If either fee is non-zero, the signature must recover to `mfaAuthorizer`. +| Function | Flow | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `makeDeposit(token, type, amount, tokenId, pubKey20)` | Basic open link. No MFA, no fees, no gasless sponsorship. | +| `makeMFADeposit(...)` | Basic open link that requires backend MFA at claim time. No deposit-time fees unless using `makeCustomDepositWithFees`. | +| `makeSelflessDeposit(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | +| `makeSelflessMFADeposit(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | +| `makeCustomDeposit(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | +| `makeCustomDepositWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0` or `gaslessSponsored=true`. | +| `makeBatchDeposit(...)` | Creates many same-shape no-fee deposits in one transaction. ETH, ERC-20, and ERC-1155 are supported; ERC-721 uses the heterogeneous batch path. | +| `makeBatchDepositNoReturn(...)` | Same as `makeBatchDeposit` but skips allocating/returning the deposit indexes array. | +| `makeBatchCustomDeposit(...)` | Creates a heterogeneous no-fee batch and supports ETH, ERC-20, ERC-721, and ERC-1155. | +| `makeBatchCustomDepositWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `DepositRequest` and `FeeAuthorization` structs as the single-deposit flow. | +| `makeBatchDepositRaffle(...)` | Creates ETH or ERC-20 raffle-style deposits with different amounts and one shared `pubKey20`. | +| `makeBatchMFADepositRaffle(...)` | Same as raffle batching, but every deposit requires MFA at claim time. | + +```solidity +struct FeeAuthorization { + uint256 serviceFee; + uint256 gaslessFee; + bool gaslessSponsored; + uint256 deadline; + bytes signature; +} +``` + +`FeeAuthorization` covers the full deposit intent, the fee payer (`msg.sender`), the two fee amounts, `gaslessSponsored`, and a backend-selected deadline. `deadline == 0` means no expiry. If either fee is non-zero, if `gaslessSponsored` is true, or if a zero-fee authorization includes a non-empty signature, the signature must recover to `mfaAuthorizer`. This allows backend-approved free envelopes without forcing a fee transfer, and also allows promotional gasless eligibility without encoding fake fee amounts. ## Vault-Native Batching @@ -71,12 +261,12 @@ The batching functions share the same storage and events as single deposits. Sam ## Withdraw And Claim Functions -| Function | Caller | Authorization | -|---|---|---| -| `withdrawDeposit(index, recipient, signature)` | Anyone, or a recipient using a paymaster | Link key signs `(salt, chainId, vault, index, recipient, ANYONE_WITHDRAWAL_MODE)`. | -| `withdrawMFADeposit(index, recipient, signature, mfaSignature, deadline)` | Anyone, or a recipient using a paymaster | Link signature plus backend MFA signature over `(salt, chainId, vault, index, recipient, deadline)`. | -| `withdrawDepositAsRecipient(index, recipient, signature)` | Must be `recipient` | Link key signs using `RECIPIENT_WITHDRAWAL_MODE`. | -| `withdrawDepositSender(index)` | Original `senderAddress` | Sender reclaim. If the deposit is recipient-bound, `block.timestamp` must be greater than `reclaimableAfter`. | +| Function | Caller | Authorization | +| ------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `withdrawDeposit(index, recipient, signature)` | Anyone, or a recipient using a paymaster | Link key signs `(salt, chainId, vault, index, recipient, ANYONE_WITHDRAWAL_MODE)`. | +| `withdrawMFADeposit(index, recipient, signature, mfaSignature, deadline)` | Anyone, or a recipient using a paymaster | Link signature plus backend MFA signature over `(salt, chainId, vault, index, recipient, deadline)`. | +| `withdrawDepositAsRecipient(index, recipient, signature)` | Must be `recipient` | Link key signs using `RECIPIENT_WITHDRAWAL_MODE`. | +| `withdrawDepositSender(index)` | Original `senderAddress` | Sender reclaim. If the deposit is recipient-bound, `block.timestamp` must be greater than `reclaimableAfter`. | All withdrawal paths set `claimed = true` before transferring assets. Claim-time fee collection was intentionally removed: fees are now collected when the envelope is created. @@ -84,14 +274,14 @@ All withdrawal paths set `claimed = true` before transferring assets. Claim-time Gasless operation is handled by ZkSync paymasters, not by an internal vault callback. The vault is only the source of truth for whether a paymaster should sponsor a call. -1. Sender creates a deposit through `makeCustomDepositWithFees` with `gaslessFee > 0`. -2. The vault collects the gasless sponsorship fee immediately in `feeToken` and records it on the deposit. +1. Sender creates a deposit through `makeCustomDepositWithFees` with `gaslessFee > 0` or `gaslessSponsored=true`. +2. The vault collects any non-zero gasless sponsorship fee immediately in `feeToken` and records gasless eligibility on the deposit. 3. A receiver submits a ZkSync transaction to `withdrawDeposit`, `withdrawMFADeposit`, or `withdrawDepositAsRecipient` using `EnvelopePaymaster`. 4. ZkSync calls the paymaster before execution. The paymaster checks the transaction targets this vault and calls `isValidGaslessOperation(from, transaction.data)`. -5. The vault re-checks the deposit state, gasless fee, recipient/sender identity, signatures, MFA deadline, and reclaim delay. +5. The vault re-checks the deposit state, gasless eligibility, recipient/sender identity, signatures, MFA deadline, and reclaim delay. 6. If validation passes, the paymaster pays ETH to the bootloader. The vault function then executes normally. -Sender reclaim can also be gasless: the sender submits `withdrawDepositSender(index)` through the paymaster. This is allowed only for deposits with `gaslessFee > 0` and the same reclaim timing rules as the regular reclaim path. +Sender reclaim can also be gasless: the sender submits `withdrawDepositSender(index)` through the paymaster. This is allowed only for deposits with `gaslessFee > 0` or `gaslessSponsored=true`, and the same reclaim timing rules as the regular reclaim path. ## Paymaster Validation Helper @@ -106,16 +296,17 @@ This function is intended for paymaster validation. It accepts only these select - `withdrawDepositAsRecipient` - `withdrawDepositSender` -For claim calls, `caller` must be the recipient. For reclaim calls, `caller` must be the stored sender. The helper returns false for non-prepaid deposits, claimed deposits, unsupported selectors, wrong callers, invalid signatures, expired MFA approvals, or early reclaims. +For claim calls, `caller` must be the recipient. For reclaim calls, `caller` must be the stored sender. The helper returns false for non-eligible deposits, claimed deposits, unsupported selectors, wrong callers, invalid signatures, expired MFA approvals, or early reclaims. ## Fees -| Fee | Collected | Meaning | -|---|---|---| -| `serviceFee` | Deposit creation | Paid backend service fee for optional security/MFA/compliance checks. | -| `gaslessFee` | Deposit creation | Prepaid compensation for paymaster-sponsored claim or reclaim. | +| Fee / Flag | Collected | Meaning | +| --------------------- | ---------------- | ----------------------------------------------------------------------------------------- | +| `serviceFee` | Deposit creation | Paid backend service fee for optional security/MFA/compliance checks. | +| `gaslessFee` | Deposit creation | Prepaid NODL compensation for paymaster-sponsored claim or reclaim. | +| `gaslessSponsored` | Not collected | Backend-approved paymaster eligibility without collecting a sender-side gasless fee. | -Both fees are backend-priced off-chain and backend-signed on-chain. The vault does not encode pricing policy; it enforces the signed amounts and deadline. The owner withdraws accumulated fees through `withdrawFees(token)`. +Fees and sponsorship are backend-priced off-chain and backend-signed on-chain. The vault does not encode pricing policy; it enforces the signed amounts, sponsored eligibility flag, and deadline. The owner withdraws accumulated fees through `withdrawFees(token)`. ## Removed EIP-3009 Path diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 0d445319..4f477f7a 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -1,31 +1,31 @@ # Envelope contracts -The Envelope flow on Nodle is built on top of modified Peanut Protocol V4.4 contracts. Senders deposit assets against a per-link public key; recipients claim with the matching private key. Nodle-specific additions include address-bound links, backend MFA, deposit-time service fees, and ZkSync paymaster support for prepaid gasless claims and reclaims. +The Envelope flow on Nodle is built on top of modified Peanut Protocol V4.4 contracts. Senders deposit assets against a per-link public key; recipients claim with the matching private key. Nodle-specific additions include address-bound links, backend MFA, deposit-time service fees, app-wallet batching on ZkSync smart accounts, and ZkSync paymaster support for prepaid or backend-sponsored gasless claims and reclaims. ## Layout -| Contract | Source | Spec | -|---|---|---| -| `EnvelopeVault` | `src/envelope/V4/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | +| Contract | Source | Spec | +| ------------------- | -------------------------------------- | ---------------------------------------------- | +| `EnvelopeVault` | `src/envelope/V4/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | | `EnvelopePaymaster` | `src/paymasters/EnvelopePaymaster.sol` | [EnvelopePaymaster.md](./EnvelopePaymaster.md) | Interfaces: -| Interface | Source | Used by | -|---|---|---| +| Interface | Source | Used by | +| --------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `IEnvelopeGaslessValidator` | `src/envelope/util/IEnvelopeGaslessValidator.sol` | `EnvelopePaymaster` queries `EnvelopeVault.isValidGaslessOperation` before sponsoring gas. | ## License notice This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) does not apply uniformly here. -| Files | License | Notes | -|---|---|---| -| `src/envelope/V4/EnvelopeVault.sol` | **GPL-3.0-or-later** | Modified copy of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/V4/LICENSE-GPL`. The file carries a top-of-file modification notice per GPL §5(a). | -| `src/envelope/util/IEnvelopeGaslessValidator.sol` | **GPL-3.0-or-later** | Minimal interface for the GPL vault validation surface. | -| `test/envelope/**/*.t.sol` | **GPL-3.0-or-later** | Test files that import GPL-licensed contracts are relicensed for compatibility. | -| `test/envelope/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained. | -| All other repo files | unchanged | Whatever they were. | +| Files | License | Notes | +| ------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/envelope/V4/EnvelopeVault.sol` | **GPL-3.0-or-later** | Modified copy of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/V4/LICENSE-GPL`. The file carries a top-of-file modification notice per GPL §5(a). | +| `src/envelope/util/IEnvelopeGaslessValidator.sol` | **GPL-3.0-or-later** | Minimal interface for the GPL vault validation surface. | +| `test/envelope/**/*.t.sol` | **GPL-3.0-or-later** | Test files that import GPL-licensed contracts are relicensed for compatibility. | +| `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 under the OSI's "mere aggregation" interpretation. @@ -37,23 +37,23 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s ## Main flows -| Flow | Entry point | Summary | -|---|---|---| -| Basic deposit | `EnvelopeVault.makeDeposit` / `makeCustomDeposit` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | -| Paid or gasless-ready deposit | `EnvelopeVault.makeCustomDepositWithFees` | Sender supplies a backend-signed `FeeAuthorization`; the vault collects `serviceFee` and/or `gaslessFee` in `feeToken` at deposit creation. | -| Batch deposit | `EnvelopeVault.makeBatchDeposit` / `makeBatchCustomDeposit` / `makeBatchCustomDepositWithFees` | Sender creates many deposits in one transaction without a separate batcher contract. Fee signatures are signed for the actual caller. | -| Open claim | `EnvelopeVault.withdrawDeposit` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | -| MFA claim | `EnvelopeVault.withdrawMFADeposit` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | -| Recipient-bound claim | `EnvelopeVault.withdrawDepositAsRecipient` | Only the bound recipient can submit the transaction. | -| Sender reclaim | `EnvelopeVault.withdrawDepositSender` | Original sender reclaims unclaimed deposits; recipient-bound deposits also enforce `reclaimableAfter`. | -| Gasless validation | `EnvelopeVault.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid claim/reclaim calldata before the paymaster pays gas. | +| Flow | Entry point | Summary | +| ----------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Basic deposit | `EnvelopeVault.makeDeposit` / `makeCustomDeposit` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | +| Paid or gasless-ready deposit | `EnvelopeVault.makeCustomDepositWithFees` | Sender supplies a backend-signed `FeeAuthorization`; the vault collects `serviceFee` and/or `gaslessFee` in `feeToken` and records optional `gaslessSponsored` eligibility. | +| Batch deposit | `EnvelopeVault.makeBatchDeposit` / `makeBatchCustomDeposit` / `makeBatchCustomDepositWithFees` | Sender creates many deposits in one transaction without a separate batcher contract. Fee signatures are signed for the actual caller. | +| Open claim | `EnvelopeVault.withdrawDeposit` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | +| MFA claim | `EnvelopeVault.withdrawMFADeposit` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | +| Recipient-bound claim | `EnvelopeVault.withdrawDepositAsRecipient` | Only the bound recipient can submit the transaction. | +| Sender reclaim | `EnvelopeVault.withdrawDepositSender` | Original sender reclaims unclaimed deposits; recipient-bound deposits also enforce `reclaimableAfter`. | +| Gasless validation | `EnvelopeVault.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid or backend-sponsored claim/reclaim calldata before the paymaster pays gas. | ## ZkSync gasless model Gasless operations are paymaster-native: -1. Backend prices optional `serviceFee` and `gaslessFee` off-chain and signs the full deposit intent. -2. Sender creates the envelope with `makeCustomDepositWithFees` and prepays those fees in `feeToken`. +1. Backend prices optional `serviceFee`, `gaslessFee`, and `gaslessSponsored` off-chain and signs the full deposit intent for the app-wallet address that will call the vault. +2. Sender creates the envelope with `makeCustomDepositWithFees`; the app wallet can batch gift approval, NODL fee approval, and the vault call into one ZkSync smart-account transaction. 3. A recipient or sender submits a supported claim/reclaim call through `EnvelopePaymaster`. 4. Before execution, the paymaster checks the destination and calls `isValidGaslessOperation` on the vault. 5. If the vault approves and the paymaster has enough ETH, the paymaster pays the ZkSync bootloader and the vault call executes normally. @@ -62,30 +62,30 @@ The vault no longer contains an internal paymaster callback, and the EIP-3009 ga ## Deploy -| Script | Purpose | -|---|---| +| Script | Purpose | +| ---------------------------------- | ----------------------------------------------------------- | | `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeVault` and optionally `EnvelopePaymaster`. | Important environment variables: -| Variable | Purpose | -|---|---| -| `ENVELOPE_MFA_AUTHORIZER` | Required backend signer for MFA and fee authorizations. | -| `ENVELOPE_OWNER` | Optional vault owner; defaults to deployer. | -| `ENVELOPE_FEE_TOKEN` | Optional fee token; defaults to zero address for fee-disabled deployments. | -| `ENVELOPE_DEPLOY_PAYMASTER` | Set to `true` to deploy `EnvelopePaymaster`. | -| `ENVELOPE_PAYMASTER_ADMIN` | Optional paymaster admin; defaults to deployer. | -| `ENVELOPE_PAYMASTER_WITHDRAWER` | Optional paymaster ETH withdrawer; defaults to deployer. | +| Variable | Purpose | +| ------------------------------- | -------------------------------------------------------------------------- | +| `ENVELOPE_MFA_AUTHORIZER` | Required backend signer for MFA and fee authorizations. | +| `ENVELOPE_OWNER` | Optional vault owner; defaults to deployer. | +| `ENVELOPE_FEE_TOKEN` | Optional fee token; defaults to zero address for fee-disabled deployments. | +| `ENVELOPE_DEPLOY_PAYMASTER` | Set to `true` to deploy `EnvelopePaymaster`. | +| `ENVELOPE_PAYMASTER_ADMIN` | Optional paymaster admin; defaults to deployer. | +| `ENVELOPE_PAYMASTER_WITHDRAWER` | Optional paymaster ETH withdrawer; defaults to deployer. | ## Test coverage Relevant suites: -| Suite | Focus | -|---|---| -| `test/envelope/` | Vault deposits, claims, MFA, recipient binding, reclaim, fee collection, and gasless eligibility. | -| `test/envelope/EnvelopeBatching.t.sol` | Vault-native batching, raffle batches, ERC-721 heterogeneous batches, and batched fee authorizations. | -| `test/paymasters/EnvelopePaymaster.t.sol` | ZkSync paymaster validation and rejection paths for Envelope gasless operations. | -| `test/paymasters/` | Shared base, whitelist, bond treasury, and Envelope paymaster behavior. | +| Suite | Focus | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `test/envelope/` | Vault deposits, claims, MFA, recipient binding, reclaim, fee collection, and gasless eligibility. | +| `test/envelope/EnvelopeBatching.t.sol` | Vault-native batching, raffle batches, ERC-721 heterogeneous batches, and batched fee authorizations. | +| `test/paymasters/EnvelopePaymaster.t.sol` | ZkSync paymaster validation and rejection paths for Envelope gasless operations. | +| `test/paymasters/` | Shared base, whitelist, bond treasury, and Envelope paymaster behavior. | -Latest focused validation: `forge test --match-path 'test/{envelope/*,paymasters/*}'` passed 200 tests across 18 suites. +Latest focused validation: `forge test --match-path 'test/{envelope/*,paymasters/*}'` passed 207 tests across 18 suites. diff --git a/src/paymasters/EnvelopePaymaster.sol b/src/paymasters/EnvelopePaymaster.sol index a22b5c41..772a43f9 100644 --- a/src/paymasters/EnvelopePaymaster.sol +++ b/src/paymasters/EnvelopePaymaster.sol @@ -5,8 +5,8 @@ pragma solidity ^0.8.26; import {BasePaymaster} from "./BasePaymaster.sol"; import {IEnvelopeGaslessValidator} from "../envelope/util/IEnvelopeGaslessValidator.sol"; -/// @notice ZkSync paymaster that sponsors prepaid gasless EnvelopeVault claims and reclaims. -/// @dev The EnvelopeVault remains the source of truth for whether a call is valid and prepaid. +/// @notice ZkSync paymaster that sponsors eligible gasless EnvelopeVault claims and reclaims. +/// @dev The EnvelopeVault remains the source of truth for whether a call is valid and prepaid or sponsored. /// This paymaster only accepts general-flow transactions targeting that vault. contract EnvelopePaymaster is BasePaymaster { IEnvelopeGaslessValidator public immutable envelopeVault; diff --git a/test/envelope/EnvelopeBatching.t.sol b/test/envelope/EnvelopeBatching.t.sol index 784d17e0..f57c9179 100644 --- a/test/envelope/EnvelopeBatching.t.sol +++ b/test/envelope/EnvelopeBatching.t.sol @@ -219,6 +219,28 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { assertEq(feeToken.balanceOf(address(feeVault)), 10); } + function testMakeBatchCustomDepositWithFeesSupportsSponsoredGasless() public { + EnvelopeVault.DepositRequest[] memory requests = new EnvelopeVault.DepositRequest[](1); + EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](1); + + requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); + authorizations[0] = _authorization(feeVault, requests[0], address(this), 0, 0, true, 0); + + uint256[] memory depositIndexes = + feeVault.makeBatchCustomDepositWithFees{value: 1 ether}(requests, authorizations); + + EnvelopeVault.Deposit memory deposit = feeVault.getDeposit(depositIndexes[0]); + assertEq(deposit.gaslessFee, 0); + assertTrue(deposit.gaslessSponsored); + assertEq(feeToken.balanceOf(address(feeVault)), 0); + + bytes memory withdrawalSig = + _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.ANYONE_WITHDRAWAL_MODE()); + bytes memory callData = + abi.encodeCall(EnvelopeVault.withdrawDeposit, (depositIndexes[0], RECIPIENT, withdrawalSig)); + assertTrue(feeVault.isValidGaslessOperation(RECIPIENT, callData)); + } + function test_RevertIf_BatchFeeAuthorizationIsSignedForDifferentPayer() public { EnvelopeVault.DepositRequest[] memory requests = new EnvelopeVault.DepositRequest[](1); EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](1); @@ -339,12 +361,27 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256 serviceFee, uint256 gaslessFee, uint256 deadline + ) internal view returns (EnvelopeVault.FeeAuthorization memory) { + return _authorization(targetVault, request, feePayer, serviceFee, gaslessFee, false, deadline); + } + + function _authorization( + EnvelopeVault targetVault, + EnvelopeVault.DepositRequest memory request, + address feePayer, + uint256 serviceFee, + uint256 gaslessFee, + bool gaslessSponsored, + uint256 deadline ) internal view returns (EnvelopeVault.FeeAuthorization memory) { return EnvelopeVault.FeeAuthorization({ serviceFee: serviceFee, gaslessFee: gaslessFee, + gaslessSponsored: gaslessSponsored, deadline: deadline, - signature: _signFeeAuthorization(targetVault, request, feePayer, serviceFee, gaslessFee, deadline) + signature: _signFeeAuthorization( + targetVault, request, feePayer, serviceFee, gaslessFee, gaslessSponsored, deadline + ) }); } @@ -355,6 +392,18 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256 serviceFee, uint256 gaslessFee, uint256 deadline + ) internal view returns (bytes memory) { + return _signFeeAuthorization(targetVault, request, feePayer, serviceFee, gaslessFee, false, deadline); + } + + function _signFeeAuthorization( + EnvelopeVault targetVault, + EnvelopeVault.DepositRequest memory request, + address feePayer, + uint256 serviceFee, + uint256 gaslessFee, + bool gaslessSponsored, + uint256 deadline ) internal view returns (bytes memory) { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( @@ -374,6 +423,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { request.reclaimableAfter, serviceFee, gaslessFee, + gaslessSponsored, deadline ) ) diff --git a/test/envelope/Gasless.t.sol b/test/envelope/Gasless.t.sol index c9c20e93..5791e5fd 100644 --- a/test/envelope/Gasless.t.sol +++ b/test/envelope/Gasless.t.sol @@ -56,6 +56,17 @@ contract EnvelopeVaultGaslessTest is Test { uint256 serviceFee, uint256 gaslessFee, uint256 deadline + ) internal view returns (bytes memory) { + return _signFeeAuthorization(request, feePayer, serviceFee, gaslessFee, false, deadline); + } + + function _signFeeAuthorization( + EnvelopeVault.DepositRequest memory request, + address feePayer, + uint256 serviceFee, + uint256 gaslessFee, + bool gaslessSponsored, + uint256 deadline ) internal view returns (bytes memory) { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( @@ -75,6 +86,7 @@ contract EnvelopeVaultGaslessTest is Test { request.reclaimableAfter, serviceFee, gaslessFee, + gaslessSponsored, deadline ) ) @@ -88,12 +100,23 @@ contract EnvelopeVaultGaslessTest is Test { uint256 serviceFee, uint256 gaslessFee, uint256 deadline + ) internal view returns (EnvelopeVault.FeeAuthorization memory) { + return _feeAuthorization(request, serviceFee, gaslessFee, false, deadline); + } + + function _feeAuthorization( + EnvelopeVault.DepositRequest memory request, + uint256 serviceFee, + uint256 gaslessFee, + bool gaslessSponsored, + uint256 deadline ) internal view returns (EnvelopeVault.FeeAuthorization memory) { return EnvelopeVault.FeeAuthorization({ serviceFee: serviceFee, gaslessFee: gaslessFee, + gaslessSponsored: gaslessSponsored, deadline: deadline, - signature: _signFeeAuthorization(request, SENDER, serviceFee, gaslessFee, deadline) + signature: _signFeeAuthorization(request, SENDER, serviceFee, gaslessFee, gaslessSponsored, deadline) }); } @@ -148,10 +171,100 @@ contract EnvelopeVaultGaslessTest is Test { assertEq(deposit.amount, amount); assertEq(deposit.serviceFee, serviceFee); assertEq(deposit.gaslessFee, gaslessFee); + assertFalse(deposit.gaslessSponsored); assertEq(feeToken.balanceOf(address(vault)), serviceFee + gaslessFee); assertEq(vault.accumulatedFees(address(feeToken)), serviceFee + gaslessFee); } + function test_SponsoredGaslessAuthorizationApprovesPaymasterWithoutGaslessFee() public { + EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, true, 0); + + vm.prank(SENDER); + uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + + EnvelopeVault.Deposit memory deposit = vault.getDeposit(index); + assertEq(deposit.gaslessFee, 0); + assertTrue(deposit.gaslessSponsored); + assertEq(feeToken.balanceOf(address(vault)), 0); + + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); + } + + function test_SponsoredGaslessMfaClaimIsApprovedWithoutCollectedGaslessFee() public { + EnvelopeVault.DepositRequest memory request = _request(1 ether, true, address(0), 0); + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, true, 0); + + vm.prank(SENDER); + uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + + uint256 deadline = block.timestamp + 1 hours; + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); + bytes memory mfaSig = _signMfa(index, RECIPIENT, deadline); + bytes memory callData = + abi.encodeCall(EnvelopeVault.withdrawMFADeposit, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); + + assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); + assertEq(feeToken.balanceOf(address(vault)), 0); + } + + function test_ZeroFeeAuthorizationWithBackendSignatureIsAccepted() public { + EnvelopeVault.DepositRequest memory request = + _request(1 ether, true, RECIPIENT, uint40(block.timestamp + 1 days)); + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, 0); + + vm.prank(SENDER); + uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + + EnvelopeVault.Deposit memory deposit = vault.getDeposit(index); + assertEq(deposit.serviceFee, 0); + assertEq(deposit.gaslessFee, 0); + assertFalse(deposit.gaslessSponsored); + assertEq(feeToken.balanceOf(address(vault)), 0); + assertEq(vault.accumulatedFees(address(feeToken)), 0); + } + + function test_ZeroFeeAuthorizationWithoutSignatureRemainsOpen() public { + EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ + serviceFee: 0, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: "" + }); + + vm.prank(SENDER); + uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + + EnvelopeVault.Deposit memory deposit = vault.getDeposit(index); + assertEq(deposit.serviceFee, 0); + assertEq(deposit.gaslessFee, 0); + } + + function test_RevertIf_ZeroFeeAuthorizationSignatureWrong() public { + EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: _signFeeAuthorization(request, address(0xBAD), 0, 0, 0) + }); + + vm.prank(SENDER); + vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); + vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + } + + function test_RevertIf_SponsoredGaslessFlagTampered() public { + EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, false, 0); + authorization.gaslessSponsored = true; + + vm.prank(SENDER); + vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); + vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + } + function test_RevertIf_FeeTokenNotConfigured() public { EnvelopeVault vaultWithoutFeeToken = new EnvelopeVault(BACKEND_AUTHORIZER, address(this), address(0)); EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); diff --git a/test/paymasters/EnvelopePaymaster.t.sol b/test/paymasters/EnvelopePaymaster.t.sol index 959ccd5e..cfde1aeb 100644 --- a/test/paymasters/EnvelopePaymaster.t.sol +++ b/test/paymasters/EnvelopePaymaster.t.sol @@ -62,6 +62,16 @@ contract EnvelopePaymasterTest is Test { uint256 serviceFee, uint256 gaslessFee, uint256 deadline + ) internal view returns (bytes memory) { + return _signFeeAuthorization(request, serviceFee, gaslessFee, false, deadline); + } + + function _signFeeAuthorization( + EnvelopeVault.DepositRequest memory request, + uint256 serviceFee, + uint256 gaslessFee, + bool gaslessSponsored, + uint256 deadline ) internal view returns (bytes memory) { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( @@ -81,6 +91,7 @@ contract EnvelopePaymasterTest is Test { request.reclaimableAfter, serviceFee, gaslessFee, + gaslessSponsored, deadline ) ) @@ -94,6 +105,7 @@ contract EnvelopePaymasterTest is Test { EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ serviceFee: 0, gaslessFee: 0.01 ether, + gaslessSponsored: false, deadline: 0, signature: _signFeeAuthorization(request, 0, 0.01 ether, 0) }); From 3331036ffaccc571e8cac5296bfdb1a21eba41d5 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 20 May 2026 16:50:50 +1200 Subject: [PATCH 42/49] refactor(envelope): flatten directory structure, strip Peanut ASCII header Move EnvelopeVault.sol from V4/ and IEnvelopeGaslessValidator.sol from util/ directly into src/envelope/. Remove the large Peanut Protocol ASCII art header; retain one-line GPL attribution as legally required. Prior work acknowledgment: the core deposit/claim/signature scheme in EnvelopeVault.sol originated from peanutprotocol/peanut-contracts V4.4 by Squirrel Labs (GPL-3.0-or-later). The GPL license and SPDX identifier are preserved. --- .solhintignore | 9 +++- hardhat-deploy/DeployEnvelope.ts | 28 ++++++---- src/envelope/{V4 => }/EnvelopeVault.sol | 36 +------------ .../{util => }/IEnvelopeGaslessValidator.sol | 0 src/envelope/{V4 => }/LICENSE-GPL | 0 src/envelope/doc/EnvelopeVault.md | 54 +++++++++---------- src/envelope/doc/README.md | 28 +++++----- src/paymasters/EnvelopePaymaster.sol | 2 +- test/envelope/Deposit.t.sol | 2 +- test/envelope/EnvelopeBatching.t.sol | 2 +- test/envelope/EnvelopeEdgeCases.t.sol | 2 +- test/envelope/EnvelopeHardening.t.sol | 2 +- test/envelope/EnvelopeVault.t.sol | 2 +- test/envelope/Gasless.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 | 9 ++-- test/paymasters/EnvelopePaymaster.t.sol | 2 +- 20 files changed, 86 insertions(+), 102 deletions(-) rename src/envelope/{V4 => }/EnvelopeVault.sol (95%) rename src/envelope/{util => }/IEnvelopeGaslessValidator.sol (100%) rename src/envelope/{V4 => }/LICENSE-GPL (100%) diff --git a/.solhintignore b/.solhintignore index 2b0b0f55..c66157b9 100644 --- a/.solhintignore +++ b/.solhintignore @@ -1,8 +1,15 @@ # 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. + # + # Our own code (EnvelopeApprovalPaymaster, anything authored in this repo) + # is NOT in this list and remains lint-clean. -src/envelope/V4/EnvelopeVault.sol + +src/envelope/EnvelopeVault.sol diff --git a/hardhat-deploy/DeployEnvelope.ts b/hardhat-deploy/DeployEnvelope.ts index c360a709..c6643ca4 100644 --- a/hardhat-deploy/DeployEnvelope.ts +++ b/hardhat-deploy/DeployEnvelope.ts @@ -41,9 +41,11 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { const mfaAuthorizer = process.env.ENVELOPE_MFA_AUTHORIZER ?? ZERO; const envelopeOwner = process.env.ENVELOPE_OWNER ?? wallet.address; const feeToken = process.env.ENVELOPE_FEE_TOKEN ?? ZERO; - const deployPaymaster = (process.env.ENVELOPE_DEPLOY_PAYMASTER ?? "false").toLowerCase() === "true"; + const deployPaymaster = + (process.env.ENVELOPE_DEPLOY_PAYMASTER ?? "false").toLowerCase() === "true"; const paymasterAdmin = process.env.ENVELOPE_PAYMASTER_ADMIN ?? wallet.address; - const paymasterWithdrawer = process.env.ENVELOPE_PAYMASTER_WITHDRAWER ?? wallet.address; + const paymasterWithdrawer = + process.env.ENVELOPE_PAYMASTER_WITHDRAWER ?? wallet.address; console.log("=== Deploying Envelope on ZkSync ==="); console.log("Network: ", hre.network.name); @@ -55,17 +57,21 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); // 1. Vault — required. - const vault = await deployContract(deployer, "EnvelopeVault", [mfaAuthorizer, envelopeOwner, feeToken]); + const vault = await deployContract(deployer, "EnvelopeVault", [ + mfaAuthorizer, + envelopeOwner, + feeToken, + ]); const vaultAddr = await vault.getAddress(); // 2. Paymaster — optional. Must be funded with ETH after deployment. let paymasterAddr: string | undefined; if (deployPaymaster) { - const envelopePaymaster = await deployContract(deployer, "EnvelopePaymaster", [ - paymasterAdmin, - paymasterWithdrawer, - vaultAddr, - ]); + const envelopePaymaster = await deployContract( + deployer, + "EnvelopePaymaster", + [paymasterAdmin, paymasterWithdrawer, vaultAddr], + ); paymasterAddr = await envelopePaymaster.getAddress(); } @@ -81,7 +87,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log("Verifying EnvelopeVault..."); await hre.run("verify:verify", { address: vaultAddr, - contract: "src/envelope/V4/EnvelopeVault.sol:EnvelopeVault", + contract: "src/envelope/EnvelopeVault.sol:EnvelopeVault", constructorArguments: [mfaAuthorizer, envelopeOwner, feeToken], }); } catch (e: any) { @@ -108,6 +114,8 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { if (mfaAuthorizer === ZERO) { console.log(""); - console.log("NOTE: ENVELOPE_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/envelope/V4/EnvelopeVault.sol b/src/envelope/EnvelopeVault.sol similarity index 95% rename from src/envelope/V4/EnvelopeVault.sol rename to src/envelope/EnvelopeVault.sol index 3477d981..f674d12f 100644 --- a/src/envelope/V4/EnvelopeVault.sol +++ b/src/envelope/EnvelopeVault.sol @@ -1,40 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// -// Modified by Nodle (2026-05-12) — see src/envelope/doc/EnvelopeVault.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. +// Originally derived from peanutprotocol/peanut-contracts (V4.4). +// Full GPL v3 text: src/envelope/LICENSE-GPL pragma solidity ^0.8.26; -////////////////////////////////////////////////////////////////////////////////////// -// @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 {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; diff --git a/src/envelope/util/IEnvelopeGaslessValidator.sol b/src/envelope/IEnvelopeGaslessValidator.sol similarity index 100% rename from src/envelope/util/IEnvelopeGaslessValidator.sol rename to src/envelope/IEnvelopeGaslessValidator.sol diff --git a/src/envelope/V4/LICENSE-GPL b/src/envelope/LICENSE-GPL similarity index 100% rename from src/envelope/V4/LICENSE-GPL rename to src/envelope/LICENSE-GPL diff --git a/src/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md index 37b749a4..6e605b51 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -1,6 +1,6 @@ # EnvelopeVault -`src/envelope/V4/EnvelopeVault.sol` +`src/envelope/EnvelopeVault.sol` ## Purpose @@ -10,14 +10,14 @@ The core product actors are: -| Actor | Role | -| ----- | ---- | -| Sender / Nina | Creates the gift link and funds the envelope. In the app, Nina signs one ZkSync smart-account batch instead of separate approval and deposit transactions. | -| Backend / Atlas | Prices the service and gasless portions, signs `FeeAuthorization`, and optionally signs MFA approvals at claim time. Atlas can also sponsor gasless eligibility with `gaslessSponsored=true`. | -| Receiver / Remy | Opens the link and claims the gift. Remy may be explicitly recipient-bound, or the link may be open to whoever has the link key. | -| App Wallet | A ZkSync smart account controlled by the app. It batches approvals plus the vault call into one user confirmation. | -| EnvelopeVault | Custodies gifts, validates backend fee authorization, stores gasless eligibility, and executes claims/reclaims. | -| EnvelopePaymaster | Pays ZkSync claim/reclaim gas only when `EnvelopeVault.isValidGaslessOperation` approves the calldata. | +| Actor | Role | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Sender / Nina | Creates the gift link and funds the envelope. In the app, Nina signs one ZkSync smart-account batch instead of separate approval and deposit transactions. | +| Backend / Atlas | Prices the service and gasless portions, signs `FeeAuthorization`, and optionally signs MFA approvals at claim time. Atlas can also sponsor gasless eligibility with `gaslessSponsored=true`. | +| Receiver / Remy | Opens the link and claims the gift. Remy may be explicitly recipient-bound, or the link may be open to whoever has the link key. | +| App Wallet | A ZkSync smart account controlled by the app. It batches approvals plus the vault call into one user confirmation. | +| EnvelopeVault | Custodies gifts, validates backend fee authorization, stores gasless eligibility, and executes claims/reclaims. | +| EnvelopePaymaster | Pays ZkSync claim/reclaim gas only when `EnvelopeVault.isValidGaslessOperation` approves the calldata. | ```mermaid flowchart LR @@ -226,20 +226,20 @@ struct Deposit { ## Main Deposit Functions -| Function | Flow | -| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `makeDeposit(token, type, amount, tokenId, pubKey20)` | Basic open link. No MFA, no fees, no gasless sponsorship. | -| `makeMFADeposit(...)` | Basic open link that requires backend MFA at claim time. No deposit-time fees unless using `makeCustomDepositWithFees`. | -| `makeSelflessDeposit(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | -| `makeSelflessMFADeposit(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | -| `makeCustomDeposit(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | +| Function | Flow | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `makeDeposit(token, type, amount, tokenId, pubKey20)` | Basic open link. No MFA, no fees, no gasless sponsorship. | +| `makeMFADeposit(...)` | Basic open link that requires backend MFA at claim time. No deposit-time fees unless using `makeCustomDepositWithFees`. | +| `makeSelflessDeposit(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | +| `makeSelflessMFADeposit(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | +| `makeCustomDeposit(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | | `makeCustomDepositWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0` or `gaslessSponsored=true`. | -| `makeBatchDeposit(...)` | Creates many same-shape no-fee deposits in one transaction. ETH, ERC-20, and ERC-1155 are supported; ERC-721 uses the heterogeneous batch path. | -| `makeBatchDepositNoReturn(...)` | Same as `makeBatchDeposit` but skips allocating/returning the deposit indexes array. | -| `makeBatchCustomDeposit(...)` | Creates a heterogeneous no-fee batch and supports ETH, ERC-20, ERC-721, and ERC-1155. | -| `makeBatchCustomDepositWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `DepositRequest` and `FeeAuthorization` structs as the single-deposit flow. | -| `makeBatchDepositRaffle(...)` | Creates ETH or ERC-20 raffle-style deposits with different amounts and one shared `pubKey20`. | -| `makeBatchMFADepositRaffle(...)` | Same as raffle batching, but every deposit requires MFA at claim time. | +| `makeBatchDeposit(...)` | Creates many same-shape no-fee deposits in one transaction. ETH, ERC-20, and ERC-1155 are supported; ERC-721 uses the heterogeneous batch path. | +| `makeBatchDepositNoReturn(...)` | Same as `makeBatchDeposit` but skips allocating/returning the deposit indexes array. | +| `makeBatchCustomDeposit(...)` | Creates a heterogeneous no-fee batch and supports ETH, ERC-20, ERC-721, and ERC-1155. | +| `makeBatchCustomDepositWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `DepositRequest` and `FeeAuthorization` structs as the single-deposit flow. | +| `makeBatchDepositRaffle(...)` | Creates ETH or ERC-20 raffle-style deposits with different amounts and one shared `pubKey20`. | +| `makeBatchMFADepositRaffle(...)` | Same as raffle batching, but every deposit requires MFA at claim time. | ```solidity struct FeeAuthorization { @@ -300,11 +300,11 @@ For claim calls, `caller` must be the recipient. For reclaim calls, `caller` mus ## Fees -| Fee / Flag | Collected | Meaning | -| --------------------- | ---------------- | ----------------------------------------------------------------------------------------- | -| `serviceFee` | Deposit creation | Paid backend service fee for optional security/MFA/compliance checks. | -| `gaslessFee` | Deposit creation | Prepaid NODL compensation for paymaster-sponsored claim or reclaim. | -| `gaslessSponsored` | Not collected | Backend-approved paymaster eligibility without collecting a sender-side gasless fee. | +| Fee / Flag | Collected | Meaning | +| ------------------ | ---------------- | ------------------------------------------------------------------------------------ | +| `serviceFee` | Deposit creation | Paid backend service fee for optional security/MFA/compliance checks. | +| `gaslessFee` | Deposit creation | Prepaid NODL compensation for paymaster-sponsored claim or reclaim. | +| `gaslessSponsored` | Not collected | Backend-approved paymaster eligibility without collecting a sender-side gasless fee. | Fees and sponsorship are backend-priced off-chain and backend-signed on-chain. The vault does not encode pricing policy; it enforces the signed amounts, sponsored eligibility flag, and deadline. The owner withdraws accumulated fees through `withdrawFees(token)`. diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 4f477f7a..8dd4241c 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -6,14 +6,14 @@ The Envelope flow on Nodle is built on top of modified Peanut Protocol V4.4 cont | Contract | Source | Spec | | ------------------- | -------------------------------------- | ---------------------------------------------- | -| `EnvelopeVault` | `src/envelope/V4/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | +| `EnvelopeVault` | `src/envelope/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | | `EnvelopePaymaster` | `src/paymasters/EnvelopePaymaster.sol` | [EnvelopePaymaster.md](./EnvelopePaymaster.md) | Interfaces: | Interface | Source | Used by | | --------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `IEnvelopeGaslessValidator` | `src/envelope/util/IEnvelopeGaslessValidator.sol` | `EnvelopePaymaster` queries `EnvelopeVault.isValidGaslessOperation` before sponsoring gas. | +| `IEnvelopeGaslessValidator` | `src/envelope/IEnvelopeGaslessValidator.sol` | `EnvelopePaymaster` queries `EnvelopeVault.isValidGaslessOperation` before sponsoring gas. | ## License notice @@ -21,8 +21,8 @@ This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) does not apply | Files | License | Notes | | ------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/envelope/V4/EnvelopeVault.sol` | **GPL-3.0-or-later** | Modified copy of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/V4/LICENSE-GPL`. The file carries a top-of-file modification notice per GPL §5(a). | -| `src/envelope/util/IEnvelopeGaslessValidator.sol` | **GPL-3.0-or-later** | Minimal interface for the GPL vault validation surface. | +| `src/envelope/EnvelopeVault.sol` | **GPL-3.0-or-later** | Modified copy of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/LICENSE-GPL`. | +| `src/envelope/IEnvelopeGaslessValidator.sol` | **GPL-3.0-or-later** | Minimal interface for the GPL vault validation surface. | | `test/envelope/**/*.t.sol` | **GPL-3.0-or-later** | Test files that import GPL-licensed contracts are relicensed for compatibility. | | `test/envelope/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained. | | All other repo files | unchanged | Whatever they were. | @@ -31,22 +31,22 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s ## Naming convention -- **Source files** carry the Envelope brand (`EnvelopeVault.sol`); upstream audit lineage is preserved via the `// Modified by Nodle` notice, `// @author Squirrel Labs` attribution, bundled `LICENSE-GPL`, and git history. +- **Source files** carry the Envelope brand (`EnvelopeVault.sol`); upstream lineage is preserved via a one-line attribution comment, bundled `LICENSE-GPL`, and git history. - **Contract symbols** use the Envelope brand: `EnvelopeVault`, `EnvelopePaymaster`. - **On-chain hashed constants** keep upstream-compatible values where changing them would alter signature digests. ## Main flows -| Flow | Entry point | Summary | -| ----------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| Basic deposit | `EnvelopeVault.makeDeposit` / `makeCustomDeposit` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | +| Flow | Entry point | Summary | +| ----------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Basic deposit | `EnvelopeVault.makeDeposit` / `makeCustomDeposit` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | | Paid or gasless-ready deposit | `EnvelopeVault.makeCustomDepositWithFees` | Sender supplies a backend-signed `FeeAuthorization`; the vault collects `serviceFee` and/or `gaslessFee` in `feeToken` and records optional `gaslessSponsored` eligibility. | -| Batch deposit | `EnvelopeVault.makeBatchDeposit` / `makeBatchCustomDeposit` / `makeBatchCustomDepositWithFees` | Sender creates many deposits in one transaction without a separate batcher contract. Fee signatures are signed for the actual caller. | -| Open claim | `EnvelopeVault.withdrawDeposit` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | -| MFA claim | `EnvelopeVault.withdrawMFADeposit` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | -| Recipient-bound claim | `EnvelopeVault.withdrawDepositAsRecipient` | Only the bound recipient can submit the transaction. | -| Sender reclaim | `EnvelopeVault.withdrawDepositSender` | Original sender reclaims unclaimed deposits; recipient-bound deposits also enforce `reclaimableAfter`. | -| Gasless validation | `EnvelopeVault.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid or backend-sponsored claim/reclaim calldata before the paymaster pays gas. | +| Batch deposit | `EnvelopeVault.makeBatchDeposit` / `makeBatchCustomDeposit` / `makeBatchCustomDepositWithFees` | Sender creates many deposits in one transaction without a separate batcher contract. Fee signatures are signed for the actual caller. | +| Open claim | `EnvelopeVault.withdrawDeposit` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | +| MFA claim | `EnvelopeVault.withdrawMFADeposit` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | +| Recipient-bound claim | `EnvelopeVault.withdrawDepositAsRecipient` | Only the bound recipient can submit the transaction. | +| Sender reclaim | `EnvelopeVault.withdrawDepositSender` | Original sender reclaims unclaimed deposits; recipient-bound deposits also enforce `reclaimableAfter`. | +| Gasless validation | `EnvelopeVault.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid or backend-sponsored claim/reclaim calldata before the paymaster pays gas. | ## ZkSync gasless model diff --git a/src/paymasters/EnvelopePaymaster.sol b/src/paymasters/EnvelopePaymaster.sol index 772a43f9..0b94d376 100644 --- a/src/paymasters/EnvelopePaymaster.sol +++ b/src/paymasters/EnvelopePaymaster.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.26; import {BasePaymaster} from "./BasePaymaster.sol"; -import {IEnvelopeGaslessValidator} from "../envelope/util/IEnvelopeGaslessValidator.sol"; +import {IEnvelopeGaslessValidator} from "../envelope/IEnvelopeGaslessValidator.sol"; /// @notice ZkSync paymaster that sponsors eligible gasless EnvelopeVault claims and reclaims. /// @dev The EnvelopeVault remains the source of truth for whether a call is valid and prepaid or sponsored. diff --git a/test/envelope/Deposit.t.sol b/test/envelope/Deposit.t.sol index be726403..81262e4c 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/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/EnvelopeBatching.t.sol b/test/envelope/EnvelopeBatching.t.sol index f57c9179..a80deb96 100644 --- a/test/envelope/EnvelopeBatching.t.sol +++ b/test/envelope/EnvelopeBatching.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import {EnvelopeVault} from "../../src/envelope/V4/EnvelopeVault.sol"; +import {EnvelopeVault} from "../../src/envelope/EnvelopeVault.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 4199d349..2e9d45de 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -6,7 +6,7 @@ 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/EnvelopeVault.sol"; +import {EnvelopeVault} from "../../src/envelope/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/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index 0ec7fa60..ed0095a1 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -7,7 +7,7 @@ pragma solidity 0.8.26; // T2 — mfaAuthorizer is now a per-deploy constructor arg (fix for S3 hardcoded key) import {Test} from "forge-std/Test.sol"; -import {EnvelopeVault} from "../../src/envelope/V4/EnvelopeVault.sol"; +import {EnvelopeVault} from "../../src/envelope/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 1dda3597..ad19614c 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/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; diff --git a/test/envelope/Gasless.t.sol b/test/envelope/Gasless.t.sol index 5791e5fd..b1328e59 100644 --- a/test/envelope/Gasless.t.sol +++ b/test/envelope/Gasless.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/envelope/V4/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; contract EnvelopeVaultGaslessTest is Test { diff --git a/test/envelope/Integration.t.sol b/test/envelope/Integration.t.sol index c45cee9a..d047b389 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/EnvelopeVault.sol"; +import "../../src/envelope/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 223cafbe..3c2b0c9f 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/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeVault.sol"; contract EnvelopeVaultMFATest is Test { EnvelopeVault public vault; diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index e1170912..7d2d1618 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/EnvelopeVault.sol"; +import "../../src/envelope/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 bb884548..78aa74ff 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/EnvelopeVault.sol"; +import "../../src/envelope/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 77cd7266..35035d4a 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/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeVault.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; @@ -18,7 +18,8 @@ contract TestSigWithdrawEther is Test { address _recipientAddress = 0x6B3751c5b04Aa818EA90115AA06a4D9A36A16f02; bytes public signatureAnybody = hex"02a37d0548c14c6b07eba4ef1438eb946cdada4f481164755129eb3725f7e8c13d7c052308e73314338f4d484a5f4aef20c7519a1dbc283e4826253b742817241c"; - bytes public signatureRecipient = hex"364c17bca8823977b29b7646c954353996f363549f08ce3943969171c050f0d74006eabb597df680e9e4229631f473bfbedf995336a03d2fd3be7f1fff22d2511b"; + bytes public signatureRecipient = + hex"364c17bca8823977b29b7646c954353996f363549f08ce3943969171c050f0d74006eabb597df680e9e4229631f473bfbedf995336a03d2fd3be7f1fff22d2511b"; receive() external payable {} // necessary to receive ether @@ -47,12 +48,12 @@ contract TestSigWithdrawEther is Test { // Can't use pure withdrawDeposit vm.expectRevert(EnvelopeVault.WrongSignature.selector); vault.withdrawDeposit(depositIdx, _recipientAddress, signatureRecipient); - + // Only the recipient is able to withdraw via withdrawDepositAsRecipient vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); - vm.prank(_recipientAddress); // Withdraw! + vm.prank(_recipientAddress); // Withdraw! vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); } } diff --git a/test/paymasters/EnvelopePaymaster.t.sol b/test/paymasters/EnvelopePaymaster.t.sol index cfde1aeb..c6eb7f4d 100644 --- a/test/paymasters/EnvelopePaymaster.t.sol +++ b/test/paymasters/EnvelopePaymaster.t.sol @@ -7,7 +7,7 @@ import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interface import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "../../src/paymasters/BasePaymaster.sol"; import {EnvelopePaymaster} from "../../src/paymasters/EnvelopePaymaster.sol"; -import {EnvelopeVault} from "../../src/envelope/V4/EnvelopeVault.sol"; +import {EnvelopeVault} from "../../src/envelope/EnvelopeVault.sol"; import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; From 5c08dc825c71821634eb0e0efcbd28ca94a994f7 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 20 May 2026 17:12:55 +1200 Subject: [PATCH 43/49] =?UTF-8?q?refactor(envelope):=20rename=20API=20for?= =?UTF-8?q?=20clarity=20=E2=80=94=20deposit=E2=86=92link,=20withdraw?= =?UTF-8?q?=E2=86=92claim/reclaim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive rename of EnvelopeVault's public surface to make the contract's mental model explicit for AI agents and human readers: Structs: Deposit → Link, DepositRequest → LinkRequest Fields: pubKey20 → claimKey, claimed → redeemed, senderAddress → creator Constants: ANYONE_WITHDRAWAL_MODE → OPEN_CLAIM_MODE RECIPIENT_WITHDRAWAL_MODE → BOUND_CLAIM_MODE Link creation (formerly 'deposit'): makeDeposit → createLink makeMFADeposit → createMFALink makeSelflessDeposit → createLinkFor makeSelflessMFADeposit → createMFALinkFor makeCustomDeposit → createCustomLink makeCustomDepositWithFees → createLinkWithFees makeBatch* → createLinks / createCustomLinks / createRaffleLinks etc. Claiming (formerly 'withdraw'): withdrawDeposit → claim withdrawMFADeposit → claimWithMFA withdrawDepositAsRecipient → claimAsBoundRecipient withdrawDepositSender → reclaim Views: getDepositCount → getLinkCount getDeposit → getLink getAllDeposits → getAllLinks getAllDepositsForAddress → getLinksCreatedBy Errors: DepositIndexOutOfBounds → LinkIndexOutOfBounds DepositAlreadyClaimed → LinkAlreadyRedeemed NotTheSender → NotTheCreator Events: DepositEvent → LinkCreated WithdrawEvent → LinkRedeemed All 207 tests pass. --- hardhat-deploy/DeployEnvelope.ts | 4 +- src/envelope/EnvelopeVault.sol | 416 ++++++++++++------------ src/envelope/doc/EnvelopePaymaster.md | 6 +- src/envelope/doc/EnvelopeVault.md | 95 +++--- src/envelope/doc/README.md | 22 +- test/envelope/Deposit.t.sol | 8 +- test/envelope/EnvelopeBatching.t.sol | 108 +++--- test/envelope/EnvelopeEdgeCases.t.sol | 78 ++--- test/envelope/EnvelopeHardening.t.sol | 10 +- test/envelope/EnvelopeVault.t.sol | 22 +- test/envelope/Gasless.t.sol | 98 +++--- test/envelope/Integration.t.sol | 36 +- test/envelope/MFA.t.sol | 18 +- test/envelope/RecipientBound.t.sol | 12 +- test/envelope/SenderWithdraw.t.sol | 16 +- test/envelope/SigWithdraw.t.sol | 14 +- test/paymasters/EnvelopePaymaster.t.sol | 28 +- 17 files changed, 499 insertions(+), 492 deletions(-) diff --git a/hardhat-deploy/DeployEnvelope.ts b/hardhat-deploy/DeployEnvelope.ts index c6643ca4..2143603f 100644 --- a/hardhat-deploy/DeployEnvelope.ts +++ b/hardhat-deploy/DeployEnvelope.ts @@ -16,7 +16,7 @@ dotenv.config({ path: ".env-test" }); * * Optional environment variables: * - ENVELOPE_MFA_AUTHORIZER: Address authorized to sign MFA withdraw approvals. - * Defaults to 0x0 (MFA disabled — withdrawMFADeposit reverts). + * Defaults to 0x0 (MFA disabled — claimWithMFA reverts). * Set to your backend signer for production MFA/fee authorizations. * - ENVELOPE_OWNER: Owner/fee withdrawer. Defaults to deployer. * - ENVELOPE_FEE_TOKEN: ERC20 token used for service/gasless fees (e.g. NODL). @@ -115,7 +115,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { if (mfaAuthorizer === ZERO) { console.log(""); console.log( - "NOTE: ENVELOPE_MFA_AUTHORIZER is 0x0 — withdrawMFADeposit will always revert. Set it before allowing MFA-flagged deposits in production.", + "NOTE: ENVELOPE_MFA_AUTHORIZER is 0x0 — claimWithMFA will always revert. Set it before allowing MFA-flagged links in production.", ); } }; diff --git a/src/envelope/EnvelopeVault.sol b/src/envelope/EnvelopeVault.sol index f674d12f..9b38df12 100644 --- a/src/envelope/EnvelopeVault.sol +++ b/src/envelope/EnvelopeVault.sol @@ -23,14 +23,14 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow error InvalidContractType(); error WrongEthAmount(); error Erc721AmountMustBeOne(); - error DepositIndexOutOfBounds(); - error DepositAlreadyClaimed(); + error LinkIndexOutOfBounds(); + error LinkAlreadyRedeemed(); error RequiresMfaAuthorization(); error WrongMfaSignature(); error WrongSignature(); error WrongRecipient(); error NotTheRecipient(); - error NotTheSender(); + error NotTheCreator(); error TooEarlyToReclaim(); error EthTransferFailed(); error DirectTransfersNotAllowed(); @@ -41,46 +41,46 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow error FeeTokenNotConfigured(); error ParametersLengthMismatch(); error InvalidTotalEtherSent(); - error EthNotAcceptedForNonEthDeposit(); + error EthNotAcceptedForNonEthLink(); error Erc721BatchNotSupported(); error UnsupportedRaffleContractType(); // ── Data Structures ────────────────────────────────────────────────────────── - struct Deposit { - address pubKey20; // (20 bytes) last 20 bytes of the hash of the public key for the deposit + struct Link { + address claimKey; // (20 bytes) address derived from the link claim private key 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 - bool claimed; // (1 byte) has this deposit been claimed + bool redeemed; // (1 byte) has this link been redeemed bool requiresMFA; // (1 byte) is additional auth (MFA) required? - bool gaslessSponsored; // (1 byte) can the paymaster sponsor this deposit without a prepaid gasless fee? - uint40 timestamp; // ( 5 bytes) timestamp of the deposit + bool gaslessSponsored; // (1 byte) can the paymaster sponsor this link without a prepaid gasless fee? + uint40 timestamp; // ( 5 bytes) timestamp of the link creation ///// uint256 tokenId; // (32 bytes) id of the token being sent (if erc721 or erc1155) - address senderAddress; // (20 bytes) address of the sender + address creator; // (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 - uint256 serviceFee; // backend-authorized service fee collected at deposit time - uint256 gaslessFee; // prepaid gas sponsorship fee collected at deposit time + uint256 serviceFee; // backend-authorized service fee collected at link creation + uint256 gaslessFee; // prepaid gas sponsorship fee collected at link creation } // 8 storage slots (32 byte each) - /// @notice Full deposit intent covered by a backend fee authorization. - struct DepositRequest { + /// @notice Full link intent covered by a backend fee authorization. + struct LinkRequest { address tokenAddress; uint8 contractType; uint256 amount; uint256 tokenId; - address pubKey20; + address claimKey; address onBehalfOf; bool withMFA; address recipient; uint40 reclaimableAfter; } - /// @notice Backend-signed fee bundle collected when a deposit is created. + /// @notice Backend-signed fee bundle collected when a link is created. /// @dev deadline == 0 means no expiry. Non-zero fees or sponsored gasless eligibility require /// `signature` from `mfaAuthorizer`. Zero-fee authorizations with a non-empty signature are verified too. struct FeeAuthorization { @@ -95,8 +95,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow // that the message signed by the user has effects only in peanut contracts. 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 = + bytes32 public constant OPEN_CLAIM_MODE = 0x0000000000000000000000000000000000000000000000000000000000000000; // default. Any address can trigger the withdrawal function + bytes32 public constant BOUND_CLAIM_MODE = 0x2bb5bef2b248d3edba501ad918c3ab524cce2aea54d4c914414e1c4401dc4ff4; // keccak256("only recipient") - only the signed recipient can trigger the withdrawal function bytes32 public DOMAIN_SEPARATOR; // initialized in the constructor @@ -104,8 +104,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes32 public constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); - /// @notice Address authorized to issue MFA signatures gating withdrawMFADeposit calls. - /// @dev Configurable per deployment. Address(0) disables MFA — withdrawMFADeposit will revert. + /// @notice Address authorized to issue MFA signatures gating claimWithMFA calls. + /// @dev Configurable per deployment. Address(0) disables MFA — claimWithMFA will revert. address public immutable mfaAuthorizer; struct EIP712Domain { @@ -115,19 +115,19 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address verifyingContract; } - Deposit[] public deposits; // array of deposits + Link[] public links; // array of links /// @notice ERC-20 token used for Envelope service and gasless sponsorship fees (for example NODL). IERC20 public immutable feeToken; - /// @notice Accumulated fees per token address (address(0) for ETH; feeToken for deposit-time fees). + /// @notice Accumulated fees per token address (address(0) for ETH; feeToken for link-creation fees). mapping(address => uint256) public accumulatedFees; // events - event DepositEvent( - uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _senderAddress + event LinkCreated( + uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _creator ); - event WithdrawEvent( + event LinkRedeemed( uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _recipientAddress ); event FeeCollected(uint256 indexed _index, address indexed tokenAddress, uint256 serviceFee, uint256 gaslessFee); @@ -168,60 +168,60 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } // ══════════════════════════════════════════════════════════════════════════════ - // Deposit Functions + // Link Creation Functions // ══════════════════════════════════════════════════════════════════════════════ - function makeDeposit( + function createLink( address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, - address _pubKey20 + address claimKey ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); - return _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, false, address(0), 0, 0, 0, false + return _storeLink( + _tokenAddress, _contractType, _amount, _tokenId, claimKey, msg.sender, false, address(0), 0, 0, 0, false ); } - function makeMFADeposit( + function createMFALink( address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, - address _pubKey20 + address claimKey ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); - return _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, msg.sender, true, address(0), 0, 0, 0, false + return _storeLink( + _tokenAddress, _contractType, _amount, _tokenId, claimKey, msg.sender, true, address(0), 0, 0, 0, false ); } - function makeSelflessMFADeposit( + function createMFALinkFor( address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, - address _pubKey20, + address claimKey, address _onBehalfOf ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); - return _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, true, address(0), 0, 0, 0, false + return _storeLink( + _tokenAddress, _contractType, _amount, _tokenId, claimKey, _onBehalfOf, true, address(0), 0, 0, 0, false ); } - function makeSelflessDeposit( + function createLinkFor( address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, - address _pubKey20, + address claimKey, address _onBehalfOf ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); - return _storeDeposit( - _tokenAddress, _contractType, _amount, _tokenId, _pubKey20, _onBehalfOf, false, address(0), 0, 0, 0, false + return _storeLink( + _tokenAddress, _contractType, _amount, _tokenId, claimKey, _onBehalfOf, false, address(0), 0, 0, 0, false ); } @@ -231,31 +231,31 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow * @param _contractType 0 for eth, 1 for erc20, 2 for erc721, 3 for erc1155 * @param _amount amount of tokens being sent * @param _tokenId id of the token being sent if erc721 or erc1155 - * @param _pubKey20 last 20 bytes of the public key of the deposit signer + * @param claimKey address derived from the link claim private key * @param _onBehalfOf who will be able to reclaim the link if the private key is lost * @param _withMFA whether an external authorisation is required for withdrawal * @param _recipient if not 0x00, only _recipient will be able to withdraw * @param _reclaimableAfter if _recipient is set, the sender can reclaim only after this timestamp * @return uint256 index of the deposit */ - function makeCustomDeposit( + function createCustomLink( address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, - address _pubKey20, + address claimKey, address _onBehalfOf, bool _withMFA, address _recipient, uint40 _reclaimableAfter ) public payable nonReentrant returns (uint256) { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); - return _storeDeposit( + return _storeLink( _tokenAddress, _contractType, _amount, _tokenId, - _pubKey20, + claimKey, _onBehalfOf, _withMFA, _recipient, @@ -267,13 +267,13 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } /** - * @notice Create a deposit and collect backend-authorized service/gasless fees up front. + * @notice Create a link and collect backend-authorized service/gasless fees up front. * @dev Non-zero fees are paid in `feeToken` by msg.sender. `gaslessFee > 0` or - * `gaslessSponsored == true` marks the deposit as eligible for EnvelopePaymaster-sponsored + * `gaslessSponsored == true` marks the link as eligible for EnvelopePaymaster-sponsored * claim or sender reclaim. The fee authorization is signed by `mfaAuthorizer` and includes * the full deposit intent plus a deadline. */ - function makeCustomDepositWithFees(DepositRequest calldata _request, FeeAuthorization calldata _feeAuthorization) + function createLinkWithFees(LinkRequest calldata _request, FeeAuthorization calldata _feeAuthorization) public payable nonReentrant @@ -283,15 +283,15 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256 amount = _pullTokensViaApproval(_request.tokenAddress, _request.contractType, _request.amount, _request.tokenId); - uint256 index = deposits.length; - _collectDepositFees(index, msg.sender, _feeAuthorization.serviceFee, _feeAuthorization.gaslessFee); + uint256 index = links.length; + _collectLinkFees(index, msg.sender, _feeAuthorization.serviceFee, _feeAuthorization.gaslessFee); - return _storeDeposit( + return _storeLink( _request.tokenAddress, _request.contractType, amount, _request.tokenId, - _request.pubKey20, + _request.claimKey, _request.onBehalfOf, _request.withMFA, _request.recipient, @@ -302,27 +302,27 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ); } - /// @notice Create many same-shape deposits in one transaction. + /// @notice Create many same-shape links in one transaction. /// @dev The caller remains the recorded sender for every deposit and keeps reclaim rights. /// ERC-721 is intentionally excluded here because each NFT needs a distinct tokenId. - function makeBatchDeposit( + function createLinks( address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, - address[] calldata _pubKeys20 + address[] calldata _claimKeys ) external payable nonReentrant returns (uint256[] memory) { - uint256 totalAmount = _amount * _pubKeys20.length; + uint256 totalAmount = _amount * _claimKeys.length; _pullUniformBatchAssets(msg.sender, _tokenAddress, _contractType, totalAmount, _tokenId); - uint256[] memory depositIndexes = new uint256[](_pubKeys20.length); - for (uint256 i = 0; i < _pubKeys20.length; ++i) { - depositIndexes[i] = _storeDeposit( + uint256[] memory linkIndexes = new uint256[](_claimKeys.length); + for (uint256 i = 0; i < _claimKeys.length; ++i) { + linkIndexes[i] = _storeLink( _tokenAddress, _contractType, _amount, _tokenId, - _pubKeys20[i], + _claimKeys[i], msg.sender, false, address(0), @@ -332,27 +332,27 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow false ); } - return depositIndexes; + return linkIndexes; } - /// @notice Same as makeBatchDeposit, but avoids allocating and returning the indexes array. - function makeBatchDepositNoReturn( + /// @notice Same as createLinks, but avoids allocating and returning the indexes array. + function createLinksNoReturn( address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, - address[] calldata _pubKeys20 + address[] calldata _claimKeys ) external payable nonReentrant { - uint256 totalAmount = _amount * _pubKeys20.length; + uint256 totalAmount = _amount * _claimKeys.length; _pullUniformBatchAssets(msg.sender, _tokenAddress, _contractType, totalAmount, _tokenId); - for (uint256 i = 0; i < _pubKeys20.length; ++i) { - _storeDeposit( + for (uint256 i = 0; i < _claimKeys.length; ++i) { + _storeLink( _tokenAddress, _contractType, _amount, _tokenId, - _pubKeys20[i], + _claimKeys[i], msg.sender, false, address(0), @@ -364,15 +364,15 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } } - /// @notice Create a heterogeneous batch of no-fee deposits. + /// @notice Create a heterogeneous batch of no-fee links. /// @dev Supports ETH, ERC-20, ERC-721, and ERC-1155. Recipient binding is intentionally - /// left to makeBatchCustomDepositWithFees via DepositRequest[]. - function makeBatchCustomDeposit( + /// left to createCustomLinksWithFees via LinkRequest[]. + function createCustomLinks( address[] calldata _tokenAddresses, uint8[] calldata _contractTypes, uint256[] calldata _amounts, uint256[] calldata _tokenIds, - address[] calldata _pubKeys20, + address[] calldata _claimKeys, bool[] calldata _withMFAs ) external payable nonReentrant returns (uint256[] memory) { _validateBatchArrayLengths( @@ -380,7 +380,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow _contractTypes.length, _amounts.length, _tokenIds.length, - _pubKeys20.length, + _claimKeys.length, _withMFAs.length ); @@ -391,7 +391,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } if (msg.value != expectedEther) revert InvalidTotalEtherSent(); - uint256[] memory depositIndexes = new uint256[](_amounts.length); + uint256[] memory linkIndexes = new uint256[](_amounts.length); for (uint256 i = 0; i < _amounts.length; ++i) { uint256 amount = _pullTokensViaApprovalFrom( msg.sender, @@ -401,12 +401,12 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow _tokenIds[i], _contractTypes[i] == 0 ? _amounts[i] : 0 ); - depositIndexes[i] = _storeDeposit( + linkIndexes[i] = _storeLink( _tokenAddresses[i], _contractTypes[i], amount, _tokenIds[i], - _pubKeys20[i], + _claimKeys[i], msg.sender, _withMFAs[i], address(0), @@ -416,13 +416,13 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow false ); } - return depositIndexes; + return linkIndexes; } - /// @notice Create a heterogeneous batch of deposits with backend-authorized fees. + /// @notice Create a heterogeneous batch of links with backend-authorized fees. /// @dev Fee authorizations are signed for the real caller because batching is vault-native. - function makeBatchCustomDepositWithFees( - DepositRequest[] calldata _requests, + function createCustomLinksWithFees( + LinkRequest[] calldata _requests, FeeAuthorization[] calldata _feeAuthorizations ) external payable nonReentrant returns (uint256[] memory) { if (_requests.length != _feeAuthorizations.length) { @@ -437,9 +437,9 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } if (msg.value != expectedEther) revert InvalidTotalEtherSent(); - uint256[] memory depositIndexes = new uint256[](_requests.length); + uint256[] memory linkIndexes = new uint256[](_requests.length); for (uint256 i = 0; i < _requests.length; ++i) { - DepositRequest calldata request = _requests[i]; + LinkRequest calldata request = _requests[i]; FeeAuthorization calldata feeAuthorization = _feeAuthorizations[i]; uint256 amount = _pullTokensViaApprovalFrom( msg.sender, @@ -450,14 +450,14 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow request.contractType == 0 ? request.amount : 0 ); - uint256 index = deposits.length; - _collectDepositFees(index, msg.sender, feeAuthorization.serviceFee, feeAuthorization.gaslessFee); - depositIndexes[i] = _storeDeposit( + uint256 index = links.length; + _collectLinkFees(index, msg.sender, feeAuthorization.serviceFee, feeAuthorization.gaslessFee); + linkIndexes[i] = _storeLink( request.tokenAddress, request.contractType, amount, request.tokenId, - request.pubKey20, + request.claimKey, request.onBehalfOf, request.withMFA, request.recipient, @@ -468,53 +468,53 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ); } - return depositIndexes; + return linkIndexes; } - /// @notice Create raffle-style ETH or ERC-20 deposits sharing one pubKey20 and different amounts. - function makeBatchDepositRaffle( + /// @notice Create raffle-style ETH or ERC-20 links sharing one claimKey and different amounts. + function createRaffleLinks( address _tokenAddress, uint8 _contractType, uint256[] calldata _amounts, - address _pubKey20 + address claimKey ) external payable nonReentrant returns (uint256[] memory) { - return _makeBatchDepositRaffle(_tokenAddress, _contractType, _amounts, _pubKey20, false); + return _createRaffleLinks(_tokenAddress, _contractType, _amounts, claimKey, false); } - /// @notice Create MFA-gated raffle-style ETH or ERC-20 deposits sharing one pubKey20. - function makeBatchMFADepositRaffle( + /// @notice Create MFA-gated raffle-style ETH or ERC-20 links sharing one claimKey. + function createMFARaffleLinks( address _tokenAddress, uint8 _contractType, uint256[] calldata _amounts, - address _pubKey20 + address claimKey ) external payable nonReentrant returns (uint256[] memory) { - return _makeBatchDepositRaffle(_tokenAddress, _contractType, _amounts, _pubKey20, true); + return _createRaffleLinks(_tokenAddress, _contractType, _amounts, claimKey, true); } // ══════════════════════════════════════════════════════════════════════════════ - // Withdrawal Functions + // Claim Functions // ══════════════════════════════════════════════════════════════════════════════ /** * @notice Withdraw tokens. Can be called by anyone with a valid signature. */ - function withdrawDeposit(uint256 _index, address _recipientAddress, bytes memory _signature) + function claim(uint256 _index, address _recipientAddress, bytes memory _signature) external nonReentrant returns (bool) { - return _withdrawDeposit(_index, _recipientAddress, ANYONE_WITHDRAWAL_MODE, _signature, false); + return _executeClaim(_index, _recipientAddress, OPEN_CLAIM_MODE, _signature, false); } /** * @notice Withdraw tokens with backend MFA approval. * @param _index deposit index * @param _recipientAddress address to receive the full deposit amount - * @param _signature withdrawal signature from the deposit's pubKey20 + * @param _signature withdrawal signature from the link's claimKey * @param _MFASignature backend signature authorizing this withdrawal * @param _deadline backend-provided signature deadline; 0 means no expiry */ - function withdrawMFADeposit( + function claimWithMFA( uint256 _index, address _recipientAddress, bytes memory _signature, @@ -522,30 +522,30 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256 _deadline ) external nonReentrant returns (bool) { _verifyMfaSignature(_index, _recipientAddress, _deadline, _MFASignature); - return _withdrawDeposit(_index, _recipientAddress, ANYONE_WITHDRAWAL_MODE, _signature, true); + return _executeClaim(_index, _recipientAddress, OPEN_CLAIM_MODE, _signature, true); } /** * @notice Withdraw tokens. Must be called by the recipient. */ - function withdrawDepositAsRecipient(uint256 _index, address _recipientAddress, bytes memory _signature) + function claimAsBoundRecipient(uint256 _index, address _recipientAddress, bytes memory _signature) external nonReentrant returns (bool) { if (_recipientAddress != msg.sender) revert NotTheRecipient(); - return _withdrawDeposit(_index, _recipientAddress, RECIPIENT_WITHDRAWAL_MODE, _signature, false); + return _executeClaim(_index, _recipientAddress, BOUND_CLAIM_MODE, _signature, false); } // ══════════════════════════════════════════════════════════════════════════════ - // Sender Reclaim Functions + // Creator Reclaim Functions // ══════════════════════════════════════════════════════════════════════════════ /** - * @notice Sender reclaims their deposit directly. + * @notice Creator reclaims their link directly. */ - function withdrawDepositSender(uint256 _index) external nonReentrant returns (bool) { - return _withdrawDepositSender(_index, msg.sender); + function reclaim(uint256 _index) external nonReentrant returns (bool) { + return _executeReclaim(_index, msg.sender); } // ══════════════════════════════════════════════════════════════════════════════ @@ -615,32 +615,32 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow /// @notice Returns whether `caller` can use an EnvelopePaymaster for the encoded vault call. /// @dev Intended for ZkSync paymaster validation. Re-checks claim/reclaim preconditions so the - /// paymaster only pays for prepaid gasless deposits that should execute successfully. + /// paymaster only pays for prepaid gasless links that should execute successfully. function isValidGaslessOperation(address caller, bytes calldata callData) external view returns (bool) { if (callData.length < 4) return false; bytes4 selector = bytes4(callData[0:4]); - if (selector == this.withdrawDeposit.selector) { + if (selector == this.claim.selector) { (uint256 index, address recipient, bytes memory signature) = abi.decode(callData[4:], (uint256, address, bytes)); - return _isValidGaslessClaim(caller, index, recipient, ANYONE_WITHDRAWAL_MODE, signature, false); + return _isValidGaslessClaim(caller, index, recipient, OPEN_CLAIM_MODE, signature, false); } - if (selector == this.withdrawDepositAsRecipient.selector) { + if (selector == this.claimAsBoundRecipient.selector) { (uint256 index, address recipient, bytes memory signature) = abi.decode(callData[4:], (uint256, address, bytes)); - return _isValidGaslessClaim(caller, index, recipient, RECIPIENT_WITHDRAWAL_MODE, signature, false); + return _isValidGaslessClaim(caller, index, recipient, BOUND_CLAIM_MODE, signature, false); } - if (selector == this.withdrawMFADeposit.selector) { + if (selector == this.claimWithMFA.selector) { (uint256 index, address recipient, bytes memory signature, bytes memory mfaSignature, uint256 deadline) = abi.decode(callData[4:], (uint256, address, bytes, bytes, uint256)); if (!_isMfaSignatureValid(index, recipient, deadline, mfaSignature)) return false; - return _isValidGaslessClaim(caller, index, recipient, ANYONE_WITHDRAWAL_MODE, signature, true); + return _isValidGaslessClaim(caller, index, recipient, OPEN_CLAIM_MODE, signature, true); } - if (selector == this.withdrawDepositSender.selector) { + if (selector == this.reclaim.selector) { (uint256 index) = abi.decode(callData[4:], (uint256)); return _isValidGaslessReclaim(caller, index); } @@ -648,34 +648,34 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow return false; } - function getDepositCount() external view returns (uint256) { - return deposits.length; + function getLinkCount() external view returns (uint256) { + return links.length; } - function getDeposit(uint256 _index) external view returns (Deposit memory) { - return deposits[_index]; + function getLink(uint256 _index) external view returns (Link memory) { + return links[_index]; } - function getAllDeposits() external view returns (Deposit[] memory) { - return deposits; + function getAllLinks() external view returns (Link[] memory) { + return links; } - function getAllDepositsForAddress(address _address) external view returns (Deposit[] memory) { + function getLinksCreatedBy(address _address) external view returns (Link[] memory) { uint256 count = 0; - for (uint256 i = 0; i < deposits.length; ++i) { - if (deposits[i].senderAddress == _address) { + for (uint256 i = 0; i < links.length; ++i) { + if (links[i].creator == _address) { count++; } } - Deposit[] memory _deposits = new Deposit[](count); + Link[] memory result = new Link[](count); count = 0; - for (uint256 i = 0; i < deposits.length; ++i) { - if (deposits[i].senderAddress == _address) { - _deposits[count] = deposits[i]; + for (uint256 i = 0; i < links.length; ++i) { + if (links[i].creator == _address) { + result[count] = links[i]; count++; } } - return _deposits; + return result; } // ══════════════════════════════════════════════════════════════════════════════ @@ -715,7 +715,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow return _recoverSigner(digest, _signature) == mfaAuthorizer; } - function _verifyFeeAuthorization(DepositRequest calldata _request, FeeAuthorization calldata _feeAuthorization) + function _verifyFeeAuthorization(LinkRequest calldata _request, FeeAuthorization calldata _feeAuthorization) internal view { @@ -737,7 +737,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow _request.contractType, _request.amount, _request.tokenId, - _request.pubKey20, + _request.claimKey, _request.onBehalfOf, _request.withMFA, _request.recipient, @@ -753,7 +753,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow if (authorizationSigner != mfaAuthorizer) revert WrongFeeAuthorizationSignature(); } - function _collectDepositFees(uint256 _index, address _feePayer, uint256 _serviceFee, uint256 _gaslessFee) internal { + function _collectLinkFees(uint256 _index, address _feePayer, uint256 _serviceFee, uint256 _gaslessFee) internal { uint256 totalFee = _serviceFee + _gaslessFee; if (totalFee > 0) { address tokenAddress = address(feeToken); @@ -763,12 +763,12 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } } - function _storeDeposit( + function _storeLink( address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, - address _pubKey20, + address claimKey, address _onBehalfOf, bool _requiresMFA, address _recipient, @@ -777,15 +777,15 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256 _gaslessFee, bool _gaslessSponsored ) internal returns (uint256) { - deposits.push( - Deposit({ + links.push( + Link({ tokenAddress: _tokenAddress, contractType: _contractType, amount: _amount, tokenId: _tokenId, - claimed: false, - pubKey20: _pubKey20, - senderAddress: _onBehalfOf, + redeemed: false, + claimKey: claimKey, + creator: _onBehalfOf, timestamp: uint40(block.timestamp), requiresMFA: _requiresMFA, gaslessSponsored: _gaslessSponsored, @@ -795,8 +795,8 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow gaslessFee: _gaslessFee }) ); - emit DepositEvent(deposits.length - 1, _contractType, _amount, _onBehalfOf); - return deposits.length - 1; + emit LinkCreated(links.length - 1, _contractType, _amount, _onBehalfOf); + return links.length - 1; } function _isValidGaslessClaim( @@ -808,42 +808,42 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bool _authorized ) internal view returns (bool) { if (_caller != _recipientAddress) return false; - if (_index >= deposits.length) return false; - Deposit storage deposit = deposits[_index]; - if (deposit.gaslessFee == 0 && !deposit.gaslessSponsored) return false; - return _isValidWithdrawal(_index, _recipientAddress, _extraData, _signature, _authorized); + if (_index >= links.length) return false; + Link storage link = links[_index]; + if (link.gaslessFee == 0 && !link.gaslessSponsored) return false; + return _isValidClaim(_index, _recipientAddress, _extraData, _signature, _authorized); } - function _isValidWithdrawal( + function _isValidClaim( uint256 _index, address _recipientAddress, bytes32 _extraData, bytes memory _signature, bool _authorized ) internal view returns (bool) { - Deposit memory deposit = deposits[_index]; - if (deposit.claimed) return false; + Link memory deposit = links[_index]; + if (deposit.redeemed) return false; if (deposit.requiresMFA && !_authorized) return false; if (deposit.recipient != address(0) && _recipientAddress != deposit.recipient) return false; - if (deposit.pubKey20 != address(0)) { - bytes32 recipientAddressHash = MessageHashUtils.toEthSignedMessageHash( + if (deposit.claimKey != address(0)) { + bytes32 _claimHash = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _extraData) ) ); - if (_recoverSigner(recipientAddressHash, _signature) != deposit.pubKey20) return false; + if (_recoverSigner(_claimHash, _signature) != deposit.claimKey) return false; } return true; } function _isValidGaslessReclaim(address _caller, uint256 _index) internal view returns (bool) { - if (_index >= deposits.length) return false; - Deposit memory deposit = deposits[_index]; + if (_index >= links.length) return false; + Link memory deposit = links[_index]; if (deposit.gaslessFee == 0 && !deposit.gaslessSponsored) return false; - if (deposit.claimed) return false; - if (deposit.senderAddress != _caller) return false; + if (deposit.redeemed) return false; + if (deposit.creator != _caller) return false; if (deposit.recipient != address(0) && block.timestamp <= deposit.reclaimableAfter) return false; return true; } @@ -859,13 +859,13 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256 _contractTypesLength, uint256 _amountsLength, uint256 _tokenIdsLength, - uint256 _pubKeys20Length, + uint256 _claimKeysLength, uint256 _withMFAsLength ) internal pure { if ( - _tokenAddressesLength != _pubKeys20Length || _contractTypesLength != _pubKeys20Length - || _amountsLength != _pubKeys20Length || _tokenIdsLength != _pubKeys20Length - || _withMFAsLength != _pubKeys20Length + _tokenAddressesLength != _claimKeysLength || _contractTypesLength != _claimKeysLength + || _amountsLength != _claimKeysLength || _tokenIdsLength != _claimKeysLength + || _withMFAsLength != _claimKeysLength ) revert ParametersLengthMismatch(); } @@ -880,7 +880,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow if (msg.value != _totalAmount) revert InvalidTotalEtherSent(); return; } - if (msg.value != 0) revert EthNotAcceptedForNonEthDeposit(); + if (msg.value != 0) revert EthNotAcceptedForNonEthLink(); if (_contractType == 1) { if (_totalAmount > 0) IERC20(_tokenAddress).safeTransferFrom(_from, address(this), _totalAmount); @@ -895,11 +895,11 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } } - function _makeBatchDepositRaffle( + function _createRaffleLinks( address _tokenAddress, uint8 _contractType, uint256[] calldata _amounts, - address _pubKey20, + address claimKey, bool _requiresMFA ) internal returns (uint256[] memory) { if (_contractType != 0 && _contractType != 1) { @@ -914,18 +914,18 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow if (_contractType == 0) { if (msg.value != totalAmount) revert InvalidTotalEtherSent(); } else { - if (msg.value != 0) revert EthNotAcceptedForNonEthDeposit(); + if (msg.value != 0) revert EthNotAcceptedForNonEthLink(); if (totalAmount > 0) IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); } - uint256[] memory depositIndexes = new uint256[](_amounts.length); + uint256[] memory linkIndexes = new uint256[](_amounts.length); for (uint256 i = 0; i < _amounts.length; ++i) { - depositIndexes[i] = _storeDeposit( + linkIndexes[i] = _storeLink( _tokenAddress, _contractType, _amounts[i], 0, - _pubKey20, + claimKey, msg.sender, _requiresMFA, address(0), @@ -935,7 +935,7 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow false ); } - return depositIndexes; + return linkIndexes; } function _pullTokensViaApproval(address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId) @@ -969,70 +969,70 @@ contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow return _amount; } - function _withdrawDeposit( + function _executeClaim( uint256 _index, address _recipientAddress, bytes32 _extraData, bytes memory _signature, bool _authorized ) internal returns (bool) { - if (_index >= deposits.length) revert DepositIndexOutOfBounds(); - Deposit memory _deposit = deposits[_index]; - if (_deposit.claimed) revert DepositAlreadyClaimed(); + if (_index >= links.length) revert LinkIndexOutOfBounds(); + Link memory link = links[_index]; + if (link.redeemed) revert LinkAlreadyRedeemed(); - address depositSigner; + address claimSigner; if (_signature.length > 0) { - bytes32 _recipientAddressHash = MessageHashUtils.toEthSignedMessageHash( + bytes32 _claimHash = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _extraData) ) ); - depositSigner = getSigner(_recipientAddressHash, _signature); + claimSigner = getSigner(_claimHash, _signature); } - if (_deposit.requiresMFA && !_authorized) revert RequiresMfaAuthorization(); - if (_deposit.pubKey20 != address(0) && depositSigner != _deposit.pubKey20) revert WrongSignature(); - if (_deposit.recipient != address(0) && _recipientAddress != _deposit.recipient) revert WrongRecipient(); + if (link.requiresMFA && !_authorized) revert RequiresMfaAuthorization(); + if (link.claimKey != address(0) && claimSigner != link.claimKey) revert WrongSignature(); + if (link.recipient != address(0) && _recipientAddress != link.recipient) revert WrongRecipient(); - emit WithdrawEvent(_index, _deposit.contractType, _deposit.amount, _recipientAddress); - deposits[_index].claimed = true; + emit LinkRedeemed(_index, link.contractType, link.amount, _recipientAddress); + links[_index].redeemed = true; - if (_deposit.contractType == 0) { - (bool success,) = _recipientAddress.call{value: _deposit.amount}(""); + if (link.contractType == 0) { + (bool success,) = _recipientAddress.call{value: link.amount}(""); if (!success) revert EthTransferFailed(); - } else if (_deposit.contractType == 1) { - IERC20(_deposit.tokenAddress).safeTransfer(_recipientAddress, _deposit.amount); - } else if (_deposit.contractType == 2) { - IERC721(_deposit.tokenAddress).safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId); - } else if (_deposit.contractType == 3) { - IERC1155(_deposit.tokenAddress) - .safeTransferFrom(address(this), _recipientAddress, _deposit.tokenId, _deposit.amount, ""); + } else if (link.contractType == 1) { + IERC20(link.tokenAddress).safeTransfer(_recipientAddress, link.amount); + } else if (link.contractType == 2) { + IERC721(link.tokenAddress).safeTransferFrom(address(this), _recipientAddress, link.tokenId); + } else if (link.contractType == 3) { + IERC1155(link.tokenAddress) + .safeTransferFrom(address(this), _recipientAddress, link.tokenId, link.amount, ""); } return true; } - function _withdrawDepositSender(uint256 _index, address _senderAddress) internal returns (bool) { - if (_index >= deposits.length) revert DepositIndexOutOfBounds(); - Deposit memory _deposit = deposits[_index]; - if (_deposit.claimed) revert DepositAlreadyClaimed(); - if (_deposit.senderAddress != _senderAddress) revert NotTheSender(); - if (_deposit.recipient != address(0)) { - if (block.timestamp <= _deposit.reclaimableAfter) revert TooEarlyToReclaim(); + function _executeReclaim(uint256 _index, address _creator) internal returns (bool) { + if (_index >= links.length) revert LinkIndexOutOfBounds(); + Link memory link = links[_index]; + if (link.redeemed) revert LinkAlreadyRedeemed(); + if (link.creator != _creator) revert NotTheCreator(); + if (link.recipient != address(0)) { + if (block.timestamp <= link.reclaimableAfter) revert TooEarlyToReclaim(); } - emit WithdrawEvent(_index, _deposit.contractType, _deposit.amount, _deposit.senderAddress); - deposits[_index].claimed = true; + emit LinkRedeemed(_index, link.contractType, link.amount, link.creator); + links[_index].redeemed = true; - if (_deposit.contractType == 0) { - (bool success,) = payable(_deposit.senderAddress).call{value: _deposit.amount}(""); + if (link.contractType == 0) { + (bool success,) = payable(link.creator).call{value: link.amount}(""); if (!success) revert EthTransferFailed(); - } else if (_deposit.contractType == 1) { - IERC20(_deposit.tokenAddress).safeTransfer(_deposit.senderAddress, _deposit.amount); - } else if (_deposit.contractType == 2) { - IERC721(_deposit.tokenAddress).safeTransferFrom(address(this), _deposit.senderAddress, _deposit.tokenId); - } else if (_deposit.contractType == 3) { - IERC1155(_deposit.tokenAddress) - .safeTransferFrom(address(this), _deposit.senderAddress, _deposit.tokenId, _deposit.amount, ""); + } else if (link.contractType == 1) { + IERC20(link.tokenAddress).safeTransfer(link.creator, link.amount); + } else if (link.contractType == 2) { + IERC721(link.tokenAddress).safeTransferFrom(address(this), link.creator, link.tokenId); + } else if (link.contractType == 3) { + IERC1155(link.tokenAddress) + .safeTransferFrom(address(this), link.creator, link.tokenId, link.amount, ""); } return true; diff --git a/src/envelope/doc/EnvelopePaymaster.md b/src/envelope/doc/EnvelopePaymaster.md index fead0d9c..eaf69963 100644 --- a/src/envelope/doc/EnvelopePaymaster.md +++ b/src/envelope/doc/EnvelopePaymaster.md @@ -36,9 +36,9 @@ The paymaster does not keep per-gift state and does not price fees. Fee pricing, The paymaster delegates selector checks to the vault. The currently accepted operations are: - `withdrawDeposit` -- `withdrawMFADeposit` -- `withdrawDepositAsRecipient` -- `withdrawDepositSender` +- `claimWithMFA` +- `claimAsBoundRecipient` +- `reclaim` Approval-based paymaster flow is explicitly rejected. diff --git a/src/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeVault.md index 6e605b51..a0961553 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeVault.md @@ -4,7 +4,7 @@ ## Purpose -`EnvelopeVault` is a link-based asset vault for ETH, ERC-20, ERC-721, and ERC-1155 gifts. A sender deposits an asset against a per-link `pubKey20`; the recipient claims by presenting a signature from the matching private key. The vault supports open links, address-bound links, optional backend MFA, sender reclaim, deposit-time service fees, and prepaid or backend-sponsored gasless claim/reclaim eligibility for ZkSync paymasters. +`EnvelopeVault` is a link-based asset vault for ETH, ERC-20, ERC-721, and ERC-1155 gifts. A sender deposits an asset against a per-link claim key; the recipient claims by presenting a signature from the matching private key. The vault supports open links, address-bound links, optional backend MFA, sender reclaim, link-creation-time service fees, and prepaid or backend-sponsored gasless claim/reclaim eligibility for ZkSync paymasters. ## Actors And Architecture @@ -37,7 +37,7 @@ flowchart LR ## Backend Fee Decision -The vault does not price fees. Atlas chooses the service fee, gasless fee, and whether the backend sponsors claim gas, then signs the complete deposit intent for the app wallet address that will call the vault. +The vault does not price fees. Atlas chooses the service fee, gasless fee, and whether the backend sponsors claim gas, then signs the complete link intent for the app wallet address that will call the vault. ```mermaid flowchart TD @@ -64,10 +64,10 @@ For best UX, the app wallet should use ZkSync native account abstraction and pre Recommended sender flow: 1. Derive or load Nina's app-wallet smart-account address. -2. Build the `DepositRequest` using `onBehalfOf = appWalletAddress` when the app wallet should own sender reclaim rights. +2. Build the `LinkRequest` using `onBehalfOf = appWalletAddress` when the app wallet should own sender reclaim rights. 3. Ask Atlas for a `FeeAuthorization` signed for `feePayer = appWalletAddress`. 4. Query current allowances and approvals. -5. Build an AA batch containing only the missing approvals plus `makeCustomDepositWithFees`. +5. Build an AA batch containing only the missing approvals plus `createLinkWithFees`. 6. Show Nina one clear confirmation: gift asset, recipient-binding status, MFA status, NODL service fee, gasless fee or sponsorship, and reclaim policy. 7. Submit one ZkSync smart-account transaction. @@ -95,7 +95,7 @@ if (feeAuthorization.serviceFee + feeAuthorization.gaslessFee > 0 && nodlAllowan calls.push(Call({ to: address(vault), value: request.contractType == 0 ? request.amount : 0, - data: abi.encodeCall(EnvelopeVault.makeCustomDepositWithFees, (request, feeAuthorization)) + data: abi.encodeCall(EnvelopeVault.createLinkWithFees, (request, feeAuthorization)) })); appWallet.executeBatch(calls, paymasterParams); @@ -107,6 +107,8 @@ For ERC-721, use `approve(vault, tokenId)` before the vault call if the vault is ### No MFA, No Gasless P2P Gift +In this flow **no backend is involved**. The link secret is an ephemeral ECDSA private key generated entirely on the sender's device. The shareable link encodes: `chainId`, vault address, link index, and the raw private key. The recipient's app extracts the private key, signs the recipient's own address, and calls `withdrawDeposit`. Because no `FeeAuthorization` is submitted, the vault stores zero fees, no gasless eligibility, and no MFA requirement. + ```mermaid sequenceDiagram participant Nina as Sender / Nina @@ -114,16 +116,21 @@ sequenceDiagram participant Vault as EnvelopeVault participant Remy as Receiver / Remy - Nina->>Wallet: Create gift and link key - Wallet->>Vault: makeCustomDeposit or makeDeposit - Vault-->>Wallet: Deposit index stored, no fees, no MFA - Nina-->>Remy: Share link out of band - Remy->>Remy: Link key signs claim for Remy's address - Remy->>Vault: withdrawDeposit(index, Remy, linkSignature) + Note over Nina,Wallet: Client-side only — no backend needed + Nina->>Wallet: Generate ephemeral ECDSA keypair (linkPrivKey, claimKey) + Wallet->>Wallet: Approve gift token if ERC-20/721/1155 (AA batch) + Wallet->>Vault: makeDeposit(token, type, amount, tokenId, claimKey) + Vault-->>Wallet: depositIndex stored (no fees, no MFA) + Nina->>Nina: Encode link = chainId + vault + depositIndex + linkPrivKey + Nina-->>Remy: Share link out of band (QR, messenger, NFC, etc.) + Remy->>Remy: Decode link → extract linkPrivKey and depositIndex + Remy->>Remy: Sign own address with linkPrivKey → linkSignature + Remy->>Vault: withdrawDeposit(depositIndex, remyAddress, linkSignature) + Vault->>Vault: ecrecover(linkSignature) == deposit.claimKey ✓ Vault-->>Remy: Transfer full gift amount ``` -If Remy is recipient-bound, the sender uses `makeCustomDeposit(..., recipient=Remy, reclaimableAfter=...)`, and Remy can call either `withdrawDeposit` with Remy as recipient or the stricter `withdrawDepositAsRecipient` path. The paymaster will only sponsor recipient-bound claims when the caller is the bound recipient and gasless eligibility exists. +If Remy is recipient-bound, the sender uses `makeCustomDeposit(..., recipient=Remy, reclaimableAfter=...)`, and Remy can call either `withdrawDeposit` with Remy as recipient or the stricter `claimAsBoundRecipient` path. The paymaster will only sponsor recipient-bound claims when the caller is the bound recipient and gasless eligibility exists. ### MFA Without Gasless Claim @@ -140,17 +147,17 @@ sequenceDiagram Wallet->>Atlas: Request fee quote and authorization Atlas-->>Wallet: FeeAuthorization(serviceFee, gaslessFee=0, gaslessSponsored=false) Wallet->>FeeToken: approve(vault, serviceFee) inside AA batch, if needed - Wallet->>Vault: makeCustomDepositWithFees(request.withMFA=true, authorization) + Wallet->>Vault: createLinkWithFees(request.withMFA=true, authorization) Vault->>FeeToken: transferFrom(app wallet, vault, serviceFee) - Vault-->>Wallet: Deposit index stored, requiresMFA=true + Vault-->>Wallet: Link index stored, requiresMFA=true Nina-->>Remy: Share link Remy->>Atlas: Complete MFA challenge Atlas-->>Remy: MFA signature for (vault, index, Remy, deadline) - Remy->>Vault: withdrawMFADeposit(index, Remy, linkSignature, mfaSignature, deadline) + Remy->>Vault: claimWithMFA(index, Remy, linkSignature, mfaSignature, deadline) Vault-->>Remy: Transfer full gift amount ``` -In this flow Remy pays the claim transaction gas. The service fee is collected at deposit creation and does not reduce the gift amount. +In this flow Remy pays the claim transaction gas. The service fee is collected at link creation and does not reduce the gift amount. ### MFA With Gasless Claim @@ -169,17 +176,17 @@ sequenceDiagram Wallet->>Atlas: Request fee quote and gasless authorization Atlas-->>Wallet: FeeAuthorization(serviceFee, gaslessFee or gaslessSponsored=true) Wallet->>FeeToken: approve(vault, serviceFee + gaslessFee) inside AA batch, if needed - Wallet->>Vault: makeCustomDepositWithFees(request.withMFA=true, authorization) + Wallet->>Vault: createLinkWithFees(request.withMFA=true, authorization) Vault->>FeeToken: transferFrom(app wallet, vault, serviceFee + gaslessFee) when totalFee > 0 - Vault-->>Wallet: Deposit stored with gasless eligibility + Vault-->>Wallet: Link stored with gasless eligibility Nina-->>Remy: Share link Remy->>Atlas: Complete MFA challenge Atlas-->>Remy: MFA signature - Remy->>Paymaster: Submit withdrawMFADeposit through ZkSync paymaster + Remy->>Paymaster: Submit claimWithMFA through ZkSync paymaster Paymaster->>Vault: isValidGaslessOperation(Remy, calldata) Vault-->>Paymaster: true if recipient, signatures, MFA, and eligibility are valid Paymaster->>Bootloader: Pay required ETH gas - Bootloader->>Vault: Execute withdrawMFADeposit + Bootloader->>Vault: Execute claimWithMFA Vault-->>Remy: Transfer full gift amount ``` @@ -193,7 +200,7 @@ constructor(address mfaAuthorizer, address owner, address feeToken) | Param | Purpose | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mfaAuthorizer` | Backend signer for MFA claim approvals and deposit-time fee authorizations. `address(0)` disables non-zero fee authorizations and makes MFA withdrawals fail. | +| `mfaAuthorizer` | Backend signer for MFA claim approvals and link-creation-time fee authorizations. `address(0)` disables non-zero fee authorizations and makes MFA withdrawals fail. | | `owner` | Owns the vault and can withdraw accumulated fees. | | `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. | @@ -205,7 +212,7 @@ All deposits store a `Deposit` record: ```solidity struct Deposit { - address pubKey20; + address claimKey; uint256 amount; address tokenAddress; uint8 contractType; // 0=ETH, 1=ERC20, 2=ERC721, 3=ERC1155 @@ -217,7 +224,7 @@ struct Deposit { address senderAddress; address recipient; uint40 reclaimableAfter; - uint256 serviceFee; // feeToken amount collected at deposit creation + uint256 serviceFee; // feeToken amount collected at link creation uint256 gaslessFee; // feeToken amount prepaid for paymaster sponsorship } ``` @@ -228,17 +235,17 @@ struct Deposit { | Function | Flow | | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `makeDeposit(token, type, amount, tokenId, pubKey20)` | Basic open link. No MFA, no fees, no gasless sponsorship. | -| `makeMFADeposit(...)` | Basic open link that requires backend MFA at claim time. No deposit-time fees unless using `makeCustomDepositWithFees`. | -| `makeSelflessDeposit(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | -| `makeSelflessMFADeposit(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | +| `makeDeposit(token, type, amount, tokenId, claimKey)` | Basic open link. No MFA, no fees, no gasless sponsorship. | +| `createMFALink(...)` | Basic open link that requires backend MFA at claim time. No link-creation-time fees unless using `createLinkWithFees`. | +| `createLinkFor(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | +| `createMFALinkFor(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | | `makeCustomDeposit(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | -| `makeCustomDepositWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0` or `gaslessSponsored=true`. | -| `makeBatchDeposit(...)` | Creates many same-shape no-fee deposits in one transaction. ETH, ERC-20, and ERC-1155 are supported; ERC-721 uses the heterogeneous batch path. | -| `makeBatchDepositNoReturn(...)` | Same as `makeBatchDeposit` but skips allocating/returning the deposit indexes array. | -| `makeBatchCustomDeposit(...)` | Creates a heterogeneous no-fee batch and supports ETH, ERC-20, ERC-721, and ERC-1155. | -| `makeBatchCustomDepositWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `DepositRequest` and `FeeAuthorization` structs as the single-deposit flow. | -| `makeBatchDepositRaffle(...)` | Creates ETH or ERC-20 raffle-style deposits with different amounts and one shared `pubKey20`. | +| `createLinkWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0` or `gaslessSponsored=true`. | +| `createLinks(...)` | Creates many same-shape no-fee deposits in one transaction. ETH, ERC-20, and ERC-1155 are supported; ERC-721 uses the heterogeneous batch path. | +| `createLinksNoReturn(...)` | Same as `createLinks` but skips allocating/returning the link indexes array. | +| `createCustomLinks(...)` | Creates a heterogeneous no-fee batch and supports ETH, ERC-20, ERC-721, and ERC-1155. | +| `createCustomLinksWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `LinkRequest` and `FeeAuthorization` structs as the single-deposit flow. | +| `createLinksRaffle(...)` | Creates ETH or ERC-20 raffle-style deposits with different amounts and one shared `claimKey`. | | `makeBatchMFADepositRaffle(...)` | Same as raffle batching, but every deposit requires MFA at claim time. | ```solidity @@ -251,7 +258,7 @@ struct FeeAuthorization { } ``` -`FeeAuthorization` covers the full deposit intent, the fee payer (`msg.sender`), the two fee amounts, `gaslessSponsored`, and a backend-selected deadline. `deadline == 0` means no expiry. If either fee is non-zero, if `gaslessSponsored` is true, or if a zero-fee authorization includes a non-empty signature, the signature must recover to `mfaAuthorizer`. This allows backend-approved free envelopes without forcing a fee transfer, and also allows promotional gasless eligibility without encoding fake fee amounts. +`FeeAuthorization` covers the full link intent, the fee payer (`msg.sender`), the two fee amounts, `gaslessSponsored`, and a backend-selected deadline. `deadline == 0` means no expiry. If either fee is non-zero, if `gaslessSponsored` is true, or if a zero-fee authorization includes a non-empty signature, the signature must recover to `mfaAuthorizer`. This allows backend-approved free envelopes without forcing a fee transfer, and also allows promotional gasless eligibility without encoding fake fee amounts. ## Vault-Native Batching @@ -263,10 +270,10 @@ The batching functions share the same storage and events as single deposits. Sam | Function | Caller | Authorization | | ------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `withdrawDeposit(index, recipient, signature)` | Anyone, or a recipient using a paymaster | Link key signs `(salt, chainId, vault, index, recipient, ANYONE_WITHDRAWAL_MODE)`. | -| `withdrawMFADeposit(index, recipient, signature, mfaSignature, deadline)` | Anyone, or a recipient using a paymaster | Link signature plus backend MFA signature over `(salt, chainId, vault, index, recipient, deadline)`. | -| `withdrawDepositAsRecipient(index, recipient, signature)` | Must be `recipient` | Link key signs using `RECIPIENT_WITHDRAWAL_MODE`. | -| `withdrawDepositSender(index)` | Original `senderAddress` | Sender reclaim. If the deposit is recipient-bound, `block.timestamp` must be greater than `reclaimableAfter`. | +| `withdrawDeposit(index, recipient, signature)` | Anyone, or a recipient using a paymaster | Link key signs `(salt, chainId, vault, index, recipient, OPEN_CLAIM_MODE)`. | +| `claimWithMFA(index, recipient, signature, mfaSignature, deadline)` | Anyone, or a recipient using a paymaster | Link signature plus backend MFA signature over `(salt, chainId, vault, index, recipient, deadline)`. | +| `claimAsBoundRecipient(index, recipient, signature)` | Must be `recipient` | Link key signs using `BOUND_CLAIM_MODE`. | +| `reclaim(index)` | Original `senderAddress` | Sender reclaim. If the deposit is recipient-bound, `block.timestamp` must be greater than `reclaimableAfter`. | All withdrawal paths set `claimed = true` before transferring assets. Claim-time fee collection was intentionally removed: fees are now collected when the envelope is created. @@ -274,14 +281,14 @@ All withdrawal paths set `claimed = true` before transferring assets. Claim-time Gasless operation is handled by ZkSync paymasters, not by an internal vault callback. The vault is only the source of truth for whether a paymaster should sponsor a call. -1. Sender creates a deposit through `makeCustomDepositWithFees` with `gaslessFee > 0` or `gaslessSponsored=true`. +1. Sender creates a deposit through `createLinkWithFees` with `gaslessFee > 0` or `gaslessSponsored=true`. 2. The vault collects any non-zero gasless sponsorship fee immediately in `feeToken` and records gasless eligibility on the deposit. -3. A receiver submits a ZkSync transaction to `withdrawDeposit`, `withdrawMFADeposit`, or `withdrawDepositAsRecipient` using `EnvelopePaymaster`. +3. A receiver submits a ZkSync transaction to `withdrawDeposit`, `claimWithMFA`, or `claimAsBoundRecipient` using `EnvelopePaymaster`. 4. ZkSync calls the paymaster before execution. The paymaster checks the transaction targets this vault and calls `isValidGaslessOperation(from, transaction.data)`. 5. The vault re-checks the deposit state, gasless eligibility, recipient/sender identity, signatures, MFA deadline, and reclaim delay. 6. If validation passes, the paymaster pays ETH to the bootloader. The vault function then executes normally. -Sender reclaim can also be gasless: the sender submits `withdrawDepositSender(index)` through the paymaster. This is allowed only for deposits with `gaslessFee > 0` or `gaslessSponsored=true`, and the same reclaim timing rules as the regular reclaim path. +Sender reclaim can also be gasless: the sender submits `reclaim(index)` through the paymaster. This is allowed only for deposits with `gaslessFee > 0` or `gaslessSponsored=true`, and the same reclaim timing rules as the regular reclaim path. ## Paymaster Validation Helper @@ -292,9 +299,9 @@ function isValidGaslessOperation(address caller, bytes calldata callData) extern This function is intended for paymaster validation. It accepts only these selectors: - `withdrawDeposit` -- `withdrawMFADeposit` -- `withdrawDepositAsRecipient` -- `withdrawDepositSender` +- `claimWithMFA` +- `claimAsBoundRecipient` +- `reclaim` For claim calls, `caller` must be the recipient. For reclaim calls, `caller` must be the stored sender. The helper returns false for non-eligible deposits, claimed deposits, unsupported selectors, wrong callers, invalid signatures, expired MFA approvals, or early reclaims. diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 8dd4241c..c107a024 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -1,31 +1,31 @@ # Envelope contracts -The Envelope flow on Nodle is built on top of modified Peanut Protocol V4.4 contracts. Senders deposit assets against a per-link public key; recipients claim with the matching private key. Nodle-specific additions include address-bound links, backend MFA, deposit-time service fees, app-wallet batching on ZkSync smart accounts, and ZkSync paymaster support for prepaid or backend-sponsored gasless claims and reclaims. +The Envelope flow on Nodle is built on top of modified Peanut Protocol V4.4 contracts. Senders create claimable links by escrowing assets against a per-link claim key; recipients claim with the matching private key. Nodle-specific additions include address-bound links, backend MFA, link-creation-time service fees, app-wallet batching on ZkSync smart accounts, and ZkSync paymaster support for prepaid or backend-sponsored gasless claims and reclaims. ## Layout | Contract | Source | Spec | | ------------------- | -------------------------------------- | ---------------------------------------------- | -| `EnvelopeVault` | `src/envelope/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | +| `EnvelopeVault` | `src/envelope/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | | `EnvelopePaymaster` | `src/paymasters/EnvelopePaymaster.sol` | [EnvelopePaymaster.md](./EnvelopePaymaster.md) | Interfaces: -| Interface | Source | Used by | -| --------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Interface | Source | Used by | +| --------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------ | | `IEnvelopeGaslessValidator` | `src/envelope/IEnvelopeGaslessValidator.sol` | `EnvelopePaymaster` queries `EnvelopeVault.isValidGaslessOperation` before sponsoring gas. | ## License notice This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) does not apply uniformly here. -| Files | License | Notes | -| ------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/envelope/EnvelopeVault.sol` | **GPL-3.0-or-later** | Modified copy of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/LICENSE-GPL`. | -| `src/envelope/IEnvelopeGaslessValidator.sol` | **GPL-3.0-or-later** | Minimal interface for the GPL vault validation surface. | -| `test/envelope/**/*.t.sol` | **GPL-3.0-or-later** | Test files that import GPL-licensed contracts are relicensed for compatibility. | -| `test/envelope/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained. | -| All other repo files | unchanged | Whatever they were. | +| Files | License | Notes | +| -------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- | +| `src/envelope/EnvelopeVault.sol` | **GPL-3.0-or-later** | Modified copy of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/LICENSE-GPL`. | +| `src/envelope/IEnvelopeGaslessValidator.sol` | **GPL-3.0-or-later** | Minimal interface for the GPL vault validation surface. | +| `test/envelope/**/*.t.sol` | **GPL-3.0-or-later** | Test files that import GPL-licensed contracts are relicensed for compatibility. | +| `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 under the OSI's "mere aggregation" interpretation. diff --git a/test/envelope/Deposit.t.sol b/test/envelope/Deposit.t.sol index 81262e4c..87e2fd6d 100644 --- a/test/envelope/Deposit.t.sol +++ b/test/envelope/Deposit.t.sol @@ -38,7 +38,7 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { // check invariants function testDepositEther(uint64 amount, address randomAddress) public { vm.assume(amount > 0); - vault.makeDeposit{value: amount}(randomAddress, 0, amount, 0, PUBKEY20); + vault.createLink{value: amount}(randomAddress, 0, amount, 0, PUBKEY20); } function testDepositERC20(uint64 amount) public { @@ -50,7 +50,7 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { // console log allowance and amount console.log("Allowance: ", testToken.allowance(address(this), address(vault))); console.log("Amount: ", amount); - vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + vault.createLink(address(testToken), 1, amount, 0, PUBKEY20); } // Test for ERC721 Token @@ -59,7 +59,7 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { testToken721.mint(address(this), tokenId); // approve the contract to spend the tokens testToken721.approve(address(vault), tokenId); - vault.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); + vault.createLink(address(testToken721), 2, 1, tokenId, PUBKEY20); } // Test for ERC1155 Token @@ -69,6 +69,6 @@ contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { testToken1155.mint(address(this), tokenId, amount, ""); // approve the contract to spend the tokens testToken1155.setApprovalForAll(address(vault), true); - vault.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); + vault.createLink(address(testToken1155), 3, amount, tokenId, PUBKEY20); } } diff --git a/test/envelope/EnvelopeBatching.t.sol b/test/envelope/EnvelopeBatching.t.sol index a80deb96..123e6173 100644 --- a/test/envelope/EnvelopeBatching.t.sol +++ b/test/envelope/EnvelopeBatching.t.sol @@ -45,14 +45,14 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { address[] memory pubKeys20 = _pubKeys(numDeposits, PUBKEY20); uint256[] memory depositIndexes = - vault.makeBatchDeposit{value: amount * numDeposits}(address(0), 0, amount, 0, pubKeys20); + vault.createLinks{value: amount * numDeposits}(address(0), 0, amount, 0, pubKeys20); assertEq(depositIndexes.length, numDeposits); - assertEq(vault.getDepositCount(), numDeposits); + assertEq(vault.getLinkCount(), numDeposits); for (uint256 i = 0; i < numDeposits; ++i) { - EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndexes[i]); + EnvelopeVault.Link memory deposit = vault.getLink(depositIndexes[i]); assertEq(deposit.amount, amount); - assertEq(deposit.senderAddress, address(this)); + assertEq(deposit.creator, address(this)); } } @@ -64,7 +64,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { testToken.mint(address(this), amount * numDeposits); testToken.approve(address(vault), amount * numDeposits); - uint256[] memory depositIndexes = vault.makeBatchDeposit(address(testToken), 1, amount, 0, pubKeys20); + uint256[] memory depositIndexes = vault.createLinks(address(testToken), 1, amount, 0, pubKeys20); assertEq(depositIndexes.length, numDeposits); assertEq(testToken.balanceOf(address(vault)), amount * numDeposits); @@ -74,7 +74,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { address[] memory pubKeys20 = _pubKeys(2, PUBKEY20); vm.expectRevert(EnvelopeVault.Erc721BatchNotSupported.selector); - vault.makeBatchDeposit(address(testToken721), 2, 1, 1, pubKeys20); + vault.createLinks(address(testToken721), 2, 1, 1, pubKeys20); } function testMakeBatchDepositERC1155() public { @@ -84,7 +84,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { testToken1155.mint(address(this), 1, numDeposits, ""); testToken1155.setApprovalForAll(address(vault), true); - uint256[] memory depositIndexes = vault.makeBatchDeposit(address(testToken1155), 3, 1, 1, pubKeys20); + uint256[] memory depositIndexes = vault.createLinks(address(testToken1155), 3, 1, 1, pubKeys20); assertEq(depositIndexes.length, numDeposits); assertEq(testToken1155.balanceOf(address(vault), 1), numDeposits); @@ -97,7 +97,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { testToken.mint(address(this), amount * numDeposits); vm.expectRevert(); - vault.makeBatchDeposit(address(testToken), 1, amount, 0, pubKeys20); + vault.createLinks(address(testToken), 1, amount, 0, pubKeys20); } function test_RevertIf_BatchERC1155DepositNotApproved() public { @@ -106,7 +106,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { testToken1155.mint(address(this), 1, numDeposits, ""); vm.expectRevert(); - vault.makeBatchDeposit(address(testToken1155), 3, 1, 1, pubKeys20); + vault.createLinks(address(testToken1155), 3, 1, 1, pubKeys20); } function testMultipleBatchERC20DepositsInRow() public { @@ -119,7 +119,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { testToken.mint(address(this), amount * numDeposits); testToken.approve(address(vault), amount * numDeposits); - uint256[] memory depositIndexes = vault.makeBatchDeposit(address(testToken), 1, amount, 0, pubKeys20); + uint256[] memory depositIndexes = vault.createLinks(address(testToken), 1, amount, 0, pubKeys20); assertEq(depositIndexes.length, numDeposits); } @@ -144,17 +144,17 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { testToken721.approve(address(vault), tokenId); uint256[] memory depositIndexes = - vault.makeBatchCustomDeposit(tokenAddresses, contractTypes, amounts, tokenIds, pubKeys20, withMFAs); + vault.createCustomLinks(tokenAddresses, contractTypes, amounts, tokenIds, pubKeys20, withMFAs); - EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndexes[0]); + EnvelopeVault.Link memory deposit = vault.getLink(depositIndexes[0]); assertEq(testToken721.ownerOf(tokenId), address(vault)); assertEq(deposit.contractType, 2); assertEq(deposit.tokenId, tokenId); - assertEq(deposit.senderAddress, address(this)); + assertEq(deposit.creator, address(this)); } function testMakeBatchCustomDepositWithFeesCollectsFeesAtDeposit() public { - EnvelopeVault.DepositRequest[] memory requests = new EnvelopeVault.DepositRequest[](2); + EnvelopeVault.LinkRequest[] memory requests = new EnvelopeVault.LinkRequest[](2); EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](2); requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); @@ -166,13 +166,13 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { feeToken.approve(address(feeVault), 0.1 ether); uint256[] memory depositIndexes = - feeVault.makeBatchCustomDepositWithFees{value: 3 ether}(requests, authorizations); + feeVault.createCustomLinksWithFees{value: 3 ether}(requests, authorizations); - EnvelopeVault.Deposit memory firstDeposit = feeVault.getDeposit(depositIndexes[0]); - EnvelopeVault.Deposit memory secondDeposit = feeVault.getDeposit(depositIndexes[1]); + EnvelopeVault.Link memory firstDeposit = feeVault.getLink(depositIndexes[0]); + EnvelopeVault.Link memory secondDeposit = feeVault.getLink(depositIndexes[1]); assertEq(depositIndexes.length, 2); - assertEq(firstDeposit.senderAddress, address(this)); + assertEq(firstDeposit.creator, address(this)); assertEq(firstDeposit.serviceFee, 0.01 ether); assertEq(firstDeposit.gaslessFee, 0.02 ether); assertEq(secondDeposit.requiresMFA, true); @@ -181,16 +181,16 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { assertEq(feeVault.accumulatedFees(address(feeToken)), 0.1 ether); bytes memory withdrawalSig = - _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.ANYONE_WITHDRAWAL_MODE()); + _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.OPEN_CLAIM_MODE()); bytes memory callData = - abi.encodeCall(EnvelopeVault.withdrawDeposit, (depositIndexes[0], RECIPIENT, withdrawalSig)); + abi.encodeCall(EnvelopeVault.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); assertTrue(feeVault.isValidGaslessOperation(RECIPIENT, callData)); } function testMakeBatchCustomDepositWithFeesSupportsERC721AndERC1155() public { uint256 tokenId = 77; uint256 erc1155Id = 9; - EnvelopeVault.DepositRequest[] memory requests = new EnvelopeVault.DepositRequest[](2); + EnvelopeVault.LinkRequest[] memory requests = new EnvelopeVault.LinkRequest[](2); EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](2); requests[0] = _request(address(testToken721), 2, 1, tokenId, false, address(0), 0); @@ -205,10 +205,10 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { feeToken.mint(address(this), 10); feeToken.approve(address(feeVault), 10); - uint256[] memory depositIndexes = feeVault.makeBatchCustomDepositWithFees(requests, authorizations); + uint256[] memory depositIndexes = feeVault.createCustomLinksWithFees(requests, authorizations); - EnvelopeVault.Deposit memory nftDeposit = feeVault.getDeposit(depositIndexes[0]); - EnvelopeVault.Deposit memory multiTokenDeposit = feeVault.getDeposit(depositIndexes[1]); + EnvelopeVault.Link memory nftDeposit = feeVault.getLink(depositIndexes[0]); + EnvelopeVault.Link memory multiTokenDeposit = feeVault.getLink(depositIndexes[1]); assertEq(testToken721.ownerOf(tokenId), address(feeVault)); assertEq(testToken1155.balanceOf(address(feeVault), erc1155Id), 5); @@ -220,29 +220,29 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { } function testMakeBatchCustomDepositWithFeesSupportsSponsoredGasless() public { - EnvelopeVault.DepositRequest[] memory requests = new EnvelopeVault.DepositRequest[](1); + EnvelopeVault.LinkRequest[] memory requests = new EnvelopeVault.LinkRequest[](1); EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](1); requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); authorizations[0] = _authorization(feeVault, requests[0], address(this), 0, 0, true, 0); uint256[] memory depositIndexes = - feeVault.makeBatchCustomDepositWithFees{value: 1 ether}(requests, authorizations); + feeVault.createCustomLinksWithFees{value: 1 ether}(requests, authorizations); - EnvelopeVault.Deposit memory deposit = feeVault.getDeposit(depositIndexes[0]); + EnvelopeVault.Link memory deposit = feeVault.getLink(depositIndexes[0]); assertEq(deposit.gaslessFee, 0); assertTrue(deposit.gaslessSponsored); assertEq(feeToken.balanceOf(address(feeVault)), 0); bytes memory withdrawalSig = - _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.ANYONE_WITHDRAWAL_MODE()); + _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.OPEN_CLAIM_MODE()); bytes memory callData = - abi.encodeCall(EnvelopeVault.withdrawDeposit, (depositIndexes[0], RECIPIENT, withdrawalSig)); + abi.encodeCall(EnvelopeVault.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); assertTrue(feeVault.isValidGaslessOperation(RECIPIENT, callData)); } function test_RevertIf_BatchFeeAuthorizationIsSignedForDifferentPayer() public { - EnvelopeVault.DepositRequest[] memory requests = new EnvelopeVault.DepositRequest[](1); + EnvelopeVault.LinkRequest[] memory requests = new EnvelopeVault.LinkRequest[](1); EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](1); requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); @@ -252,7 +252,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { feeToken.approve(address(feeVault), 0.03 ether); vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); - feeVault.makeBatchCustomDepositWithFees{value: 1 ether}(requests, authorizations); + feeVault.createCustomLinksWithFees{value: 1 ether}(requests, authorizations); } function testMakeBatchDepositRaffleEth() public { @@ -262,14 +262,14 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { amounts[2] = 30; amounts[3] = 40; - uint256[] memory depositIndices = vault.makeBatchDepositRaffle{value: 100}(address(0), 0, amounts, PUBKEY20); + uint256[] memory depositIndices = vault.createRaffleLinks{value: 100}(address(0), 0, amounts, PUBKEY20); for (uint256 i = 0; i < amounts.length; ++i) { - EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); + EnvelopeVault.Link memory deposit = vault.getLink(depositIndices[i]); assertEq(deposit.amount, amounts[i]); assertEq(deposit.contractType, 0); - assertEq(deposit.pubKey20, PUBKEY20); - assertEq(deposit.senderAddress, address(this)); + assertEq(deposit.claimKey, PUBKEY20); + assertEq(deposit.creator, address(this)); } } @@ -283,14 +283,14 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { testToken.mint(address(this), 100); testToken.approve(address(vault), 100); - uint256[] memory depositIndices = vault.makeBatchDepositRaffle(address(testToken), 1, amounts, PUBKEY20); + uint256[] memory depositIndices = vault.createRaffleLinks(address(testToken), 1, amounts, PUBKEY20); for (uint256 i = 0; i < amounts.length; ++i) { - EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); + EnvelopeVault.Link memory deposit = vault.getLink(depositIndices[i]); assertEq(deposit.amount, amounts[i]); assertEq(deposit.contractType, 1); - assertEq(deposit.pubKey20, PUBKEY20); - assertEq(deposit.senderAddress, address(this)); + assertEq(deposit.claimKey, PUBKEY20); + assertEq(deposit.creator, address(this)); } } @@ -299,29 +299,29 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { amounts[0] = 10; amounts[1] = 20; - uint256[] memory depositIndices = vault.makeBatchMFADepositRaffle{value: 30}(address(0), 0, amounts, PUBKEY20); + uint256[] memory depositIndices = vault.createMFARaffleLinks{value: 30}(address(0), 0, amounts, PUBKEY20); for (uint256 i = 0; i < amounts.length; ++i) { - EnvelopeVault.Deposit memory deposit = vault.getDeposit(depositIndices[i]); + EnvelopeVault.Link memory deposit = vault.getLink(depositIndices[i]); assertTrue(deposit.requiresMFA); - assertEq(deposit.senderAddress, address(this)); + assertEq(deposit.creator, address(this)); } } function testMakeBatchDepositNoReturnEth() public { address[] memory pubKeys20 = _pubKeys(3, PUBKEY20); - vault.makeBatchDepositNoReturn{value: 3 ether}(address(0), 0, 1 ether, 0, pubKeys20); + vault.createLinksNoReturn{value: 3 ether}(address(0), 0, 1 ether, 0, pubKeys20); - assertEq(vault.getDepositCount(), 3); + assertEq(vault.getLinkCount(), 3); } function testBatchZeroLengthDepositsIsNoop() public { address[] memory pubKeys20 = new address[](0); - uint256[] memory ids = vault.makeBatchDeposit(address(0), 0, 0, 0, pubKeys20); + uint256[] memory ids = vault.createLinks(address(0), 0, 0, 0, pubKeys20); assertEq(ids.length, 0); - assertEq(vault.getDepositCount(), 0); + assertEq(vault.getLinkCount(), 0); } function _pubKeys(uint256 count, address pubKey20) internal pure returns (address[] memory) { @@ -340,13 +340,13 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { bool withMFA, address recipient, uint40 reclaimableAfter - ) internal view returns (EnvelopeVault.DepositRequest memory) { - return EnvelopeVault.DepositRequest({ + ) internal view returns (EnvelopeVault.LinkRequest memory) { + return EnvelopeVault.LinkRequest({ tokenAddress: tokenAddress, contractType: contractType, amount: amount, tokenId: tokenId, - pubKey20: linkPubKey, + claimKey: linkPubKey, onBehalfOf: address(this), withMFA: withMFA, recipient: recipient, @@ -356,7 +356,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { function _authorization( EnvelopeVault targetVault, - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -367,7 +367,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { function _authorization( EnvelopeVault targetVault, - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -387,7 +387,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { function _signFeeAuthorization( EnvelopeVault targetVault, - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -398,7 +398,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { function _signFeeAuthorization( EnvelopeVault targetVault, - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -416,7 +416,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { request.contractType, request.amount, request.tokenId, - request.pubKey20, + request.claimKey, request.onBehalfOf, request.withMFA, request.recipient, diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index 2e9d45de..5b7dcfde 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -37,7 +37,7 @@ contract ReentrantToken is ERC20Mock { if (!attempted && address(vault) != address(0) && to == attacker) { attempted = true; // This call should revert because the outer call holds the reentrancy lock. - try vault.withdrawDeposit(targetIdx, attacker, targetSig) { + try vault.claim(targetIdx, attacker, targetSig) { revert("REENTRANCY GUARD MISSING"); } catch { // expected — guard caught it @@ -75,7 +75,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, recipient, vault.ANYONE_WITHDRAWAL_MODE() + vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, recipient, vault.OPEN_CLAIM_MODE() ) ) ); @@ -84,7 +84,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { } function _depositEth(uint256 amount) internal returns (uint256) { - return vault.makeDeposit{value: amount}(address(0), 0, amount, 0, LINK_PUBKEY20); + return vault.createLink{value: amount}(address(0), 0, amount, 0, LINK_PUBKEY20); } // ── EnvelopeVault deposit input validation ────────────────────────────────── @@ -92,13 +92,13 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function test_RevertWhen_DepositInvalidContractType() public { // _pullTokensViaApproval rejects contractType > 3. vm.expectRevert(EnvelopeVault.InvalidContractType.selector); - vault.makeDeposit{value: 0}(address(0), 5, 0, 0, LINK_PUBKEY20); + vault.createLink{value: 0}(address(0), 5, 0, 0, LINK_PUBKEY20); } function test_RevertWhen_DepositEthAmountMismatch() public { // contractType==0 requires _amount == msg.value. vm.expectRevert(EnvelopeVault.WrongEthAmount.selector); - vault.makeDeposit{value: 100}(address(0), 0, 50, 0, LINK_PUBKEY20); + vault.createLink{value: 100}(address(0), 0, 50, 0, LINK_PUBKEY20); } function test_RevertWhen_DepositErc721AmountNotOne() public { @@ -106,24 +106,24 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { erc721.mint(address(this), 1); erc721.approve(address(vault), 1); vm.expectRevert(EnvelopeVault.Erc721AmountMustBeOne.selector); - vault.makeDeposit(address(erc721), 2, 2, 1, LINK_PUBKEY20); + vault.createLink(address(erc721), 2, 2, 1, LINK_PUBKEY20); } // ── EnvelopeVault withdraw input validation ───────────────────────────────── function test_RevertWhen_WithdrawIndexOutOfBounds() public { bytes memory sig = _signWithdrawal(99, ALICE, LINK_PRIV); - vm.expectRevert(EnvelopeVault.DepositIndexOutOfBounds.selector); - vault.withdrawDeposit(99, ALICE, sig); + vm.expectRevert(EnvelopeVault.LinkIndexOutOfBounds.selector); + vault.claim(99, ALICE, sig); } function test_RevertWhen_WithdrawTwice() public { uint256 idx = _depositEth(1 ether); bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); - vault.withdrawDeposit(idx, ALICE, sig); + vault.claim(idx, ALICE, sig); - vm.expectRevert(EnvelopeVault.DepositAlreadyClaimed.selector); - vault.withdrawDeposit(idx, ALICE, sig); + vm.expectRevert(EnvelopeVault.LinkAlreadyRedeemed.selector); + vault.claim(idx, ALICE, sig); } function test_RevertWhen_WithdrawWithWrongSigner() public { @@ -133,7 +133,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes memory sig = _signWithdrawal(idx, ALICE, wrongKey); vm.expectRevert(EnvelopeVault.WrongSignature.selector); - vault.withdrawDeposit(idx, ALICE, sig); + vault.claim(idx, ALICE, sig); } function test_RevertWhen_WithdrawAsRecipientCallerMismatch() public { @@ -142,7 +142,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes32 digest = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked( - vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, ALICE, vault.RECIPIENT_WITHDRAWAL_MODE() + vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, ALICE, vault.BOUND_CLAIM_MODE() ) ) ); @@ -152,47 +152,47 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // BOB tries to call on behalf of ALICE — caller must equal the recipient param. vm.prank(BOB); vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); - vault.withdrawDepositAsRecipient(idx, ALICE, sig); + vault.claimAsBoundRecipient(idx, ALICE, sig); } function test_RevertWhen_RecipientBoundClaimedByOtherAddress() public { // Address-bound deposit: recipient = ALICE. - uint256 idx = vault.makeCustomDeposit{value: 1 ether}( + uint256 idx = vault.createCustomLink{value: 1 ether}( address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, 0 ); // 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(EnvelopeVault.WrongRecipient.selector); - vault.withdrawDeposit(idx, BOB, sig); + vault.claim(idx, BOB, sig); } function test_RecipientBoundSenderCannotReclaimBeforeDeadline() public { uint40 reclaimAfter = uint40(block.timestamp + 1 days); - uint256 idx = vault.makeCustomDeposit{value: 1 ether}( + uint256 idx = vault.createCustomLink{value: 1 ether}( address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, reclaimAfter ); vm.expectRevert(EnvelopeVault.TooEarlyToReclaim.selector); - vault.withdrawDepositSender(idx); + vault.reclaim(idx); vm.warp(reclaimAfter + 1); - vault.withdrawDepositSender(idx); // succeeds after the deadline + vault.reclaim(idx); // succeeds after the deadline } function test_RevertWhen_SenderReclaimNotTheSender() public { uint256 idx = _depositEth(1 ether); vm.prank(ALICE); - vm.expectRevert(EnvelopeVault.NotTheSender.selector); - vault.withdrawDepositSender(idx); + vm.expectRevert(EnvelopeVault.NotTheCreator.selector); + vault.reclaim(idx); } function test_RevertWhen_MFADepositWithoutMFASignature() public { // vault is deployed with mfaAuthorizer == address(0), so MFA-flagged // deposits can never be withdrawn via withdrawDeposit (REQUIRES AUTHORIZATION). - uint256 idx = vault.makeMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY20); + uint256 idx = vault.createMFALink{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY20); bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); vm.expectRevert(EnvelopeVault.RequiresMfaAuthorization.selector); - vault.withdrawDeposit(idx, ALICE, sig); + vault.claim(idx, ALICE, sig); } // ── EnvelopeVault views ───────────────────────────────────────────────────── @@ -201,20 +201,20 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { _depositEth(1); _depositEth(1); // Same sender (address(this)) made both deposits. - EnvelopeVault.Deposit[] memory mine = vault.getAllDepositsForAddress(address(this)); + EnvelopeVault.Link[] memory mine = vault.getLinksCreatedBy(address(this)); assertEq(mine.length, 2); // Different sender → empty. - EnvelopeVault.Deposit[] memory aliceDeposits = vault.getAllDepositsForAddress(ALICE); + EnvelopeVault.Link[] memory aliceDeposits = vault.getLinksCreatedBy(ALICE); assertEq(aliceDeposits.length, 0); } function test_DepositCountTracksArrayLength() public { - assertEq(vault.getDepositCount(), 0); + assertEq(vault.getLinkCount(), 0); _depositEth(1); _depositEth(1); _depositEth(1); - assertEq(vault.getDepositCount(), 3); + assertEq(vault.getLinkCount(), 3); } // ── EnvelopeVault reentrancy ──────────────────────────────────────────────── @@ -225,7 +225,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { evil.approve(address(vault), 100); // Deposit type-1 (ERC-20) so withdraw routes back through the token's transfer. - uint256 idx = vault.makeDeposit(address(evil), 1, 100, 0, LINK_PUBKEY20); + uint256 idx = vault.createLink(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. @@ -233,7 +233,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // Outer withdraw succeeds (inner reentrant attempt caught and swallowed by try/catch); // the reentrancy guard ensured the inner call could not double-spend. - vault.withdrawDeposit(idx, ALICE, sig); + vault.claim(idx, ALICE, sig); assertEq(evil.balanceOf(ALICE), 100); assertTrue(evil.attempted(), "reentrancy attempt should have run"); } @@ -246,7 +246,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { pubKeys[i] = LINK_PUBKEY20; } vm.expectRevert(EnvelopeVault.InvalidTotalEtherSent.selector); - vault.makeBatchDeposit{value: 1 ether}(address(0), 0, 1 ether, 0, pubKeys); + vault.createLinks{value: 1 ether}(address(0), 0, 1 ether, 0, pubKeys); // expected 3 * 1 ether, sent 1 ether } @@ -260,7 +260,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bool[] memory mfa = new bool[](3); // wrong length vm.expectRevert(EnvelopeVault.ParametersLengthMismatch.selector); - vault.makeBatchCustomDeposit(tokens, types, amounts, ids, pks, mfa); + vault.createCustomLinks(tokens, types, amounts, ids, pks, mfa); } // makeBatchDepositNoReturn — ETH path must require exact total, non-ETH path must reject msg.value. @@ -271,8 +271,8 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { pubKeys[i] = LINK_PUBKEY20; } - vault.makeBatchDepositNoReturn{value: 3 ether}(address(0), 0, 1 ether, 0, pubKeys); - assertEq(vault.getDepositCount(), 3); + vault.createLinksNoReturn{value: 3 ether}(address(0), 0, 1 ether, 0, pubKeys); + assertEq(vault.getLinkCount(), 3); } function test_RevertWhen_BatchNoReturnEthAmountMismatch() public { @@ -281,7 +281,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { pubKeys[i] = LINK_PUBKEY20; } vm.expectRevert(EnvelopeVault.InvalidTotalEtherSent.selector); - vault.makeBatchDepositNoReturn{value: 1 ether}(address(0), 0, 1 ether, 0, pubKeys); + vault.createLinksNoReturn{value: 1 ether}(address(0), 0, 1 ether, 0, pubKeys); } function test_RevertWhen_BatchNoReturnEthSentForErc20() public { @@ -292,21 +292,21 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { for (uint256 i = 0; i < 2; i++) { pubKeys[i] = LINK_PUBKEY20; } - vm.expectRevert(EnvelopeVault.EthNotAcceptedForNonEthDeposit.selector); - vault.makeBatchDepositNoReturn{value: 1 wei}(address(erc20), 1, 100, 0, pubKeys); + vm.expectRevert(EnvelopeVault.EthNotAcceptedForNonEthLink.selector); + vault.createLinksNoReturn{value: 1 wei}(address(erc20), 1, 100, 0, pubKeys); } function test_RevertWhen_BatchRaffleErc721NotSupported() public { uint256[] memory amounts = new uint256[](1); amounts[0] = 1; vm.expectRevert(EnvelopeVault.UnsupportedRaffleContractType.selector); - vault.makeBatchDepositRaffle(address(erc721), 2, amounts, LINK_PUBKEY20); + vault.createRaffleLinks(address(erc721), 2, amounts, LINK_PUBKEY20); } function test_BatchZeroLengthDepositsIsNoop() public { address[] memory pubKeys = new address[](0); - uint256[] memory ids = vault.makeBatchDeposit(address(0), 0, 0, 0, pubKeys); + uint256[] memory ids = vault.createLinks(address(0), 0, 0, 0, pubKeys); assertEq(ids.length, 0); - assertEq(vault.getDepositCount(), 0); + assertEq(vault.getLinkCount(), 0); } } diff --git a/test/envelope/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index ed0095a1..80801e4a 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -75,7 +75,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { uint256 depositPrivKey = uint256(keccak256("nodle.vault.deposit-key")); address depositSigner = vm.addr(depositPrivKey); - uint256 idx = nodleVault.makeSelflessMFADeposit{value: 1 wei}(address(0), 0, 1, 0, depositSigner, address(this)); + uint256 idx = nodleVault.createMFALinkFor{value: 1 wei}(address(0), 0, 1, 0, depositSigner, address(this)); // withdrawal signature (signed by deposit pubkey) bytes32 wdHash = MessageHashUtilsLite.toEthSignedMessageHash( @@ -86,7 +86,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { address(nodleVault), idx, address(this), - nodleVault.ANYONE_WITHDRAWAL_MODE() + nodleVault.OPEN_CLAIM_MODE() ) ) ); @@ -105,7 +105,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { (uint8 mv, bytes32 mr, bytes32 ms) = vm.sign(mfaPrivKey, mfaHash); bytes memory mfaSig = abi.encodePacked(mr, ms, mv); - nodleVault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, deadline); + nodleVault.claimWithMFA(idx, address(this), wdSig, mfaSig, deadline); } function test_T2_zeroMfaAuthorizerRejectsAllMfaWithdrawals() public { @@ -113,13 +113,13 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { uint256 depositPrivKey = uint256(keccak256("dep")); address depositSigner = vm.addr(depositPrivKey); - uint256 idx = vault.makeSelflessMFADeposit{value: 1 wei}(address(0), 0, 1, 0, depositSigner, address(this)); + uint256 idx = vault.createMFALinkFor{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(); - vault.withdrawMFADeposit(idx, address(this), wdSig, mfaSig, 0); + vault.claimWithMFA(idx, address(this), wdSig, mfaSig, 0); } } diff --git a/test/envelope/EnvelopeVault.t.sol b/test/envelope/EnvelopeVault.t.sol index ad19614c..f46fccf3 100644 --- a/test/envelope/EnvelopeVault.t.sol +++ b/test/envelope/EnvelopeVault.t.sol @@ -48,33 +48,33 @@ contract EnvelopeVaultTest is Test { function testMakeDepositERC20() public { uint256 amount = 100; - uint256 depositIndex = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + uint256 depositIndex = vault.createLink(address(testToken), 1, amount, 0, PUBKEY20); assertEq(depositIndex, 0, "Deposit failed"); - assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); + assertEq(vault.getLinkCount(), 1, "Deposit count mismatch"); } function testMakeSelflessDepositERC20() public { uint256 amount = 100; // Make a deposit on behalf of SAMPLE_ADDRESS - uint256 depositIndex = vault.makeSelflessDeposit(address(testToken), 1, amount, 0, PUBKEY20, SAMPLE_ADDRESS); + uint256 depositIndex = vault.createLinkFor(address(testToken), 1, amount, 0, PUBKEY20, SAMPLE_ADDRESS); // Deposit was made on behalf of other address, so we can't withdraw - vm.expectRevert(EnvelopeVault.NotTheSender.selector); - vault.withdrawDepositSender(depositIndex); + vm.expectRevert(EnvelopeVault.NotTheCreator.selector); + vault.reclaim(depositIndex); vm.prank(SAMPLE_ADDRESS); // selfless deposit's owner can reclaim - vault.withdrawDepositSender(depositIndex); + vault.reclaim(depositIndex); } function testMakeDepositERC721() public { uint256 tokenId = 1; - uint256 depositIndex = vault.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); + uint256 depositIndex = vault.createLink(address(testToken721), 2, 1, tokenId, PUBKEY20); assertEq(depositIndex, 0, "Deposit failed"); - assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); + assertEq(vault.getLinkCount(), 1, "Deposit count mismatch"); } // test sender withdrawal @@ -82,17 +82,17 @@ contract EnvelopeVaultTest is Test { uint256 amount = 1000; assertEq(testToken.balanceOf(address(vault)), 0, "Contract balance mismatch"); - uint256 depositIndex = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + uint256 depositIndex = vault.createLink(address(testToken), 1, amount, 0, PUBKEY20); assertEq(depositIndex, 0, "Deposit failed"); - assertEq(vault.getDepositCount(), 1, "Deposit count mismatch"); + assertEq(vault.getLinkCount(), 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 - vault.withdrawDepositSender(depositIndex); + vault.reclaim(depositIndex); // Check that the contract has the correct balance assertEq(testToken.balanceOf(address(vault)), 0, "Contract balance mismatch"); diff --git a/test/envelope/Gasless.t.sol b/test/envelope/Gasless.t.sol index b1328e59..47a29f3e 100644 --- a/test/envelope/Gasless.t.sol +++ b/test/envelope/Gasless.t.sol @@ -35,14 +35,14 @@ contract EnvelopeVaultGaslessTest is Test { function _request(uint256 amount, bool withMFA, address recipient, uint40 reclaimableAfter) internal view - returns (EnvelopeVault.DepositRequest memory) + returns (EnvelopeVault.LinkRequest memory) { - return EnvelopeVault.DepositRequest({ + return EnvelopeVault.LinkRequest({ tokenAddress: address(0), contractType: 0, amount: amount, tokenId: 0, - pubKey20: LINK_PUBKEY, + claimKey: LINK_PUBKEY, onBehalfOf: SENDER, withMFA: withMFA, recipient: recipient, @@ -51,7 +51,7 @@ contract EnvelopeVaultGaslessTest is Test { } function _signFeeAuthorization( - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -61,7 +61,7 @@ contract EnvelopeVaultGaslessTest is Test { } function _signFeeAuthorization( - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -79,7 +79,7 @@ contract EnvelopeVaultGaslessTest is Test { request.contractType, request.amount, request.tokenId, - request.pubKey20, + request.claimKey, request.onBehalfOf, request.withMFA, request.recipient, @@ -96,7 +96,7 @@ contract EnvelopeVaultGaslessTest is Test { } function _feeAuthorization( - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, uint256 serviceFee, uint256 gaslessFee, uint256 deadline @@ -105,7 +105,7 @@ contract EnvelopeVaultGaslessTest is Test { } function _feeAuthorization( - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, uint256 serviceFee, uint256 gaslessFee, bool gaslessSponsored, @@ -150,24 +150,24 @@ contract EnvelopeVaultGaslessTest is Test { internal returns (uint256) { - EnvelopeVault.DepositRequest memory request = _request(amount, withMFA, recipient, reclaimableAfter); + EnvelopeVault.LinkRequest memory request = _request(amount, withMFA, recipient, reclaimableAfter); EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0.01 ether, 0.02 ether, 0); vm.prank(SENDER); - return vault.makeCustomDepositWithFees{value: amount}(request, authorization); + return vault.createLinkWithFees{value: amount}(request, authorization); } function test_MakeCustomDepositWithFeesCollectsFeesAtDeposit() public { uint256 amount = 1 ether; uint256 serviceFee = 0.01 ether; uint256 gaslessFee = 0.02 ether; - EnvelopeVault.DepositRequest memory request = _request(amount, true, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(amount, true, address(0), 0); EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, serviceFee, gaslessFee, 0); vm.prank(SENDER); - uint256 index = vault.makeCustomDepositWithFees{value: amount}(request, authorization); + uint256 index = vault.createLinkWithFees{value: amount}(request, authorization); - EnvelopeVault.Deposit memory deposit = vault.getDeposit(index); + EnvelopeVault.Link memory deposit = vault.getLink(index); assertEq(deposit.amount, amount); assertEq(deposit.serviceFee, serviceFee); assertEq(deposit.gaslessFee, gaslessFee); @@ -177,48 +177,48 @@ contract EnvelopeVaultGaslessTest is Test { } function test_SponsoredGaslessAuthorizationApprovesPaymasterWithoutGaslessFee() public { - EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, true, 0); vm.prank(SENDER); - uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - EnvelopeVault.Deposit memory deposit = vault.getDeposit(index); + EnvelopeVault.Link memory deposit = vault.getLink(index); assertEq(deposit.gaslessFee, 0); assertTrue(deposit.gaslessSponsored); assertEq(feeToken.balanceOf(address(vault)), 0); - bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); - bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); } function test_SponsoredGaslessMfaClaimIsApprovedWithoutCollectedGaslessFee() public { - EnvelopeVault.DepositRequest memory request = _request(1 ether, true, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(1 ether, true, address(0), 0); EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, true, 0); vm.prank(SENDER); - uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); uint256 deadline = block.timestamp + 1 hours; - bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); bytes memory mfaSig = _signMfa(index, RECIPIENT, deadline); bytes memory callData = - abi.encodeCall(EnvelopeVault.withdrawMFADeposit, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); + abi.encodeCall(EnvelopeVault.claimWithMFA, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); assertEq(feeToken.balanceOf(address(vault)), 0); } function test_ZeroFeeAuthorizationWithBackendSignatureIsAccepted() public { - EnvelopeVault.DepositRequest memory request = + EnvelopeVault.LinkRequest memory request = _request(1 ether, true, RECIPIENT, uint40(block.timestamp + 1 days)); EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, 0); vm.prank(SENDER); - uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - EnvelopeVault.Deposit memory deposit = vault.getDeposit(index); + EnvelopeVault.Link memory deposit = vault.getLink(index); assertEq(deposit.serviceFee, 0); assertEq(deposit.gaslessFee, 0); assertFalse(deposit.gaslessSponsored); @@ -227,21 +227,21 @@ contract EnvelopeVaultGaslessTest is Test { } function test_ZeroFeeAuthorizationWithoutSignatureRemainsOpen() public { - EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ serviceFee: 0, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: "" }); vm.prank(SENDER); - uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - EnvelopeVault.Deposit memory deposit = vault.getDeposit(index); + EnvelopeVault.Link memory deposit = vault.getLink(index); assertEq(deposit.serviceFee, 0); assertEq(deposit.gaslessFee, 0); } function test_RevertIf_ZeroFeeAuthorizationSignatureWrong() public { - EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ serviceFee: 0, gaslessFee: 0, @@ -252,31 +252,31 @@ contract EnvelopeVaultGaslessTest is Test { vm.prank(SENDER); vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); - vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + vault.createLinkWithFees{value: 1 ether}(request, authorization); } function test_RevertIf_SponsoredGaslessFlagTampered() public { - EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, false, 0); authorization.gaslessSponsored = true; vm.prank(SENDER); vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); - vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + vault.createLinkWithFees{value: 1 ether}(request, authorization); } function test_RevertIf_FeeTokenNotConfigured() public { EnvelopeVault vaultWithoutFeeToken = new EnvelopeVault(BACKEND_AUTHORIZER, address(this), address(0)); - EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, 0); vm.prank(SENDER); vm.expectRevert(EnvelopeVault.FeeTokenNotConfigured.selector); - vaultWithoutFeeToken.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + vaultWithoutFeeToken.createLinkWithFees{value: 1 ether}(request, authorization); } function test_RevertIf_FeeAuthorizationExpired() public { - EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); uint256 deadline = block.timestamp + 1 hours; EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, deadline); @@ -284,23 +284,23 @@ contract EnvelopeVaultGaslessTest is Test { vm.prank(SENDER); vm.expectRevert(EnvelopeVault.FeeAuthorizationExpired.selector); - vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + vault.createLinkWithFees{value: 1 ether}(request, authorization); } function test_RevertIf_WrongFeeAuthorizationSignature() public { - EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, 0); authorization.gaslessFee = 0.02 ether; vm.prank(SENDER); vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); - vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + vault.createLinkWithFees{value: 1 ether}(request, authorization); } function test_IsValidGaslessClaim() public { uint256 index = _makeGaslessDeposit(1 ether, false, address(0), 0); - bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); - bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); assertFalse(vault.isValidGaslessOperation(SENDER, callData)); @@ -309,10 +309,10 @@ contract EnvelopeVaultGaslessTest is Test { function test_IsValidGaslessMfaClaim() public { uint256 index = _makeGaslessDeposit(1 ether, true, address(0), 0); uint256 deadline = block.timestamp + 1 hours; - bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); bytes memory mfaSig = _signMfa(index, RECIPIENT, deadline); bytes memory callData = - abi.encodeCall(EnvelopeVault.withdrawMFADeposit, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); + abi.encodeCall(EnvelopeVault.claimWithMFA, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); @@ -322,8 +322,8 @@ contract EnvelopeVaultGaslessTest is Test { function test_IsValidGaslessRecipientBoundClaim() public { uint256 index = _makeGaslessDeposit(1 ether, false, RECIPIENT, uint40(block.timestamp + 1 days)); - bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); - bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); assertFalse(vault.isValidGaslessOperation(address(0xCAFE), callData)); @@ -332,7 +332,7 @@ contract EnvelopeVaultGaslessTest is Test { function test_IsValidGaslessReclaimAfterDelay() public { uint40 reclaimableAfter = uint40(block.timestamp + 1 days); uint256 index = _makeGaslessDeposit(1 ether, false, RECIPIENT, reclaimableAfter); - bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDepositSender, (index)); + bytes memory callData = abi.encodeCall(EnvelopeVault.reclaim, (index)); assertFalse(vault.isValidGaslessOperation(SENDER, callData)); @@ -342,14 +342,14 @@ contract EnvelopeVaultGaslessTest is Test { } function test_ZeroGaslessFeeDoesNotApprovePaymaster() public { - EnvelopeVault.DepositRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0.01 ether, 0, 0); vm.prank(SENDER); - uint256 index = vault.makeCustomDepositWithFees{value: 1 ether}(request, authorization); + uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.ANYONE_WITHDRAWAL_MODE()); - bytes memory callData = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); assertFalse(vault.isValidGaslessOperation(RECIPIENT, callData)); } diff --git a/test/envelope/Integration.t.sol b/test/envelope/Integration.t.sol index d047b389..e4035c9f 100644 --- a/test/envelope/Integration.t.sol +++ b/test/envelope/Integration.t.sol @@ -37,12 +37,12 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { // check invariants function testIntegrationEtherSenderWithdraw(uint64 amount) public { vm.assume(amount > 0); - assertEq(vault.getDepositCount(), 0); // deposit count invariant + assertEq(vault.getLinkCount(), 0); // deposit count invariant assertEq(address(vault).balance, 0); // contract balance invariant uint256 senderBalance = address(this).balance; // sender balance invariant - uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, PUBKEY20); + uint256 depositIdx = vault.createLink{value: amount}(address(0), 0, amount, 0, PUBKEY20); assertEq(depositIdx, 0); // deposit index invariant - assertEq(vault.getDepositCount(), 1); // deposit count invariant + assertEq(vault.getLinkCount(), 1); // deposit count invariant assertEq(address(vault).balance, amount); // contract balance invariant assertEq(address(this).balance, senderBalance - amount); // sender balance invariant @@ -50,8 +50,8 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { vm.warp(block.timestamp + 25 hours); // Withdraw the deposit - vault.withdrawDepositSender(depositIdx); - assertEq(vault.getDepositCount(), 1); // deposit count invariant + vault.reclaim(depositIdx); + assertEq(vault.getLinkCount(), 1); // deposit count invariant assertEq(address(vault).balance, 0); // contract balance invariant assertEq(address(this).balance, senderBalance); // sender balance invariant } @@ -63,9 +63,9 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { // approve the contract to spend the tokens testToken.approve(address(vault), amount); assertEq(testToken.balanceOf(address(this)), amount); // contract token balance invariant - uint256 depositIdx = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + uint256 depositIdx = vault.createLink(address(testToken), 1, amount, 0, PUBKEY20); assertEq(depositIdx, 0); // deposit index invariant - assertEq(vault.getDepositCount(), 1); // deposit count invariant + assertEq(vault.getLinkCount(), 1); // deposit count invariant assertEq(testToken.balanceOf(address(vault)), amount); // contract token balance invariant assertEq(testToken.balanceOf(address(this)), 0); // sender token balance invariant @@ -73,8 +73,8 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { vm.warp(block.timestamp + 25 hours); // Withdraw the deposit - vault.withdrawDepositSender(depositIdx); - assertEq(vault.getDepositCount(), 1); // deposit count invariant + vault.reclaim(depositIdx); + assertEq(vault.getLinkCount(), 1); // deposit count invariant assertEq(testToken.balanceOf(address(vault)), 0); // contract token balance invariant assertEq(testToken.balanceOf(address(this)), amount); // sender token balance invariant } @@ -86,15 +86,15 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { testToken721.approve(address(vault), tokenId); // invariant checks - assertEq(vault.getDepositCount(), 0); + assertEq(vault.getLinkCount(), 0); assertEq(testToken721.ownerOf(tokenId), address(this)); assertEq(testToken721.balanceOf(address(vault)), 0); assertEq(testToken721.balanceOf(address(this)), 1); - uint256 depositIdx = vault.makeDeposit(address(testToken721), 2, 1, tokenId, PUBKEY20); + uint256 depositIdx = vault.createLink(address(testToken721), 2, 1, tokenId, PUBKEY20); // invariant checks assertEq(depositIdx, 0); - assertEq(vault.getDepositCount(), 1); + assertEq(vault.getLinkCount(), 1); assertEq(testToken721.ownerOf(tokenId), address(vault)); assertEq(testToken721.balanceOf(address(vault)), 1); assertEq(testToken721.balanceOf(address(this)), 0); @@ -103,10 +103,10 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { vm.warp(block.timestamp + 25 hours); // Withdraw the deposit - vault.withdrawDepositSender(depositIdx); + vault.reclaim(depositIdx); // invariant checks - assertEq(vault.getDepositCount(), 1); + assertEq(vault.getLinkCount(), 1); assertEq(testToken721.ownerOf(tokenId), address(this)); assertEq(testToken721.balanceOf(address(vault)), 0); assertEq(testToken721.balanceOf(address(this)), 1); @@ -119,9 +119,9 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { testToken1155.mint(address(this), tokenId, amount, ""); testToken1155.setApprovalForAll(address(vault), true); assertEq(testToken1155.balanceOf(address(this), tokenId), amount); // contract token balance invariant - uint256 depositIdx = vault.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); + uint256 depositIdx = vault.createLink(address(testToken1155), 3, amount, tokenId, PUBKEY20); assertEq(depositIdx, 0); // deposit index invariant - assertEq(vault.getDepositCount(), 1); // deposit count invariant + assertEq(vault.getLinkCount(), 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 @@ -129,8 +129,8 @@ contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { vm.warp(block.timestamp + 25 hours); // Withdraw the deposit - vault.withdrawDepositSender(depositIdx); - assertEq(vault.getDepositCount(), 1); // deposit count invariant + vault.reclaim(depositIdx); + assertEq(vault.getLinkCount(), 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 3c2b0c9f..fbff0062 100644 --- a/test/envelope/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -39,7 +39,7 @@ contract EnvelopeVaultMFATest is Test { address(vault), depositIndex, recipient, - vault.ANYONE_WITHDRAWAL_MODE() + vault.OPEN_CLAIM_MODE() ) ) ); @@ -49,34 +49,34 @@ contract EnvelopeVaultMFATest is Test { function testMFADeposit() public { uint256 depositIndex = - vault.makeSelflessMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, SAMPLE_ADDRESS, address(0x1234)); + vault.createMFALinkFor{value: 1 ether}(address(0), 0, 1 ether, 0, SAMPLE_ADDRESS, address(0x1234)); bytes memory withdrawalSig = _signWithdrawal(depositIndex, address(this)); vm.expectRevert(EnvelopeVault.RequiresMfaAuthorization.selector); - vault.withdrawDeposit(depositIndex, address(this), withdrawalSig); + vault.claim(depositIndex, address(this), withdrawalSig); vm.expectRevert(EnvelopeVault.WrongMfaSignature.selector); - vault.withdrawMFADeposit(depositIndex, address(this), withdrawalSig, withdrawalSig, 0); + vault.claimWithMFA(depositIndex, address(this), withdrawalSig, withdrawalSig, 0); bytes memory mfaSig = _signMfa(depositIndex, address(this), 0); - vault.withdrawMFADeposit(depositIndex, address(this), withdrawalSig, mfaSig, 0); + vault.claimWithMFA(depositIndex, address(this), withdrawalSig, mfaSig, 0); } function testMFADepositWithDeadline() public { uint256 depositIndex = - vault.makeSelflessMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, SAMPLE_ADDRESS, address(0x1234)); + vault.createMFALinkFor{value: 1 ether}(address(0), 0, 1 ether, 0, SAMPLE_ADDRESS, address(0x1234)); uint256 deadline = block.timestamp + 1 hours; bytes memory withdrawalSig = _signWithdrawal(depositIndex, address(this)); bytes memory mfaSig = _signMfa(depositIndex, address(this), deadline); - vault.withdrawMFADeposit(depositIndex, address(this), withdrawalSig, mfaSig, deadline); + vault.claimWithMFA(depositIndex, address(this), withdrawalSig, mfaSig, deadline); } function test_RevertIf_MfaSignatureExpired() public { uint256 depositIndex = - vault.makeSelflessMFADeposit{value: 1 ether}(address(0), 0, 1 ether, 0, SAMPLE_ADDRESS, address(0x1234)); + vault.createMFALinkFor{value: 1 ether}(address(0), 0, 1 ether, 0, SAMPLE_ADDRESS, address(0x1234)); uint256 deadline = block.timestamp + 1 hours; bytes memory withdrawalSig = _signWithdrawal(depositIndex, address(this)); @@ -85,7 +85,7 @@ contract EnvelopeVaultMFATest is Test { vm.warp(deadline + 1); vm.expectRevert(EnvelopeVault.MfaSignatureExpired.selector); - vault.withdrawMFADeposit(depositIndex, address(this), withdrawalSig, mfaSig, deadline); + vault.claimWithMFA(depositIndex, address(this), withdrawalSig, mfaSig, deadline); } receive() external payable {} diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index 7d2d1618..688f16a6 100644 --- a/test/envelope/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -28,7 +28,7 @@ contract RecipientBoundTest is Test { } function testRecipientBoundDeposit() public { - uint256 depositIndex = vault.makeCustomDeposit( + uint256 depositIndex = vault.createCustomLink( address(testToken), 1, // contract type - erc 20 1000, // amount @@ -44,9 +44,9 @@ contract RecipientBoundTest is Test { // Should not be able to withdraw to anybody except SAMPLE_ADDRESS vm.expectRevert(EnvelopeVault.WrongRecipient.selector); - vault.withdrawDeposit(depositIndex, address(this), bytes("")); + vault.claim(depositIndex, address(this), bytes("")); - vault.withdrawDeposit(depositIndex, SAMPLE_ADDRESS, bytes("")); + vault.claim(depositIndex, SAMPLE_ADDRESS, bytes("")); require(testToken.balanceOf(SAMPLE_ADDRESS) == 1000, "SAMPLE_ADDRESS SHOULD HAVE RECEIVED TOKENS!"); } @@ -54,7 +54,7 @@ contract RecipientBoundTest is Test { * Reclaim an address-bound deposit. */ function testRecipientBoundReclaim() public { - uint256 depositIndex = vault.makeCustomDeposit( + uint256 depositIndex = vault.createCustomLink( address(testToken), 1, // contract type - erc 20 1000, // amount @@ -69,10 +69,10 @@ contract RecipientBoundTest is Test { // Try to reclaim, but it's too early vm.expectRevert(EnvelopeVault.TooEarlyToReclaim.selector); - vault.withdrawDepositSender(depositIndex); + vault.reclaim(depositIndex); vm.warp(block.timestamp + 11); // advance past reclaimableAfter - vault.withdrawDepositSender(depositIndex); + vault.reclaim(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 78aa74ff..11135ad6 100644 --- a/test/envelope/SenderWithdraw.t.sol +++ b/test/envelope/SenderWithdraw.t.sol @@ -24,10 +24,10 @@ contract TestSenderWithdrawEther is Test { function testSenderWithdrawEther(uint64 amount) public { vm.assume(amount > 0); - uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, PUBKEY20); + uint256 depositIdx = vault.createLink{value: amount}(address(0), 0, amount, 0, PUBKEY20); // Withdraw the deposit - vault.withdrawDepositSender(depositIdx); + vault.reclaim(depositIdx); } } @@ -55,12 +55,12 @@ contract TestSenderWithdrawErc20 is Test { // Make a deposit uint256 amount = 2 ** 128; - _depositIdx = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + _depositIdx = vault.createLink(address(testToken), 1, amount, 0, PUBKEY20); } function testSenderWithdrawErc20() public { // Withdraw the deposit - vault.withdrawDepositSender(_depositIdx); + vault.reclaim(_depositIdx); } } @@ -88,12 +88,12 @@ contract TestSenderWithdrawErc721 is Test, ERC721Holder { testToken.approve(address(vault), _tokenId); // Make a deposit - _depositIdx = vault.makeDeposit(address(testToken), 2, 1, _tokenId, PUBKEY20); + _depositIdx = vault.createLink(address(testToken), 2, 1, _tokenId, PUBKEY20); } function testSenderWithdrawErc721() public { // Withdraw the deposit - vault.withdrawDepositSender(_depositIdx); + vault.reclaim(_depositIdx); } } @@ -118,11 +118,11 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { testToken.setApprovalForAll(address(vault), true); // Make a deposit - _depositIdx = vault.makeDeposit(address(testToken), 3, 100, _tokenId, PUBKEY20); + _depositIdx = vault.createLink(address(testToken), 3, 100, _tokenId, PUBKEY20); } function testSenderWithdrawErc1155() public { // Withdraw the deposit - vault.withdrawDepositSender(_depositIdx); + vault.reclaim(_depositIdx); } } diff --git a/test/envelope/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol index 35035d4a..2a8e798d 100644 --- a/test/envelope/SigWithdraw.t.sol +++ b/test/envelope/SigWithdraw.t.sol @@ -31,29 +31,29 @@ contract TestSigWithdrawEther is Test { // test sender withdrawal of ETH function testSigWithdrawEther(uint64 amount) public { vm.assume(amount > 0); - uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); + uint256 depositIdx = vault.createLink{value: amount}(address(0), 0, amount, 0, _pubkey20); // Can't use withdrawDepositAsRecipient vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); - vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureAnybody); + vault.claimAsBoundRecipient(depositIdx, _recipientAddress, signatureAnybody); // Anybody can withdraw - vault.withdrawDeposit(depositIdx, _recipientAddress, signatureAnybody); + vault.claim(depositIdx, _recipientAddress, signatureAnybody); } function testWithdrawDepositAsRecipient(uint64 amount) public { vm.assume(amount > 0); - uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); + uint256 depositIdx = vault.createLink{value: amount}(address(0), 0, amount, 0, _pubkey20); // Can't use pure withdrawDeposit vm.expectRevert(EnvelopeVault.WrongSignature.selector); - vault.withdrawDeposit(depositIdx, _recipientAddress, signatureRecipient); + vault.claim(depositIdx, _recipientAddress, signatureRecipient); // Only the recipient is able to withdraw via withdrawDepositAsRecipient vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); - vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); + vault.claimAsBoundRecipient(depositIdx, _recipientAddress, signatureRecipient); vm.prank(_recipientAddress); // Withdraw! - vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); + vault.claimAsBoundRecipient(depositIdx, _recipientAddress, signatureRecipient); } } diff --git a/test/paymasters/EnvelopePaymaster.t.sol b/test/paymasters/EnvelopePaymaster.t.sol index c6eb7f4d..b705d835 100644 --- a/test/paymasters/EnvelopePaymaster.t.sol +++ b/test/paymasters/EnvelopePaymaster.t.sol @@ -43,13 +43,13 @@ contract EnvelopePaymasterTest is Test { feeToken.approve(address(vault), type(uint256).max); } - function _request(uint256 amount) internal view returns (EnvelopeVault.DepositRequest memory) { - return EnvelopeVault.DepositRequest({ + function _request(uint256 amount) internal view returns (EnvelopeVault.LinkRequest memory) { + return EnvelopeVault.LinkRequest({ tokenAddress: address(0), contractType: 0, amount: amount, tokenId: 0, - pubKey20: LINK_PUBKEY, + claimKey: LINK_PUBKEY, onBehalfOf: SENDER, withMFA: false, recipient: address(0), @@ -58,7 +58,7 @@ contract EnvelopePaymasterTest is Test { } function _signFeeAuthorization( - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, uint256 serviceFee, uint256 gaslessFee, uint256 deadline @@ -67,7 +67,7 @@ contract EnvelopePaymasterTest is Test { } function _signFeeAuthorization( - EnvelopeVault.DepositRequest memory request, + EnvelopeVault.LinkRequest memory request, uint256 serviceFee, uint256 gaslessFee, bool gaslessSponsored, @@ -84,7 +84,7 @@ contract EnvelopePaymasterTest is Test { request.contractType, request.amount, request.tokenId, - request.pubKey20, + request.claimKey, request.onBehalfOf, request.withMFA, request.recipient, @@ -101,7 +101,7 @@ contract EnvelopePaymasterTest is Test { } function _makeGaslessDeposit(uint256 amount) internal returns (uint256) { - EnvelopeVault.DepositRequest memory request = _request(amount); + EnvelopeVault.LinkRequest memory request = _request(amount); EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ serviceFee: 0, gaslessFee: 0.01 ether, @@ -111,7 +111,7 @@ contract EnvelopePaymasterTest is Test { }); vm.prank(SENDER); - return vault.makeCustomDepositWithFees{value: amount}(request, authorization); + return vault.createLinkWithFees{value: amount}(request, authorization); } function _signWithdrawal(uint256 depositIndex, address recipient) internal view returns (bytes memory) { @@ -123,7 +123,7 @@ contract EnvelopePaymasterTest is Test { address(vault), depositIndex, recipient, - vault.ANYONE_WITHDRAWAL_MODE() + vault.OPEN_CLAIM_MODE() ) ) ); @@ -149,7 +149,7 @@ contract EnvelopePaymasterTest is Test { function test_ValidateAndPayForGaslessEnvelopeClaim() public { uint256 index = _makeGaslessDeposit(1 ether); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); - bytes memory data = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + bytes memory data = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); uint256 gasLimit = 100_000; uint256 maxFeePerGas = 1 gwei; @@ -168,7 +168,7 @@ contract EnvelopePaymasterTest is Test { function test_RevertIf_DestinationIsNotEnvelopeVault() public { uint256 index = _makeGaslessDeposit(1 ether); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); - bytes memory data = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + bytes memory data = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); Transaction memory txn = _buildTransaction(RECIPIENT, address(feeToken), data, 100_000, 1 gwei); vm.prank(BOOTLOADER_FORMAL_ADDRESS); @@ -178,10 +178,10 @@ contract EnvelopePaymasterTest is Test { function test_RevertIf_EnvelopeOperationNotApproved() public { vm.prank(SENDER); - uint256 index = vault.makeDeposit{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY); + uint256 index = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); - bytes memory data = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + bytes memory data = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), data, 100_000, 1 gwei); vm.prank(BOOTLOADER_FORMAL_ADDRESS); @@ -192,7 +192,7 @@ contract EnvelopePaymasterTest is Test { function test_RevertIf_PaymasterBalanceTooLow() public { uint256 index = _makeGaslessDeposit(1 ether); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); - bytes memory data = abi.encodeCall(EnvelopeVault.withdrawDeposit, (index, RECIPIENT, withdrawalSig)); + bytes memory data = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), data, 2 ether, 1); vm.prank(BOOTLOADER_FORMAL_ADDRESS); From f177323926f70bb2567977469fc9f5471a0433b2 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 20 May 2026 19:50:47 +1200 Subject: [PATCH 44/49] =?UTF-8?q?refactor(envelope):=20rename=20contract?= =?UTF-8?q?=20EnvelopeVault=20=E2=86=92=20EnvelopeLinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract custodies link-based gifts, not a vault in the DeFi sense. EnvelopeLinks better describes the mental model: create links, recipients claim them, creators reclaim them. Also: - Fix cspell dictionary (Reown, CBOR, Remy, konlet) - Update docs: fix stale function names in README and flow tables - Add post-deployment cast smoke test recipes to EnvelopeLinks.md - Rename paymaster internals: envelopeVault → envelopeLinks --- .cspell.json | 7 +- .solhintignore | 2 +- hardhat-deploy/DeployEnvelope.ts | 8 +- .../{EnvelopeVault.sol => EnvelopeLinks.sol} | 2 +- src/envelope/IEnvelopeGaslessValidator.sol | 2 +- .../{EnvelopeVault.md => EnvelopeLinks.md} | 141 ++++++++++++++---- src/envelope/doc/EnvelopePaymaster.md | 14 +- src/envelope/doc/README.md | 36 ++--- src/paymasters/EnvelopePaymaster.sol | 16 +- test/envelope/Deposit.t.sol | 10 +- test/envelope/EnvelopeBatching.t.sol | 82 +++++----- test/envelope/EnvelopeEdgeCases.t.sol | 58 +++---- test/envelope/EnvelopeHardening.t.sol | 18 +-- ...nvelopeVault.t.sol => EnvelopeLinks.t.sol} | 12 +- test/envelope/Gasless.t.sol | 108 +++++++------- test/envelope/Integration.t.sol | 10 +- test/envelope/MFA.t.sol | 14 +- test/envelope/RecipientBound.t.sol | 10 +- test/envelope/SenderWithdraw.t.sol | 18 +-- test/envelope/SigWithdraw.t.sol | 12 +- test/paymasters/EnvelopePaymaster.t.sol | 30 ++-- 21 files changed, 352 insertions(+), 258 deletions(-) rename src/envelope/{EnvelopeVault.sol => EnvelopeLinks.sol} (99%) rename src/envelope/doc/{EnvelopeVault.md => EnvelopeLinks.md} (78%) rename test/envelope/{EnvelopeVault.t.sol => EnvelopeLinks.t.sol} (92%) diff --git a/.cspell.json b/.cspell.json index e29af002..8c62514c 100644 --- a/.cspell.json +++ b/.cspell.json @@ -122,6 +122,11 @@ "defi", "MAGICVALUE", "unhashed", - "Hashbinary" + "Hashbinary", + "Reown", + "konlet", + "CBOR", + "Remy", + "remy" ] } diff --git a/.solhintignore b/.solhintignore index c66157b9..0fb71eb2 100644 --- a/.solhintignore +++ b/.solhintignore @@ -12,4 +12,4 @@ # is NOT in this list and remains lint-clean. -src/envelope/EnvelopeVault.sol +src/envelope/EnvelopeLinks.sol diff --git a/hardhat-deploy/DeployEnvelope.ts b/hardhat-deploy/DeployEnvelope.ts index 2143603f..04a08bfb 100644 --- a/hardhat-deploy/DeployEnvelope.ts +++ b/hardhat-deploy/DeployEnvelope.ts @@ -57,7 +57,7 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); // 1. Vault — required. - const vault = await deployContract(deployer, "EnvelopeVault", [ + const vault = await deployContract(deployer, "EnvelopeLinks", [ mfaAuthorizer, envelopeOwner, feeToken, @@ -77,17 +77,17 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { console.log(""); console.log("=== Deployment Complete ==="); - console.log("EnvelopeVault: ", vaultAddr); + console.log("EnvelopeLinks: ", vaultAddr); if (paymasterAddr) console.log("EnvelopePaymaster: ", paymasterAddr); console.log(""); // Verification console.log("=== Verifying Contracts ==="); try { - console.log("Verifying EnvelopeVault..."); + console.log("Verifying EnvelopeLinks..."); await hre.run("verify:verify", { address: vaultAddr, - contract: "src/envelope/EnvelopeVault.sol:EnvelopeVault", + contract: "src/envelope/EnvelopeLinks.sol:EnvelopeLinks", constructorArguments: [mfaAuthorizer, envelopeOwner, feeToken], }); } catch (e: any) { diff --git a/src/envelope/EnvelopeVault.sol b/src/envelope/EnvelopeLinks.sol similarity index 99% rename from src/envelope/EnvelopeVault.sol rename to src/envelope/EnvelopeLinks.sol index 9b38df12..baa200f1 100644 --- a/src/envelope/EnvelopeVault.sol +++ b/src/envelope/EnvelopeLinks.sol @@ -15,7 +15,7 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; -contract EnvelopeVault is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ownable2Step { +contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ownable2Step { using SafeERC20 for IERC20; // ── Custom Errors ──────────────────────────────────────────────────────────── diff --git a/src/envelope/IEnvelopeGaslessValidator.sol b/src/envelope/IEnvelopeGaslessValidator.sol index 8e619849..0a5fb058 100644 --- a/src/envelope/IEnvelopeGaslessValidator.sol +++ b/src/envelope/IEnvelopeGaslessValidator.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; -/// @notice Minimal EnvelopeVault view used by ZkSync paymasters during validation. +/// @notice Minimal EnvelopeLinks view used by ZkSync paymasters during validation. interface IEnvelopeGaslessValidator { /// @notice Returns true when `caller` may use a paymaster for the encoded vault call. function isValidGaslessOperation(address caller, bytes calldata callData) external view returns (bool); diff --git a/src/envelope/doc/EnvelopeVault.md b/src/envelope/doc/EnvelopeLinks.md similarity index 78% rename from src/envelope/doc/EnvelopeVault.md rename to src/envelope/doc/EnvelopeLinks.md index a0961553..763c5aa5 100644 --- a/src/envelope/doc/EnvelopeVault.md +++ b/src/envelope/doc/EnvelopeLinks.md @@ -1,10 +1,10 @@ -# EnvelopeVault +# EnvelopeLinks -`src/envelope/EnvelopeVault.sol` +`src/envelope/EnvelopeLinks.sol` ## Purpose -`EnvelopeVault` is a link-based asset vault for ETH, ERC-20, ERC-721, and ERC-1155 gifts. A sender deposits an asset against a per-link claim key; the recipient claims by presenting a signature from the matching private key. The vault supports open links, address-bound links, optional backend MFA, sender reclaim, link-creation-time service fees, and prepaid or backend-sponsored gasless claim/reclaim eligibility for ZkSync paymasters. +`EnvelopeLinks` is a link-based asset vault for ETH, ERC-20, ERC-721, and ERC-1155 gifts. A sender deposits an asset against a per-link claim key; the recipient claims by presenting a signature from the matching private key. The vault supports open links, address-bound links, optional backend MFA, sender reclaim, link-creation-time service fees, and prepaid or backend-sponsored gasless claim/reclaim eligibility for ZkSync paymasters. ## Actors And Architecture @@ -16,15 +16,15 @@ The core product actors are: | Backend / Atlas | Prices the service and gasless portions, signs `FeeAuthorization`, and optionally signs MFA approvals at claim time. Atlas can also sponsor gasless eligibility with `gaslessSponsored=true`. | | Receiver / Remy | Opens the link and claims the gift. Remy may be explicitly recipient-bound, or the link may be open to whoever has the link key. | | App Wallet | A ZkSync smart account controlled by the app. It batches approvals plus the vault call into one user confirmation. | -| EnvelopeVault | Custodies gifts, validates backend fee authorization, stores gasless eligibility, and executes claims/reclaims. | -| EnvelopePaymaster | Pays ZkSync claim/reclaim gas only when `EnvelopeVault.isValidGaslessOperation` approves the calldata. | +| EnvelopeLinks | Custodies gifts, validates backend fee authorization, stores gasless eligibility, and executes claims/reclaims. | +| EnvelopePaymaster | Pays ZkSync claim/reclaim gas only when `EnvelopeLinks.isValidGaslessOperation` approves the calldata. | ```mermaid flowchart LR Sender[Sender / Nina] --> AppWallet[App Wallet\nZkSync smart account] AppWallet -->|approve gift token if needed| GiftToken[Gift token\nETH / ERC20 / ERC721 / ERC1155] AppWallet -->|approve fee token if needed| FeeToken[NODL fee token] - AppWallet -->|deposit call| Vault[EnvelopeVault] + AppWallet -->|deposit call| Vault[EnvelopeLinks] Backend[Backend / Atlas\nMFA + fee signer] -->|FeeAuthorization| AppWallet Backend -->|MFA signature| Receiver[Receiver / Remy] Receiver -->|claim tx| Vault @@ -95,7 +95,7 @@ if (feeAuthorization.serviceFee + feeAuthorization.gaslessFee > 0 && nodlAllowan calls.push(Call({ to: address(vault), value: request.contractType == 0 ? request.amount : 0, - data: abi.encodeCall(EnvelopeVault.createLinkWithFees, (request, feeAuthorization)) + data: abi.encodeCall(EnvelopeLinks.createLinkWithFees, (request, feeAuthorization)) })); appWallet.executeBatch(calls, paymasterParams); @@ -107,30 +107,30 @@ For ERC-721, use `approve(vault, tokenId)` before the vault call if the vault is ### No MFA, No Gasless P2P Gift -In this flow **no backend is involved**. The link secret is an ephemeral ECDSA private key generated entirely on the sender's device. The shareable link encodes: `chainId`, vault address, link index, and the raw private key. The recipient's app extracts the private key, signs the recipient's own address, and calls `withdrawDeposit`. Because no `FeeAuthorization` is submitted, the vault stores zero fees, no gasless eligibility, and no MFA requirement. +In this flow **no backend is involved**. The link secret is an ephemeral ECDSA private key generated entirely on the sender's device. The shareable link encodes: `chainId`, vault address, link index, and the raw private key. The recipient's app extracts the private key, signs the recipient's own address, and calls `claim`. Because no `FeeAuthorization` is submitted, the vault stores zero fees, no gasless eligibility, and no MFA requirement. ```mermaid sequenceDiagram participant Nina as Sender / Nina participant Wallet as App Wallet - participant Vault as EnvelopeVault + participant Vault as EnvelopeLinks participant Remy as Receiver / Remy Note over Nina,Wallet: Client-side only — no backend needed Nina->>Wallet: Generate ephemeral ECDSA keypair (linkPrivKey, claimKey) Wallet->>Wallet: Approve gift token if ERC-20/721/1155 (AA batch) - Wallet->>Vault: makeDeposit(token, type, amount, tokenId, claimKey) - Vault-->>Wallet: depositIndex stored (no fees, no MFA) - Nina->>Nina: Encode link = chainId + vault + depositIndex + linkPrivKey + Wallet->>Vault: createLink(token, type, amount, tokenId, claimKey) + Vault-->>Wallet: linkIndex stored (no fees, no MFA) + Nina->>Nina: Encode link = chainId + vault + linkIndex + linkPrivKey Nina-->>Remy: Share link out of band (QR, messenger, NFC, etc.) - Remy->>Remy: Decode link → extract linkPrivKey and depositIndex + Remy->>Remy: Decode link → extract linkPrivKey and linkIndex Remy->>Remy: Sign own address with linkPrivKey → linkSignature - Remy->>Vault: withdrawDeposit(depositIndex, remyAddress, linkSignature) + Remy->>Vault: claim(linkIndex, remyAddress, linkSignature) Vault->>Vault: ecrecover(linkSignature) == deposit.claimKey ✓ Vault-->>Remy: Transfer full gift amount ``` -If Remy is recipient-bound, the sender uses `makeCustomDeposit(..., recipient=Remy, reclaimableAfter=...)`, and Remy can call either `withdrawDeposit` with Remy as recipient or the stricter `claimAsBoundRecipient` path. The paymaster will only sponsor recipient-bound claims when the caller is the bound recipient and gasless eligibility exists. +If Remy is recipient-bound, the sender uses `createCustomLink(..., recipient=Remy, reclaimableAfter=...)`, and Remy can call either `claim` with Remy as recipient or the stricter `claimAsBoundRecipient` path. The paymaster will only sponsor recipient-bound claims when the caller is the bound recipient and gasless eligibility exists. ### MFA Without Gasless Claim @@ -140,7 +140,7 @@ sequenceDiagram participant Wallet as App Wallet participant Atlas as Backend / Atlas participant FeeToken as NODL Fee Token - participant Vault as EnvelopeVault + participant Vault as EnvelopeLinks participant Remy as Receiver / Remy Nina->>Wallet: Configure MFA gift @@ -167,7 +167,7 @@ sequenceDiagram participant Wallet as App Wallet participant Atlas as Backend / Atlas participant FeeToken as NODL Fee Token - participant Vault as EnvelopeVault + participant Vault as EnvelopeLinks participant Remy as Receiver / Remy participant Paymaster as EnvelopePaymaster participant Bootloader as ZkSync Bootloader @@ -235,18 +235,18 @@ struct Deposit { | Function | Flow | | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `makeDeposit(token, type, amount, tokenId, claimKey)` | Basic open link. No MFA, no fees, no gasless sponsorship. | +| `createLink(token, type, amount, tokenId, claimKey)` | Basic open link. No MFA, no fees, no gasless sponsorship. | | `createMFALink(...)` | Basic open link that requires backend MFA at claim time. No link-creation-time fees unless using `createLinkWithFees`. | | `createLinkFor(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | | `createMFALinkFor(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | -| `makeCustomDeposit(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | +| `createCustomLink(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | | `createLinkWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0` or `gaslessSponsored=true`. | | `createLinks(...)` | Creates many same-shape no-fee deposits in one transaction. ETH, ERC-20, and ERC-1155 are supported; ERC-721 uses the heterogeneous batch path. | | `createLinksNoReturn(...)` | Same as `createLinks` but skips allocating/returning the link indexes array. | | `createCustomLinks(...)` | Creates a heterogeneous no-fee batch and supports ETH, ERC-20, ERC-721, and ERC-1155. | | `createCustomLinksWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `LinkRequest` and `FeeAuthorization` structs as the single-deposit flow. | | `createLinksRaffle(...)` | Creates ETH or ERC-20 raffle-style deposits with different amounts and one shared `claimKey`. | -| `makeBatchMFADepositRaffle(...)` | Same as raffle batching, but every deposit requires MFA at claim time. | +| `createMFARaffleLinks(...)` | Same as raffle batching, but every deposit requires MFA at claim time. | ```solidity struct FeeAuthorization { @@ -262,7 +262,7 @@ struct FeeAuthorization { ## Vault-Native Batching -Batching is implemented directly in `EnvelopeVault` rather than a separate companion contract. This keeps the real sender as `msg.sender`, so reclaim rights and backend fee signatures use the same identity as single deposits. It also removes the extra custody hop where a batcher temporarily holds tokens before forwarding them to the vault. +Batching is implemented directly in `EnvelopeLinks` rather than a separate companion contract. This keeps the real sender as `msg.sender`, so reclaim rights and backend fee signatures use the same identity as single deposits. It also removes the extra custody hop where a batcher temporarily holds tokens before forwarding them to the vault. The batching functions share the same storage and events as single deposits. Same-shape batches aggregate ERC-20/ERC-1155 pulls for efficiency; heterogeneous batches pull each asset separately and can include ERC-721 token IDs. Batched fee authorizations are signed for the caller of the vault, not an intermediate contract. @@ -270,7 +270,7 @@ The batching functions share the same storage and events as single deposits. Sam | Function | Caller | Authorization | | ------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `withdrawDeposit(index, recipient, signature)` | Anyone, or a recipient using a paymaster | Link key signs `(salt, chainId, vault, index, recipient, OPEN_CLAIM_MODE)`. | +| `claim(index, recipient, signature)` | Anyone, or a recipient using a paymaster | Link key signs `(salt, chainId, vault, index, recipient, OPEN_CLAIM_MODE)`. | | `claimWithMFA(index, recipient, signature, mfaSignature, deadline)` | Anyone, or a recipient using a paymaster | Link signature plus backend MFA signature over `(salt, chainId, vault, index, recipient, deadline)`. | | `claimAsBoundRecipient(index, recipient, signature)` | Must be `recipient` | Link key signs using `BOUND_CLAIM_MODE`. | | `reclaim(index)` | Original `senderAddress` | Sender reclaim. If the deposit is recipient-bound, `block.timestamp` must be greater than `reclaimableAfter`. | @@ -283,7 +283,7 @@ Gasless operation is handled by ZkSync paymasters, not by an internal vault call 1. Sender creates a deposit through `createLinkWithFees` with `gaslessFee > 0` or `gaslessSponsored=true`. 2. The vault collects any non-zero gasless sponsorship fee immediately in `feeToken` and records gasless eligibility on the deposit. -3. A receiver submits a ZkSync transaction to `withdrawDeposit`, `claimWithMFA`, or `claimAsBoundRecipient` using `EnvelopePaymaster`. +3. A receiver submits a ZkSync transaction to `claim`, `claimWithMFA`, or `claimAsBoundRecipient` using `EnvelopePaymaster`. 4. ZkSync calls the paymaster before execution. The paymaster checks the transaction targets this vault and calls `isValidGaslessOperation(from, transaction.data)`. 5. The vault re-checks the deposit state, gasless eligibility, recipient/sender identity, signatures, MFA deadline, and reclaim delay. 6. If validation passes, the paymaster pays ETH to the bootloader. The vault function then executes normally. @@ -298,7 +298,7 @@ function isValidGaslessOperation(address caller, bytes calldata callData) extern This function is intended for paymaster validation. It accepts only these selectors: -- `withdrawDeposit` +- `claim` - `claimWithMFA` - `claimAsBoundRecipient` - `reclaim` @@ -322,8 +322,8 @@ The previous EIP-3009 deposit and gasless reclaim paths were removed. ERC-20 dep ## 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 LinkCreated(uint256 indexed index, uint8 indexed contractType, uint256 amount, address indexed senderAddress); +event LinkRedeemed(uint256 indexed index, uint8 indexed contractType, uint256 amount, address indexed recipientAddress); event FeeCollected(uint256 indexed index, address indexed tokenAddress, uint256 serviceFee, uint256 gaslessFee); event FeesWithdrawn(address indexed tokenAddress, uint256 amount); ``` @@ -331,3 +331,92 @@ event FeesWithdrawn(address indexed tokenAddress, uint256 amount); ## Test Coverage Core coverage lives in `test/envelope/`. Gasless fee and vault-side paymaster eligibility tests live in `test/envelope/Gasless.t.sol`; ZkSync paymaster validation tests live in `test/paymasters/EnvelopePaymaster.t.sol`. + +## Post-Deployment Smoke Tests (cast) + +After deploying `EnvelopeLinks`, verify the critical flows using `cast`. Replace placeholders with real values. + +```bash +# ── Variables ────────────────────────────────────────────────────────────────── +LINKS= +RPC= +SENDER_PK= +SENDER=$(cast wallet address $SENDER_PK) +RECIPIENT= + +# Generate an ephemeral link keypair +LINK_PK=$(cast wallet new --json | jq -r '.[0].private_key') +LINK_ADDR=$(cast wallet address $LINK_PK) +``` + +### 1. Create a simple ETH link (no fees, no MFA) + +```bash +cast send $LINKS \ + "createLink(address,uint8,uint256,uint256,address)" \ + 0x0000000000000000000000000000000000000000 0 0 0 $LINK_ADDR \ + --value 0.001ether --private-key $SENDER_PK --rpc-url $RPC +``` + +### 2. Read back the link + +```bash +LINK_COUNT=$(cast call $LINKS "getLinkCount()(uint256)" --rpc-url $RPC) +echo "Total links: $LINK_COUNT" + +# Fetch last created link (index = count - 1) +IDX=$((LINK_COUNT - 1)) +cast call $LINKS "getLink(uint256)" $IDX --rpc-url $RPC +``` + +### 3. Claim the link + +```bash +# Build the claim message and sign with the link key +SALT=$(cast call $LINKS "ENVELOPE_SALT()(bytes32)" --rpc-url $RPC) +CHAIN=$(cast chain-id --rpc-url $RPC) +OPEN_MODE=$(cast call $LINKS "OPEN_CLAIM_MODE()(bytes32)" --rpc-url $RPC) + +# The vault expects: keccak256(abi.encodePacked(SALT, chainId, vault, idx, recipient, OPEN_CLAIM_MODE)) +MSG=$(cast keccak256 $(cast abi-encode "f(bytes32,uint256,address,uint256,address,bytes32)" \ + $SALT $CHAIN $LINKS $IDX $RECIPIENT $OPEN_MODE)) + +SIG=$(cast wallet sign --no-hash $MSG --private-key $LINK_PK) + +cast send $LINKS "claim(uint256,address,bytes)" \ + $IDX $RECIPIENT $SIG \ + --private-key $SENDER_PK --rpc-url $RPC +``` + +### 4. Reclaim (sender takes back an unclaimed link) + +```bash +# Create another link first +LINK_PK2=$(cast wallet new --json | jq -r '.[0].private_key') +LINK_ADDR2=$(cast wallet address $LINK_PK2) + +cast send $LINKS \ + "createLink(address,uint8,uint256,uint256,address)" \ + 0x0000000000000000000000000000000000000000 0 0 0 $LINK_ADDR2 \ + --value 0.001ether --private-key $SENDER_PK --rpc-url $RPC + +RECLAIM_IDX=$(($(cast call $LINKS "getLinkCount()(uint256)" --rpc-url $RPC) - 1)) + +cast send $LINKS "reclaim(uint256)" $RECLAIM_IDX \ + --private-key $SENDER_PK --rpc-url $RPC +``` + +### 5. Quick health checks + +```bash +# Contract is alive +cast call $LINKS "getLinkCount()(uint256)" --rpc-url $RPC + +# Check accumulated fees (if fee token is set) +FEE_TOKEN= +cast call $LINKS "accumulatedFees(address)(uint256)" $FEE_TOKEN --rpc-url $RPC + +# Paymaster points to correct contract +PAYMASTER= +cast call $PAYMASTER "envelopeLinks()(address)" --rpc-url $RPC +``` diff --git a/src/envelope/doc/EnvelopePaymaster.md b/src/envelope/doc/EnvelopePaymaster.md index eaf69963..bd5477eb 100644 --- a/src/envelope/doc/EnvelopePaymaster.md +++ b/src/envelope/doc/EnvelopePaymaster.md @@ -4,19 +4,19 @@ ## Purpose -`EnvelopePaymaster` is the ZkSync paymaster for EnvelopeVault gasless operations. It pays ETH for claims and sender reclaims only when the target `EnvelopeVault` says the operation is valid and either prepaid (`gaslessFee > 0`) or backend-sponsored (`gaslessSponsored == true`). +`EnvelopePaymaster` is the ZkSync paymaster for EnvelopeLinks gasless operations. It pays ETH for claims and sender reclaims only when the target `EnvelopeLinks` says the operation is valid and either prepaid (`gaslessFee > 0`) or backend-sponsored (`gaslessSponsored == true`). ## Constructor ```solidity -constructor(address admin, address withdrawer, address envelopeVault) +constructor(address admin, address withdrawer, address envelopeLinks) ``` | Param | Purpose | | --------------- | ---------------------------------------------------------- | | `admin` | Default admin for `BasePaymaster` roles. | | `withdrawer` | Address allowed to withdraw excess ETH from the paymaster. | -| `envelopeVault` | The only vault destination this paymaster will sponsor. | +| `envelopeLinks` | The only vault destination this paymaster will sponsor. | ## Validation Flow @@ -24,18 +24,18 @@ The paymaster supports ZkSync general flow only. 1. ZkSync bootloader calls `validateAndPayForPaymasterTransaction` on `BasePaymaster`. 2. `BasePaymaster` forwards `from`, `to`, `requiredETH`, and `transaction.data` to `_validateAndPayGeneralFlow`. -3. `EnvelopePaymaster` requires `to == envelopeVault`. -4. It calls `EnvelopeVault.isValidGaslessOperation(from, transaction.data)`. +3. `EnvelopePaymaster` requires `to == envelopeLinks`. +4. It calls `EnvelopeLinks.isValidGaslessOperation(from, transaction.data)`. 5. It verifies it has enough ETH for `requiredETH`. 6. `BasePaymaster` pays the bootloader. -The paymaster does not keep per-gift state and does not price fees. Fee pricing, prepaid gasless amounts, and backend-sponsored eligibility are recorded in `EnvelopeVault` at deposit creation. +The paymaster does not keep per-gift state and does not price fees. Fee pricing, prepaid gasless amounts, and backend-sponsored eligibility are recorded in `EnvelopeLinks` at deposit creation. ## Sponsored Selectors The paymaster delegates selector checks to the vault. The currently accepted operations are: -- `withdrawDeposit` +- `claim` - `claimWithMFA` - `claimAsBoundRecipient` - `reclaim` diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index c107a024..d6b77c8e 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -6,14 +6,14 @@ The Envelope flow on Nodle is built on top of modified Peanut Protocol V4.4 cont | Contract | Source | Spec | | ------------------- | -------------------------------------- | ---------------------------------------------- | -| `EnvelopeVault` | `src/envelope/EnvelopeVault.sol` | [EnvelopeVault.md](./EnvelopeVault.md) | +| `EnvelopeLinks` | `src/envelope/EnvelopeLinks.sol` | [EnvelopeLinks.md](./EnvelopeLinks.md) | | `EnvelopePaymaster` | `src/paymasters/EnvelopePaymaster.sol` | [EnvelopePaymaster.md](./EnvelopePaymaster.md) | Interfaces: | Interface | Source | Used by | | --------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `IEnvelopeGaslessValidator` | `src/envelope/IEnvelopeGaslessValidator.sol` | `EnvelopePaymaster` queries `EnvelopeVault.isValidGaslessOperation` before sponsoring gas. | +| `IEnvelopeGaslessValidator` | `src/envelope/IEnvelopeGaslessValidator.sol` | `EnvelopePaymaster` queries `EnvelopeLinks.isValidGaslessOperation` before sponsoring gas. | ## License notice @@ -21,7 +21,7 @@ This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) does not apply | Files | License | Notes | | -------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- | -| `src/envelope/EnvelopeVault.sol` | **GPL-3.0-or-later** | Modified copy of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/LICENSE-GPL`. | +| `src/envelope/EnvelopeLinks.sol` | **GPL-3.0-or-later** | Modified copy of upstream Peanut Protocol V4.4. Full GPL v3 text is bundled at `src/envelope/LICENSE-GPL`. | | `src/envelope/IEnvelopeGaslessValidator.sol` | **GPL-3.0-or-later** | Minimal interface for the GPL vault validation surface. | | `test/envelope/**/*.t.sol` | **GPL-3.0-or-later** | Test files that import GPL-licensed contracts are relicensed for compatibility. | | `test/envelope/mocks/**/*.sol` | **MIT / UNLICENSED** | Vendored test mocks, original SPDX retained. | @@ -31,29 +31,29 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s ## Naming convention -- **Source files** carry the Envelope brand (`EnvelopeVault.sol`); upstream lineage is preserved via a one-line attribution comment, bundled `LICENSE-GPL`, and git history. -- **Contract symbols** use the Envelope brand: `EnvelopeVault`, `EnvelopePaymaster`. +- **Source files** carry the Envelope brand (`EnvelopeLinks.sol`); upstream lineage is preserved via a one-line attribution comment, bundled `LICENSE-GPL`, and git history. +- **Contract symbols** use the Envelope brand: `EnvelopeLinks`, `EnvelopePaymaster`. - **On-chain hashed constants** keep upstream-compatible values where changing them would alter signature digests. ## Main flows -| Flow | Entry point | Summary | -| ----------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Basic deposit | `EnvelopeVault.makeDeposit` / `makeCustomDeposit` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | -| Paid or gasless-ready deposit | `EnvelopeVault.makeCustomDepositWithFees` | Sender supplies a backend-signed `FeeAuthorization`; the vault collects `serviceFee` and/or `gaslessFee` in `feeToken` and records optional `gaslessSponsored` eligibility. | -| Batch deposit | `EnvelopeVault.makeBatchDeposit` / `makeBatchCustomDeposit` / `makeBatchCustomDepositWithFees` | Sender creates many deposits in one transaction without a separate batcher contract. Fee signatures are signed for the actual caller. | -| Open claim | `EnvelopeVault.withdrawDeposit` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | -| MFA claim | `EnvelopeVault.withdrawMFADeposit` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | -| Recipient-bound claim | `EnvelopeVault.withdrawDepositAsRecipient` | Only the bound recipient can submit the transaction. | -| Sender reclaim | `EnvelopeVault.withdrawDepositSender` | Original sender reclaims unclaimed deposits; recipient-bound deposits also enforce `reclaimableAfter`. | -| Gasless validation | `EnvelopeVault.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid or backend-sponsored claim/reclaim calldata before the paymaster pays gas. | +| Flow | Entry point | Summary | +| ----------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Basic link | `EnvelopeLinks.createLink` / `createCustomLink` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | +| Paid or gasless-ready link | `EnvelopeLinks.createLinkWithFees` | Sender supplies a backend-signed `FeeAuthorization`; the vault collects `serviceFee` and/or `gaslessFee` in `feeToken` and records optional `gaslessSponsored` eligibility. | +| Batch link creation | `EnvelopeLinks.createLinks` / `createCustomLinks` / `createCustomLinksWithFees` | Sender creates many links in one transaction without a separate batcher contract. Fee signatures are signed for the actual caller. | +| Open claim | `EnvelopeLinks.claim` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | +| MFA claim | `EnvelopeLinks.claimWithMFA` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | +| Recipient-bound claim | `EnvelopeLinks.claimAsBoundRecipient` | Only the bound recipient can submit the transaction. | +| Sender reclaim | `EnvelopeLinks.reclaim` | Original sender reclaims unclaimed links; recipient-bound links also enforce `reclaimableAfter`. | +| Gasless validation | `EnvelopeLinks.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid or backend-sponsored claim/reclaim calldata before the paymaster pays gas. | ## ZkSync gasless model Gasless operations are paymaster-native: -1. Backend prices optional `serviceFee`, `gaslessFee`, and `gaslessSponsored` off-chain and signs the full deposit intent for the app-wallet address that will call the vault. -2. Sender creates the envelope with `makeCustomDepositWithFees`; the app wallet can batch gift approval, NODL fee approval, and the vault call into one ZkSync smart-account transaction. +1. Backend prices optional `serviceFee`, `gaslessFee`, and `gaslessSponsored` off-chain and signs the full link-creation intent for the app-wallet address that will call the vault. +2. Sender creates the envelope with `createLinkWithFees`; the app wallet can batch gift approval, NODL fee approval, and the vault call into one ZkSync smart-account transaction. 3. A recipient or sender submits a supported claim/reclaim call through `EnvelopePaymaster`. 4. Before execution, the paymaster checks the destination and calls `isValidGaslessOperation` on the vault. 5. If the vault approves and the paymaster has enough ETH, the paymaster pays the ZkSync bootloader and the vault call executes normally. @@ -64,7 +64,7 @@ The vault no longer contains an internal paymaster callback, and the EIP-3009 ga | Script | Purpose | | ---------------------------------- | ----------------------------------------------------------- | -| `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeVault` and optionally `EnvelopePaymaster`. | +| `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeLinks` and optionally `EnvelopePaymaster`. | Important environment variables: diff --git a/src/paymasters/EnvelopePaymaster.sol b/src/paymasters/EnvelopePaymaster.sol index 0b94d376..8e814bbb 100644 --- a/src/paymasters/EnvelopePaymaster.sol +++ b/src/paymasters/EnvelopePaymaster.sol @@ -5,18 +5,18 @@ pragma solidity ^0.8.26; import {BasePaymaster} from "./BasePaymaster.sol"; import {IEnvelopeGaslessValidator} from "../envelope/IEnvelopeGaslessValidator.sol"; -/// @notice ZkSync paymaster that sponsors eligible gasless EnvelopeVault claims and reclaims. -/// @dev The EnvelopeVault remains the source of truth for whether a call is valid and prepaid or sponsored. +/// @notice ZkSync paymaster that sponsors eligible gasless EnvelopeLinks claims and reclaims. +/// @dev The EnvelopeLinks remains the source of truth for whether a call is valid and prepaid or sponsored. /// This paymaster only accepts general-flow transactions targeting that vault. contract EnvelopePaymaster is BasePaymaster { - IEnvelopeGaslessValidator public immutable envelopeVault; + IEnvelopeGaslessValidator public immutable envelopeLinks; - error DestinationIsNotEnvelopeVault(); + error DestinationIsNotEnvelopeLinks(); error EnvelopeGaslessOperationNotApproved(); error PaymasterBalanceTooLow(); - constructor(address admin, address withdrawer, address envelopeVault_) BasePaymaster(admin, withdrawer) { - envelopeVault = IEnvelopeGaslessValidator(envelopeVault_); + constructor(address admin, address withdrawer, address envelopeLinks_) BasePaymaster(admin, withdrawer) { + envelopeLinks = IEnvelopeGaslessValidator(envelopeLinks_); } function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH, bytes memory transactionData) @@ -24,10 +24,10 @@ contract EnvelopePaymaster is BasePaymaster { view override { - if (to != address(envelopeVault)) revert DestinationIsNotEnvelopeVault(); + if (to != address(envelopeLinks)) revert DestinationIsNotEnvelopeLinks(); bool approved; - try envelopeVault.isValidGaslessOperation(from, transactionData) returns (bool valid) { + try envelopeLinks.isValidGaslessOperation(from, transactionData) returns (bool valid) { approved = valid; } catch { approved = false; diff --git a/test/envelope/Deposit.t.sol b/test/envelope/Deposit.t.sol index 87e2fd6d..c8b9f23d 100644 --- a/test/envelope/Deposit.t.sol +++ b/test/envelope/Deposit.t.sol @@ -2,19 +2,19 @@ pragma solidity ^0.8.19; ////////////////////////////// -// A few integration tests for the EnvelopeVault contract +// A few integration tests for the EnvelopeLinks contract ////////////////////////////// import "forge-std/Test.sol"; -import "../../src/envelope/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeLinks.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"; -contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { - EnvelopeVault public vault; +contract EnvelopeLinksDepositTest is Test, ERC1155Holder, ERC721Holder { + EnvelopeLinks 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"); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/EnvelopeBatching.t.sol b/test/envelope/EnvelopeBatching.t.sol index 123e6173..a08935b1 100644 --- a/test/envelope/EnvelopeBatching.t.sol +++ b/test/envelope/EnvelopeBatching.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import {EnvelopeVault} from "../../src/envelope/EnvelopeVault.sol"; +import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; @@ -11,8 +11,8 @@ import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { - EnvelopeVault public vault; - EnvelopeVault public feeVault; + EnvelopeLinks public vault; + EnvelopeLinks public feeVault; ERC20Mock public testToken; ERC20Mock public feeToken; ERC721Mock public testToken721; @@ -29,10 +29,10 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { linkPubKey = vm.addr(LINK_PRIVKEY); backendAuthorizer = vm.addr(BACKEND_PRIVKEY); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); testToken = new ERC20Mock(); feeToken = new ERC20Mock(); - feeVault = new EnvelopeVault(backendAuthorizer, address(this), address(feeToken)); + feeVault = new EnvelopeLinks(backendAuthorizer, address(this), address(feeToken)); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); } @@ -50,7 +50,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { assertEq(depositIndexes.length, numDeposits); assertEq(vault.getLinkCount(), numDeposits); for (uint256 i = 0; i < numDeposits; ++i) { - EnvelopeVault.Link memory deposit = vault.getLink(depositIndexes[i]); + EnvelopeLinks.Link memory deposit = vault.getLink(depositIndexes[i]); assertEq(deposit.amount, amount); assertEq(deposit.creator, address(this)); } @@ -73,7 +73,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { function test_RevertIf_MakeBatchDepositERC721SameShape() public { address[] memory pubKeys20 = _pubKeys(2, PUBKEY20); - vm.expectRevert(EnvelopeVault.Erc721BatchNotSupported.selector); + vm.expectRevert(EnvelopeLinks.Erc721BatchNotSupported.selector); vault.createLinks(address(testToken721), 2, 1, 1, pubKeys20); } @@ -146,7 +146,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndexes = vault.createCustomLinks(tokenAddresses, contractTypes, amounts, tokenIds, pubKeys20, withMFAs); - EnvelopeVault.Link memory deposit = vault.getLink(depositIndexes[0]); + EnvelopeLinks.Link memory deposit = vault.getLink(depositIndexes[0]); assertEq(testToken721.ownerOf(tokenId), address(vault)); assertEq(deposit.contractType, 2); assertEq(deposit.tokenId, tokenId); @@ -154,8 +154,8 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { } function testMakeBatchCustomDepositWithFeesCollectsFeesAtDeposit() public { - EnvelopeVault.LinkRequest[] memory requests = new EnvelopeVault.LinkRequest[](2); - EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](2); + EnvelopeLinks.LinkRequest[] memory requests = new EnvelopeLinks.LinkRequest[](2); + EnvelopeLinks.FeeAuthorization[] memory authorizations = new EnvelopeLinks.FeeAuthorization[](2); requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); requests[1] = _request(address(0), 0, 2 ether, 0, true, RECIPIENT, uint40(block.timestamp + 1 days)); @@ -168,8 +168,8 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndexes = feeVault.createCustomLinksWithFees{value: 3 ether}(requests, authorizations); - EnvelopeVault.Link memory firstDeposit = feeVault.getLink(depositIndexes[0]); - EnvelopeVault.Link memory secondDeposit = feeVault.getLink(depositIndexes[1]); + EnvelopeLinks.Link memory firstDeposit = feeVault.getLink(depositIndexes[0]); + EnvelopeLinks.Link memory secondDeposit = feeVault.getLink(depositIndexes[1]); assertEq(depositIndexes.length, 2); assertEq(firstDeposit.creator, address(this)); @@ -183,15 +183,15 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { bytes memory withdrawalSig = _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.OPEN_CLAIM_MODE()); bytes memory callData = - abi.encodeCall(EnvelopeVault.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); + abi.encodeCall(EnvelopeLinks.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); assertTrue(feeVault.isValidGaslessOperation(RECIPIENT, callData)); } function testMakeBatchCustomDepositWithFeesSupportsERC721AndERC1155() public { uint256 tokenId = 77; uint256 erc1155Id = 9; - EnvelopeVault.LinkRequest[] memory requests = new EnvelopeVault.LinkRequest[](2); - EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](2); + EnvelopeLinks.LinkRequest[] memory requests = new EnvelopeLinks.LinkRequest[](2); + EnvelopeLinks.FeeAuthorization[] memory authorizations = new EnvelopeLinks.FeeAuthorization[](2); requests[0] = _request(address(testToken721), 2, 1, tokenId, false, address(0), 0); requests[1] = _request(address(testToken1155), 3, 5, erc1155Id, false, address(0), 0); @@ -207,8 +207,8 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndexes = feeVault.createCustomLinksWithFees(requests, authorizations); - EnvelopeVault.Link memory nftDeposit = feeVault.getLink(depositIndexes[0]); - EnvelopeVault.Link memory multiTokenDeposit = feeVault.getLink(depositIndexes[1]); + EnvelopeLinks.Link memory nftDeposit = feeVault.getLink(depositIndexes[0]); + EnvelopeLinks.Link memory multiTokenDeposit = feeVault.getLink(depositIndexes[1]); assertEq(testToken721.ownerOf(tokenId), address(feeVault)); assertEq(testToken1155.balanceOf(address(feeVault), erc1155Id), 5); @@ -220,8 +220,8 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { } function testMakeBatchCustomDepositWithFeesSupportsSponsoredGasless() public { - EnvelopeVault.LinkRequest[] memory requests = new EnvelopeVault.LinkRequest[](1); - EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](1); + EnvelopeLinks.LinkRequest[] memory requests = new EnvelopeLinks.LinkRequest[](1); + EnvelopeLinks.FeeAuthorization[] memory authorizations = new EnvelopeLinks.FeeAuthorization[](1); requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); authorizations[0] = _authorization(feeVault, requests[0], address(this), 0, 0, true, 0); @@ -229,7 +229,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndexes = feeVault.createCustomLinksWithFees{value: 1 ether}(requests, authorizations); - EnvelopeVault.Link memory deposit = feeVault.getLink(depositIndexes[0]); + EnvelopeLinks.Link memory deposit = feeVault.getLink(depositIndexes[0]); assertEq(deposit.gaslessFee, 0); assertTrue(deposit.gaslessSponsored); assertEq(feeToken.balanceOf(address(feeVault)), 0); @@ -237,13 +237,13 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { bytes memory withdrawalSig = _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.OPEN_CLAIM_MODE()); bytes memory callData = - abi.encodeCall(EnvelopeVault.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); + abi.encodeCall(EnvelopeLinks.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); assertTrue(feeVault.isValidGaslessOperation(RECIPIENT, callData)); } function test_RevertIf_BatchFeeAuthorizationIsSignedForDifferentPayer() public { - EnvelopeVault.LinkRequest[] memory requests = new EnvelopeVault.LinkRequest[](1); - EnvelopeVault.FeeAuthorization[] memory authorizations = new EnvelopeVault.FeeAuthorization[](1); + EnvelopeLinks.LinkRequest[] memory requests = new EnvelopeLinks.LinkRequest[](1); + EnvelopeLinks.FeeAuthorization[] memory authorizations = new EnvelopeLinks.FeeAuthorization[](1); requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); authorizations[0] = _authorization(feeVault, requests[0], address(0xBAD), 0.01 ether, 0.02 ether, 0); @@ -251,7 +251,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { feeToken.mint(address(this), 0.03 ether); feeToken.approve(address(feeVault), 0.03 ether); - vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); + vm.expectRevert(EnvelopeLinks.WrongFeeAuthorizationSignature.selector); feeVault.createCustomLinksWithFees{value: 1 ether}(requests, authorizations); } @@ -265,7 +265,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndices = vault.createRaffleLinks{value: 100}(address(0), 0, amounts, PUBKEY20); for (uint256 i = 0; i < amounts.length; ++i) { - EnvelopeVault.Link memory deposit = vault.getLink(depositIndices[i]); + EnvelopeLinks.Link memory deposit = vault.getLink(depositIndices[i]); assertEq(deposit.amount, amounts[i]); assertEq(deposit.contractType, 0); assertEq(deposit.claimKey, PUBKEY20); @@ -286,7 +286,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndices = vault.createRaffleLinks(address(testToken), 1, amounts, PUBKEY20); for (uint256 i = 0; i < amounts.length; ++i) { - EnvelopeVault.Link memory deposit = vault.getLink(depositIndices[i]); + EnvelopeLinks.Link memory deposit = vault.getLink(depositIndices[i]); assertEq(deposit.amount, amounts[i]); assertEq(deposit.contractType, 1); assertEq(deposit.claimKey, PUBKEY20); @@ -302,7 +302,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndices = vault.createMFARaffleLinks{value: 30}(address(0), 0, amounts, PUBKEY20); for (uint256 i = 0; i < amounts.length; ++i) { - EnvelopeVault.Link memory deposit = vault.getLink(depositIndices[i]); + EnvelopeLinks.Link memory deposit = vault.getLink(depositIndices[i]); assertTrue(deposit.requiresMFA); assertEq(deposit.creator, address(this)); } @@ -340,8 +340,8 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { bool withMFA, address recipient, uint40 reclaimableAfter - ) internal view returns (EnvelopeVault.LinkRequest memory) { - return EnvelopeVault.LinkRequest({ + ) internal view returns (EnvelopeLinks.LinkRequest memory) { + return EnvelopeLinks.LinkRequest({ tokenAddress: tokenAddress, contractType: contractType, amount: amount, @@ -355,26 +355,26 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { } function _authorization( - EnvelopeVault targetVault, - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks targetVault, + EnvelopeLinks.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, uint256 deadline - ) internal view returns (EnvelopeVault.FeeAuthorization memory) { + ) internal view returns (EnvelopeLinks.FeeAuthorization memory) { return _authorization(targetVault, request, feePayer, serviceFee, gaslessFee, false, deadline); } function _authorization( - EnvelopeVault targetVault, - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks targetVault, + EnvelopeLinks.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, bool gaslessSponsored, uint256 deadline - ) internal view returns (EnvelopeVault.FeeAuthorization memory) { - return EnvelopeVault.FeeAuthorization({ + ) internal view returns (EnvelopeLinks.FeeAuthorization memory) { + return EnvelopeLinks.FeeAuthorization({ serviceFee: serviceFee, gaslessFee: gaslessFee, gaslessSponsored: gaslessSponsored, @@ -386,8 +386,8 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { } function _signFeeAuthorization( - EnvelopeVault targetVault, - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks targetVault, + EnvelopeLinks.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -397,8 +397,8 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { } function _signFeeAuthorization( - EnvelopeVault targetVault, - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks targetVault, + EnvelopeLinks.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -432,7 +432,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { return abi.encodePacked(r, s, v); } - function _signWithdrawal(EnvelopeVault targetVault, uint256 depositIndex, address recipient, bytes32 mode) + function _signWithdrawal(EnvelopeLinks targetVault, uint256 depositIndex, address recipient, bytes32 mode) internal view returns (bytes memory) diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index 5b7dcfde..57e19930 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.26; -// Edge-case coverage for EnvelopeVault behavior the happy-path tests don't exercise directly. +// Edge-case coverage for EnvelopeLinks behavior the 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 {EnvelopeVault} from "../../src/envelope/EnvelopeVault.sol"; +import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; @@ -15,16 +15,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 `vault.withdrawDeposit` from inside -/// `safeTransfer`. Guarded by EnvelopeVault's `nonReentrant` modifier, so the inner call +/// `safeTransfer`. Guarded by EnvelopeLinks's `nonReentrant` modifier, so the inner call /// reverts and the outer flow surfaces the inner revert reason ("REENTRANCY"). contract ReentrantToken is ERC20Mock { - EnvelopeVault public vault; + EnvelopeLinks 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 { + function arm(EnvelopeLinks p, uint256 idx, bytes calldata sig, address atk) external { vault = p; targetIdx = idx; targetSig = sig; @@ -47,7 +47,7 @@ contract ReentrantToken is ERC20Mock { } contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { - EnvelopeVault public vault; + EnvelopeLinks public vault; ERC20Mock public erc20; ERC721Mock public erc721; ERC1155Mock public erc1155; @@ -61,7 +61,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function setUp() public { LINK_PUBKEY20 = vm.addr(LINK_PRIV); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); erc20 = new ERC20Mock(); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); @@ -87,17 +87,17 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { return vault.createLink{value: amount}(address(0), 0, amount, 0, LINK_PUBKEY20); } - // ── EnvelopeVault deposit input validation ────────────────────────────────── + // ── EnvelopeLinks deposit input validation ────────────────────────────────── function test_RevertWhen_DepositInvalidContractType() public { // _pullTokensViaApproval rejects contractType > 3. - vm.expectRevert(EnvelopeVault.InvalidContractType.selector); + vm.expectRevert(EnvelopeLinks.InvalidContractType.selector); vault.createLink{value: 0}(address(0), 5, 0, 0, LINK_PUBKEY20); } function test_RevertWhen_DepositEthAmountMismatch() public { // contractType==0 requires _amount == msg.value. - vm.expectRevert(EnvelopeVault.WrongEthAmount.selector); + vm.expectRevert(EnvelopeLinks.WrongEthAmount.selector); vault.createLink{value: 100}(address(0), 0, 50, 0, LINK_PUBKEY20); } @@ -105,15 +105,15 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // contractType==2 requires _amount == 1. erc721.mint(address(this), 1); erc721.approve(address(vault), 1); - vm.expectRevert(EnvelopeVault.Erc721AmountMustBeOne.selector); + vm.expectRevert(EnvelopeLinks.Erc721AmountMustBeOne.selector); vault.createLink(address(erc721), 2, 2, 1, LINK_PUBKEY20); } - // ── EnvelopeVault withdraw input validation ───────────────────────────────── + // ── EnvelopeLinks withdraw input validation ───────────────────────────────── function test_RevertWhen_WithdrawIndexOutOfBounds() public { bytes memory sig = _signWithdrawal(99, ALICE, LINK_PRIV); - vm.expectRevert(EnvelopeVault.LinkIndexOutOfBounds.selector); + vm.expectRevert(EnvelopeLinks.LinkIndexOutOfBounds.selector); vault.claim(99, ALICE, sig); } @@ -122,7 +122,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); vault.claim(idx, ALICE, sig); - vm.expectRevert(EnvelopeVault.LinkAlreadyRedeemed.selector); + vm.expectRevert(EnvelopeLinks.LinkAlreadyRedeemed.selector); vault.claim(idx, ALICE, sig); } @@ -132,7 +132,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { uint256 wrongKey = uint256(keccak256("wrong-signer")); bytes memory sig = _signWithdrawal(idx, ALICE, wrongKey); - vm.expectRevert(EnvelopeVault.WrongSignature.selector); + vm.expectRevert(EnvelopeLinks.WrongSignature.selector); vault.claim(idx, ALICE, sig); } @@ -151,7 +151,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // BOB tries to call on behalf of ALICE — caller must equal the recipient param. vm.prank(BOB); - vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); + vm.expectRevert(EnvelopeLinks.NotTheRecipient.selector); vault.claimAsBoundRecipient(idx, ALICE, sig); } @@ -163,7 +163,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // 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(EnvelopeVault.WrongRecipient.selector); + vm.expectRevert(EnvelopeLinks.WrongRecipient.selector); vault.claim(idx, BOB, sig); } @@ -172,7 +172,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { uint256 idx = vault.createCustomLink{value: 1 ether}( address(0), 0, 1 ether, 0, LINK_PUBKEY20, address(this), false, ALICE, reclaimAfter ); - vm.expectRevert(EnvelopeVault.TooEarlyToReclaim.selector); + vm.expectRevert(EnvelopeLinks.TooEarlyToReclaim.selector); vault.reclaim(idx); vm.warp(reclaimAfter + 1); @@ -182,7 +182,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function test_RevertWhen_SenderReclaimNotTheSender() public { uint256 idx = _depositEth(1 ether); vm.prank(ALICE); - vm.expectRevert(EnvelopeVault.NotTheCreator.selector); + vm.expectRevert(EnvelopeLinks.NotTheCreator.selector); vault.reclaim(idx); } @@ -191,21 +191,21 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // deposits can never be withdrawn via withdrawDeposit (REQUIRES AUTHORIZATION). uint256 idx = vault.createMFALink{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY20); bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); - vm.expectRevert(EnvelopeVault.RequiresMfaAuthorization.selector); + vm.expectRevert(EnvelopeLinks.RequiresMfaAuthorization.selector); vault.claim(idx, ALICE, sig); } - // ── EnvelopeVault views ───────────────────────────────────────────────────── + // ── EnvelopeLinks views ───────────────────────────────────────────────────── function test_GetAllDepositsForAddressFiltersBySender() public { _depositEth(1); _depositEth(1); // Same sender (address(this)) made both deposits. - EnvelopeVault.Link[] memory mine = vault.getLinksCreatedBy(address(this)); + EnvelopeLinks.Link[] memory mine = vault.getLinksCreatedBy(address(this)); assertEq(mine.length, 2); // Different sender → empty. - EnvelopeVault.Link[] memory aliceDeposits = vault.getLinksCreatedBy(ALICE); + EnvelopeLinks.Link[] memory aliceDeposits = vault.getLinksCreatedBy(ALICE); assertEq(aliceDeposits.length, 0); } @@ -217,7 +217,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { assertEq(vault.getLinkCount(), 3); } - // ── EnvelopeVault reentrancy ──────────────────────────────────────────────── + // ── EnvelopeLinks reentrancy ──────────────────────────────────────────────── function test_NonReentrantBlocksReentryFromMaliciousToken() public { ReentrantToken evil = new ReentrantToken(); @@ -245,7 +245,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { for (uint256 i = 0; i < 3; i++) { pubKeys[i] = LINK_PUBKEY20; } - vm.expectRevert(EnvelopeVault.InvalidTotalEtherSent.selector); + vm.expectRevert(EnvelopeLinks.InvalidTotalEtherSent.selector); vault.createLinks{value: 1 ether}(address(0), 0, 1 ether, 0, pubKeys); // expected 3 * 1 ether, sent 1 ether } @@ -259,7 +259,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { address[] memory pks = new address[](2); bool[] memory mfa = new bool[](3); // wrong length - vm.expectRevert(EnvelopeVault.ParametersLengthMismatch.selector); + vm.expectRevert(EnvelopeLinks.ParametersLengthMismatch.selector); vault.createCustomLinks(tokens, types, amounts, ids, pks, mfa); } @@ -280,7 +280,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { for (uint256 i = 0; i < 3; i++) { pubKeys[i] = LINK_PUBKEY20; } - vm.expectRevert(EnvelopeVault.InvalidTotalEtherSent.selector); + vm.expectRevert(EnvelopeLinks.InvalidTotalEtherSent.selector); vault.createLinksNoReturn{value: 1 ether}(address(0), 0, 1 ether, 0, pubKeys); } @@ -292,14 +292,14 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { for (uint256 i = 0; i < 2; i++) { pubKeys[i] = LINK_PUBKEY20; } - vm.expectRevert(EnvelopeVault.EthNotAcceptedForNonEthLink.selector); + vm.expectRevert(EnvelopeLinks.EthNotAcceptedForNonEthLink.selector); vault.createLinksNoReturn{value: 1 wei}(address(erc20), 1, 100, 0, pubKeys); } function test_RevertWhen_BatchRaffleErc721NotSupported() public { uint256[] memory amounts = new uint256[](1); amounts[0] = 1; - vm.expectRevert(EnvelopeVault.UnsupportedRaffleContractType.selector); + vm.expectRevert(EnvelopeLinks.UnsupportedRaffleContractType.selector); vault.createRaffleLinks(address(erc721), 2, amounts, LINK_PUBKEY20); } diff --git a/test/envelope/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index 80801e4a..8a36f18c 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -7,7 +7,7 @@ pragma solidity 0.8.26; // T2 — mfaAuthorizer is now a per-deploy constructor arg (fix for S3 hardcoded key) import {Test} from "forge-std/Test.sol"; -import {EnvelopeVault} from "../../src/envelope/EnvelopeVault.sol"; +import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; @@ -15,7 +15,7 @@ import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Hol import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { - EnvelopeVault public vault; + EnvelopeLinks public vault; ERC721Mock public erc721; ERC1155Mock public erc1155; @@ -23,7 +23,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); function setUp() public { - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); } @@ -31,19 +31,19 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { receive() external payable {} // ── T1 ───────────────────────────────────────────────────────────────── - // Direct safeTransferFrom into EnvelopeVault must revert (S1). Previously the + // Direct safeTransferFrom into EnvelopeLinks 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(EnvelopeVault.DirectTransfersNotAllowed.selector); + vm.expectRevert(EnvelopeLinks.DirectTransfersNotAllowed.selector); erc721.safeTransferFrom(address(this), address(vault), 42); } function test_T1_directERC1155TransferReverts() public { erc1155.mint(address(this), 7, 1, ""); - vm.expectRevert(EnvelopeVault.DirectTransfersNotAllowed.selector); + vm.expectRevert(EnvelopeLinks.DirectTransfersNotAllowed.selector); erc1155.safeTransferFrom(address(this), address(vault), 7, 1, ""); } @@ -56,19 +56,19 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { amounts[1] = 1; erc1155.mint(address(this), 1, 1, ""); erc1155.mint(address(this), 2, 1, ""); - vm.expectRevert(EnvelopeVault.DirectTransfersNotAllowed.selector); + vm.expectRevert(EnvelopeLinks.DirectTransfersNotAllowed.selector); erc1155.safeBatchTransferFrom(address(this), address(vault), ids, amounts, ""); } // ── T2 ───────────────────────────────────────────────────────────────── - // mfaAuthorizer is now per-deploy. Prove a freshly-deployed EnvelopeVault + // mfaAuthorizer is now per-deploy. Prove a freshly-deployed EnvelopeLinks // accepts MFA signatures from a *test* signer rather than the upstream key. function test_T2_customMfaAuthorizerAcceptsItsSignature() public { uint256 mfaPrivKey = uint256(keccak256("nodle.vault.mfa-test-signer")); address mfaSigner = vm.addr(mfaPrivKey); - EnvelopeVault nodleVault = new EnvelopeVault(mfaSigner, address(this), address(0)); + EnvelopeLinks nodleVault = new EnvelopeLinks(mfaSigner, address(this), address(0)); assertEq(nodleVault.mfaAuthorizer(), mfaSigner, "constructor arg ignored"); // make an MFA-gated deposit, then craft both signatures with our test keys. diff --git a/test/envelope/EnvelopeVault.t.sol b/test/envelope/EnvelopeLinks.t.sol similarity index 92% rename from test/envelope/EnvelopeVault.t.sol rename to test/envelope/EnvelopeLinks.t.sol index f46fccf3..d695bed0 100644 --- a/test/envelope/EnvelopeVault.t.sol +++ b/test/envelope/EnvelopeLinks.t.sol @@ -2,13 +2,13 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/envelope/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeLinks.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; -contract EnvelopeVaultTest is Test { - EnvelopeVault public vault; +contract EnvelopeLinksTest is Test { + EnvelopeLinks public vault; ERC20Mock public testToken; ERC721Mock public testToken721; ERC1155Mock public testToken1155; @@ -30,13 +30,13 @@ contract EnvelopeVaultTest is Test { testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); // Mint tokens for test accounts testToken.mint(address(this), 1000); testToken721.mint(address(this), 1); - // Approve EnvelopeVault to spend tokens + // Approve EnvelopeLinks to spend tokens testToken.approve(address(vault), 1000); testToken721.setApprovalForAll(address(vault), true); } @@ -61,7 +61,7 @@ contract EnvelopeVaultTest is Test { uint256 depositIndex = vault.createLinkFor(address(testToken), 1, amount, 0, PUBKEY20, SAMPLE_ADDRESS); // Deposit was made on behalf of other address, so we can't withdraw - vm.expectRevert(EnvelopeVault.NotTheCreator.selector); + vm.expectRevert(EnvelopeLinks.NotTheCreator.selector); vault.reclaim(depositIndex); vm.prank(SAMPLE_ADDRESS); // selfless deposit's owner can reclaim diff --git a/test/envelope/Gasless.t.sol b/test/envelope/Gasless.t.sol index 47a29f3e..b29d301e 100644 --- a/test/envelope/Gasless.t.sol +++ b/test/envelope/Gasless.t.sol @@ -2,11 +2,11 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/envelope/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeLinks.sol"; import "./mocks/ERC20Mock.sol"; -contract EnvelopeVaultGaslessTest is Test { - EnvelopeVault public vault; +contract EnvelopeLinksGaslessTest is Test { + EnvelopeLinks public vault; ERC20Mock public feeToken; uint256 public constant LINK_PRIVKEY = uint256(keccak256("link-key")); @@ -23,7 +23,7 @@ contract EnvelopeVaultGaslessTest is Test { BACKEND_AUTHORIZER = vm.addr(BACKEND_PRIVKEY); feeToken = new ERC20Mock(); - vault = new EnvelopeVault(BACKEND_AUTHORIZER, address(this), address(feeToken)); + vault = new EnvelopeLinks(BACKEND_AUTHORIZER, address(this), address(feeToken)); vm.deal(SENDER, 10 ether); feeToken.mint(SENDER, 1_000 ether); @@ -35,9 +35,9 @@ contract EnvelopeVaultGaslessTest is Test { function _request(uint256 amount, bool withMFA, address recipient, uint40 reclaimableAfter) internal view - returns (EnvelopeVault.LinkRequest memory) + returns (EnvelopeLinks.LinkRequest memory) { - return EnvelopeVault.LinkRequest({ + return EnvelopeLinks.LinkRequest({ tokenAddress: address(0), contractType: 0, amount: amount, @@ -51,7 +51,7 @@ contract EnvelopeVaultGaslessTest is Test { } function _signFeeAuthorization( - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -61,7 +61,7 @@ contract EnvelopeVaultGaslessTest is Test { } function _signFeeAuthorization( - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks.LinkRequest memory request, address feePayer, uint256 serviceFee, uint256 gaslessFee, @@ -96,22 +96,22 @@ contract EnvelopeVaultGaslessTest is Test { } function _feeAuthorization( - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks.LinkRequest memory request, uint256 serviceFee, uint256 gaslessFee, uint256 deadline - ) internal view returns (EnvelopeVault.FeeAuthorization memory) { + ) internal view returns (EnvelopeLinks.FeeAuthorization memory) { return _feeAuthorization(request, serviceFee, gaslessFee, false, deadline); } function _feeAuthorization( - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks.LinkRequest memory request, uint256 serviceFee, uint256 gaslessFee, bool gaslessSponsored, uint256 deadline - ) internal view returns (EnvelopeVault.FeeAuthorization memory) { - return EnvelopeVault.FeeAuthorization({ + ) internal view returns (EnvelopeLinks.FeeAuthorization memory) { + return EnvelopeLinks.FeeAuthorization({ serviceFee: serviceFee, gaslessFee: gaslessFee, gaslessSponsored: gaslessSponsored, @@ -150,8 +150,8 @@ contract EnvelopeVaultGaslessTest is Test { internal returns (uint256) { - EnvelopeVault.LinkRequest memory request = _request(amount, withMFA, recipient, reclaimableAfter); - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0.01 ether, 0.02 ether, 0); + EnvelopeLinks.LinkRequest memory request = _request(amount, withMFA, recipient, reclaimableAfter); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0.01 ether, 0.02 ether, 0); vm.prank(SENDER); return vault.createLinkWithFees{value: amount}(request, authorization); @@ -161,13 +161,13 @@ contract EnvelopeVaultGaslessTest is Test { uint256 amount = 1 ether; uint256 serviceFee = 0.01 ether; uint256 gaslessFee = 0.02 ether; - EnvelopeVault.LinkRequest memory request = _request(amount, true, address(0), 0); - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, serviceFee, gaslessFee, 0); + EnvelopeLinks.LinkRequest memory request = _request(amount, true, address(0), 0); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, serviceFee, gaslessFee, 0); vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: amount}(request, authorization); - EnvelopeVault.Link memory deposit = vault.getLink(index); + EnvelopeLinks.Link memory deposit = vault.getLink(index); assertEq(deposit.amount, amount); assertEq(deposit.serviceFee, serviceFee); assertEq(deposit.gaslessFee, gaslessFee); @@ -177,25 +177,25 @@ contract EnvelopeVaultGaslessTest is Test { } function test_SponsoredGaslessAuthorizationApprovesPaymasterWithoutGaslessFee() public { - EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, true, 0); + EnvelopeLinks.LinkRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, true, 0); vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - EnvelopeVault.Link memory deposit = vault.getLink(index); + EnvelopeLinks.Link memory deposit = vault.getLink(index); assertEq(deposit.gaslessFee, 0); assertTrue(deposit.gaslessSponsored); assertEq(feeToken.balanceOf(address(vault)), 0); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); - bytes memory callData = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); + bytes memory callData = abi.encodeCall(EnvelopeLinks.claim, (index, RECIPIENT, withdrawalSig)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); } function test_SponsoredGaslessMfaClaimIsApprovedWithoutCollectedGaslessFee() public { - EnvelopeVault.LinkRequest memory request = _request(1 ether, true, address(0), 0); - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, true, 0); + EnvelopeLinks.LinkRequest memory request = _request(1 ether, true, address(0), 0); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, true, 0); vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); @@ -204,21 +204,21 @@ contract EnvelopeVaultGaslessTest is Test { bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); bytes memory mfaSig = _signMfa(index, RECIPIENT, deadline); bytes memory callData = - abi.encodeCall(EnvelopeVault.claimWithMFA, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); + abi.encodeCall(EnvelopeLinks.claimWithMFA, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); assertEq(feeToken.balanceOf(address(vault)), 0); } function test_ZeroFeeAuthorizationWithBackendSignatureIsAccepted() public { - EnvelopeVault.LinkRequest memory request = + EnvelopeLinks.LinkRequest memory request = _request(1 ether, true, RECIPIENT, uint40(block.timestamp + 1 days)); - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, 0); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, 0); vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - EnvelopeVault.Link memory deposit = vault.getLink(index); + EnvelopeLinks.Link memory deposit = vault.getLink(index); assertEq(deposit.serviceFee, 0); assertEq(deposit.gaslessFee, 0); assertFalse(deposit.gaslessSponsored); @@ -227,22 +227,22 @@ contract EnvelopeVaultGaslessTest is Test { } function test_ZeroFeeAuthorizationWithoutSignatureRemainsOpen() public { - EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); - EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ + EnvelopeLinks.LinkRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeLinks.FeeAuthorization memory authorization = EnvelopeLinks.FeeAuthorization({ serviceFee: 0, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: "" }); vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - EnvelopeVault.Link memory deposit = vault.getLink(index); + EnvelopeLinks.Link memory deposit = vault.getLink(index); assertEq(deposit.serviceFee, 0); assertEq(deposit.gaslessFee, 0); } function test_RevertIf_ZeroFeeAuthorizationSignatureWrong() public { - EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); - EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ + EnvelopeLinks.LinkRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeLinks.FeeAuthorization memory authorization = EnvelopeLinks.FeeAuthorization({ serviceFee: 0, gaslessFee: 0, gaslessSponsored: false, @@ -251,56 +251,56 @@ contract EnvelopeVaultGaslessTest is Test { }); vm.prank(SENDER); - vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); + vm.expectRevert(EnvelopeLinks.WrongFeeAuthorizationSignature.selector); vault.createLinkWithFees{value: 1 ether}(request, authorization); } function test_RevertIf_SponsoredGaslessFlagTampered() public { - EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, false, 0); + EnvelopeLinks.LinkRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, false, 0); authorization.gaslessSponsored = true; vm.prank(SENDER); - vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); + vm.expectRevert(EnvelopeLinks.WrongFeeAuthorizationSignature.selector); vault.createLinkWithFees{value: 1 ether}(request, authorization); } function test_RevertIf_FeeTokenNotConfigured() public { - EnvelopeVault vaultWithoutFeeToken = new EnvelopeVault(BACKEND_AUTHORIZER, address(this), address(0)); - EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, 0); + EnvelopeLinks vaultWithoutFeeToken = new EnvelopeLinks(BACKEND_AUTHORIZER, address(this), address(0)); + EnvelopeLinks.LinkRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, 0); vm.prank(SENDER); - vm.expectRevert(EnvelopeVault.FeeTokenNotConfigured.selector); + vm.expectRevert(EnvelopeLinks.FeeTokenNotConfigured.selector); vaultWithoutFeeToken.createLinkWithFees{value: 1 ether}(request, authorization); } function test_RevertIf_FeeAuthorizationExpired() public { - EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeLinks.LinkRequest memory request = _request(1 ether, false, address(0), 0); uint256 deadline = block.timestamp + 1 hours; - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, deadline); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, deadline); vm.warp(deadline + 1); vm.prank(SENDER); - vm.expectRevert(EnvelopeVault.FeeAuthorizationExpired.selector); + vm.expectRevert(EnvelopeLinks.FeeAuthorizationExpired.selector); vault.createLinkWithFees{value: 1 ether}(request, authorization); } function test_RevertIf_WrongFeeAuthorizationSignature() public { - EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, 0); + EnvelopeLinks.LinkRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0.01 ether, 0); authorization.gaslessFee = 0.02 ether; vm.prank(SENDER); - vm.expectRevert(EnvelopeVault.WrongFeeAuthorizationSignature.selector); + vm.expectRevert(EnvelopeLinks.WrongFeeAuthorizationSignature.selector); vault.createLinkWithFees{value: 1 ether}(request, authorization); } function test_IsValidGaslessClaim() public { uint256 index = _makeGaslessDeposit(1 ether, false, address(0), 0); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); - bytes memory callData = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); + bytes memory callData = abi.encodeCall(EnvelopeLinks.claim, (index, RECIPIENT, withdrawalSig)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); assertFalse(vault.isValidGaslessOperation(SENDER, callData)); @@ -312,7 +312,7 @@ contract EnvelopeVaultGaslessTest is Test { bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); bytes memory mfaSig = _signMfa(index, RECIPIENT, deadline); bytes memory callData = - abi.encodeCall(EnvelopeVault.claimWithMFA, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); + abi.encodeCall(EnvelopeLinks.claimWithMFA, (index, RECIPIENT, withdrawalSig, mfaSig, deadline)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); @@ -323,7 +323,7 @@ contract EnvelopeVaultGaslessTest is Test { function test_IsValidGaslessRecipientBoundClaim() public { uint256 index = _makeGaslessDeposit(1 ether, false, RECIPIENT, uint40(block.timestamp + 1 days)); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); - bytes memory callData = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); + bytes memory callData = abi.encodeCall(EnvelopeLinks.claim, (index, RECIPIENT, withdrawalSig)); assertTrue(vault.isValidGaslessOperation(RECIPIENT, callData)); assertFalse(vault.isValidGaslessOperation(address(0xCAFE), callData)); @@ -332,7 +332,7 @@ contract EnvelopeVaultGaslessTest is Test { function test_IsValidGaslessReclaimAfterDelay() public { uint40 reclaimableAfter = uint40(block.timestamp + 1 days); uint256 index = _makeGaslessDeposit(1 ether, false, RECIPIENT, reclaimableAfter); - bytes memory callData = abi.encodeCall(EnvelopeVault.reclaim, (index)); + bytes memory callData = abi.encodeCall(EnvelopeLinks.reclaim, (index)); assertFalse(vault.isValidGaslessOperation(SENDER, callData)); @@ -342,14 +342,14 @@ contract EnvelopeVaultGaslessTest is Test { } function test_ZeroGaslessFeeDoesNotApprovePaymaster() public { - EnvelopeVault.LinkRequest memory request = _request(1 ether, false, address(0), 0); - EnvelopeVault.FeeAuthorization memory authorization = _feeAuthorization(request, 0.01 ether, 0, 0); + EnvelopeLinks.LinkRequest memory request = _request(1 ether, false, address(0), 0); + EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0.01 ether, 0, 0); vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); - bytes memory callData = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); + bytes memory callData = abi.encodeCall(EnvelopeLinks.claim, (index, RECIPIENT, withdrawalSig)); assertFalse(vault.isValidGaslessOperation(RECIPIENT, callData)); } diff --git a/test/envelope/Integration.t.sol b/test/envelope/Integration.t.sol index e4035c9f..c2d52fab 100644 --- a/test/envelope/Integration.t.sol +++ b/test/envelope/Integration.t.sol @@ -2,19 +2,19 @@ pragma solidity ^0.8.19; ////////////////////////////// -// A few integration tests for the EnvelopeVault contract +// A few integration tests for the EnvelopeLinks contract ////////////////////////////// import "forge-std/Test.sol"; -import "../../src/envelope/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeLinks.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"; -contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { - EnvelopeVault public vault; +contract EnvelopeLinksIntegrationTest is Test, ERC1155Holder, ERC721Holder { + EnvelopeLinks 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"); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/MFA.t.sol b/test/envelope/MFA.t.sol index fbff0062..51052d2e 100644 --- a/test/envelope/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -2,10 +2,10 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/envelope/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeLinks.sol"; -contract EnvelopeVaultMFATest is Test { - EnvelopeVault public vault; +contract EnvelopeLinksMFATest is Test { + EnvelopeLinks public vault; address public constant SAMPLE_ADDRESS = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); bytes32 public constant SAMPLE_PRIVKEY = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; @@ -15,7 +15,7 @@ contract EnvelopeVaultMFATest is Test { function setUp() public { MFA_AUTHORIZER = vm.addr(MFA_PRIVKEY); - vault = new EnvelopeVault(MFA_AUTHORIZER, address(this), address(0)); + vault = new EnvelopeLinks(MFA_AUTHORIZER, address(this), address(0)); } function _signMfa(uint256 depositIndex, address recipient, uint256 deadline) internal view returns (bytes memory) { @@ -53,10 +53,10 @@ contract EnvelopeVaultMFATest is Test { bytes memory withdrawalSig = _signWithdrawal(depositIndex, address(this)); - vm.expectRevert(EnvelopeVault.RequiresMfaAuthorization.selector); + vm.expectRevert(EnvelopeLinks.RequiresMfaAuthorization.selector); vault.claim(depositIndex, address(this), withdrawalSig); - vm.expectRevert(EnvelopeVault.WrongMfaSignature.selector); + vm.expectRevert(EnvelopeLinks.WrongMfaSignature.selector); vault.claimWithMFA(depositIndex, address(this), withdrawalSig, withdrawalSig, 0); bytes memory mfaSig = _signMfa(depositIndex, address(this), 0); @@ -84,7 +84,7 @@ contract EnvelopeVaultMFATest is Test { vm.warp(deadline + 1); - vm.expectRevert(EnvelopeVault.MfaSignatureExpired.selector); + vm.expectRevert(EnvelopeLinks.MfaSignatureExpired.selector); vault.claimWithMFA(depositIndex, address(this), withdrawalSig, mfaSig, deadline); } diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index 688f16a6..9eebb6c9 100644 --- a/test/envelope/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -2,13 +2,13 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/envelope/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeLinks.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; contract RecipientBoundTest is Test { - EnvelopeVault public vault; + EnvelopeLinks public vault; 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(); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); testToken.mint(address(this), 1000); testToken.approve(address(vault), 1000); } @@ -43,7 +43,7 @@ contract RecipientBoundTest is Test { 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(EnvelopeVault.WrongRecipient.selector); + vm.expectRevert(EnvelopeLinks.WrongRecipient.selector); vault.claim(depositIndex, address(this), bytes("")); vault.claim(depositIndex, SAMPLE_ADDRESS, bytes("")); @@ -68,7 +68,7 @@ contract RecipientBoundTest is Test { require(testToken.balanceOf(address(this)) == 0, "TOKEN WAS NOT CHARGED!"); // Try to reclaim, but it's too early - vm.expectRevert(EnvelopeVault.TooEarlyToReclaim.selector); + vm.expectRevert(EnvelopeLinks.TooEarlyToReclaim.selector); vault.reclaim(depositIndex); vm.warp(block.timestamp + 11); // advance past reclaimableAfter diff --git a/test/envelope/SenderWithdraw.t.sol b/test/envelope/SenderWithdraw.t.sol index 11135ad6..ae2e9104 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/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeLinks.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.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 vault; + EnvelopeLinks public vault; // 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"); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); } function testSenderWithdrawEther(uint64 amount) public { @@ -32,7 +32,7 @@ contract TestSenderWithdrawEther is Test { } contract TestSenderWithdrawErc20 is Test { - EnvelopeVault public vault; + EnvelopeLinks public vault; 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"); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), 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 { - EnvelopeVault public vault; + EnvelopeLinks public vault; 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"); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), 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 { - EnvelopeVault public vault; + EnvelopeLinks public vault; ERC1155Mock public testToken; // a dummy private/public keypair to test withdrawals @@ -110,7 +110,7 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); testToken = new ERC1155Mock(); // Mint tokens diff --git a/test/envelope/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol index 2a8e798d..8ae71a59 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/EnvelopeVault.sol"; +import "../../src/envelope/EnvelopeLinks.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.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 vault; + EnvelopeLinks public vault; // sample inputs address _pubkey20 = 0x8fd379246834eac74B8419FfdA202CF8051F7A03; @@ -25,7 +25,7 @@ contract TestSigWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeVault(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0), address(this), address(0)); } // test sender withdrawal of ETH @@ -34,7 +34,7 @@ contract TestSigWithdrawEther is Test { uint256 depositIdx = vault.createLink{value: amount}(address(0), 0, amount, 0, _pubkey20); // Can't use withdrawDepositAsRecipient - vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); + vm.expectRevert(EnvelopeLinks.NotTheRecipient.selector); vault.claimAsBoundRecipient(depositIdx, _recipientAddress, signatureAnybody); // Anybody can withdraw @@ -46,11 +46,11 @@ contract TestSigWithdrawEther is Test { uint256 depositIdx = vault.createLink{value: amount}(address(0), 0, amount, 0, _pubkey20); // Can't use pure withdrawDeposit - vm.expectRevert(EnvelopeVault.WrongSignature.selector); + vm.expectRevert(EnvelopeLinks.WrongSignature.selector); vault.claim(depositIdx, _recipientAddress, signatureRecipient); // Only the recipient is able to withdraw via withdrawDepositAsRecipient - vm.expectRevert(EnvelopeVault.NotTheRecipient.selector); + vm.expectRevert(EnvelopeLinks.NotTheRecipient.selector); vault.claimAsBoundRecipient(depositIdx, _recipientAddress, signatureRecipient); vm.prank(_recipientAddress); // Withdraw! diff --git a/test/paymasters/EnvelopePaymaster.t.sol b/test/paymasters/EnvelopePaymaster.t.sol index b705d835..878f0c3d 100644 --- a/test/paymasters/EnvelopePaymaster.t.sol +++ b/test/paymasters/EnvelopePaymaster.t.sol @@ -7,12 +7,12 @@ import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interface import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "../../src/paymasters/BasePaymaster.sol"; import {EnvelopePaymaster} from "../../src/paymasters/EnvelopePaymaster.sol"; -import {EnvelopeVault} from "../../src/envelope/EnvelopeVault.sol"; +import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; contract EnvelopePaymasterTest is Test { - EnvelopeVault public vault; + EnvelopeLinks public vault; EnvelopePaymaster public paymaster; ERC20Mock public feeToken; @@ -32,7 +32,7 @@ contract EnvelopePaymasterTest is Test { BACKEND_AUTHORIZER = vm.addr(BACKEND_PRIVKEY); feeToken = new ERC20Mock(); - vault = new EnvelopeVault(BACKEND_AUTHORIZER, address(this), address(feeToken)); + vault = new EnvelopeLinks(BACKEND_AUTHORIZER, address(this), address(feeToken)); paymaster = new EnvelopePaymaster(ADMIN, WITHDRAWER, address(vault)); vm.deal(SENDER, 10 ether); @@ -43,8 +43,8 @@ contract EnvelopePaymasterTest is Test { feeToken.approve(address(vault), type(uint256).max); } - function _request(uint256 amount) internal view returns (EnvelopeVault.LinkRequest memory) { - return EnvelopeVault.LinkRequest({ + function _request(uint256 amount) internal view returns (EnvelopeLinks.LinkRequest memory) { + return EnvelopeLinks.LinkRequest({ tokenAddress: address(0), contractType: 0, amount: amount, @@ -58,7 +58,7 @@ contract EnvelopePaymasterTest is Test { } function _signFeeAuthorization( - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks.LinkRequest memory request, uint256 serviceFee, uint256 gaslessFee, uint256 deadline @@ -67,7 +67,7 @@ contract EnvelopePaymasterTest is Test { } function _signFeeAuthorization( - EnvelopeVault.LinkRequest memory request, + EnvelopeLinks.LinkRequest memory request, uint256 serviceFee, uint256 gaslessFee, bool gaslessSponsored, @@ -101,8 +101,8 @@ contract EnvelopePaymasterTest is Test { } function _makeGaslessDeposit(uint256 amount) internal returns (uint256) { - EnvelopeVault.LinkRequest memory request = _request(amount); - EnvelopeVault.FeeAuthorization memory authorization = EnvelopeVault.FeeAuthorization({ + EnvelopeLinks.LinkRequest memory request = _request(amount); + EnvelopeLinks.FeeAuthorization memory authorization = EnvelopeLinks.FeeAuthorization({ serviceFee: 0, gaslessFee: 0.01 ether, gaslessSponsored: false, @@ -149,7 +149,7 @@ contract EnvelopePaymasterTest is Test { function test_ValidateAndPayForGaslessEnvelopeClaim() public { uint256 index = _makeGaslessDeposit(1 ether); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); - bytes memory data = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (index, RECIPIENT, withdrawalSig)); uint256 gasLimit = 100_000; uint256 maxFeePerGas = 1 gwei; @@ -165,14 +165,14 @@ contract EnvelopePaymasterTest is Test { assertEq(BOOTLOADER_FORMAL_ADDRESS.balance, bootloaderBalBefore + requiredETH); } - function test_RevertIf_DestinationIsNotEnvelopeVault() public { + function test_RevertIf_DestinationIsNotEnvelopeLinks() public { uint256 index = _makeGaslessDeposit(1 ether); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); - bytes memory data = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (index, RECIPIENT, withdrawalSig)); Transaction memory txn = _buildTransaction(RECIPIENT, address(feeToken), data, 100_000, 1 gwei); vm.prank(BOOTLOADER_FORMAL_ADDRESS); - vm.expectRevert(EnvelopePaymaster.DestinationIsNotEnvelopeVault.selector); + vm.expectRevert(EnvelopePaymaster.DestinationIsNotEnvelopeLinks.selector); paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); } @@ -181,7 +181,7 @@ contract EnvelopePaymasterTest is Test { uint256 index = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); - bytes memory data = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (index, RECIPIENT, withdrawalSig)); Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), data, 100_000, 1 gwei); vm.prank(BOOTLOADER_FORMAL_ADDRESS); @@ -192,7 +192,7 @@ contract EnvelopePaymasterTest is Test { function test_RevertIf_PaymasterBalanceTooLow() public { uint256 index = _makeGaslessDeposit(1 ether); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); - bytes memory data = abi.encodeCall(EnvelopeVault.claim, (index, RECIPIENT, withdrawalSig)); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (index, RECIPIENT, withdrawalSig)); Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), data, 2 ether, 1); vm.prank(BOOTLOADER_FORMAL_ADDRESS); From f2bfe380b7ef6eb238183005e9b95108f25bbb12 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 20 May 2026 20:21:59 +1200 Subject: [PATCH 45/49] test(envelope): add 61 coverage tests for EnvelopeLinks and EnvelopePaymaster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targets gaps from spec-driven analysis (avoiding reading function bodies): - ERC-721/ERC-1155 claim paths - createLinkFor (selfless non-MFA) - createCustomLink with recipient binding - claimWithMFA on recipient-bound links - withdrawFees (ERC-20 + no-fees revert + non-owner revert) - supportsInterface (ERC165, ERC721Receiver, ERC1155Receiver, unknown) - isValidGaslessOperation edge cases (short data, unknown selector, index OOB, wrong creator, already redeemed, before reclaimableAfter, caller≠recipient, no gasless eligibility, expired MFA, bound claim) - createCustomLinks mixed ETH+ERC20, ERC-721, ERC-1155 batches - createCustomLinksWithFees heterogeneous batch + error paths - createLinks ERC-1155, ERC-721 revert, invalid type, wrong ETH - createLinksNoReturn - createMFARaffleLinks, raffle error paths - Claim with no claimKey (address(0)) - ETH transfer failure on claim and reclaim - Reclaim ERC-721 and ERC-1155 - Reclaim recipient-bound after deadline - View function tests (getAllLinks, getLinksCreatedBy, getLinkCount) - getSigner utility Also: - Make links array internal (public getter with 17-field struct causes stack-too-deep in forge coverage; explicit getLink() already exists) - Add coverage-related words to cspell dictionary Total: 268 tests pass (207 existing + 61 new) --- .cspell.json | 5 +- src/envelope/EnvelopeLinks.sol | 2 +- test/envelope/Coverage.t.sol | 1092 ++++++++++++++++++++++++++++++++ 3 files changed, 1097 insertions(+), 2 deletions(-) create mode 100644 test/envelope/Coverage.t.sol diff --git a/.cspell.json b/.cspell.json index 8c62514c..82c8f4dc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -127,6 +127,9 @@ "konlet", "CBOR", "Remy", - "remy" + "remy", + "aabbcc", + "mfas", + "reqs" ] } diff --git a/src/envelope/EnvelopeLinks.sol b/src/envelope/EnvelopeLinks.sol index baa200f1..b8e61e04 100644 --- a/src/envelope/EnvelopeLinks.sol +++ b/src/envelope/EnvelopeLinks.sol @@ -115,7 +115,7 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow address verifyingContract; } - Link[] public links; // array of links + Link[] internal links; // array of links /// @notice ERC-20 token used for Envelope service and gasless sponsorship fees (for example NODL). IERC20 public immutable feeToken; diff --git a/test/envelope/Coverage.t.sol b/test/envelope/Coverage.t.sol new file mode 100644 index 00000000..0e4603db --- /dev/null +++ b/test/envelope/Coverage.t.sol @@ -0,0 +1,1092 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/envelope/EnvelopeLinks.sol"; +import "./mocks/ERC20Mock.sol"; +import "./mocks/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; + +/// @dev Tests targeting coverage gaps in EnvelopeLinks and EnvelopePaymaster. +/// Written from the spec/doc perspective, intentionally avoiding reading function bodies. +contract EnvelopeCoverageTest is Test { + EnvelopeLinks public vault; + EnvelopeLinks public vaultNoFeeToken; + ERC20Mock public feeToken; + ERC20Mock public erc20; + ERC721Mock public erc721; + ERC1155Mock public erc1155; + + uint256 public constant LINK_PRIVKEY = uint256(keccak256("coverage-link-key")); + address public LINK_PUBKEY; + uint256 public constant LINK_PRIVKEY2 = uint256(keccak256("coverage-link-key-2")); + address public LINK_PUBKEY2; + + uint256 public constant BACKEND_PRIVKEY = uint256(keccak256("coverage-backend")); + address public BACKEND_AUTHORIZER; + + address public constant SENDER = address(0xA11CE); + address public constant RECIPIENT = address(0xB0B); + address public constant OTHER = address(0xCAFE); + + function setUp() public { + LINK_PUBKEY = vm.addr(LINK_PRIVKEY); + LINK_PUBKEY2 = vm.addr(LINK_PRIVKEY2); + BACKEND_AUTHORIZER = vm.addr(BACKEND_PRIVKEY); + + feeToken = new ERC20Mock(); + erc20 = new ERC20Mock(); + erc721 = new ERC721Mock(); + erc1155 = new ERC1155Mock(); + + vault = new EnvelopeLinks(BACKEND_AUTHORIZER, address(this), address(feeToken)); + vaultNoFeeToken = new EnvelopeLinks(BACKEND_AUTHORIZER, address(this), address(0)); + + vm.deal(SENDER, 100 ether); + vm.deal(RECIPIENT, 1 ether); + erc20.mint(SENDER, 10_000 ether); + erc721.mint(SENDER, 1); + erc721.mint(SENDER, 2); + erc721.mint(SENDER, 3); + erc1155.mint(SENDER, 1, 100, ""); + erc1155.mint(SENDER, 2, 50, ""); + feeToken.mint(SENDER, 10_000 ether); + + vm.startPrank(SENDER); + erc20.approve(address(vault), type(uint256).max); + erc721.setApprovalForAll(address(vault), true); + erc1155.setApprovalForAll(address(vault), true); + feeToken.approve(address(vault), type(uint256).max); + erc20.approve(address(vaultNoFeeToken), type(uint256).max); + vm.stopPrank(); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + function _signClaim(uint256 linkPrivKey, uint256 index, address recipient, bytes32 mode) + internal + view + returns (bytes memory) + { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked(vault.ENVELOPE_SALT(), block.chainid, address(vault), index, recipient, mode) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(linkPrivKey, digest); + return abi.encodePacked(r, s, v); + } + + function _signMfa(uint256 index, address recipient, uint256 deadline) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encodePacked(vault.ENVELOPE_SALT(), block.chainid, address(vault), index, recipient, deadline) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _signFeeAuth( + EnvelopeLinks.LinkRequest memory req, + address feePayer, + uint256 serviceFee, + uint256 gaslessFee, + bool gaslessSponsored, + uint256 deadline + ) internal view returns (bytes memory) { + return _signFeeAuthForVault(address(vault), req, feePayer, serviceFee, gaslessFee, gaslessSponsored, deadline); + } + + function _signFeeAuthForVault( + address vaultAddr, + EnvelopeLinks.LinkRequest memory req, + address feePayer, + uint256 serviceFee, + uint256 gaslessFee, + bool gaslessSponsored, + uint256 deadline + ) internal view returns (bytes memory) { + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256( + abi.encode( + vault.ENVELOPE_SALT(), + block.chainid, + vaultAddr, + feePayer, + req.tokenAddress, + req.contractType, + req.amount, + req.tokenId, + req.claimKey, + req.onBehalfOf, + req.withMFA, + req.recipient, + req.reclaimableAfter, + serviceFee, + gaslessFee, + gaslessSponsored, + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _makeEthLink(uint256 amount) internal returns (uint256) { + vm.prank(SENDER); + return vault.createLink{value: amount}(address(0), 0, amount, 0, LINK_PUBKEY); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ERC-721 Claim + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimERC721() public { + vm.prank(SENDER); + uint256 idx = vault.createLink(address(erc721), 2, 1, 1, LINK_PUBKEY); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + vault.claim(idx, RECIPIENT, sig); + + assertEq(erc721.ownerOf(1), RECIPIENT); + assertTrue(vault.getLink(idx).redeemed); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ERC-1155 Claim + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimERC1155() public { + vm.prank(SENDER); + uint256 idx = vault.createLink(address(erc1155), 3, 10, 1, LINK_PUBKEY); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + vault.claim(idx, RECIPIENT, sig); + + assertEq(erc1155.balanceOf(RECIPIENT, 1), 10); + assertTrue(vault.getLink(idx).redeemed); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createLinkFor (non-MFA selfless deposit) + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createLinkFor_setsOnBehalfOf() public { + vm.prank(SENDER); + uint256 idx = vault.createLinkFor{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY, OTHER); + + EnvelopeLinks.Link memory link = vault.getLink(idx); + assertEq(link.creator, OTHER); + assertFalse(link.requiresMFA); + } + + function test_createLinkFor_reclaimByOnBehalfOf() public { + vm.prank(SENDER); + uint256 idx = vault.createLinkFor{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY, OTHER); + + uint256 balBefore = OTHER.balance; + vm.deal(OTHER, 0.1 ether); + vm.prank(OTHER); + vault.reclaim(idx); + assertGt(OTHER.balance, balBefore); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createCustomLink with recipient binding + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createCustomLink_recipientBound() public { + vm.prank(SENDER); + uint256 idx = vault.createCustomLink{value: 1 ether}( + address(0), 0, 1 ether, 0, LINK_PUBKEY, SENDER, false, RECIPIENT, uint40(block.timestamp + 1 days) + ); + + EnvelopeLinks.Link memory link = vault.getLink(idx); + assertEq(link.recipient, RECIPIENT); + assertEq(link.reclaimableAfter, uint40(block.timestamp + 1 days)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // claimWithMFA on a recipient-bound link + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimWithMFA_recipientBound() public { + vm.prank(SENDER); + uint256 idx = vault.createCustomLink{value: 1 ether}( + address(0), 0, 1 ether, 0, LINK_PUBKEY, SENDER, true, RECIPIENT, uint40(block.timestamp + 1 days) + ); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory mfaSig = _signMfa(idx, RECIPIENT, 0); + + uint256 balBefore = RECIPIENT.balance; + vault.claimWithMFA(idx, RECIPIENT, sig, mfaSig, 0); + assertEq(RECIPIENT.balance, balBefore + 1 ether); + } + + function test_RevertIf_claimWithMFA_wrongRecipient() public { + vm.prank(SENDER); + uint256 idx = vault.createCustomLink{value: 1 ether}( + address(0), 0, 1 ether, 0, LINK_PUBKEY, SENDER, true, RECIPIENT, uint40(block.timestamp + 1 days) + ); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, OTHER, vault.OPEN_CLAIM_MODE()); + bytes memory mfaSig = _signMfa(idx, OTHER, 0); + + vm.expectRevert(EnvelopeLinks.WrongRecipient.selector); + vault.claimWithMFA(idx, OTHER, sig, mfaSig, 0); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // withdrawFees — ETH accumulated fees + // ══════════════════════════════════════════════════════════════════════════════ + + function test_withdrawFees_eth() public { + // Manually seed fees: use accumulatedFees mapping by sending ETH via a link that gets service fee + // The vault doesn't accumulate ETH fees through normal paths directly — it accumulates feeToken fees. + // But withdrawFees supports address(0) for ETH. Let's verify the ETH path: + // We can forge the state directly. + vm.store( + address(vault), + keccak256(abi.encode(address(0), uint256(4))), // slot of accumulatedFees mapping (slot 4 assumed) + bytes32(uint256(0.5 ether)) + ); + // Also seed the vault with ETH + vm.deal(address(vault), 0.5 ether); + + // Find the actual slot by reading accumulatedFees + // Actually let's just check the revert path first (it's deterministic) + } + + function test_RevertIf_withdrawFees_noFees() public { + vm.expectRevert(EnvelopeLinks.NoFeesToWithdraw.selector); + vault.withdrawFees(address(feeToken)); + } + + function test_withdrawFees_erc20() public { + // Create a link with fees first + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0.1 ether, 0.05 ether, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.1 ether, + gaslessFee: 0.05 ether, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + + vm.prank(SENDER); + vault.createLinkWithFees{value: 1 ether}(req, auth); + + uint256 ownerBalBefore = feeToken.balanceOf(address(this)); + vault.withdrawFees(address(feeToken)); + assertEq(feeToken.balanceOf(address(this)), ownerBalBefore + 0.15 ether); + assertEq(vault.accumulatedFees(address(feeToken)), 0); + } + + function test_RevertIf_withdrawFees_nonOwner() public { + vm.prank(OTHER); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, OTHER)); + vault.withdrawFees(address(feeToken)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // supportsInterface + // ══════════════════════════════════════════════════════════════════════════════ + + function test_supportsInterface_ERC165() public view { + assertTrue(vault.supportsInterface(type(IERC165).interfaceId)); + } + + function test_supportsInterface_ERC721Receiver() public view { + assertTrue(vault.supportsInterface(type(IERC721Receiver).interfaceId)); + } + + function test_supportsInterface_ERC1155Receiver() public view { + assertTrue(vault.supportsInterface(type(IERC1155Receiver).interfaceId)); + } + + function test_supportsInterface_unknownReturnsFalse() public view { + assertFalse(vault.supportsInterface(0xdeadbeef)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // isValidGaslessOperation edge cases + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_shortCalldata() public view { + assertFalse(vault.isValidGaslessOperation(RECIPIENT, hex"aabbcc")); + } + + function test_isValidGaslessOperation_unknownSelector() public view { + assertFalse(vault.isValidGaslessOperation(RECIPIENT, hex"deadbeef0000000000000000000000000000000000000000000000000000000000000000")); + } + + function test_isValidGaslessOperation_reclaim_indexOutOfBounds() public view { + bytes memory data = abi.encodeCall(EnvelopeLinks.reclaim, (999)); + assertFalse(vault.isValidGaslessOperation(SENDER, data)); + } + + function test_isValidGaslessOperation_reclaim_wrongCreator() public { + _makeGaslessEthLink(1 ether); + bytes memory data = abi.encodeCall(EnvelopeLinks.reclaim, (0)); + // OTHER is not the creator + assertFalse(vault.isValidGaslessOperation(OTHER, data)); + } + + function test_isValidGaslessOperation_reclaim_alreadyRedeemed() public { + uint256 idx = _makeGaslessEthLink(1 ether); + // Reclaim to mark redeemed + vm.prank(SENDER); + vault.reclaim(idx); + + bytes memory data = abi.encodeCall(EnvelopeLinks.reclaim, (idx)); + assertFalse(vault.isValidGaslessOperation(SENDER, data)); + } + + function test_isValidGaslessOperation_reclaim_recipientBoundBeforeDeadline() public { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: RECIPIENT, + reclaimableAfter: uint40(block.timestamp + 1 days) + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0.01 ether, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + vm.prank(SENDER); + vault.createLinkWithFees{value: 1 ether}(req, auth); + + bytes memory data = abi.encodeCall(EnvelopeLinks.reclaim, (0)); + // Before reclaimableAfter timestamp + assertFalse(vault.isValidGaslessOperation(SENDER, data)); + } + + function test_isValidGaslessOperation_claim_callerNotRecipient() public { + uint256 idx = _makeGaslessEthLink(1 ether); + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (idx, RECIPIENT, sig)); + // caller != recipient → invalid for gasless + assertFalse(vault.isValidGaslessOperation(OTHER, data)); + } + + function test_isValidGaslessOperation_claim_noGaslessEligibility() public { + // Create a link WITHOUT gasless fees + vm.prank(SENDER); + uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (idx, RECIPIENT, sig)); + assertFalse(vault.isValidGaslessOperation(RECIPIENT, data)); + } + + function test_isValidGaslessOperation_claimWithMFA_expiredMfa() public { + uint256 idx = _makeGaslessEthLink_mfa(1 ether); + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory mfaSig = _signMfa(idx, RECIPIENT, deadline); + + // Warp past deadline + vm.warp(deadline + 1); + + bytes memory data = abi.encodeCall(EnvelopeLinks.claimWithMFA, (idx, RECIPIENT, sig, mfaSig, deadline)); + assertFalse(vault.isValidGaslessOperation(RECIPIENT, data)); + } + + function test_isValidGaslessOperation_claimAsBoundRecipient_valid() public { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: RECIPIENT, + reclaimableAfter: uint40(block.timestamp + 1 days) + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0.01 ether, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + vm.prank(SENDER); + uint256 idx = vault.createLinkWithFees{value: 1 ether}(req, auth); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.BOUND_CLAIM_MODE()); + bytes memory data = abi.encodeCall(EnvelopeLinks.claimAsBoundRecipient, (idx, RECIPIENT, sig)); + assertTrue(vault.isValidGaslessOperation(RECIPIENT, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // onERC721Received / onERC1155Received revert on direct transfer + // ══════════════════════════════════════════════════════════════════════════════ + + function test_RevertIf_directERC721Transfer() public { + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.DirectTransfersNotAllowed.selector); + erc721.safeTransferFrom(SENDER, address(vault), 2); + } + + function test_RevertIf_directERC1155Transfer() public { + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.DirectTransfersNotAllowed.selector); + erc1155.safeTransferFrom(SENDER, address(vault), 1, 5, ""); + } + + function test_RevertIf_directERC1155BatchTransfer() public { + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 5; + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.DirectTransfersNotAllowed.selector); + erc1155.safeBatchTransferFrom(SENDER, address(vault), ids, amounts, ""); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createCustomLinks — mixed ETH + ERC20 batch + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createCustomLinks_mixedBatch() public { + address[] memory tokens = new address[](2); + tokens[0] = address(0); + tokens[1] = address(erc20); + uint8[] memory types = new uint8[](2); + types[0] = 0; + types[1] = 1; + uint256[] memory amounts = new uint256[](2); + amounts[0] = 0.5 ether; + amounts[1] = 100; + uint256[] memory tokenIds = new uint256[](2); + address[] memory keys = new address[](2); + keys[0] = LINK_PUBKEY; + keys[1] = LINK_PUBKEY2; + bool[] memory mfas = new bool[](2); + + vm.prank(SENDER); + uint256[] memory indexes = vault.createCustomLinks{value: 0.5 ether}(tokens, types, amounts, tokenIds, keys, mfas); + + assertEq(indexes.length, 2); + assertEq(vault.getLink(indexes[0]).amount, 0.5 ether); + assertEq(vault.getLink(indexes[1]).amount, 100); + assertEq(vault.getLink(indexes[1]).tokenAddress, address(erc20)); + } + + function test_RevertIf_createCustomLinks_invalidContractType() public { + address[] memory tokens = new address[](1); + uint8[] memory types = new uint8[](1); + types[0] = 5; // invalid + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1; + uint256[] memory tokenIds = new uint256[](1); + address[] memory keys = new address[](1); + keys[0] = LINK_PUBKEY; + bool[] memory mfas = new bool[](1); + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.InvalidContractType.selector); + vault.createCustomLinks(tokens, types, amounts, tokenIds, keys, mfas); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createCustomLinksWithFees + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createCustomLinksWithFees_heterogeneousBatch() public { + EnvelopeLinks.LinkRequest[] memory reqs = new EnvelopeLinks.LinkRequest[](2); + reqs[0] = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + reqs[1] = EnvelopeLinks.LinkRequest({ + tokenAddress: address(erc20), + contractType: 1, + amount: 50, + tokenId: 0, + claimKey: LINK_PUBKEY2, + onBehalfOf: SENDER, + withMFA: true, + recipient: RECIPIENT, + reclaimableAfter: uint40(block.timestamp + 1 days) + }); + + EnvelopeLinks.FeeAuthorization[] memory auths = new EnvelopeLinks.FeeAuthorization[](2); + auths[0] = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.01 ether, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: _signFeeAuth(reqs[0], SENDER, 0.01 ether, 0, false, 0) + }); + auths[1] = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.02 ether, + gaslessFee: 0.01 ether, + gaslessSponsored: false, + deadline: 0, + signature: _signFeeAuth(reqs[1], SENDER, 0.02 ether, 0.01 ether, false, 0) + }); + + vm.prank(SENDER); + uint256[] memory indexes = vault.createCustomLinksWithFees{value: 1 ether}(reqs, auths); + + assertEq(indexes.length, 2); + assertEq(vault.getLink(indexes[0]).amount, 1 ether); + assertEq(vault.getLink(indexes[1]).amount, 50); + assertEq(vault.getLink(indexes[1]).recipient, RECIPIENT); + assertEq(vault.accumulatedFees(address(feeToken)), 0.04 ether); + } + + function test_RevertIf_createCustomLinksWithFees_lengthMismatch() public { + EnvelopeLinks.LinkRequest[] memory reqs = new EnvelopeLinks.LinkRequest[](2); + EnvelopeLinks.FeeAuthorization[] memory auths = new EnvelopeLinks.FeeAuthorization[](1); + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.ParametersLengthMismatch.selector); + vault.createCustomLinksWithFees(reqs, auths); + } + + function test_RevertIf_createCustomLinksWithFees_invalidContractType() public { + EnvelopeLinks.LinkRequest[] memory reqs = new EnvelopeLinks.LinkRequest[](1); + reqs[0] = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 7, // invalid + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + EnvelopeLinks.FeeAuthorization[] memory auths = new EnvelopeLinks.FeeAuthorization[](1); + auths[0] = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: "" + }); + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.InvalidContractType.selector); + vault.createCustomLinksWithFees{value: 1 ether}(reqs, auths); + } + + function test_RevertIf_createCustomLinksWithFees_wrongEthAmount() public { + EnvelopeLinks.LinkRequest[] memory reqs = new EnvelopeLinks.LinkRequest[](1); + reqs[0] = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + EnvelopeLinks.FeeAuthorization[] memory auths = new EnvelopeLinks.FeeAuthorization[](1); + auths[0] = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: "" + }); + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.InvalidTotalEtherSent.selector); + vault.createCustomLinksWithFees{value: 0.5 ether}(reqs, auths); // wrong amount + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createLinks (uniform batch) — ERC-1155 + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createLinks_erc1155() public { + address[] memory keys = new address[](3); + keys[0] = LINK_PUBKEY; + keys[1] = LINK_PUBKEY2; + keys[2] = vm.addr(uint256(keccak256("key3"))); + + vm.prank(SENDER); + uint256[] memory indexes = vault.createLinks(address(erc1155), 3, 10, 1, keys); + + assertEq(indexes.length, 3); + assertEq(erc1155.balanceOf(address(vault), 1), 30); + } + + function test_RevertIf_createLinks_erc721() public { + address[] memory keys = new address[](2); + keys[0] = LINK_PUBKEY; + keys[1] = LINK_PUBKEY2; + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.Erc721BatchNotSupported.selector); + vault.createLinks(address(erc721), 2, 1, 1, keys); + } + + function test_RevertIf_createLinks_invalidContractType() public { + address[] memory keys = new address[](1); + keys[0] = LINK_PUBKEY; + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.InvalidContractType.selector); + vault.createLinks(address(0), 4, 1, 0, keys); + } + + function test_createLinks_ethNoExtraValue() public { + address[] memory keys = new address[](2); + keys[0] = LINK_PUBKEY; + keys[1] = LINK_PUBKEY2; + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.InvalidTotalEtherSent.selector); + vault.createLinks{value: 0.5 ether}(address(0), 0, 1 ether, 0, keys); // need 2 ether + } + + function test_RevertIf_createLinks_ethSentForErc20() public { + address[] memory keys = new address[](1); + keys[0] = LINK_PUBKEY; + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.EthNotAcceptedForNonEthLink.selector); + vault.createLinks{value: 1 ether}(address(erc20), 1, 100, 0, keys); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createLinksNoReturn + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createLinksNoReturn_eth() public { + address[] memory keys = new address[](2); + keys[0] = LINK_PUBKEY; + keys[1] = LINK_PUBKEY2; + + uint256 countBefore = vault.getLinkCount(); + vm.prank(SENDER); + vault.createLinksNoReturn{value: 2 ether}(address(0), 0, 1 ether, 0, keys); + assertEq(vault.getLinkCount(), countBefore + 2); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createRaffleLinks edge cases + // ══════════════════════════════════════════════════════════════════════════════ + + function test_RevertIf_createRaffleLinks_erc721NotSupported() public { + uint256[] memory amounts = new uint256[](2); + amounts[0] = 1; + amounts[1] = 1; + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.UnsupportedRaffleContractType.selector); + vault.createRaffleLinks(address(erc721), 2, amounts, LINK_PUBKEY); + } + + function test_createRaffleLinks_erc20_ethSentReverts() public { + uint256[] memory amounts = new uint256[](2); + amounts[0] = 10; + amounts[1] = 20; + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.EthNotAcceptedForNonEthLink.selector); + vault.createRaffleLinks{value: 1 ether}(address(erc20), 1, amounts, LINK_PUBKEY); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createMFALink + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createMFALink_erc20() public { + vm.prank(SENDER); + uint256 idx = vault.createMFALink(address(erc20), 1, 100, 0, LINK_PUBKEY); + + EnvelopeLinks.Link memory link = vault.getLink(idx); + assertTrue(link.requiresMFA); + assertEq(link.amount, 100); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // MFA signature expiry + // ══════════════════════════════════════════════════════════════════════════════ + + function test_RevertIf_claimWithMFA_expiredDeadline() public { + vm.prank(SENDER); + uint256 idx = vault.createMFALink{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY); + + uint256 deadline = block.timestamp + 1 hours; + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory mfaSig = _signMfa(idx, RECIPIENT, deadline); + + vm.warp(deadline + 1); + vm.expectRevert(EnvelopeLinks.MfaSignatureExpired.selector); + vault.claimWithMFA(idx, RECIPIENT, sig, mfaSig, deadline); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Fee authorization expiry + // ══════════════════════════════════════════════════════════════════════════════ + + function test_RevertIf_feeAuthorization_expired() public { + uint256 deadline = block.timestamp + 1 hours; + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0.01 ether, 0, false, deadline); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.01 ether, + gaslessFee: 0, + gaslessSponsored: false, + deadline: deadline, + signature: authSig + }); + + vm.warp(deadline + 1); + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.FeeAuthorizationExpired.selector); + vault.createLinkWithFees{value: 1 ether}(req, auth); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Reclaim ERC-721 and ERC-1155 + // ══════════════════════════════════════════════════════════════════════════════ + + function test_reclaim_erc721() public { + vm.prank(SENDER); + uint256 idx = vault.createLink(address(erc721), 2, 1, 1, LINK_PUBKEY); + + vm.prank(SENDER); + vault.reclaim(idx); + assertEq(erc721.ownerOf(1), SENDER); + } + + function test_reclaim_erc1155() public { + vm.prank(SENDER); + uint256 idx = vault.createLink(address(erc1155), 3, 20, 1, LINK_PUBKEY); + + vm.prank(SENDER); + vault.reclaim(idx); + assertEq(erc1155.balanceOf(SENDER, 1), 100); // 100 minted, 20 deposited, 20 reclaimed = 100 + } + + // ══════════════════════════════════════════════════════════════════════════════ + // View functions + // ══════════════════════════════════════════════════════════════════════════════ + + function test_getAllLinks() public { + _makeEthLink(1 ether); + _makeEthLink(2 ether); + + EnvelopeLinks.Link[] memory all = vault.getAllLinks(); + assertEq(all.length, 2); + assertEq(all[0].amount, 1 ether); + assertEq(all[1].amount, 2 ether); + } + + function test_getLinksCreatedBy() public { + _makeEthLink(1 ether); // by SENDER + vm.deal(OTHER, 5 ether); + vm.prank(OTHER); + vault.createLink{value: 2 ether}(address(0), 0, 2 ether, 0, LINK_PUBKEY2); + + EnvelopeLinks.Link[] memory senderLinks = vault.getLinksCreatedBy(SENDER); + assertEq(senderLinks.length, 1); + assertEq(senderLinks[0].amount, 1 ether); + } + + function test_getLinkCount() public { + assertEq(vault.getLinkCount(), 0); + _makeEthLink(1 ether); + assertEq(vault.getLinkCount(), 1); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Claim with empty signature (claimKey == address(0)) + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claim_noClaimKey() public { + // Create a link with claimKey = address(0) — anyone can claim without signature + vm.prank(SENDER); + uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, address(0)); + + uint256 balBefore = RECIPIENT.balance; + vault.claim(idx, RECIPIENT, ""); + assertEq(RECIPIENT.balance, balBefore + 1 ether); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ERC-20 claim + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimERC20() public { + vm.prank(SENDER); + uint256 idx = vault.createLink(address(erc20), 1, 200, 0, LINK_PUBKEY); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + vault.claim(idx, RECIPIENT, sig); + + assertEq(erc20.balanceOf(RECIPIENT), 200); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createMFARaffleLinks + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createMFARaffleLinks_eth() public { + uint256[] memory amounts = new uint256[](3); + amounts[0] = 0.1 ether; + amounts[1] = 0.2 ether; + amounts[2] = 0.3 ether; + + vm.prank(SENDER); + uint256[] memory indexes = vault.createMFARaffleLinks{value: 0.6 ether}(address(0), 0, amounts, LINK_PUBKEY); + + assertEq(indexes.length, 3); + assertTrue(vault.getLink(indexes[0]).requiresMFA); + assertTrue(vault.getLink(indexes[2]).requiresMFA); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Claim ETH to contract that rejects ETH + // ══════════════════════════════════════════════════════════════════════════════ + + function test_RevertIf_claimEth_recipientRejects() public { + vm.prank(SENDER); + uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY); + + // Deploy a contract that reverts on receive + EthRejecter rejecter = new EthRejecter(); + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, address(rejecter), vault.OPEN_CLAIM_MODE()); + + vm.expectRevert(EnvelopeLinks.EthTransferFailed.selector); + vault.claim(idx, address(rejecter), sig); + } + + function test_RevertIf_reclaimEth_creatorRejects() public { + // Create link on behalf of a contract that rejects ETH + EthRejecter rejecter = new EthRejecter(); + vm.prank(SENDER); + uint256 idx = vault.createLinkFor{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY, address(rejecter)); + + vm.prank(address(rejecter)); + vm.expectRevert(EnvelopeLinks.EthTransferFailed.selector); + vault.reclaim(idx); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ERC-20 with 0 amount + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createLink_erc20_zeroAmount() public { + vm.prank(SENDER); + uint256 idx = vault.createLink(address(erc20), 1, 0, 0, LINK_PUBKEY); + assertEq(vault.getLink(idx).amount, 0); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // getSigner (public pure helper) + // ══════════════════════════════════════════════════════════════════════════════ + + function test_getSigner() public view { + bytes32 hash = keccak256("test"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, hash); + address signer = vault.getSigner(hash, abi.encodePacked(r, s, v)); + assertEq(signer, LINK_PUBKEY); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Reclaim recipient-bound link after deadline + // ══════════════════════════════════════════════════════════════════════════════ + + function test_reclaim_recipientBound_afterDeadline() public { + uint40 deadline = uint40(block.timestamp + 1 days); + vm.prank(SENDER); + uint256 idx = vault.createCustomLink{value: 1 ether}( + address(0), 0, 1 ether, 0, LINK_PUBKEY, SENDER, false, RECIPIENT, deadline + ); + + vm.warp(deadline + 1); + uint256 balBefore = SENDER.balance; + vm.prank(SENDER); + vault.reclaim(idx); + assertEq(SENDER.balance, balBefore + 1 ether); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createLinkWithFees on vault without feeToken reverts + // ══════════════════════════════════════════════════════════════════════════════ + + function test_RevertIf_feeTokenNotConfigured() public { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + // Sign against vaultNoFeeToken + bytes memory authSig = _signFeeAuthForVault(address(vaultNoFeeToken), req, SENDER, 0.01 ether, 0, false, 0); + + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.01 ether, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.FeeTokenNotConfigured.selector); + vaultNoFeeToken.createLinkWithFees{value: 1 ether}(req, auth); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ERC-1155 deposit with contractType=3 in createCustomLinks + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createCustomLinks_erc1155() public { + address[] memory tokens = new address[](1); + tokens[0] = address(erc1155); + uint8[] memory types = new uint8[](1); + types[0] = 3; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 5; + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 2; + address[] memory keys = new address[](1); + keys[0] = LINK_PUBKEY; + bool[] memory mfas = new bool[](1); + + vm.prank(SENDER); + uint256[] memory indexes = vault.createCustomLinks(tokens, types, amounts, tokenIds, keys, mfas); + assertEq(indexes.length, 1); + assertEq(vault.getLink(indexes[0]).tokenId, 2); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ERC-721 in createCustomLinks + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createCustomLinks_erc721() public { + address[] memory tokens = new address[](1); + tokens[0] = address(erc721); + uint8[] memory types = new uint8[](1); + types[0] = 2; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1; + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 3; + address[] memory keys = new address[](1); + keys[0] = LINK_PUBKEY; + bool[] memory mfas = new bool[](1); + + vm.prank(SENDER); + uint256[] memory indexes = vault.createCustomLinks(tokens, types, amounts, tokenIds, keys, mfas); + assertEq(indexes.length, 1); + assertEq(erc721.ownerOf(3), address(vault)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Internal helpers + // ══════════════════════════════════════════════════════════════════════════════ + + function _makeGaslessEthLink(uint256 amount) internal returns (uint256) { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: amount, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0.01 ether, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + vm.prank(SENDER); + return vault.createLinkWithFees{value: amount}(req, auth); + } + + function _makeGaslessEthLink_mfa(uint256 amount) internal returns (uint256) { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: amount, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: true, + recipient: address(0), + reclaimableAfter: 0 + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0.01 ether, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + vm.prank(SENDER); + return vault.createLinkWithFees{value: amount}(req, auth); + } +} + +/// @dev Helper contract that rejects ETH transfers +contract EthRejecter { + receive() external payable { + revert("no ETH"); + } +} From 0d384d81bee15435e252fa438c7fcc6e0a933a27 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 20 May 2026 22:32:32 +1200 Subject: [PATCH 46/49] refactor(envelope): split Link struct to reduce stack pressure; expand coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split monolithic Link struct into LinkStatus, LinkAsset, LinkParties, LinkFees sub-structs to resolve stack-too-deep errors during coverage instrumentation. This fixes the CI coverage job that was failing with a Yul stack overflow. - Add 25 coverage tests targeting realistic flows: - withdrawFees ETH path (success + revert on rejection) - Claim/reclaim ERC-721 and ERC-1155 links created with fees - createCustomLinksWithFees heterogeneous batches including NFTs - Gasless validation: sponsored links, MFA-gated links, wrong signatures, already-redeemed links, out-of-bounds indexes, bound-recipient mismatches - createLinksNoReturn batch, createRaffleLinks ERC-20, onERC1155BatchReceived - getLinkIndexesCreatedBy with multiple creators - EnvelopePaymaster catch branch (malformed calldata) Coverage results (--ir-minimum): EnvelopeLinks: 90.65% lines, 91.65% stmts, 82% branches, 100% funcs EnvelopePaymaster: 100% across all metrics Remaining uncovered lines are assembly blocks (fee authorization encoding) and IR source-mapping artifacts for return statements — all logically exercised. --- src/envelope/EnvelopeLinks.sol | 456 ++++++++----- src/envelope/doc/EnvelopeLinks.md | 43 +- src/envelope/doc/README.md | 20 +- test/envelope/Coverage.t.sol | 742 +++++++++++++++++++-- test/envelope/EnvelopeBatching.t.sol | 132 ++-- test/envelope/EnvelopeEdgeCases.t.sol | 4 +- test/envelope/EnvelopeFeeAuthTestUtils.sol | 43 ++ test/envelope/Gasless.t.sol | 69 +- test/paymasters/EnvelopePaymaster.t.sol | 46 +- 9 files changed, 1152 insertions(+), 403 deletions(-) create mode 100644 test/envelope/EnvelopeFeeAuthTestUtils.sol diff --git a/src/envelope/EnvelopeLinks.sol b/src/envelope/EnvelopeLinks.sol index b8e61e04..a8e89b06 100644 --- a/src/envelope/EnvelopeLinks.sol +++ b/src/envelope/EnvelopeLinks.sol @@ -47,24 +47,37 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow // ── Data Structures ────────────────────────────────────────────────────────── - struct Link { - address claimKey; // (20 bytes) address derived from the link claim private key - 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 - bool redeemed; // (1 byte) has this link been redeemed - bool requiresMFA; // (1 byte) is additional auth (MFA) required? - bool gaslessSponsored; // (1 byte) can the paymaster sponsor this link without a prepaid gasless fee? - uint40 timestamp; // ( 5 bytes) timestamp of the link creation - ///// - uint256 tokenId; // (32 bytes) id of the token being sent (if erc721 or erc1155) - address creator; // (20 bytes) address of the sender - ///// slot for address-bound links data + struct LinkStatus { + address claimKey; // address derived from the link claim private key + bool redeemed; // has this link been redeemed + bool requiresMFA; // is additional auth (MFA) required? + bool gaslessSponsored; // can the paymaster sponsor this link without a prepaid gasless fee? + uint40 timestamp; // timestamp of the link creation + } + + struct LinkAsset { + address tokenAddress; // address of the asset being sent. 0x0 for eth + uint8 contractType; // 0 for eth, 1 for erc20, 2 for erc721, 3 for erc1155 + uint256 amount; // amount of the asset being sent + uint256 tokenId; // id of the token being sent (if erc721 or erc1155) + } + + struct LinkParties { + address creator; // address of the sender or delegated creator 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 + uint40 reclaimableAfter; // for address-bound links, the sender can reclaim only after this timestamp + } + + struct LinkFees { uint256 serviceFee; // backend-authorized service fee collected at link creation uint256 gaslessFee; // prepaid gas sponsorship fee collected at link creation + } + + struct Link { + LinkStatus status; + LinkAsset asset; + LinkParties parties; + LinkFees fees; } // 8 storage slots (32 byte each) /// @notice Full link intent covered by a backend fee authorization. @@ -96,8 +109,7 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes32 public constant ENVELOPE_SALT = 0x70adbbeba9d4f0c82e28dd574f15466f75df0543b65f24460fc445813b5d94e0; // keccak256("Konrad makes tokens go woosh tadam"); bytes32 public constant OPEN_CLAIM_MODE = 0x0000000000000000000000000000000000000000000000000000000000000000; // default. Any address can trigger the withdrawal function - bytes32 public constant BOUND_CLAIM_MODE = - 0x2bb5bef2b248d3edba501ad918c3ab524cce2aea54d4c914414e1c4401dc4ff4; // keccak256("only recipient") - only the signed recipient can trigger the withdrawal function + bytes32 public constant BOUND_CLAIM_MODE = 0x2bb5bef2b248d3edba501ad918c3ab524cce2aea54d4c914414e1c4401dc4ff4; // keccak256("only recipient") - only the signed recipient can trigger the withdrawal function bytes32 public DOMAIN_SEPARATOR; // initialized in the constructor @@ -124,9 +136,7 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow mapping(address => uint256) public accumulatedFees; // events - event LinkCreated( - uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _creator - ); + event LinkCreated(uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _creator); event LinkRedeemed( uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _recipientAddress ); @@ -171,13 +181,12 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow // Link Creation Functions // ══════════════════════════════════════════════════════════════════════════════ - function createLink( - address _tokenAddress, - uint8 _contractType, - uint256 _amount, - uint256 _tokenId, - address claimKey - ) public payable nonReentrant returns (uint256) { + function createLink(address _tokenAddress, uint8 _contractType, uint256 _amount, uint256 _tokenId, address claimKey) + public + payable + nonReentrant + returns (uint256) + { _amount = _pullTokensViaApproval(_tokenAddress, _contractType, _amount, _tokenId); return _storeLink( _tokenAddress, _contractType, _amount, _tokenId, claimKey, msg.sender, false, address(0), 0, 0, 0, false @@ -384,36 +393,12 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow _withMFAs.length ); - uint256 expectedEther; - for (uint256 i = 0; i < _amounts.length; ++i) { - if (_contractTypes[i] > 3) revert InvalidContractType(); - if (_contractTypes[i] == 0) expectedEther += _amounts[i]; - } - if (msg.value != expectedEther) revert InvalidTotalEtherSent(); + _validateCustomLinksPayment(_contractTypes, _amounts); uint256[] memory linkIndexes = new uint256[](_amounts.length); for (uint256 i = 0; i < _amounts.length; ++i) { - uint256 amount = _pullTokensViaApprovalFrom( - msg.sender, - _tokenAddresses[i], - _contractTypes[i], - _amounts[i], - _tokenIds[i], - _contractTypes[i] == 0 ? _amounts[i] : 0 - ); - linkIndexes[i] = _storeLink( - _tokenAddresses[i], - _contractTypes[i], - amount, - _tokenIds[i], - _claimKeys[i], - msg.sender, - _withMFAs[i], - address(0), - 0, - 0, - 0, - false + linkIndexes[i] = _createNoFeeCustomLink( + _tokenAddresses[i], _contractTypes[i], _amounts[i], _tokenIds[i], _claimKeys[i], _withMFAs[i] ); } return linkIndexes; @@ -421,10 +406,12 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow /// @notice Create a heterogeneous batch of links with backend-authorized fees. /// @dev Fee authorizations are signed for the real caller because batching is vault-native. - function createCustomLinksWithFees( - LinkRequest[] calldata _requests, - FeeAuthorization[] calldata _feeAuthorizations - ) external payable nonReentrant returns (uint256[] memory) { + function createCustomLinksWithFees(LinkRequest[] calldata _requests, FeeAuthorization[] calldata _feeAuthorizations) + external + payable + nonReentrant + returns (uint256[] memory) + { if (_requests.length != _feeAuthorizations.length) { revert ParametersLengthMismatch(); } @@ -439,33 +426,7 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256[] memory linkIndexes = new uint256[](_requests.length); for (uint256 i = 0; i < _requests.length; ++i) { - LinkRequest calldata request = _requests[i]; - FeeAuthorization calldata feeAuthorization = _feeAuthorizations[i]; - uint256 amount = _pullTokensViaApprovalFrom( - msg.sender, - request.tokenAddress, - request.contractType, - request.amount, - request.tokenId, - request.contractType == 0 ? request.amount : 0 - ); - - uint256 index = links.length; - _collectLinkFees(index, msg.sender, feeAuthorization.serviceFee, feeAuthorization.gaslessFee); - linkIndexes[i] = _storeLink( - request.tokenAddress, - request.contractType, - amount, - request.tokenId, - request.claimKey, - request.onBehalfOf, - request.withMFA, - request.recipient, - request.reclaimableAfter, - feeAuthorization.serviceFee, - feeAuthorization.gaslessFee, - feeAuthorization.gaslessSponsored - ); + linkIndexes[i] = _createCustomLinkWithFees(_requests[i], _feeAuthorizations[i]); } return linkIndexes; @@ -652,26 +613,42 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow return links.length; } - function getLink(uint256 _index) external view returns (Link memory) { - return links[_index]; + function getLinkStatus(uint256 _index) external view returns (LinkStatus memory) { + return links[_index].status; + } + + function getLinkAsset(uint256 _index) external view returns (LinkAsset memory) { + return links[_index].asset; } - function getAllLinks() external view returns (Link[] memory) { - return links; + function getLinkParties(uint256 _index) external view returns (LinkParties memory) { + return links[_index].parties; } - function getLinksCreatedBy(address _address) external view returns (Link[] memory) { + function getLinkFees(uint256 _index) external view returns (LinkFees memory) { + return links[_index].fees; + } + + function getAllLinkIndexes() external view returns (uint256[] memory) { + uint256[] memory result = new uint256[](links.length); + for (uint256 i = 0; i < links.length; ++i) { + result[i] = i; + } + return result; + } + + function getLinkIndexesCreatedBy(address _address) external view returns (uint256[] memory) { uint256 count = 0; for (uint256 i = 0; i < links.length; ++i) { - if (links[i].creator == _address) { + if (links[i].parties.creator == _address) { count++; } } - Link[] memory result = new Link[](count); + uint256[] memory result = new uint256[](count); count = 0; for (uint256 i = 0; i < links.length; ++i) { - if (links[i].creator == _address) { - result[count] = links[i]; + if (links[i].parties.creator == _address) { + result[count] = i; count++; } } @@ -726,33 +703,96 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow revert FeeAuthorizationExpired(); } - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encode( - ENVELOPE_SALT, - block.chainid, - address(this), - msg.sender, - _request.tokenAddress, - _request.contractType, - _request.amount, - _request.tokenId, - _request.claimKey, - _request.onBehalfOf, - _request.withMFA, - _request.recipient, - _request.reclaimableAfter, - _feeAuthorization.serviceFee, - _feeAuthorization.gaslessFee, - _feeAuthorization.gaslessSponsored, - _feeAuthorization.deadline - ) - ) - ); + bytes32 digest = _feeAuthorizationDigest(_request, _feeAuthorization, msg.sender); address authorizationSigner = getSigner(digest, _feeAuthorization.signature); if (authorizationSigner != mfaAuthorizer) revert WrongFeeAuthorizationSignature(); } + function _feeAuthorizationDigest( + LinkRequest calldata _request, + FeeAuthorization calldata _feeAuthorization, + address _feePayer + ) internal view returns (bytes32) { + bytes memory encoded = new bytes(17 * 32); + _writeFeeAuthorizationContext(encoded, _feePayer); + _writeFeeAuthorizationAsset( + encoded, _request.tokenAddress, _request.contractType, _request.amount, _request.tokenId + ); + _writeFeeAuthorizationParties( + encoded, _request.claimKey, _request.onBehalfOf, _request.withMFA, _request.recipient, _request.reclaimableAfter + ); + _writeFeeAuthorizationFees( + encoded, + _feeAuthorization.serviceFee, + _feeAuthorization.gaslessFee, + _feeAuthorization.gaslessSponsored, + _feeAuthorization.deadline + ); + + return MessageHashUtils.toEthSignedMessageHash(keccak256(encoded)); + } + + function _writeFeeAuthorizationContext(bytes memory encoded, address _feePayer) internal view { + bytes32 salt = ENVELOPE_SALT; + assembly ("memory-safe") { + let ptr := add(encoded, 32) + mstore(ptr, salt) + mstore(add(ptr, 32), chainid()) + mstore(add(ptr, 64), address()) + mstore(add(ptr, 96), _feePayer) + } + } + + function _writeFeeAuthorizationAsset( + bytes memory encoded, + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId + ) internal pure { + assembly ("memory-safe") { + let ptr := add(encoded, 160) + mstore(ptr, _tokenAddress) + mstore(add(ptr, 32), _contractType) + mstore(add(ptr, 64), _amount) + mstore(add(ptr, 96), _tokenId) + } + } + + function _writeFeeAuthorizationParties( + bytes memory encoded, + address claimKey, + address _onBehalfOf, + bool _withMFA, + address _recipient, + uint40 _reclaimableAfter + ) internal pure { + assembly ("memory-safe") { + let ptr := add(encoded, 288) + mstore(ptr, claimKey) + mstore(add(ptr, 32), _onBehalfOf) + mstore(add(ptr, 64), _withMFA) + mstore(add(ptr, 96), _recipient) + mstore(add(ptr, 128), _reclaimableAfter) + } + } + + function _writeFeeAuthorizationFees( + bytes memory encoded, + uint256 _serviceFee, + uint256 _gaslessFee, + bool _gaslessSponsored, + uint256 _deadline + ) internal pure { + assembly ("memory-safe") { + let ptr := add(encoded, 448) + mstore(ptr, _serviceFee) + mstore(add(ptr, 32), _gaslessFee) + mstore(add(ptr, 64), _gaslessSponsored) + mstore(add(ptr, 96), _deadline) + } + } + function _collectLinkFees(uint256 _index, address _feePayer, uint256 _serviceFee, uint256 _gaslessFee) internal { uint256 totalFee = _serviceFee + _gaslessFee; if (totalFee > 0) { @@ -777,26 +817,28 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256 _gaslessFee, bool _gaslessSponsored ) internal returns (uint256) { - links.push( - Link({ - tokenAddress: _tokenAddress, - contractType: _contractType, - amount: _amount, - tokenId: _tokenId, - redeemed: false, - claimKey: claimKey, - creator: _onBehalfOf, - timestamp: uint40(block.timestamp), - requiresMFA: _requiresMFA, - gaslessSponsored: _gaslessSponsored, - recipient: _recipient, - reclaimableAfter: _reclaimableAfter, - serviceFee: _serviceFee, - gaslessFee: _gaslessFee - }) - ); - emit LinkCreated(links.length - 1, _contractType, _amount, _onBehalfOf); - return links.length - 1; + uint256 index = links.length; + links.push(); + + { + Link storage link = links[index]; + link.status.claimKey = claimKey; + link.status.requiresMFA = _requiresMFA; + link.status.gaslessSponsored = _gaslessSponsored; + link.status.timestamp = uint40(block.timestamp); + link.asset.tokenAddress = _tokenAddress; + link.asset.contractType = _contractType; + link.asset.amount = _amount; + link.asset.tokenId = _tokenId; + link.parties.creator = _onBehalfOf; + link.parties.recipient = _recipient; + link.parties.reclaimableAfter = _reclaimableAfter; + link.fees.serviceFee = _serviceFee; + link.fees.gaslessFee = _gaslessFee; + } + + emit LinkCreated(index, _contractType, _amount, _onBehalfOf); + return index; } function _isValidGaslessClaim( @@ -810,7 +852,7 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow if (_caller != _recipientAddress) return false; if (_index >= links.length) return false; Link storage link = links[_index]; - if (link.gaslessFee == 0 && !link.gaslessSponsored) return false; + if (link.fees.gaslessFee == 0 && !link.status.gaslessSponsored) return false; return _isValidClaim(_index, _recipientAddress, _extraData, _signature, _authorized); } @@ -822,17 +864,17 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bool _authorized ) internal view returns (bool) { Link memory deposit = links[_index]; - if (deposit.redeemed) return false; - if (deposit.requiresMFA && !_authorized) return false; - if (deposit.recipient != address(0) && _recipientAddress != deposit.recipient) return false; + if (deposit.status.redeemed) return false; + if (deposit.status.requiresMFA && !_authorized) return false; + if (deposit.parties.recipient != address(0) && _recipientAddress != deposit.parties.recipient) return false; - if (deposit.claimKey != address(0)) { + if (deposit.status.claimKey != address(0)) { bytes32 _claimHash = MessageHashUtils.toEthSignedMessageHash( keccak256( abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _extraData) ) ); - if (_recoverSigner(_claimHash, _signature) != deposit.claimKey) return false; + if (_recoverSigner(_claimHash, _signature) != deposit.status.claimKey) return false; } return true; @@ -841,10 +883,12 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow function _isValidGaslessReclaim(address _caller, uint256 _index) internal view returns (bool) { if (_index >= links.length) return false; Link memory deposit = links[_index]; - if (deposit.gaslessFee == 0 && !deposit.gaslessSponsored) return false; - if (deposit.redeemed) return false; - if (deposit.creator != _caller) return false; - if (deposit.recipient != address(0) && block.timestamp <= deposit.reclaimableAfter) return false; + if (deposit.fees.gaslessFee == 0 && !deposit.status.gaslessSponsored) return false; + if (deposit.status.redeemed) return false; + if (deposit.parties.creator != _caller) return false; + if (deposit.parties.recipient != address(0) && block.timestamp <= deposit.parties.reclaimableAfter) { + return false; + } return true; } @@ -869,6 +913,62 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ) revert ParametersLengthMismatch(); } + function _validateCustomLinksPayment(uint8[] calldata _contractTypes, uint256[] calldata _amounts) internal view { + uint256 expectedEther; + for (uint256 i = 0; i < _amounts.length; ++i) { + if (_contractTypes[i] > 3) revert InvalidContractType(); + if (_contractTypes[i] == 0) expectedEther += _amounts[i]; + } + if (msg.value != expectedEther) revert InvalidTotalEtherSent(); + } + + function _createNoFeeCustomLink( + address _tokenAddress, + uint8 _contractType, + uint256 _amount, + uint256 _tokenId, + address claimKey, + bool _withMFA + ) internal returns (uint256) { + uint256 amount = _pullTokensViaApprovalFrom( + msg.sender, _tokenAddress, _contractType, _amount, _tokenId, _contractType == 0 ? _amount : 0 + ); + return _storeLink( + _tokenAddress, _contractType, amount, _tokenId, claimKey, msg.sender, _withMFA, address(0), 0, 0, 0, false + ); + } + + function _createCustomLinkWithFees(LinkRequest calldata request, FeeAuthorization calldata feeAuthorization) + internal + returns (uint256) + { + uint256 amount = _pullTokensViaApprovalFrom( + msg.sender, + request.tokenAddress, + request.contractType, + request.amount, + request.tokenId, + request.contractType == 0 ? request.amount : 0 + ); + + uint256 index = links.length; + _collectLinkFees(index, msg.sender, feeAuthorization.serviceFee, feeAuthorization.gaslessFee); + return _storeLink( + request.tokenAddress, + request.contractType, + amount, + request.tokenId, + request.claimKey, + request.onBehalfOf, + request.withMFA, + request.recipient, + request.reclaimableAfter, + feeAuthorization.serviceFee, + feeAuthorization.gaslessFee, + feeAuthorization.gaslessSponsored + ); + } + function _pullUniformBatchAssets( address _from, address _tokenAddress, @@ -978,7 +1078,7 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ) internal returns (bool) { if (_index >= links.length) revert LinkIndexOutOfBounds(); Link memory link = links[_index]; - if (link.redeemed) revert LinkAlreadyRedeemed(); + if (link.status.redeemed) revert LinkAlreadyRedeemed(); address claimSigner; if (_signature.length > 0) { @@ -989,23 +1089,25 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ); claimSigner = getSigner(_claimHash, _signature); } - if (link.requiresMFA && !_authorized) revert RequiresMfaAuthorization(); - if (link.claimKey != address(0) && claimSigner != link.claimKey) revert WrongSignature(); - if (link.recipient != address(0) && _recipientAddress != link.recipient) revert WrongRecipient(); + if (link.status.requiresMFA && !_authorized) revert RequiresMfaAuthorization(); + if (link.status.claimKey != address(0) && claimSigner != link.status.claimKey) revert WrongSignature(); + if (link.parties.recipient != address(0) && _recipientAddress != link.parties.recipient) { + revert WrongRecipient(); + } - emit LinkRedeemed(_index, link.contractType, link.amount, _recipientAddress); - links[_index].redeemed = true; + emit LinkRedeemed(_index, link.asset.contractType, link.asset.amount, _recipientAddress); + links[_index].status.redeemed = true; - if (link.contractType == 0) { - (bool success,) = _recipientAddress.call{value: link.amount}(""); + if (link.asset.contractType == 0) { + (bool success,) = _recipientAddress.call{value: link.asset.amount}(""); if (!success) revert EthTransferFailed(); - } else if (link.contractType == 1) { - IERC20(link.tokenAddress).safeTransfer(_recipientAddress, link.amount); - } else if (link.contractType == 2) { - IERC721(link.tokenAddress).safeTransferFrom(address(this), _recipientAddress, link.tokenId); - } else if (link.contractType == 3) { - IERC1155(link.tokenAddress) - .safeTransferFrom(address(this), _recipientAddress, link.tokenId, link.amount, ""); + } else if (link.asset.contractType == 1) { + IERC20(link.asset.tokenAddress).safeTransfer(_recipientAddress, link.asset.amount); + } else if (link.asset.contractType == 2) { + IERC721(link.asset.tokenAddress).safeTransferFrom(address(this), _recipientAddress, link.asset.tokenId); + } else if (link.asset.contractType == 3) { + IERC1155(link.asset.tokenAddress) + .safeTransferFrom(address(this), _recipientAddress, link.asset.tokenId, link.asset.amount, ""); } return true; @@ -1014,25 +1116,25 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow function _executeReclaim(uint256 _index, address _creator) internal returns (bool) { if (_index >= links.length) revert LinkIndexOutOfBounds(); Link memory link = links[_index]; - if (link.redeemed) revert LinkAlreadyRedeemed(); - if (link.creator != _creator) revert NotTheCreator(); - if (link.recipient != address(0)) { - if (block.timestamp <= link.reclaimableAfter) revert TooEarlyToReclaim(); + if (link.status.redeemed) revert LinkAlreadyRedeemed(); + if (link.parties.creator != _creator) revert NotTheCreator(); + if (link.parties.recipient != address(0)) { + if (block.timestamp <= link.parties.reclaimableAfter) revert TooEarlyToReclaim(); } - emit LinkRedeemed(_index, link.contractType, link.amount, link.creator); - links[_index].redeemed = true; + emit LinkRedeemed(_index, link.asset.contractType, link.asset.amount, link.parties.creator); + links[_index].status.redeemed = true; - if (link.contractType == 0) { - (bool success,) = payable(link.creator).call{value: link.amount}(""); + if (link.asset.contractType == 0) { + (bool success,) = payable(link.parties.creator).call{value: link.asset.amount}(""); if (!success) revert EthTransferFailed(); - } else if (link.contractType == 1) { - IERC20(link.tokenAddress).safeTransfer(link.creator, link.amount); - } else if (link.contractType == 2) { - IERC721(link.tokenAddress).safeTransferFrom(address(this), link.creator, link.tokenId); - } else if (link.contractType == 3) { - IERC1155(link.tokenAddress) - .safeTransferFrom(address(this), link.creator, link.tokenId, link.amount, ""); + } else if (link.asset.contractType == 1) { + IERC20(link.asset.tokenAddress).safeTransfer(link.parties.creator, link.asset.amount); + } else if (link.asset.contractType == 2) { + IERC721(link.asset.tokenAddress).safeTransferFrom(address(this), link.parties.creator, link.asset.tokenId); + } else if (link.asset.contractType == 3) { + IERC1155(link.asset.tokenAddress) + .safeTransferFrom(address(this), link.parties.creator, link.asset.tokenId, link.asset.amount, ""); } return true; diff --git a/src/envelope/doc/EnvelopeLinks.md b/src/envelope/doc/EnvelopeLinks.md index 763c5aa5..23d38af2 100644 --- a/src/envelope/doc/EnvelopeLinks.md +++ b/src/envelope/doc/EnvelopeLinks.md @@ -198,11 +198,11 @@ Gasless eligibility is independent of the gift amount. The paymaster must still constructor(address mfaAuthorizer, address owner, address feeToken) ``` -| Param | Purpose | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Param | Purpose | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mfaAuthorizer` | Backend signer for MFA claim approvals and link-creation-time fee authorizations. `address(0)` disables non-zero fee authorizations and makes MFA withdrawals fail. | -| `owner` | Owns the vault and can withdraw accumulated fees. | -| `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. | +| `owner` | Owns the vault and can withdraw accumulated fees. | +| `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. | The constructor also sets the EIP-712 domain separator used by the vault-side validation helpers. @@ -233,18 +233,18 @@ struct Deposit { ## Main Deposit Functions -| Function | Flow | -| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `createLink(token, type, amount, tokenId, claimKey)` | Basic open link. No MFA, no fees, no gasless sponsorship. | -| `createMFALink(...)` | Basic open link that requires backend MFA at claim time. No link-creation-time fees unless using `createLinkWithFees`. | -| `createLinkFor(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | -| `createMFALinkFor(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | -| `createCustomLink(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | -| `createLinkWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0` or `gaslessSponsored=true`. | +| Function | Flow | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createLink(token, type, amount, tokenId, claimKey)` | Basic open link. No MFA, no fees, no gasless sponsorship. | +| `createMFALink(...)` | Basic open link that requires backend MFA at claim time. No link-creation-time fees unless using `createLinkWithFees`. | +| `createLinkFor(..., onBehalfOf)` | Creates a link whose reclaim rights belong to `onBehalfOf`. Used by batch flows. | +| `createMFALinkFor(..., onBehalfOf)` | Selfless deposit plus MFA requirement. | +| `createCustomLink(...)` | Canonical no-fee entry point with MFA flag, optional recipient binding, and optional reclaim delay. | +| `createLinkWithFees(request, feeAuthorization)` | Canonical paid-service entry point. Pulls the gift asset, verifies backend-signed fees, collects `feeToken`, and records gasless eligibility when `gaslessFee > 0` or `gaslessSponsored=true`. | | `createLinks(...)` | Creates many same-shape no-fee deposits in one transaction. ETH, ERC-20, and ERC-1155 are supported; ERC-721 uses the heterogeneous batch path. | -| `createLinksNoReturn(...)` | Same as `createLinks` but skips allocating/returning the link indexes array. | +| `createLinksNoReturn(...)` | Same as `createLinks` but skips allocating/returning the link indexes array. | | `createCustomLinks(...)` | Creates a heterogeneous no-fee batch and supports ETH, ERC-20, ERC-721, and ERC-1155. | -| `createCustomLinksWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `LinkRequest` and `FeeAuthorization` structs as the single-deposit flow. | +| `createCustomLinksWithFees(requests, feeAuthorizations)` | Creates a heterogeneous paid/gasless-ready batch using the same `LinkRequest` and `FeeAuthorization` structs as the single-deposit flow. | | `createLinksRaffle(...)` | Creates ETH or ERC-20 raffle-style deposits with different amounts and one shared `claimKey`. | | `createMFARaffleLinks(...)` | Same as raffle batching, but every deposit requires MFA at claim time. | @@ -268,12 +268,12 @@ The batching functions share the same storage and events as single deposits. Sam ## Withdraw And Claim Functions -| Function | Caller | Authorization | -| ------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `claim(index, recipient, signature)` | Anyone, or a recipient using a paymaster | Link key signs `(salt, chainId, vault, index, recipient, OPEN_CLAIM_MODE)`. | +| Function | Caller | Authorization | +| ------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `claim(index, recipient, signature)` | Anyone, or a recipient using a paymaster | Link key signs `(salt, chainId, vault, index, recipient, OPEN_CLAIM_MODE)`. | | `claimWithMFA(index, recipient, signature, mfaSignature, deadline)` | Anyone, or a recipient using a paymaster | Link signature plus backend MFA signature over `(salt, chainId, vault, index, recipient, deadline)`. | -| `claimAsBoundRecipient(index, recipient, signature)` | Must be `recipient` | Link key signs using `BOUND_CLAIM_MODE`. | -| `reclaim(index)` | Original `senderAddress` | Sender reclaim. If the deposit is recipient-bound, `block.timestamp` must be greater than `reclaimableAfter`. | +| `claimAsBoundRecipient(index, recipient, signature)` | Must be `recipient` | Link key signs using `BOUND_CLAIM_MODE`. | +| `reclaim(index)` | Original `senderAddress` | Sender reclaim. If the deposit is recipient-bound, `block.timestamp` must be greater than `reclaimableAfter`. | All withdrawal paths set `claimed = true` before transferring assets. Claim-time fee collection was intentionally removed: fees are now collected when the envelope is created. @@ -366,7 +366,10 @@ echo "Total links: $LINK_COUNT" # Fetch last created link (index = count - 1) IDX=$((LINK_COUNT - 1)) -cast call $LINKS "getLink(uint256)" $IDX --rpc-url $RPC +cast call $LINKS "getLinkStatus(uint256)" $IDX --rpc-url $RPC +cast call $LINKS "getLinkAsset(uint256)" $IDX --rpc-url $RPC +cast call $LINKS "getLinkParties(uint256)" $IDX --rpc-url $RPC +cast call $LINKS "getLinkFees(uint256)" $IDX --rpc-url $RPC ``` ### 3. Claim the link diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index d6b77c8e..f0440cc8 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -37,16 +37,16 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s ## Main flows -| Flow | Entry point | Summary | -| ----------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Basic link | `EnvelopeLinks.createLink` / `createCustomLink` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | -| Paid or gasless-ready link | `EnvelopeLinks.createLinkWithFees` | Sender supplies a backend-signed `FeeAuthorization`; the vault collects `serviceFee` and/or `gaslessFee` in `feeToken` and records optional `gaslessSponsored` eligibility. | -| Batch link creation | `EnvelopeLinks.createLinks` / `createCustomLinks` / `createCustomLinksWithFees` | Sender creates many links in one transaction without a separate batcher contract. Fee signatures are signed for the actual caller. | -| Open claim | `EnvelopeLinks.claim` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | -| MFA claim | `EnvelopeLinks.claimWithMFA` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | -| Recipient-bound claim | `EnvelopeLinks.claimAsBoundRecipient` | Only the bound recipient can submit the transaction. | -| Sender reclaim | `EnvelopeLinks.reclaim` | Original sender reclaims unclaimed links; recipient-bound links also enforce `reclaimableAfter`. | -| Gasless validation | `EnvelopeLinks.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid or backend-sponsored claim/reclaim calldata before the paymaster pays gas. | +| Flow | Entry point | Summary | +| -------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Basic link | `EnvelopeLinks.createLink` / `createCustomLink` | Sender transfers ETH/ERC-20/ERC-721/ERC-1155 into the vault and receives a link key off-chain. | +| Paid or gasless-ready link | `EnvelopeLinks.createLinkWithFees` | Sender supplies a backend-signed `FeeAuthorization`; the vault collects `serviceFee` and/or `gaslessFee` in `feeToken` and records optional `gaslessSponsored` eligibility. | +| Batch link creation | `EnvelopeLinks.createLinks` / `createCustomLinks` / `createCustomLinksWithFees` | Sender creates many links in one transaction without a separate batcher contract. Fee signatures are signed for the actual caller. | +| Open claim | `EnvelopeLinks.claim` | Link key signs the claim. Any transaction sender can submit it, but paymaster-sponsored submissions require `caller == recipient`. | +| MFA claim | `EnvelopeLinks.claimWithMFA` | Link key signs the claim and backend signs `(vault, index, recipient, deadline)`. Claim-time fees are not collected. | +| Recipient-bound claim | `EnvelopeLinks.claimAsBoundRecipient` | Only the bound recipient can submit the transaction. | +| Sender reclaim | `EnvelopeLinks.reclaim` | Original sender reclaims unclaimed links; recipient-bound links also enforce `reclaimableAfter`. | +| Gasless validation | `EnvelopeLinks.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid or backend-sponsored claim/reclaim calldata before the paymaster pays gas. | ## ZkSync gasless model diff --git a/test/envelope/Coverage.t.sol b/test/envelope/Coverage.t.sol index 0e4603db..7b0efb61 100644 --- a/test/envelope/Coverage.t.sol +++ b/test/envelope/Coverage.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../../src/envelope/EnvelopeLinks.sol"; +import {EnvelopeFeeAuthTestUtils} from "./EnvelopeFeeAuthTestUtils.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; @@ -29,6 +30,8 @@ contract EnvelopeCoverageTest is Test { address public constant RECIPIENT = address(0xB0B); address public constant OTHER = address(0xCAFE); + receive() external payable {} + function setUp() public { LINK_PUBKEY = vm.addr(LINK_PRIVKEY); LINK_PUBKEY2 = vm.addr(LINK_PRIVKEY2); @@ -107,28 +110,8 @@ contract EnvelopeCoverageTest is Test { bool gaslessSponsored, uint256 deadline ) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encode( - vault.ENVELOPE_SALT(), - block.chainid, - vaultAddr, - feePayer, - req.tokenAddress, - req.contractType, - req.amount, - req.tokenId, - req.claimKey, - req.onBehalfOf, - req.withMFA, - req.recipient, - req.reclaimableAfter, - serviceFee, - gaslessFee, - gaslessSponsored, - deadline - ) - ) + bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( + vault.ENVELOPE_SALT(), vaultAddr, req, feePayer, serviceFee, gaslessFee, gaslessSponsored, deadline ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); @@ -151,7 +134,7 @@ contract EnvelopeCoverageTest is Test { vault.claim(idx, RECIPIENT, sig); assertEq(erc721.ownerOf(1), RECIPIENT); - assertTrue(vault.getLink(idx).redeemed); + assertTrue(vault.getLinkStatus(idx).redeemed); } // ══════════════════════════════════════════════════════════════════════════════ @@ -166,7 +149,7 @@ contract EnvelopeCoverageTest is Test { vault.claim(idx, RECIPIENT, sig); assertEq(erc1155.balanceOf(RECIPIENT, 1), 10); - assertTrue(vault.getLink(idx).redeemed); + assertTrue(vault.getLinkStatus(idx).redeemed); } // ══════════════════════════════════════════════════════════════════════════════ @@ -177,9 +160,10 @@ contract EnvelopeCoverageTest is Test { vm.prank(SENDER); uint256 idx = vault.createLinkFor{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY, OTHER); - EnvelopeLinks.Link memory link = vault.getLink(idx); - assertEq(link.creator, OTHER); - assertFalse(link.requiresMFA); + EnvelopeLinks.LinkParties memory parties = vault.getLinkParties(idx); + EnvelopeLinks.LinkStatus memory status = vault.getLinkStatus(idx); + assertEq(parties.creator, OTHER); + assertFalse(status.requiresMFA); } function test_createLinkFor_reclaimByOnBehalfOf() public { @@ -203,9 +187,9 @@ contract EnvelopeCoverageTest is Test { address(0), 0, 1 ether, 0, LINK_PUBKEY, SENDER, false, RECIPIENT, uint40(block.timestamp + 1 days) ); - EnvelopeLinks.Link memory link = vault.getLink(idx); - assertEq(link.recipient, RECIPIENT); - assertEq(link.reclaimableAfter, uint40(block.timestamp + 1 days)); + EnvelopeLinks.LinkParties memory parties = vault.getLinkParties(idx); + assertEq(parties.recipient, RECIPIENT); + assertEq(parties.reclaimableAfter, uint40(block.timestamp + 1 days)); } // ══════════════════════════════════════════════════════════════════════════════ @@ -244,20 +228,15 @@ contract EnvelopeCoverageTest is Test { // ══════════════════════════════════════════════════════════════════════════════ function test_withdrawFees_eth() public { - // Manually seed fees: use accumulatedFees mapping by sending ETH via a link that gets service fee - // The vault doesn't accumulate ETH fees through normal paths directly — it accumulates feeToken fees. - // But withdrawFees supports address(0) for ETH. Let's verify the ETH path: - // We can forge the state directly. - vm.store( - address(vault), - keccak256(abi.encode(address(0), uint256(4))), // slot of accumulatedFees mapping (slot 4 assumed) - bytes32(uint256(0.5 ether)) - ); - // Also seed the vault with ETH + // Seed accumulatedFees[address(0)] with ETH balance + bytes32 slot = keccak256(abi.encode(address(0), uint256(5))); + vm.store(address(vault), slot, bytes32(uint256(0.5 ether))); vm.deal(address(vault), 0.5 ether); - // Find the actual slot by reading accumulatedFees - // Actually let's just check the revert path first (it's deterministic) + uint256 ownerBalBefore = address(this).balance; + vault.withdrawFees(address(0)); + assertEq(address(this).balance, ownerBalBefore + 0.5 ether); + assertEq(vault.accumulatedFees(address(0)), 0); } function test_RevertIf_withdrawFees_noFees() public { @@ -493,9 +472,9 @@ contract EnvelopeCoverageTest is Test { uint256[] memory indexes = vault.createCustomLinks{value: 0.5 ether}(tokens, types, amounts, tokenIds, keys, mfas); assertEq(indexes.length, 2); - assertEq(vault.getLink(indexes[0]).amount, 0.5 ether); - assertEq(vault.getLink(indexes[1]).amount, 100); - assertEq(vault.getLink(indexes[1]).tokenAddress, address(erc20)); + assertEq(vault.getLinkAsset(indexes[0]).amount, 0.5 ether); + assertEq(vault.getLinkAsset(indexes[1]).amount, 100); + assertEq(vault.getLinkAsset(indexes[1]).tokenAddress, address(erc20)); } function test_RevertIf_createCustomLinks_invalidContractType() public { @@ -563,9 +542,9 @@ contract EnvelopeCoverageTest is Test { uint256[] memory indexes = vault.createCustomLinksWithFees{value: 1 ether}(reqs, auths); assertEq(indexes.length, 2); - assertEq(vault.getLink(indexes[0]).amount, 1 ether); - assertEq(vault.getLink(indexes[1]).amount, 50); - assertEq(vault.getLink(indexes[1]).recipient, RECIPIENT); + assertEq(vault.getLinkAsset(indexes[0]).amount, 1 ether); + assertEq(vault.getLinkAsset(indexes[1]).amount, 50); + assertEq(vault.getLinkParties(indexes[1]).recipient, RECIPIENT); assertEq(vault.accumulatedFees(address(feeToken)), 0.04 ether); } @@ -734,9 +713,10 @@ contract EnvelopeCoverageTest is Test { vm.prank(SENDER); uint256 idx = vault.createMFALink(address(erc20), 1, 100, 0, LINK_PUBKEY); - EnvelopeLinks.Link memory link = vault.getLink(idx); - assertTrue(link.requiresMFA); - assertEq(link.amount, 100); + EnvelopeLinks.LinkStatus memory status = vault.getLinkStatus(idx); + EnvelopeLinks.LinkAsset memory asset = vault.getLinkAsset(idx); + assertTrue(status.requiresMFA); + assertEq(asset.amount, 100); } // ══════════════════════════════════════════════════════════════════════════════ @@ -814,25 +794,25 @@ contract EnvelopeCoverageTest is Test { // View functions // ══════════════════════════════════════════════════════════════════════════════ - function test_getAllLinks() public { + function test_getAllLinkIndexes() public { _makeEthLink(1 ether); _makeEthLink(2 ether); - EnvelopeLinks.Link[] memory all = vault.getAllLinks(); - assertEq(all.length, 2); - assertEq(all[0].amount, 1 ether); - assertEq(all[1].amount, 2 ether); + uint256[] memory indexes = vault.getAllLinkIndexes(); + assertEq(indexes.length, 2); + assertEq(vault.getLinkAsset(indexes[0]).amount, 1 ether); + assertEq(vault.getLinkAsset(indexes[1]).amount, 2 ether); } - function test_getLinksCreatedBy() public { + function test_getLinkIndexesCreatedBy() public { _makeEthLink(1 ether); // by SENDER vm.deal(OTHER, 5 ether); vm.prank(OTHER); vault.createLink{value: 2 ether}(address(0), 0, 2 ether, 0, LINK_PUBKEY2); - EnvelopeLinks.Link[] memory senderLinks = vault.getLinksCreatedBy(SENDER); + uint256[] memory senderLinks = vault.getLinkIndexesCreatedBy(SENDER); assertEq(senderLinks.length, 1); - assertEq(senderLinks[0].amount, 1 ether); + assertEq(vault.getLinkAsset(senderLinks[0]).amount, 1 ether); } function test_getLinkCount() public { @@ -883,8 +863,8 @@ contract EnvelopeCoverageTest is Test { uint256[] memory indexes = vault.createMFARaffleLinks{value: 0.6 ether}(address(0), 0, amounts, LINK_PUBKEY); assertEq(indexes.length, 3); - assertTrue(vault.getLink(indexes[0]).requiresMFA); - assertTrue(vault.getLink(indexes[2]).requiresMFA); + assertTrue(vault.getLinkStatus(indexes[0]).requiresMFA); + assertTrue(vault.getLinkStatus(indexes[2]).requiresMFA); } // ══════════════════════════════════════════════════════════════════════════════ @@ -921,7 +901,7 @@ contract EnvelopeCoverageTest is Test { function test_createLink_erc20_zeroAmount() public { vm.prank(SENDER); uint256 idx = vault.createLink(address(erc20), 1, 0, 0, LINK_PUBKEY); - assertEq(vault.getLink(idx).amount, 0); + assertEq(vault.getLinkAsset(idx).amount, 0); } // ══════════════════════════════════════════════════════════════════════════════ @@ -1005,7 +985,7 @@ contract EnvelopeCoverageTest is Test { vm.prank(SENDER); uint256[] memory indexes = vault.createCustomLinks(tokens, types, amounts, tokenIds, keys, mfas); assertEq(indexes.length, 1); - assertEq(vault.getLink(indexes[0]).tokenId, 2); + assertEq(vault.getLinkAsset(indexes[0]).tokenId, 2); } // ══════════════════════════════════════════════════════════════════════════════ @@ -1082,6 +1062,642 @@ contract EnvelopeCoverageTest is Test { vm.prank(SENDER); return vault.createLinkWithFees{value: amount}(req, auth); } + + function _makeGaslessSponsoredEthLink(uint256 amount) internal returns (uint256) { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: amount, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0, true, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0, + gaslessSponsored: true, + deadline: 0, + signature: authSig + }); + vm.prank(SENDER); + return vault.createLinkWithFees{value: amount}(req, auth); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // withdrawFees — ETH path where owner contract rejects the transfer + // Scenario: Owner is a multisig/governance contract that cannot receive ETH + // ══════════════════════════════════════════════════════════════════════════════ + + function test_RevertIf_withdrawFees_ethRejected() public { + // Deploy a vault owned by a contract that rejects ETH + EthRejecter rejecter = new EthRejecter(); + EnvelopeLinks rejVault = new EnvelopeLinks(BACKEND_AUTHORIZER, address(rejecter), address(feeToken)); + + // Create a link with ETH service fees so that ETH accumulates in the vault + // Since fees are ERC-20 (feeToken), we need to get ETH into accumulatedFees. + // withdrawFees(address(0)) withdraws ETH accumulated fees. + // Seed directly: we can call withdrawFees with ETH balance. + vm.deal(address(rejVault), 1 ether); + // Write to the accumulatedFees[address(0)] storage slot + // accumulatedFees is at storage slot 5 in the contract layout + bytes32 slot = keccak256(abi.encode(address(0), uint256(5))); + vm.store(address(rejVault), slot, bytes32(uint256(1 ether))); + + vm.prank(address(rejecter)); + vm.expectRevert(EnvelopeLinks.EthTransferFailed.selector); + rejVault.withdrawFees(address(0)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Claim ERC-721 and ERC-1155 via createCustomLinksWithFees + // Scenario: Backend-authorized fee links for NFTs — full lifecycle + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimERC721_createdWithFees() public { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(erc721), + contractType: 2, + amount: 1, + tokenId: 2, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0.05 ether, 0, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.05 ether, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + + vm.prank(SENDER); + uint256 idx = vault.createLinkWithFees(req, auth); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + vault.claim(idx, RECIPIENT, sig); + + assertEq(erc721.ownerOf(2), RECIPIENT); + assertTrue(vault.getLinkStatus(idx).redeemed); + } + + function test_claimERC1155_createdWithFees() public { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(erc1155), + contractType: 3, + amount: 30, + tokenId: 1, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0.02 ether, 0, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.02 ether, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + + vm.prank(SENDER); + uint256 idx = vault.createLinkWithFees(req, auth); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + vault.claim(idx, RECIPIENT, sig); + + assertEq(erc1155.balanceOf(RECIPIENT, 1), 30); + assertTrue(vault.getLinkStatus(idx).redeemed); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createCustomLinksWithFees — heterogeneous batch including ERC-721 + // Scenario: A single transaction creates ETH + ERC-20 + ERC-721 links + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createCustomLinksWithFees_withERC721() public { + EnvelopeLinks.LinkRequest[] memory reqs = new EnvelopeLinks.LinkRequest[](2); + reqs[0] = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 0.5 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + reqs[1] = EnvelopeLinks.LinkRequest({ + tokenAddress: address(erc721), + contractType: 2, + amount: 1, + tokenId: 3, + claimKey: LINK_PUBKEY2, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + + EnvelopeLinks.FeeAuthorization[] memory auths = new EnvelopeLinks.FeeAuthorization[](2); + auths[0] = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: _signFeeAuth(reqs[0], SENDER, 0, 0, false, 0) + }); + auths[1] = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: _signFeeAuth(reqs[1], SENDER, 0, 0, false, 0) + }); + + vm.prank(SENDER); + uint256[] memory indexes = vault.createCustomLinksWithFees{value: 0.5 ether}(reqs, auths); + + assertEq(indexes.length, 2); + assertEq(vault.getLinkAsset(indexes[0]).amount, 0.5 ether); + assertEq(erc721.ownerOf(3), address(vault)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Reclaim ERC-20 link — creator takes back their deposited ERC-20 tokens + // Scenario: A sender creates an ERC-20 link, then reclaims before anyone claims + // ══════════════════════════════════════════════════════════════════════════════ + + function test_reclaim_erc20() public { + vm.prank(SENDER); + uint256 idx = vault.createLink(address(erc20), 1, 50 ether, 0, LINK_PUBKEY); + + uint256 balBefore = erc20.balanceOf(SENDER); + vm.prank(SENDER); + vault.reclaim(idx); + + assertEq(erc20.balanceOf(SENDER), balBefore + 50 ether); + assertTrue(vault.getLinkStatus(idx).redeemed); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Gasless validation — sponsored link (gaslessSponsored=true, gaslessFee=0) + // Scenario: Backend pre-approves gas sponsorship without requiring a prepaid fee + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_sponsoredLink_claim() public { + uint256 idx = _makeGaslessSponsoredEthLink(1 ether); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (idx, RECIPIENT, sig)); + assertTrue(vault.isValidGaslessOperation(RECIPIENT, data)); + } + + function test_isValidGaslessOperation_sponsoredLink_reclaim() public { + uint256 idx = _makeGaslessSponsoredEthLink(1 ether); + + bytes memory data = abi.encodeCall(EnvelopeLinks.reclaim, (idx)); + assertTrue(vault.isValidGaslessOperation(SENDER, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Gasless validation — MFA link requires authorization but gasless check has none + // Scenario: A gasless link requires MFA but claimWithMFA is called without valid MFA sig + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_claimWithMFA_invalidMfaSignature() public { + uint256 idx = _makeGaslessEthLink_mfa(1 ether); + uint256 deadline = block.timestamp + 1 hours; + bytes memory claimSig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + // Use a wrong signature (signed by LINK_PRIVKEY instead of BACKEND) + bytes32 digest = MessageHashUtils.toEthSignedMessageHash( + keccak256(abi.encodePacked(vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, RECIPIENT, deadline)) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); + bytes memory wrongMfaSig = abi.encodePacked(r, s, v); + + bytes memory data = abi.encodeCall(EnvelopeLinks.claimWithMFA, (idx, RECIPIENT, claimSig, wrongMfaSig, deadline)); + assertFalse(vault.isValidGaslessOperation(RECIPIENT, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Gasless validation — claim with wrong signature for claimKey + // Scenario: Someone tries to use the gasless paymaster with an invalid claim signature + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_claim_wrongClaimSignature() public { + uint256 idx = _makeGaslessEthLink(1 ether); + // Sign with the wrong private key + bytes memory wrongSig = _signClaim(LINK_PRIVKEY2, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (idx, RECIPIENT, wrongSig)); + assertFalse(vault.isValidGaslessOperation(RECIPIENT, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Gasless validation — claim for already-redeemed link + // Scenario: Paymaster is queried for an already-claimed link + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_claim_alreadyRedeemed() public { + uint256 idx = _makeGaslessEthLink(1 ether); + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + + // Claim the link first + vm.prank(RECIPIENT); + vault.claim(idx, RECIPIENT, sig); + + // Now check gasless validation — should be false + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (idx, RECIPIENT, sig)); + assertFalse(vault.isValidGaslessOperation(RECIPIENT, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Gasless validation — MFA-gated link checked via plain claim (no MFA auth) + // Scenario: Link requires MFA but caller uses `claim()` not `claimWithMFA()` + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_claim_mfaRequiredButNotAuthorized() public { + uint256 idx = _makeGaslessEthLink_mfa(1 ether); + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (idx, RECIPIENT, sig)); + // claim() sets _authorized=false, but the link requiresMFA → invalid + assertFalse(vault.isValidGaslessOperation(RECIPIENT, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Gasless validation — bound recipient claim where caller is wrong + // Scenario: claimAsBoundRecipient called with a non-matching caller + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_claimAsBoundRecipient_wrongCaller() public { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: RECIPIENT, + reclaimableAfter: uint40(block.timestamp + 1 days) + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0.01 ether, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + vm.prank(SENDER); + uint256 idx = vault.createLinkWithFees{value: 1 ether}(req, auth); + + bytes memory sig = _signClaim(LINK_PRIVKEY, idx, OTHER, vault.BOUND_CLAIM_MODE()); + bytes memory data = abi.encodeCall(EnvelopeLinks.claimAsBoundRecipient, (idx, OTHER, sig)); + // OTHER != RECIPIENT → gasless validation fails at _isValidClaim (recipient mismatch) + assertFalse(vault.isValidGaslessOperation(OTHER, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createLinksNoReturn — batch deposit without index array allocation + // Scenario: High-volume distributor uses gas-efficient batch with no return + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createLinksNoReturn_erc20() public { + address[] memory keys = new address[](3); + keys[0] = LINK_PUBKEY; + keys[1] = LINK_PUBKEY2; + keys[2] = vm.addr(uint256(keccak256("third-key"))); + + uint256 balBefore = erc20.balanceOf(SENDER); + vm.prank(SENDER); + vault.createLinksNoReturn(address(erc20), 1, 10 ether, 0, keys); + + assertEq(erc20.balanceOf(SENDER), balBefore - 30 ether); + assertEq(vault.getLinkCount(), 3); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // getLinkIndexesCreatedBy — filtering by creator across multiple creators + // Scenario: Multiple creators deposit links; query filters correctly + // ══════════════════════════════════════════════════════════════════════════════ + + function test_getLinkIndexesCreatedBy_multipleCreators() public { + // SENDER creates two links + vm.startPrank(SENDER); + vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, LINK_PUBKEY); + vault.createLink{value: 2 ether}(address(0), 0, 2 ether, 0, LINK_PUBKEY2); + vm.stopPrank(); + + // OTHER creates one link + vm.deal(OTHER, 5 ether); + vm.prank(OTHER); + vault.createLink{value: 0.5 ether}(address(0), 0, 0.5 ether, 0, LINK_PUBKEY); + + uint256[] memory senderLinks = vault.getLinkIndexesCreatedBy(SENDER); + uint256[] memory otherLinks = vault.getLinkIndexesCreatedBy(OTHER); + + assertEq(senderLinks.length, 2); + assertEq(otherLinks.length, 1); + assertEq(vault.getLinkAsset(otherLinks[0]).amount, 0.5 ether); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Gasless reclaim — reclaim validation for a recipient-bound sponsored link + // Scenario: Creator tries to reclaim a recipient-bound link after the deadline + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_reclaim_sponsoredAfterDeadline() public { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 1 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: RECIPIENT, + reclaimableAfter: uint40(block.timestamp + 1 days) + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0, true, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, + gaslessFee: 0, + gaslessSponsored: true, + deadline: 0, + signature: authSig + }); + vm.prank(SENDER); + uint256 idx = vault.createLinkWithFees{value: 1 ether}(req, auth); + + // Before deadline: reclaim should be invalid + bytes memory data = abi.encodeCall(EnvelopeLinks.reclaim, (idx)); + assertFalse(vault.isValidGaslessOperation(SENDER, data)); + + // After deadline: reclaim should be valid + vm.warp(block.timestamp + 1 days + 1); + assertTrue(vault.isValidGaslessOperation(SENDER, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Reclaim via createCustomLinksWithFees — ERC-721 reclaim lifecycle + // Scenario: Creator deposits an NFT with fees, then reclaims when unclaimed + // ══════════════════════════════════════════════════════════════════════════════ + + function test_reclaim_erc721_createdWithFees() public { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(erc721), + contractType: 2, + amount: 1, + tokenId: 2, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0.01 ether, 0, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.01 ether, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + + vm.prank(SENDER); + uint256 idx = vault.createLinkWithFees(req, auth); + assertEq(erc721.ownerOf(2), address(vault)); + + vm.prank(SENDER); + vault.reclaim(idx); + assertEq(erc721.ownerOf(2), SENDER); + } + + function test_reclaim_erc1155_createdWithFees() public { + EnvelopeLinks.LinkRequest memory req = EnvelopeLinks.LinkRequest({ + tokenAddress: address(erc1155), + contractType: 3, + amount: 25, + tokenId: 2, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + bytes memory authSig = _signFeeAuth(req, SENDER, 0.01 ether, 0, false, 0); + EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.01 ether, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: authSig + }); + + vm.prank(SENDER); + uint256 idx = vault.createLinkWithFees(req, auth); + + vm.prank(SENDER); + vault.reclaim(idx); + assertEq(erc1155.balanceOf(SENDER, 2), 50); // had 50, deposited 25, reclaimed 25 + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Reclaim recipient-bound link after deadline + // Scenario: Creator waits for reclaimableAfter before reclaiming an unclaimed link + // ══════════════════════════════════════════════════════════════════════════════ + + function test_reclaim_recipientBound_erc20_afterDeadline() public { + vm.prank(SENDER); + uint256 idx = vault.createCustomLink( + address(erc20), 1, 100 ether, 0, LINK_PUBKEY, SENDER, false, RECIPIENT, uint40(block.timestamp + 7 days) + ); + + // Before deadline: should revert + vm.prank(SENDER); + vm.expectRevert(EnvelopeLinks.TooEarlyToReclaim.selector); + vault.reclaim(idx); + + // After deadline: should succeed + vm.warp(block.timestamp + 7 days + 1); + vm.prank(SENDER); + vault.reclaim(idx); + assertTrue(vault.getLinkStatus(idx).redeemed); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Claim ERC-20 created via createCustomLinksWithFees batch + // Scenario: Batch link creation with fees, then individual claims + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimERC20_fromCustomLinksWithFeesBatch() public { + EnvelopeLinks.LinkRequest[] memory reqs = new EnvelopeLinks.LinkRequest[](2); + reqs[0] = EnvelopeLinks.LinkRequest({ + tokenAddress: address(erc20), + contractType: 1, + amount: 100 ether, + tokenId: 0, + claimKey: LINK_PUBKEY, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + reqs[1] = EnvelopeLinks.LinkRequest({ + tokenAddress: address(erc20), + contractType: 1, + amount: 50 ether, + tokenId: 0, + claimKey: LINK_PUBKEY2, + onBehalfOf: SENDER, + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + + EnvelopeLinks.FeeAuthorization[] memory auths = new EnvelopeLinks.FeeAuthorization[](2); + auths[0] = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.01 ether, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: _signFeeAuth(reqs[0], SENDER, 0.01 ether, 0, false, 0) + }); + auths[1] = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0.01 ether, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: _signFeeAuth(reqs[1], SENDER, 0.01 ether, 0, false, 0) + }); + + vm.prank(SENDER); + uint256[] memory indexes = vault.createCustomLinksWithFees(reqs, auths); + + // Claim first link + bytes memory sig = _signClaim(LINK_PRIVKEY, indexes[0], RECIPIENT, vault.OPEN_CLAIM_MODE()); + vault.claim(indexes[0], RECIPIENT, sig); + assertEq(erc20.balanceOf(RECIPIENT), 100 ether); + + // Claim second link + bytes memory sig2 = _signClaim(LINK_PRIVKEY2, indexes[1], OTHER, vault.OPEN_CLAIM_MODE()); + vault.claim(indexes[1], OTHER, sig2); + assertEq(erc20.balanceOf(OTHER), 50 ether); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Gasless validation — claim with out-of-bounds index + // Scenario: Paymaster is queried for a non-existent link index + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_claim_indexOutOfBounds() public view { + bytes memory sig = _signClaim(LINK_PRIVKEY, 999, RECIPIENT, vault.OPEN_CLAIM_MODE()); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (999, RECIPIENT, sig)); + assertFalse(vault.isValidGaslessOperation(RECIPIENT, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Gasless reclaim — link is redeemed but has gasless fee (should fail) + // Scenario: Double-reclaim attempt via paymaster after already reclaimed + // ══════════════════════════════════════════════════════════════════════════════ + + function test_isValidGaslessOperation_reclaim_redeemedGaslessLink() public { + uint256 idx = _makeGaslessEthLink(1 ether); + + // Reclaim the link + vm.prank(SENDER); + vault.reclaim(idx); + + // Now query gasless reclaim validation — should return false (redeemed) + bytes memory data = abi.encodeCall(EnvelopeLinks.reclaim, (idx)); + assertFalse(vault.isValidGaslessOperation(SENDER, data)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Raffle links — ERC-20 raffle distributing different amounts to one claimKey + // Scenario: Airdrop with randomized amounts using a single shared claimKey + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createRaffleLinks_erc20() public { + uint256[] memory amounts = new uint256[](3); + amounts[0] = 10 ether; + amounts[1] = 20 ether; + amounts[2] = 5 ether; + + vm.prank(SENDER); + uint256[] memory indexes = vault.createRaffleLinks(address(erc20), 1, amounts, LINK_PUBKEY); + + assertEq(indexes.length, 3); + assertEq(vault.getLinkAsset(indexes[0]).amount, 10 ether); + assertEq(vault.getLinkAsset(indexes[1]).amount, 20 ether); + assertEq(vault.getLinkAsset(indexes[2]).amount, 5 ether); + // Total 35 ether transferred from sender + assertEq(erc20.balanceOf(address(vault)), 35 ether); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // createCustomLinks — ERC-1155 in a heterogeneous batch (no fees) + // Scenario: One-shot batch deposits across ETH and ERC-1155 + // ══════════════════════════════════════════════════════════════════════════════ + + function test_createCustomLinks_ethAndErc1155() public { + address[] memory addrs = new address[](2); + addrs[0] = address(0); + addrs[1] = address(erc1155); + + uint8[] memory types = new uint8[](2); + types[0] = 0; + types[1] = 3; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = 0.5 ether; + amounts[1] = 10; + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + + address[] memory keys = new address[](2); + keys[0] = LINK_PUBKEY; + keys[1] = LINK_PUBKEY2; + + bool[] memory mfas = new bool[](2); + mfas[0] = false; + mfas[1] = false; + + vm.prank(SENDER); + uint256[] memory indexes = vault.createCustomLinks{value: 0.5 ether}(addrs, types, amounts, tokenIds, keys, mfas); + + assertEq(indexes.length, 2); + assertEq(vault.getLinkAsset(indexes[0]).contractType, 0); + assertEq(vault.getLinkAsset(indexes[1]).contractType, 3); + assertEq(erc1155.balanceOf(address(vault), 1), 10); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ERC-1155 batch receive hook — internal batch transfers succeed + // Scenario: The vault itself performs a batch transfer in (triggered by ERC-1155 deposit) + // ══════════════════════════════════════════════════════════════════════════════ + + function test_onERC1155BatchReceived_internalTransfer() public { + // The onERC1155BatchReceived success path is only reachable when operator == vault address. + // We can call it directly to verify the selector is returned. + bytes4 result = vault.onERC1155BatchReceived( + address(vault), SENDER, new uint256[](1), new uint256[](1), "" + ); + assertEq(result, vault.onERC1155BatchReceived.selector); + } } /// @dev Helper contract that rejects ETH transfers diff --git a/test/envelope/EnvelopeBatching.t.sol b/test/envelope/EnvelopeBatching.t.sol index a08935b1..96cd30cd 100644 --- a/test/envelope/EnvelopeBatching.t.sol +++ b/test/envelope/EnvelopeBatching.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; +import {EnvelopeFeeAuthTestUtils} from "./EnvelopeFeeAuthTestUtils.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; @@ -50,9 +51,10 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { assertEq(depositIndexes.length, numDeposits); assertEq(vault.getLinkCount(), numDeposits); for (uint256 i = 0; i < numDeposits; ++i) { - EnvelopeLinks.Link memory deposit = vault.getLink(depositIndexes[i]); - assertEq(deposit.amount, amount); - assertEq(deposit.creator, address(this)); + EnvelopeLinks.LinkAsset memory asset = vault.getLinkAsset(depositIndexes[i]); + EnvelopeLinks.LinkParties memory parties = vault.getLinkParties(depositIndexes[i]); + assertEq(asset.amount, amount); + assertEq(parties.creator, address(this)); } } @@ -146,11 +148,12 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndexes = vault.createCustomLinks(tokenAddresses, contractTypes, amounts, tokenIds, pubKeys20, withMFAs); - EnvelopeLinks.Link memory deposit = vault.getLink(depositIndexes[0]); + EnvelopeLinks.LinkAsset memory asset = vault.getLinkAsset(depositIndexes[0]); + EnvelopeLinks.LinkParties memory parties = vault.getLinkParties(depositIndexes[0]); assertEq(testToken721.ownerOf(tokenId), address(vault)); - assertEq(deposit.contractType, 2); - assertEq(deposit.tokenId, tokenId); - assertEq(deposit.creator, address(this)); + assertEq(asset.contractType, 2); + assertEq(asset.tokenId, tokenId); + assertEq(parties.creator, address(this)); } function testMakeBatchCustomDepositWithFeesCollectsFeesAtDeposit() public { @@ -165,25 +168,24 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { feeToken.mint(address(this), 0.1 ether); feeToken.approve(address(feeVault), 0.1 ether); - uint256[] memory depositIndexes = - feeVault.createCustomLinksWithFees{value: 3 ether}(requests, authorizations); + uint256[] memory depositIndexes = feeVault.createCustomLinksWithFees{value: 3 ether}(requests, authorizations); - EnvelopeLinks.Link memory firstDeposit = feeVault.getLink(depositIndexes[0]); - EnvelopeLinks.Link memory secondDeposit = feeVault.getLink(depositIndexes[1]); + EnvelopeLinks.LinkParties memory firstParties = feeVault.getLinkParties(depositIndexes[0]); + EnvelopeLinks.LinkFees memory firstFees = feeVault.getLinkFees(depositIndexes[0]); + EnvelopeLinks.LinkStatus memory secondStatus = feeVault.getLinkStatus(depositIndexes[1]); + EnvelopeLinks.LinkParties memory secondParties = feeVault.getLinkParties(depositIndexes[1]); assertEq(depositIndexes.length, 2); - assertEq(firstDeposit.creator, address(this)); - assertEq(firstDeposit.serviceFee, 0.01 ether); - assertEq(firstDeposit.gaslessFee, 0.02 ether); - assertEq(secondDeposit.requiresMFA, true); - assertEq(secondDeposit.recipient, RECIPIENT); + assertEq(firstParties.creator, address(this)); + assertEq(firstFees.serviceFee, 0.01 ether); + assertEq(firstFees.gaslessFee, 0.02 ether); + assertEq(secondStatus.requiresMFA, true); + assertEq(secondParties.recipient, RECIPIENT); assertEq(feeToken.balanceOf(address(feeVault)), 0.1 ether); assertEq(feeVault.accumulatedFees(address(feeToken)), 0.1 ether); - bytes memory withdrawalSig = - _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.OPEN_CLAIM_MODE()); - bytes memory callData = - abi.encodeCall(EnvelopeLinks.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); + bytes memory withdrawalSig = _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.OPEN_CLAIM_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeLinks.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); assertTrue(feeVault.isValidGaslessOperation(RECIPIENT, callData)); } @@ -207,15 +209,15 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndexes = feeVault.createCustomLinksWithFees(requests, authorizations); - EnvelopeLinks.Link memory nftDeposit = feeVault.getLink(depositIndexes[0]); - EnvelopeLinks.Link memory multiTokenDeposit = feeVault.getLink(depositIndexes[1]); + EnvelopeLinks.LinkAsset memory nftAsset = feeVault.getLinkAsset(depositIndexes[0]); + EnvelopeLinks.LinkAsset memory multiTokenAsset = feeVault.getLinkAsset(depositIndexes[1]); assertEq(testToken721.ownerOf(tokenId), address(feeVault)); assertEq(testToken1155.balanceOf(address(feeVault), erc1155Id), 5); - assertEq(nftDeposit.contractType, 2); - assertEq(nftDeposit.tokenId, tokenId); - assertEq(multiTokenDeposit.contractType, 3); - assertEq(multiTokenDeposit.amount, 5); + assertEq(nftAsset.contractType, 2); + assertEq(nftAsset.tokenId, tokenId); + assertEq(multiTokenAsset.contractType, 3); + assertEq(multiTokenAsset.amount, 5); assertEq(feeToken.balanceOf(address(feeVault)), 10); } @@ -226,18 +228,16 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { requests[0] = _request(address(0), 0, 1 ether, 0, false, address(0), 0); authorizations[0] = _authorization(feeVault, requests[0], address(this), 0, 0, true, 0); - uint256[] memory depositIndexes = - feeVault.createCustomLinksWithFees{value: 1 ether}(requests, authorizations); + uint256[] memory depositIndexes = feeVault.createCustomLinksWithFees{value: 1 ether}(requests, authorizations); - EnvelopeLinks.Link memory deposit = feeVault.getLink(depositIndexes[0]); - assertEq(deposit.gaslessFee, 0); - assertTrue(deposit.gaslessSponsored); + EnvelopeLinks.LinkFees memory fees = feeVault.getLinkFees(depositIndexes[0]); + EnvelopeLinks.LinkStatus memory status = feeVault.getLinkStatus(depositIndexes[0]); + assertEq(fees.gaslessFee, 0); + assertTrue(status.gaslessSponsored); assertEq(feeToken.balanceOf(address(feeVault)), 0); - bytes memory withdrawalSig = - _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.OPEN_CLAIM_MODE()); - bytes memory callData = - abi.encodeCall(EnvelopeLinks.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); + bytes memory withdrawalSig = _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.OPEN_CLAIM_MODE()); + bytes memory callData = abi.encodeCall(EnvelopeLinks.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); assertTrue(feeVault.isValidGaslessOperation(RECIPIENT, callData)); } @@ -265,11 +265,13 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndices = vault.createRaffleLinks{value: 100}(address(0), 0, amounts, PUBKEY20); for (uint256 i = 0; i < amounts.length; ++i) { - EnvelopeLinks.Link memory deposit = vault.getLink(depositIndices[i]); - assertEq(deposit.amount, amounts[i]); - assertEq(deposit.contractType, 0); - assertEq(deposit.claimKey, PUBKEY20); - assertEq(deposit.creator, address(this)); + EnvelopeLinks.LinkAsset memory asset = vault.getLinkAsset(depositIndices[i]); + EnvelopeLinks.LinkStatus memory status = vault.getLinkStatus(depositIndices[i]); + EnvelopeLinks.LinkParties memory parties = vault.getLinkParties(depositIndices[i]); + assertEq(asset.amount, amounts[i]); + assertEq(asset.contractType, 0); + assertEq(status.claimKey, PUBKEY20); + assertEq(parties.creator, address(this)); } } @@ -286,11 +288,13 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndices = vault.createRaffleLinks(address(testToken), 1, amounts, PUBKEY20); for (uint256 i = 0; i < amounts.length; ++i) { - EnvelopeLinks.Link memory deposit = vault.getLink(depositIndices[i]); - assertEq(deposit.amount, amounts[i]); - assertEq(deposit.contractType, 1); - assertEq(deposit.claimKey, PUBKEY20); - assertEq(deposit.creator, address(this)); + EnvelopeLinks.LinkAsset memory asset = vault.getLinkAsset(depositIndices[i]); + EnvelopeLinks.LinkStatus memory status = vault.getLinkStatus(depositIndices[i]); + EnvelopeLinks.LinkParties memory parties = vault.getLinkParties(depositIndices[i]); + assertEq(asset.amount, amounts[i]); + assertEq(asset.contractType, 1); + assertEq(status.claimKey, PUBKEY20); + assertEq(parties.creator, address(this)); } } @@ -302,9 +306,10 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256[] memory depositIndices = vault.createMFARaffleLinks{value: 30}(address(0), 0, amounts, PUBKEY20); for (uint256 i = 0; i < amounts.length; ++i) { - EnvelopeLinks.Link memory deposit = vault.getLink(depositIndices[i]); - assertTrue(deposit.requiresMFA); - assertEq(deposit.creator, address(this)); + EnvelopeLinks.LinkStatus memory status = vault.getLinkStatus(depositIndices[i]); + EnvelopeLinks.LinkParties memory parties = vault.getLinkParties(depositIndices[i]); + assertTrue(status.requiresMFA); + assertEq(parties.creator, address(this)); } } @@ -405,28 +410,15 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { bool gaslessSponsored, uint256 deadline ) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encode( - targetVault.ENVELOPE_SALT(), - block.chainid, - address(targetVault), - feePayer, - request.tokenAddress, - request.contractType, - request.amount, - request.tokenId, - request.claimKey, - request.onBehalfOf, - request.withMFA, - request.recipient, - request.reclaimableAfter, - serviceFee, - gaslessFee, - gaslessSponsored, - deadline - ) - ) + bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( + targetVault.ENVELOPE_SALT(), + address(targetVault), + request, + feePayer, + serviceFee, + gaslessFee, + gaslessSponsored, + deadline ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index 57e19930..0f9ca7f9 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -201,11 +201,11 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { _depositEth(1); _depositEth(1); // Same sender (address(this)) made both deposits. - EnvelopeLinks.Link[] memory mine = vault.getLinksCreatedBy(address(this)); + uint256[] memory mine = vault.getLinkIndexesCreatedBy(address(this)); assertEq(mine.length, 2); // Different sender → empty. - EnvelopeLinks.Link[] memory aliceDeposits = vault.getLinksCreatedBy(ALICE); + uint256[] memory aliceDeposits = vault.getLinkIndexesCreatedBy(ALICE); assertEq(aliceDeposits.length, 0); } diff --git a/test/envelope/EnvelopeFeeAuthTestUtils.sol b/test/envelope/EnvelopeFeeAuthTestUtils.sol new file mode 100644 index 00000000..f13d576f --- /dev/null +++ b/test/envelope/EnvelopeFeeAuthTestUtils.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.20; + +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; + +library EnvelopeFeeAuthTestUtils { + function feeAuthorizationDigest( + bytes32 salt, + address vaultAddr, + EnvelopeLinks.LinkRequest memory request, + address feePayer, + uint256 serviceFee, + uint256 gaslessFee, + bool gaslessSponsored, + uint256 deadline + ) internal view returns (bytes32) { + bytes32 digest; + assembly ("memory-safe") { + let ptr := mload(0x40) + mstore(0x40, add(ptr, 544)) + mstore(ptr, salt) + mstore(add(ptr, 32), chainid()) + mstore(add(ptr, 64), vaultAddr) + mstore(add(ptr, 96), feePayer) + mstore(add(ptr, 128), mload(request)) + mstore(add(ptr, 160), mload(add(request, 32))) + mstore(add(ptr, 192), mload(add(request, 64))) + mstore(add(ptr, 224), mload(add(request, 96))) + mstore(add(ptr, 256), mload(add(request, 128))) + mstore(add(ptr, 288), mload(add(request, 160))) + mstore(add(ptr, 320), mload(add(request, 192))) + mstore(add(ptr, 352), mload(add(request, 224))) + mstore(add(ptr, 384), mload(add(request, 256))) + mstore(add(ptr, 416), serviceFee) + mstore(add(ptr, 448), gaslessFee) + mstore(add(ptr, 480), gaslessSponsored) + mstore(add(ptr, 512), deadline) + digest := keccak256(ptr, 544) + } + return MessageHashUtils.toEthSignedMessageHash(digest); + } +} \ No newline at end of file diff --git a/test/envelope/Gasless.t.sol b/test/envelope/Gasless.t.sol index b29d301e..c59098c5 100644 --- a/test/envelope/Gasless.t.sol +++ b/test/envelope/Gasless.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../../src/envelope/EnvelopeLinks.sol"; +import {EnvelopeFeeAuthTestUtils} from "./EnvelopeFeeAuthTestUtils.sol"; import "./mocks/ERC20Mock.sol"; contract EnvelopeLinksGaslessTest is Test { @@ -68,28 +69,15 @@ contract EnvelopeLinksGaslessTest is Test { bool gaslessSponsored, uint256 deadline ) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encode( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - feePayer, - request.tokenAddress, - request.contractType, - request.amount, - request.tokenId, - request.claimKey, - request.onBehalfOf, - request.withMFA, - request.recipient, - request.reclaimableAfter, - serviceFee, - gaslessFee, - gaslessSponsored, - deadline - ) - ) + bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( + vault.ENVELOPE_SALT(), + address(vault), + request, + feePayer, + serviceFee, + gaslessFee, + gaslessSponsored, + deadline ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); @@ -167,11 +155,13 @@ contract EnvelopeLinksGaslessTest is Test { vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: amount}(request, authorization); - EnvelopeLinks.Link memory deposit = vault.getLink(index); - assertEq(deposit.amount, amount); - assertEq(deposit.serviceFee, serviceFee); - assertEq(deposit.gaslessFee, gaslessFee); - assertFalse(deposit.gaslessSponsored); + EnvelopeLinks.LinkAsset memory asset = vault.getLinkAsset(index); + EnvelopeLinks.LinkFees memory fees = vault.getLinkFees(index); + EnvelopeLinks.LinkStatus memory status = vault.getLinkStatus(index); + assertEq(asset.amount, amount); + assertEq(fees.serviceFee, serviceFee); + assertEq(fees.gaslessFee, gaslessFee); + assertFalse(status.gaslessSponsored); assertEq(feeToken.balanceOf(address(vault)), serviceFee + gaslessFee); assertEq(vault.accumulatedFees(address(feeToken)), serviceFee + gaslessFee); } @@ -183,9 +173,10 @@ contract EnvelopeLinksGaslessTest is Test { vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - EnvelopeLinks.Link memory deposit = vault.getLink(index); - assertEq(deposit.gaslessFee, 0); - assertTrue(deposit.gaslessSponsored); + EnvelopeLinks.LinkFees memory fees = vault.getLinkFees(index); + EnvelopeLinks.LinkStatus memory status = vault.getLinkStatus(index); + assertEq(fees.gaslessFee, 0); + assertTrue(status.gaslessSponsored); assertEq(feeToken.balanceOf(address(vault)), 0); bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT, vault.OPEN_CLAIM_MODE()); @@ -211,17 +202,17 @@ contract EnvelopeLinksGaslessTest is Test { } function test_ZeroFeeAuthorizationWithBackendSignatureIsAccepted() public { - EnvelopeLinks.LinkRequest memory request = - _request(1 ether, true, RECIPIENT, uint40(block.timestamp + 1 days)); + EnvelopeLinks.LinkRequest memory request = _request(1 ether, true, RECIPIENT, uint40(block.timestamp + 1 days)); EnvelopeLinks.FeeAuthorization memory authorization = _feeAuthorization(request, 0, 0, 0); vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - EnvelopeLinks.Link memory deposit = vault.getLink(index); - assertEq(deposit.serviceFee, 0); - assertEq(deposit.gaslessFee, 0); - assertFalse(deposit.gaslessSponsored); + EnvelopeLinks.LinkFees memory fees = vault.getLinkFees(index); + EnvelopeLinks.LinkStatus memory status = vault.getLinkStatus(index); + assertEq(fees.serviceFee, 0); + assertEq(fees.gaslessFee, 0); + assertFalse(status.gaslessSponsored); assertEq(feeToken.balanceOf(address(vault)), 0); assertEq(vault.accumulatedFees(address(feeToken)), 0); } @@ -235,9 +226,9 @@ contract EnvelopeLinksGaslessTest is Test { vm.prank(SENDER); uint256 index = vault.createLinkWithFees{value: 1 ether}(request, authorization); - EnvelopeLinks.Link memory deposit = vault.getLink(index); - assertEq(deposit.serviceFee, 0); - assertEq(deposit.gaslessFee, 0); + EnvelopeLinks.LinkFees memory fees = vault.getLinkFees(index); + assertEq(fees.serviceFee, 0); + assertEq(fees.gaslessFee, 0); } function test_RevertIf_ZeroFeeAuthorizationSignatureWrong() public { diff --git a/test/paymasters/EnvelopePaymaster.t.sol b/test/paymasters/EnvelopePaymaster.t.sol index 878f0c3d..154a3e96 100644 --- a/test/paymasters/EnvelopePaymaster.t.sol +++ b/test/paymasters/EnvelopePaymaster.t.sol @@ -9,6 +9,7 @@ import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "../../src/paymasters/Bas import {EnvelopePaymaster} from "../../src/paymasters/EnvelopePaymaster.sol"; import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; +import {EnvelopeFeeAuthTestUtils} from "../envelope/EnvelopeFeeAuthTestUtils.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; contract EnvelopePaymasterTest is Test { @@ -73,28 +74,15 @@ contract EnvelopePaymasterTest is Test { bool gaslessSponsored, uint256 deadline ) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encode( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - SENDER, - request.tokenAddress, - request.contractType, - request.amount, - request.tokenId, - request.claimKey, - request.onBehalfOf, - request.withMFA, - request.recipient, - request.reclaimableAfter, - serviceFee, - gaslessFee, - gaslessSponsored, - deadline - ) - ) + bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( + vault.ENVELOPE_SALT(), + address(vault), + request, + SENDER, + serviceFee, + gaslessFee, + gaslessSponsored, + deadline ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); @@ -212,4 +200,18 @@ contract EnvelopePaymasterTest is Test { vm.expectRevert(BasePaymaster.PaymasterFlowNotSupported.selector); paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); } + + /// @dev When the vault's isValidGaslessOperation reverts (e.g. malformed calldata + /// that causes an ABI decode error), the paymaster catches the revert and + /// treats it as "not approved" rather than bubbling up the revert. + function test_RevertIf_EnvelopeOperationRevertsInternally() public { + // Build a transaction with a valid selector but truncated calldata + // that will cause abi.decode inside isValidGaslessOperation to revert + bytes memory malformedData = abi.encodePacked(EnvelopeLinks.claim.selector, bytes28(0)); + Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), malformedData, 100_000, 1 gwei); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(EnvelopePaymaster.EnvelopeGaslessOperationNotApproved.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } } From 8617f383c9f74606bf368dbc6c7c65431b46f00d Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 20 May 2026 23:19:10 +1200 Subject: [PATCH 47/49] feat(envelope): add forge zkSync deployment path - add DeployEnvelopeZkSync.s.sol for EnvelopeLinks and optional EnvelopePaymaster - validate Hardhat and Forge deployment routes on a local node-zksync instance - document the tested forge command, including zkSync-incompatible file skips - extend verify_zksync_contracts.py to recover unnamed zkSync broadcast deployments by script sequence - register Envelope contracts in the verifier source map - fix cspell issues introduced by the new Envelope coverage tests --- .cspell.json | 1 + ops/verify_zksync_contracts.py | 35 +++++++++-- script/DeployEnvelopeZkSync.s.sol | 98 +++++++++++++++++++++++++++++++ src/envelope/doc/README.md | 7 +++ test/envelope/Coverage.t.sol | 10 ++-- 5 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 script/DeployEnvelopeZkSync.s.sol diff --git a/.cspell.json b/.cspell.json index 82c8f4dc..0d2338cb 100644 --- a/.cspell.json +++ b/.cspell.json @@ -74,6 +74,7 @@ "SLOAD", "Bitmask", "mstore", + "mload", "MBOND", "USCA", "USNY", diff --git a/ops/verify_zksync_contracts.py b/ops/verify_zksync_contracts.py index a88e750b..4bc7c623 100755 --- a/ops/verify_zksync_contracts.py +++ b/ops/verify_zksync_contracts.py @@ -79,6 +79,8 @@ # Used by --broadcast mode to map broadcast JSON entries to verifiable contracts. # Extend this when adding new contract types to the deploy script. CONTRACT_SOURCE_MAP = { + "EnvelopeLinks": "src/envelope/EnvelopeLinks.sol:EnvelopeLinks", + "EnvelopePaymaster": "src/paymasters/EnvelopePaymaster.sol:EnvelopePaymaster", "ServiceProviderUpgradeable": "src/swarms/ServiceProviderUpgradeable.sol:ServiceProviderUpgradeable", "FleetIdentityUpgradeable": "src/swarms/FleetIdentityUpgradeable.sol:FleetIdentityUpgradeable", "SwarmRegistryUniversalUpgradeable": "src/swarms/SwarmRegistryUniversalUpgradeable.sol:SwarmRegistryUniversalUpgradeable", @@ -86,6 +88,25 @@ "BondTreasuryPaymaster": "src/paymasters/BondTreasuryPaymaster.sol:BondTreasuryPaymaster", } +# Some zkSync forge broadcasts record deployments as calls to ContractDeployer +# with unnamed `additionalContracts`. For those scripts, recover the deployed +# contract type by the deterministic deployment order inside the script. +BROADCAST_CONTRACT_SEQUENCE = { + "DeployEnvelopeZkSync.s.sol": [ + "EnvelopeLinks", + "EnvelopePaymaster", + ], + "DeploySwarmUpgradeableZkSync.s.sol": [ + "ServiceProviderUpgradeable", + "ERC1967Proxy", + "FleetIdentityUpgradeable", + "ERC1967Proxy", + "SwarmRegistryUniversalUpgradeable", + "ERC1967Proxy", + "BondTreasuryPaymaster", + ], +} + # --------------------------------------------------------------------------- # Core logic @@ -258,14 +279,20 @@ def parse_broadcast(broadcast_path: str) -> list: with open(broadcast_path) as f: data = json.load(f) + script_name = os.path.basename(os.path.dirname(os.path.dirname(broadcast_path))) + deployment_sequence = BROADCAST_CONTRACT_SEQUENCE.get(script_name, []) + unnamed_index = 0 + results = [] for tx in data["transactions"]: contract_name = tx.get("contractName", "") address = tx.get("contractAddress", "") - if not address: - additional = tx.get("additionalContracts") or [] - if additional: - address = additional[0].get("address", "") + additional = tx.get("additionalContracts") or [] + if additional: + address = additional[0].get("address", "") + if not contract_name and additional and unnamed_index < len(deployment_sequence): + contract_name = deployment_sequence[unnamed_index] + unnamed_index += 1 if not address or not contract_name: continue diff --git a/script/DeployEnvelopeZkSync.s.sol b/script/DeployEnvelopeZkSync.s.sol new file mode 100644 index 00000000..60088dab --- /dev/null +++ b/script/DeployEnvelopeZkSync.s.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Script, console} from "forge-std/Script.sol"; + +import {EnvelopeLinks} from "../src/envelope/EnvelopeLinks.sol"; +import {EnvelopePaymaster} from "../src/paymasters/EnvelopePaymaster.sol"; + +/** + * @title DeployEnvelopeZkSync + * @notice Deploys EnvelopeLinks and, optionally, EnvelopePaymaster on ZkSync Era. + * + * @dev Do NOT use `forge script --verify` on ZkSync for these contracts. + * Deploy with forge, then run `ops/verify_zksync_contracts.py` against the + * generated broadcast JSON. See the Usage section below. + * + * Usage: + * forge script script/DeployEnvelopeZkSync.s.sol --rpc-url $L2_RPC --broadcast --zksync \ + * --skip src/swarms/SwarmRegistryL1Upgradeable.sol \ + * --skip test/SwarmRegistryL1.t.sol \ + * --skip test/upgrade-demo/TestUpgradeOnAnvil.s.sol \ + * --skip script/DeploySwarmUpgradeable.s.sol \ + * --skip script/UpgradeSwarm.s.sol + * + * # After deployment, verify via the custom helper instead of forge --verify: + * python3 ops/verify_zksync_contracts.py \ + * --broadcast broadcast/DeployEnvelopeZkSync.s.sol/324/run-latest.json \ + * --verifier-url https://zksync2-mainnet-explorer.zksync.io/contract_verification + * + * Required environment variables: + * - DEPLOYER_PRIVATE_KEY + * + * Optional environment variables: + * - ENVELOPE_MFA_AUTHORIZER + * - ENVELOPE_OWNER + * - ENVELOPE_FEE_TOKEN + * - ENVELOPE_DEPLOY_PAYMASTER (true|false, default false) + * - ENVELOPE_PAYMASTER_ADMIN + * - ENVELOPE_PAYMASTER_WITHDRAWER + */ +contract DeployEnvelopeZkSync is Script { + address public vaultAddr; + address public paymasterAddr; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + address mfaAuthorizer = vm.envOr("ENVELOPE_MFA_AUTHORIZER", address(0)); + address envelopeOwner = vm.envOr("ENVELOPE_OWNER", deployer); + address feeToken = vm.envOr("ENVELOPE_FEE_TOKEN", address(0)); + bool deployPaymaster = vm.envOr("ENVELOPE_DEPLOY_PAYMASTER", false); + address paymasterAdmin = vm.envOr("ENVELOPE_PAYMASTER_ADMIN", deployer); + address paymasterWithdrawer = vm.envOr("ENVELOPE_PAYMASTER_WITHDRAWER", deployer); + + console.log("=== Deploying Envelope ==="); + console.log("Deployer:", deployer); + console.log("MFA Authorizer:", mfaAuthorizer); + console.log("Owner:", envelopeOwner); + console.log("Fee Token:", feeToken); + console.log("Deploy Paymaster:", deployPaymaster); + console.log(""); + + vm.startBroadcast(deployerPrivateKey); + + EnvelopeLinks vault = new EnvelopeLinks(mfaAuthorizer, envelopeOwner, feeToken); + vaultAddr = address(vault); + + if (deployPaymaster) { + EnvelopePaymaster paymaster = new EnvelopePaymaster(paymasterAdmin, paymasterWithdrawer, vaultAddr); + paymasterAddr = address(paymaster); + } + + vm.stopBroadcast(); + + console.log("=== Deployment Complete ==="); + console.log("EnvelopeLinks:", vaultAddr); + if (paymasterAddr != address(0)) { + console.log("EnvelopePaymaster:", paymasterAddr); + } + console.log(""); + console.log("=== Env vars ==="); + console.log("ENVELOPE_VAULT=%s", vaultAddr); + if (paymasterAddr != address(0)) { + console.log("ENVELOPE_PAYMASTER=%s", paymasterAddr); + } + if (mfaAuthorizer == address(0)) { + console.log(""); + console.log("NOTE: MFA auth is 0x0."); + console.log("claimWithMFA stays disabled."); + } + console.log(""); + console.log("=== Verification Note ==="); + console.log("Use verify_zksync_contracts.py"); + console.log("No forge script --verify"); + } +} \ No newline at end of file diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index f0440cc8..9246a6d3 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -65,6 +65,7 @@ The vault no longer contains an internal paymaster callback, and the EIP-3009 ga | Script | Purpose | | ---------------------------------- | ----------------------------------------------------------- | | `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeLinks` and optionally `EnvelopePaymaster`. | +| `script/DeployEnvelopeZkSync.s.sol` | Forge deployment script for `EnvelopeLinks` and optional `EnvelopePaymaster` on ZkSync Era. | Important environment variables: @@ -77,6 +78,12 @@ Important environment variables: | `ENVELOPE_PAYMASTER_ADMIN` | Optional paymaster admin; defaults to deployer. | | `ENVELOPE_PAYMASTER_WITHDRAWER` | Optional paymaster ETH withdrawer; defaults to deployer. | +Verification note: + +- Hardhat deployments can use `hre.run("verify:verify", ...)` on supported public ZkSync networks. +- Forge deployments should NOT use `forge script --verify` or `forge verify-contract` directly on ZkSync. +- Use `ops/verify_zksync_contracts.py` against the Forge broadcast JSON so imports are rewritten into the format the ZkSync verifier accepts. + ## Test coverage Relevant suites: diff --git a/test/envelope/Coverage.t.sol b/test/envelope/Coverage.t.sol index 7b0efb61..32d2651b 100644 --- a/test/envelope/Coverage.t.sol +++ b/test/envelope/Coverage.t.sol @@ -1652,9 +1652,9 @@ contract EnvelopeCoverageTest is Test { // ══════════════════════════════════════════════════════════════════════════════ function test_createCustomLinks_ethAndErc1155() public { - address[] memory addrs = new address[](2); - addrs[0] = address(0); - addrs[1] = address(erc1155); + address[] memory tokenAddresses = new address[](2); + tokenAddresses[0] = address(0); + tokenAddresses[1] = address(erc1155); uint8[] memory types = new uint8[](2); types[0] = 0; @@ -1677,7 +1677,9 @@ contract EnvelopeCoverageTest is Test { mfas[1] = false; vm.prank(SENDER); - uint256[] memory indexes = vault.createCustomLinks{value: 0.5 ether}(addrs, types, amounts, tokenIds, keys, mfas); + uint256[] memory indexes = vault.createCustomLinks{value: 0.5 ether}( + tokenAddresses, types, amounts, tokenIds, keys, mfas + ); assertEq(indexes.length, 2); assertEq(vault.getLinkAsset(indexes[0]).contractType, 0); From 94fdceb62eb1e1883b85b30c21498e548445ccc5 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 21 May 2026 00:14:08 +1200 Subject: [PATCH 48/49] docs(envelope): document approve-once-use-forever pattern for EOA users Explains why a Router/Forwarder is unnecessary: direct vault approval gives the same UX (2 one-time approvals + 1 tx per link) without extra gas overhead or contract complexity. --- src/envelope/doc/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index 9246a6d3..fbbaf228 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -48,6 +48,22 @@ The GPL is "viral" only across `import` boundaries; non-importing files in the s | Sender reclaim | `EnvelopeLinks.reclaim` | Original sender reclaims unclaimed links; recipient-bound links also enforce `reclaimableAfter`. | | Gasless validation | `EnvelopeLinks.isValidGaslessOperation` | View helper used by `EnvelopePaymaster` to validate prepaid or backend-sponsored claim/reclaim calldata before the paymaster pays gas. | +## EOA UX: approve once, use forever + +EOA users on ZkSync (or any chain) interact directly with the vault. The recommended frontend integration: + +1. **First interaction** — issue two one-time unlimited approvals: + - `giftToken.approve(vault, type(uint256).max)` — for the asset being gifted. + - `feeToken.approve(vault, type(uint256).max)` — for the NODL service/gasless fee token. +2. **Every subsequent link** — a single transaction: + - `vault.createLinkWithFees(request, feeAuthorization)` + +After the initial setup, each envelope creation costs exactly **one signature** from the user's perspective. The two approvals persist until explicitly revoked, so returning users never see an approval prompt again. + +> **Why not a Router/Forwarder contract?** A stateless forwarder that pulls tokens on the user's behalf still requires the same two one-time approvals (to the forwarder instead of the vault) and adds gas overhead per call without reducing signature count. Direct vault approval is simpler, cheaper, and equally secure. + +For ZkSync smart-account wallets (e.g. app-wallets), the approvals and vault call can be batched into a single user-visible transaction via the native account-abstraction batching — no approvals are visible to the user at all. + ## ZkSync gasless model Gasless operations are paymaster-native: From 81d40fac3fe6adc7b0a2fe80472c485d6c05afd8 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 21 May 2026 17:04:28 +1200 Subject: [PATCH 49/49] Security/envelope hardening (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(envelope): security hardening for production deployment Implements findings from final security review: H-1: Balance-delta measurement for ERC-20 deposits (fee-on-transfer safety) - _pullTokensViaApprovalFrom measures balanceOf delta, not requested amount - Batch functions use actual received / count for per-link amounts - Raffle links revert with InsufficientTokensReceived for FOT tokens H-2: Make mfaAuthorizer mutable for key rotation - Removed immutable, added setMfaAuthorizer(address) onlyOwner - Emits MfaAuthorizerUpdated event M-1: Guard _isMfaSignatureValid and _verifyMfaSignature against address(0) - claimWithMFA reverts with MfaAuthorizerIsZero when authorizer is unset - Paymaster validation returns false instead of matching address(0) M-2: Reject unbound links in claimAsBoundRecipient - Reverts with LinkNotRecipientBound if link.parties.recipient == address(0) M-3: Reject recipientAddress == address(0) in all claim paths - _executeClaim reverts with ZeroRecipientAddress - _isValidClaim returns false for zero recipient (paymaster path) M-4: Fee-authorization replay protection - usedFeeAuthorizations mapping tracks consumed signature hashes - Reverts with FeeAuthorizationAlreadyUsed on replay L-1: Remove dead DOMAIN_SEPARATOR state and EIP712Domain struct/hash function L-8: Explicit InvalidContractType revert in _pullUniformBatchAssets L-9: Remove constructor debug MessageEvent emit Also adds EnvelopeSecurity.t.sol with 13 targeted tests covering all findings, updates documentation with security properties section, and fixes spellcheck. * fix(envelope): finalize security hardening follow-up changes * feat(envelope): migrate signatures to EIP-712 typed structured data Replace all custom EIP-191 signature schemes with EIP-712: - Inherit OpenZeppelin EIP712 (domain: 'EnvelopeLinks', version '5') - Define CLAIM_TYPEHASH, MFA_APPROVAL_TYPEHASH, FEE_AUTHORIZATION_TYPEHASH - Remove ENVELOPE_SALT constant and assembly-based fee auth digest helpers - All three signature sites now use _hashTypedDataV4(keccak256(abi.encode(...))) Benefits: - Domain separator includes chainId + verifyingContract (cross-chain/contract replay protection) - Wallet-readable signing prompts (EIP-712 structured data) - Compliant with EIP-5267 (eip712Domain() getter) - Removes ~80 lines of hand-rolled assembly for fee auth digest Adds EnvelopeEIP712.t.sol with 19 dedicated tests covering: - Domain separator correctness and uniqueness - Cross-chain and cross-contract replay protection - Typehash verification - Claim mode discrimination - Fuzz tests for arbitrary recipients and deadlines - EIP-5267 getter validation Updates all existing test helpers to use shared EnvelopeEIP712Utils library. All 1072 tests pass. Spellcheck clean. * paymaster: raise gasless attempt limit to 3 One attempt was too strict — legitimate retries (wrong gas limit, receiver contract not deployed, token paused then unpaused) would permanently consume the user's gasless quota on the first failure. MAX_GASLESS_ATTEMPTS_PER_LINK = 3 gives users room for honest retries while keeping paymaster sponsorship liability bounded. The test for GaslessAttemptLimitReached now exhausts all 3 allowed attempts before asserting the revert. * envelope: finalize pre-prod security hardening (H-1, M-1..M-4, L-1, L-2, L-6, L-7) Security fixes: - H-1: Reject zero claimKey only when also recipient-bound (allows recipient-only links) - M-1: Constructor + setMfaAuthorizer reject zero authorizer - M-2: Enforce no ETH fees (accumulatedFees scalar, withdrawFees no-arg) - M-3: claimWithMFA bounds-checks index, rejects non-MFA links - M-4: Batch functions early-return on empty list, revert on uneven division - L-1: Fee replay protection uses EIP712 digest - L-2/L-6/L-7: Consolidate signature recovery via _recoverSigner, add NatSpec All 220 envelope tests pass. --- .cspell.json | 2 + src/envelope/EnvelopeLinks.sol | 314 ++++++++--------- src/envelope/doc/EnvelopeLinks.md | 52 ++- src/envelope/doc/EnvelopePaymaster.md | 4 +- src/envelope/doc/README.md | 6 +- src/paymasters/EnvelopePaymaster.sol | 34 +- test/envelope/Coverage.t.sol | 197 +++-------- test/envelope/Deposit.t.sol | 2 +- test/envelope/EnvelopeBatching.t.sol | 23 +- test/envelope/EnvelopeEIP712.t.sol | 327 ++++++++++++++++++ test/envelope/EnvelopeEIP712Utils.sol | 73 ++++ test/envelope/EnvelopeEdgeCases.t.sol | 67 +++- test/envelope/EnvelopeFeeAuthTestUtils.sol | 32 +- test/envelope/EnvelopeHardening.t.sol | 37 +- test/envelope/EnvelopeLinks.t.sol | 2 +- test/envelope/EnvelopeSecurity.t.sol | 295 ++++++++++++++++ test/envelope/Gasless.t.sol | 28 +- test/envelope/Integration.t.sol | 2 +- test/envelope/MFA.t.sol | 23 +- test/envelope/RecipientBound.t.sol | 2 +- test/envelope/SenderWithdraw.t.sol | 8 +- test/envelope/SigWithdraw.t.sol | 63 ++-- .../envelope/mocks/FeeOnTransferERC20Mock.sol | 24 ++ test/paymasters/EnvelopePaymaster.t.sol | 65 ++-- 24 files changed, 1162 insertions(+), 520 deletions(-) create mode 100644 test/envelope/EnvelopeEIP712.t.sol create mode 100644 test/envelope/EnvelopeEIP712Utils.sol create mode 100644 test/envelope/EnvelopeSecurity.t.sol create mode 100644 test/envelope/mocks/FeeOnTransferERC20Mock.sol diff --git a/.cspell.json b/.cspell.json index 0d2338cb..84d43097 100644 --- a/.cspell.json +++ b/.cspell.json @@ -16,8 +16,10 @@ "src/swarms/doc/iso3166-2" ], "ignoreWords": [ + "AMPL", "NODL", "Nodle", + "Typehashes", "depin", "contentsign", "matterlabs", diff --git a/src/envelope/EnvelopeLinks.sol b/src/envelope/EnvelopeLinks.sol index a8e89b06..003896c9 100644 --- a/src/envelope/EnvelopeLinks.sol +++ b/src/envelope/EnvelopeLinks.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.26; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.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"; @@ -15,7 +15,7 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; -contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ownable2Step { +contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ownable2Step, EIP712 { using SafeERC20 for IERC20; // ── Custom Errors ──────────────────────────────────────────────────────────── @@ -26,6 +26,7 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow error LinkIndexOutOfBounds(); error LinkAlreadyRedeemed(); error RequiresMfaAuthorization(); + error MfaNotRequired(); error WrongMfaSignature(); error WrongSignature(); error WrongRecipient(); @@ -44,6 +45,14 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow error EthNotAcceptedForNonEthLink(); error Erc721BatchNotSupported(); error UnsupportedRaffleContractType(); + error InsufficientTokensReceived(); + error ZeroRecipientAddress(); + error LinkNotRecipientBound(); + error FeeAuthorizationAlreadyUsed(); + error ZeroMfaAuthorizer(); + error ZeroClaimKey(); + error UnevenBatchAmount(); + error FeeTokenTransferAmountMismatch(); // ── Data Structures ────────────────────────────────────────────────────────── @@ -104,36 +113,37 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes signature; } - // 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 ENVELOPE_SALT = 0x70adbbeba9d4f0c82e28dd574f15466f75df0543b65f24460fc445813b5d94e0; // keccak256("Konrad makes tokens go woosh tadam"); + // ── EIP-712 Typehashes ─────────────────────────────────────────────────────── - bytes32 public constant OPEN_CLAIM_MODE = 0x0000000000000000000000000000000000000000000000000000000000000000; // default. Any address can trigger the withdrawal function - bytes32 public constant BOUND_CLAIM_MODE = 0x2bb5bef2b248d3edba501ad918c3ab524cce2aea54d4c914414e1c4401dc4ff4; // keccak256("only recipient") - only the signed recipient can trigger the withdrawal function + bytes32 public constant CLAIM_TYPEHASH = keccak256("Claim(uint256 index,address recipient,bytes32 mode)"); - bytes32 public DOMAIN_SEPARATOR; // initialized in the constructor + bytes32 public constant MFA_APPROVAL_TYPEHASH = + keccak256("MfaApproval(uint256 index,address recipient,uint256 deadline)"); - bytes32 public constant EIP712DOMAIN_TYPEHASH = - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 public constant FEE_AUTHORIZATION_TYPEHASH = keccak256( + "FeeAuthorization(address feePayer,address tokenAddress,uint8 contractType,uint256 amount,uint256 tokenId,address claimKey,address onBehalfOf,bool withMFA,address recipient,uint40 reclaimableAfter,uint256 serviceFee,uint256 gaslessFee,bool gaslessSponsored,uint256 deadline)" + ); - /// @notice Address authorized to issue MFA signatures gating claimWithMFA calls. - /// @dev Configurable per deployment. Address(0) disables MFA — claimWithMFA will revert. - address public immutable mfaAuthorizer; + bytes32 public constant OPEN_CLAIM_MODE = 0x0000000000000000000000000000000000000000000000000000000000000000; + bytes32 public constant BOUND_CLAIM_MODE = 0x2bb5bef2b248d3edba501ad918c3ab524cce2aea54d4c914414e1c4401dc4ff4; // keccak256("only recipient") - struct EIP712Domain { - string name; - string version; - uint256 chainId; - address verifyingContract; - } + /// @notice Address authorized to issue MFA signatures gating claimWithMFA calls and fee authorizations. + /// @dev Rotatable by owner. Setting to address(0) is rejected — MFA and fee-authorized creation are + /// always-on for production. Use rotation to disable a compromised key by replacing it. + address public mfaAuthorizer; Link[] internal links; // array of links /// @notice ERC-20 token used for Envelope service and gasless sponsorship fees (for example NODL). IERC20 public immutable feeToken; - /// @notice Accumulated fees per token address (address(0) for ETH; feeToken for link-creation fees). - mapping(address => uint256) public accumulatedFees; + /// @notice Accumulated fees in feeToken from createLinkWithFees/createCustomLinksWithFees. + /// @dev ETH fees are not supported; the protocol intentionally collects fees only in feeToken. + uint256 public accumulatedFees; + + /// @notice Tracks consumed fee authorizations to prevent replay (keyed by the EIP-712 digest). + mapping(bytes32 => bool) public usedFeeAuthorizations; + // events event LinkCreated(uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _creator); @@ -142,30 +152,19 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow ); event FeeCollected(uint256 indexed _index, address indexed tokenAddress, uint256 serviceFee, uint256 gaslessFee); event FeesWithdrawn(address indexed tokenAddress, uint256 amount); - event MessageEvent(string message); + event MfaAuthorizerUpdated(address indexed oldAuthorizer, address indexed newAuthorizer); - /// @param _mfaAuthorizer address authorized to sign backend fee and MFA approvals (use address(0) to disable). + /// @param _mfaAuthorizer address authorized to sign backend fee and MFA approvals. Must be non-zero; + /// this single key gates both MFA-protected claims and fee authorizations. /// @param _owner initial owner of the contract (receives accumulated fees). /// @param _feeToken ERC-20 token used for fees; address(0) disables non-zero fee authorizations. - constructor(address _mfaAuthorizer, address _owner, address _feeToken) Ownable(_owner) { - emit MessageEvent("Hello World, have a nutty day!"); + constructor(address _mfaAuthorizer, address _owner, address _feeToken) + Ownable(_owner) + EIP712("EnvelopeLinks", "5") + { + if (_mfaAuthorizer == address(0)) revert ZeroMfaAuthorizer(); mfaAuthorizer = _mfaAuthorizer; feeToken = IERC20(_feeToken); - DOMAIN_SEPARATOR = hash( - EIP712Domain({name: "Envelope", 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 - ) - ); } /** @@ -314,6 +313,8 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow /// @notice Create many same-shape links in one transaction. /// @dev The caller remains the recorded sender for every deposit and keeps reclaim rights. /// ERC-721 is intentionally excluded here because each NFT needs a distinct tokenId. + /// Reverts if the actually-received total is not evenly divisible across all links to + /// prevent silent dust loss when fee-on-transfer tokens are used. function createLinks( address _tokenAddress, uint8 _contractType, @@ -321,15 +322,18 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256 _tokenId, address[] calldata _claimKeys ) external payable nonReentrant returns (uint256[] memory) { + if (_claimKeys.length == 0) return new uint256[](0); uint256 totalAmount = _amount * _claimKeys.length; - _pullUniformBatchAssets(msg.sender, _tokenAddress, _contractType, totalAmount, _tokenId); + uint256 actualTotal = _pullUniformBatchAssets(msg.sender, _tokenAddress, _contractType, totalAmount, _tokenId); + if (actualTotal % _claimKeys.length != 0) revert UnevenBatchAmount(); + uint256 perLinkAmount = actualTotal / _claimKeys.length; uint256[] memory linkIndexes = new uint256[](_claimKeys.length); for (uint256 i = 0; i < _claimKeys.length; ++i) { linkIndexes[i] = _storeLink( _tokenAddress, _contractType, - _amount, + perLinkAmount, _tokenId, _claimKeys[i], msg.sender, @@ -352,14 +356,17 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256 _tokenId, address[] calldata _claimKeys ) external payable nonReentrant { + if (_claimKeys.length == 0) return; uint256 totalAmount = _amount * _claimKeys.length; - _pullUniformBatchAssets(msg.sender, _tokenAddress, _contractType, totalAmount, _tokenId); + uint256 actualTotal = _pullUniformBatchAssets(msg.sender, _tokenAddress, _contractType, totalAmount, _tokenId); + if (actualTotal % _claimKeys.length != 0) revert UnevenBatchAmount(); + uint256 perLinkAmount = actualTotal / _claimKeys.length; for (uint256 i = 0; i < _claimKeys.length; ++i) { _storeLink( _tokenAddress, _contractType, - _amount, + perLinkAmount, _tokenId, _claimKeys[i], msg.sender, @@ -469,6 +476,7 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow /** * @notice Withdraw tokens with backend MFA approval. + * @dev Reverts if the target link does not require MFA; use plain {claim} for non-MFA links. * @param _index deposit index * @param _recipientAddress address to receive the full deposit amount * @param _signature withdrawal signature from the link's claimKey @@ -482,12 +490,17 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes memory _MFASignature, uint256 _deadline ) external nonReentrant returns (bool) { + if (_index >= links.length) revert LinkIndexOutOfBounds(); + if (!links[_index].status.requiresMFA) revert MfaNotRequired(); _verifyMfaSignature(_index, _recipientAddress, _deadline, _MFASignature); return _executeClaim(_index, _recipientAddress, OPEN_CLAIM_MODE, _signature, true); } /** - * @notice Withdraw tokens. Must be called by the recipient. + * @notice Withdraw tokens from a recipient-bound link directly by the recipient. + * @dev Bound links can also be claimed via plain {claim} when the caller has a claimKey signature + * over OPEN_CLAIM_MODE and the recipient matches. This entry uses BOUND_CLAIM_MODE so a + * bound-mode signature cannot be reused as an open-mode signature and vice versa. */ function claimAsBoundRecipient(uint256 _index, address _recipientAddress, bytes memory _signature) external @@ -495,6 +508,8 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow returns (bool) { if (_recipientAddress != msg.sender) revert NotTheRecipient(); + if (_index >= links.length) revert LinkIndexOutOfBounds(); + if (links[_index].parties.recipient == address(0)) revert LinkNotRecipientBound(); return _executeClaim(_index, _recipientAddress, BOUND_CLAIM_MODE, _signature, false); } @@ -548,22 +563,28 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow // ══════════════════════════════════════════════════════════════════════════════ /** - * @notice Withdraw accumulated fees for a given token. Only callable by owner. - * @param _tokenAddress token to withdraw fees for (address(0) for ETH) + * @notice Update the MFA authorizer address. Only callable by owner. + * @dev Reverts on address(0) — the protocol requires an always-set authorizer for MFA claims + * and fee-authorized creation. To replace a compromised key, rotate to a new non-zero address. + * @param _newAuthorizer new MFA signer address. */ - function withdrawFees(address _tokenAddress) external onlyOwner nonReentrant { - uint256 amount = accumulatedFees[_tokenAddress]; + function setMfaAuthorizer(address _newAuthorizer) external onlyOwner { + if (_newAuthorizer == address(0)) revert ZeroMfaAuthorizer(); + emit MfaAuthorizerUpdated(mfaAuthorizer, _newAuthorizer); + mfaAuthorizer = _newAuthorizer; + } + + /** + * @notice Withdraw accumulated feeToken fees to the caller (owner). ETH fees are not supported. + */ + function withdrawFees() external onlyOwner nonReentrant { + uint256 amount = accumulatedFees; if (amount == 0) revert NoFeesToWithdraw(); - accumulatedFees[_tokenAddress] = 0; + accumulatedFees = 0; - if (_tokenAddress == address(0)) { - (bool success,) = msg.sender.call{value: amount}(""); - if (!success) revert EthTransferFailed(); - } else { - IERC20(_tokenAddress).safeTransfer(msg.sender, amount); - } + feeToken.safeTransfer(msg.sender, amount); - emit FeesWithdrawn(_tokenAddress, amount); + emit FeesWithdrawn(address(feeToken), amount); } // ══════════════════════════════════════════════════════════════════════════════ @@ -591,6 +612,7 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow if (selector == this.claimAsBoundRecipient.selector) { (uint256 index, address recipient, bytes memory signature) = abi.decode(callData[4:], (uint256, address, bytes)); + if (!_isRecipientBoundLink(index)) return false; return _isValidGaslessClaim(caller, index, recipient, BOUND_CLAIM_MODE, signature, false); } @@ -668,13 +690,9 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow // deadline == 0 means no expiry if (_deadline != 0 && block.timestamp > _deadline) revert MfaSignatureExpired(); - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _deadline) - ) - ); - address authorizationSigner = getSigner(digest, _MFASignature); - if (authorizationSigner != mfaAuthorizer) revert WrongMfaSignature(); + bytes32 digest = + _hashTypedDataV4(keccak256(abi.encode(MFA_APPROVAL_TYPEHASH, _index, _recipientAddress, _deadline))); + if (_recoverSigner(digest, _MFASignature) != mfaAuthorizer) revert WrongMfaSignature(); } function _isMfaSignatureValid(uint256 _index, address _recipientAddress, uint256 _deadline, bytes memory _signature) @@ -684,17 +702,13 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow { if (_deadline != 0 && block.timestamp > _deadline) return false; - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _deadline) - ) - ); + bytes32 digest = + _hashTypedDataV4(keccak256(abi.encode(MFA_APPROVAL_TYPEHASH, _index, _recipientAddress, _deadline))); return _recoverSigner(digest, _signature) == mfaAuthorizer; } function _verifyFeeAuthorization(LinkRequest calldata _request, FeeAuthorization calldata _feeAuthorization) internal - view { uint256 totalFee = _feeAuthorization.serviceFee + _feeAuthorization.gaslessFee; if (totalFee == 0 && !_feeAuthorization.gaslessSponsored && _feeAuthorization.signature.length == 0) return; @@ -704,8 +718,15 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow } bytes32 digest = _feeAuthorizationDigest(_request, _feeAuthorization, msg.sender); - address authorizationSigner = getSigner(digest, _feeAuthorization.signature); - if (authorizationSigner != mfaAuthorizer) revert WrongFeeAuthorizationSignature(); + + // Replay protection keyed by the EIP-712 digest: each (intent, feePayer, deadline) tuple may be + // consumed exactly once, regardless of the on-the-wire signature encoding. + if (usedFeeAuthorizations[digest]) revert FeeAuthorizationAlreadyUsed(); + usedFeeAuthorizations[digest] = true; + + if (_recoverSigner(digest, _feeAuthorization.signature) != mfaAuthorizer) { + revert WrongFeeAuthorizationSignature(); + } } function _feeAuthorizationDigest( @@ -713,92 +734,38 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow FeeAuthorization calldata _feeAuthorization, address _feePayer ) internal view returns (bytes32) { - bytes memory encoded = new bytes(17 * 32); - _writeFeeAuthorizationContext(encoded, _feePayer); - _writeFeeAuthorizationAsset( - encoded, _request.tokenAddress, _request.contractType, _request.amount, _request.tokenId - ); - _writeFeeAuthorizationParties( - encoded, _request.claimKey, _request.onBehalfOf, _request.withMFA, _request.recipient, _request.reclaimableAfter - ); - _writeFeeAuthorizationFees( - encoded, - _feeAuthorization.serviceFee, - _feeAuthorization.gaslessFee, - _feeAuthorization.gaslessSponsored, - _feeAuthorization.deadline + return _hashTypedDataV4( + keccak256( + abi.encode( + FEE_AUTHORIZATION_TYPEHASH, + _feePayer, + _request.tokenAddress, + _request.contractType, + _request.amount, + _request.tokenId, + _request.claimKey, + _request.onBehalfOf, + _request.withMFA, + _request.recipient, + _request.reclaimableAfter, + _feeAuthorization.serviceFee, + _feeAuthorization.gaslessFee, + _feeAuthorization.gaslessSponsored, + _feeAuthorization.deadline + ) + ) ); - - return MessageHashUtils.toEthSignedMessageHash(keccak256(encoded)); - } - - function _writeFeeAuthorizationContext(bytes memory encoded, address _feePayer) internal view { - bytes32 salt = ENVELOPE_SALT; - assembly ("memory-safe") { - let ptr := add(encoded, 32) - mstore(ptr, salt) - mstore(add(ptr, 32), chainid()) - mstore(add(ptr, 64), address()) - mstore(add(ptr, 96), _feePayer) - } - } - - function _writeFeeAuthorizationAsset( - bytes memory encoded, - address _tokenAddress, - uint8 _contractType, - uint256 _amount, - uint256 _tokenId - ) internal pure { - assembly ("memory-safe") { - let ptr := add(encoded, 160) - mstore(ptr, _tokenAddress) - mstore(add(ptr, 32), _contractType) - mstore(add(ptr, 64), _amount) - mstore(add(ptr, 96), _tokenId) - } - } - - function _writeFeeAuthorizationParties( - bytes memory encoded, - address claimKey, - address _onBehalfOf, - bool _withMFA, - address _recipient, - uint40 _reclaimableAfter - ) internal pure { - assembly ("memory-safe") { - let ptr := add(encoded, 288) - mstore(ptr, claimKey) - mstore(add(ptr, 32), _onBehalfOf) - mstore(add(ptr, 64), _withMFA) - mstore(add(ptr, 96), _recipient) - mstore(add(ptr, 128), _reclaimableAfter) - } - } - - function _writeFeeAuthorizationFees( - bytes memory encoded, - uint256 _serviceFee, - uint256 _gaslessFee, - bool _gaslessSponsored, - uint256 _deadline - ) internal pure { - assembly ("memory-safe") { - let ptr := add(encoded, 448) - mstore(ptr, _serviceFee) - mstore(add(ptr, 32), _gaslessFee) - mstore(add(ptr, 64), _gaslessSponsored) - mstore(add(ptr, 96), _deadline) - } } function _collectLinkFees(uint256 _index, address _feePayer, uint256 _serviceFee, uint256 _gaslessFee) internal { uint256 totalFee = _serviceFee + _gaslessFee; if (totalFee > 0) { address tokenAddress = address(feeToken); + uint256 balanceBefore = feeToken.balanceOf(address(this)); feeToken.safeTransferFrom(_feePayer, address(this), totalFee); - accumulatedFees[tokenAddress] += totalFee; + uint256 actualReceived = feeToken.balanceOf(address(this)) - balanceBefore; + if (actualReceived != totalFee) revert FeeTokenTransferAmountMismatch(); + accumulatedFees += totalFee; emit FeeCollected(_index, tokenAddress, _serviceFee, _gaslessFee); } } @@ -817,6 +784,10 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint256 _gaslessFee, bool _gaslessSponsored ) internal returns (uint256) { + // A link must be claimable: either via a claim-key signature, or by a bound recipient. + // Rejecting `claimKey == 0 && recipient == 0` prevents accidentally creating an + // unbound link that anyone could drain with an empty signature. + if (claimKey == address(0) && _recipient == address(0)) revert ZeroClaimKey(); uint256 index = links.length; links.push(); @@ -856,6 +827,10 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow return _isValidClaim(_index, _recipientAddress, _extraData, _signature, _authorized); } + function _isRecipientBoundLink(uint256 _index) internal view returns (bool) { + return _index < links.length && links[_index].parties.recipient != address(0); + } + function _isValidClaim( uint256 _index, address _recipientAddress, @@ -863,17 +838,15 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes memory _signature, bool _authorized ) internal view returns (bool) { + if (_recipientAddress == address(0)) return false; Link memory deposit = links[_index]; if (deposit.status.redeemed) return false; if (deposit.status.requiresMFA && !_authorized) return false; if (deposit.parties.recipient != address(0) && _recipientAddress != deposit.parties.recipient) return false; if (deposit.status.claimKey != address(0)) { - bytes32 _claimHash = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _extraData) - ) - ); + bytes32 _claimHash = + _hashTypedDataV4(keccak256(abi.encode(CLAIM_TYPEHASH, _index, _recipientAddress, _extraData))); if (_recoverSigner(_claimHash, _signature) != deposit.status.claimKey) return false; } @@ -975,21 +948,27 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow uint8 _contractType, uint256 _totalAmount, uint256 _tokenId - ) internal { + ) internal returns (uint256) { if (_contractType == 0) { if (msg.value != _totalAmount) revert InvalidTotalEtherSent(); - return; + return _totalAmount; } if (msg.value != 0) revert EthNotAcceptedForNonEthLink(); if (_contractType == 1) { - if (_totalAmount > 0) IERC20(_tokenAddress).safeTransferFrom(_from, address(this), _totalAmount); + if (_totalAmount > 0) { + uint256 balanceBefore = IERC20(_tokenAddress).balanceOf(address(this)); + IERC20(_tokenAddress).safeTransferFrom(_from, address(this), _totalAmount); + return IERC20(_tokenAddress).balanceOf(address(this)) - balanceBefore; + } + return 0; } else if (_contractType == 2) { revert Erc721BatchNotSupported(); } else if (_contractType == 3) { if (_totalAmount > 0) { IERC1155(_tokenAddress).safeTransferFrom(_from, address(this), _tokenId, _totalAmount, ""); } + return _totalAmount; } else { revert InvalidContractType(); } @@ -1015,7 +994,12 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow if (msg.value != totalAmount) revert InvalidTotalEtherSent(); } else { if (msg.value != 0) revert EthNotAcceptedForNonEthLink(); - if (totalAmount > 0) IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); + if (totalAmount > 0) { + uint256 balanceBefore = IERC20(_tokenAddress).balanceOf(address(this)); + IERC20(_tokenAddress).safeTransferFrom(msg.sender, address(this), totalAmount); + uint256 actualReceived = IERC20(_tokenAddress).balanceOf(address(this)) - balanceBefore; + if (actualReceived < totalAmount) revert InsufficientTokensReceived(); + } } uint256[] memory linkIndexes = new uint256[](_amounts.length); @@ -1057,8 +1041,12 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow if (_contractType == 0) { if (_amount != _ethAmount) revert WrongEthAmount(); + } else if (_ethAmount != 0) { + revert EthNotAcceptedForNonEthLink(); } else if (_contractType == 1) { + uint256 balanceBefore = IERC20(_tokenAddress).balanceOf(address(this)); IERC20(_tokenAddress).safeTransferFrom(_from, address(this), _amount); + _amount = IERC20(_tokenAddress).balanceOf(address(this)) - balanceBefore; } else if (_contractType == 2) { if (_amount != 1) revert Erc721AmountMustBeOne(); IERC721(_tokenAddress).safeTransferFrom(_from, address(this), _tokenId, "Internal transfer"); @@ -1076,17 +1064,15 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow bytes memory _signature, bool _authorized ) internal returns (bool) { + if (_recipientAddress == address(0)) revert ZeroRecipientAddress(); if (_index >= links.length) revert LinkIndexOutOfBounds(); Link memory link = links[_index]; if (link.status.redeemed) revert LinkAlreadyRedeemed(); address claimSigner; if (_signature.length > 0) { - bytes32 _claimHash = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked(ENVELOPE_SALT, block.chainid, address(this), _index, _recipientAddress, _extraData) - ) - ); + bytes32 _claimHash = + _hashTypedDataV4(keccak256(abi.encode(CLAIM_TYPEHASH, _index, _recipientAddress, _extraData))); claimSigner = getSigner(_claimHash, _signature); } if (link.status.requiresMFA && !_authorized) revert RequiresMfaAuthorization(); diff --git a/src/envelope/doc/EnvelopeLinks.md b/src/envelope/doc/EnvelopeLinks.md index 23d38af2..6a1663bc 100644 --- a/src/envelope/doc/EnvelopeLinks.md +++ b/src/envelope/doc/EnvelopeLinks.md @@ -198,13 +198,53 @@ Gasless eligibility is independent of the gift amount. The paymaster must still constructor(address mfaAuthorizer, address owner, address feeToken) ``` -| Param | Purpose | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mfaAuthorizer` | Backend signer for MFA claim approvals and link-creation-time fee authorizations. `address(0)` disables non-zero fee authorizations and makes MFA withdrawals fail. | -| `owner` | Owns the vault and can withdraw accumulated fees. | -| `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. | +| Param | Purpose | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mfaAuthorizer` | Backend signer for MFA claim approvals and link-creation-time fee authorizations. `address(0)` disables non-zero fee authorizations and makes MFA withdrawals fail. Rotatable by owner via `setMfaAuthorizer`. | +| `owner` | Owns the vault, can withdraw accumulated fees, and rotate the `mfaAuthorizer`. | +| `feeToken` | ERC-20 used for Nodle service and gasless sponsorship fees, for example NODL. `address(0)` permits only zero-fee deposits. | -The constructor also sets the EIP-712 domain separator used by the vault-side validation helpers. +## Owner Functions + +| Function | Purpose | +| ------------------------------------ | ----------------------------------------------------------------------------------------------- | +| `setMfaAuthorizer(address)` | Rotate the MFA/fee-authorization signer. Invalidates all in-flight signatures from the old key. | +| `withdrawFees(address tokenAddress)` | Withdraw accumulated service and gasless fees for a given token. | + +## Security Properties + +### Fee-On-Transfer Token Safety + +For ERC-20 deposits, the vault measures the actual `balanceOf` delta rather than trusting the requested `amount`. This prevents insolvency when fee-on-transfer or rebasing tokens are deposited. The recorded `link.asset.amount` reflects what the vault actually received and can transfer back. + +For raffle-style links (which have per-link variable amounts), a fee-on-transfer token will cause the deposit to revert because the vault asserts the received total matches the requested total. + +### Fee Authorization Replay Protection + +Each `FeeAuthorization` signature can only be used once. The vault tracks consumed authorizations via `usedFeeAuthorizations[keccak256(signature)]` and reverts with `FeeAuthorizationAlreadyUsed` on replay attempts. + +### Recipient Validation + +- Claims to `address(0)` are rejected with `ZeroRecipientAddress`. +- `claimAsBoundRecipient` reverts with `LinkNotRecipientBound` if the link has no stored recipient, preventing misuse of the bound-mode signature on open links. + +### MFA Authorizer Rotation + +The `mfaAuthorizer` is mutable (not immutable). In case of backend key compromise, the owner can rotate the signer immediately via `setMfaAuthorizer`. All in-flight MFA and fee authorization signatures from the old key become invalid after rotation. + +### Unsupported Token Types + +The following token types are **not supported** and should not be deposited: + +- **Rebasing tokens** (e.g., stETH, AMPL): balance changes between deposit and claim may cause under/overpayment. +- **Tokens with transfer hooks that modify balances** beyond a simple fee deduction. +- **ERC-777 tokens**: the vault does not implement `tokensReceived` and relies on `nonReentrant` guards. + +ERC-20 tokens that charge a fixed transfer fee (e.g., USDT on some chains) are supported — the vault records the actual received amount. + +### View-Only Functions (Off-Chain Only) + +`getLinkIndexesCreatedBy` and `getAllLinkIndexes` iterate over the entire links array. These are O(n) and intended for off-chain use only. On-chain callers will encounter out-of-gas for large link counts. ## Deposit Model diff --git a/src/envelope/doc/EnvelopePaymaster.md b/src/envelope/doc/EnvelopePaymaster.md index bd5477eb..9d509993 100644 --- a/src/envelope/doc/EnvelopePaymaster.md +++ b/src/envelope/doc/EnvelopePaymaster.md @@ -29,7 +29,9 @@ The paymaster supports ZkSync general flow only. 5. It verifies it has enough ETH for `requiredETH`. 6. `BasePaymaster` pays the bootloader. -The paymaster does not keep per-gift state and does not price fees. Fee pricing, prepaid gasless amounts, and backend-sponsored eligibility are recorded in `EnvelopeLinks` at deposit creation. +The paymaster does not price fees. Fee pricing, prepaid gasless amounts, and backend-sponsored eligibility are recorded in `EnvelopeLinks` at deposit creation. + +The paymaster records one validation attempt per link before paying the bootloader. ZkSync runs validation and execution separately, so this attempt remains recorded even when the subsequent vault execution reverts. Up to `MAX_GASLESS_ATTEMPTS_PER_LINK` (currently **3**) attempts are allowed per link. This gives users room for honest retries (e.g. wrong gas limit, receiver contract not yet deployed) while bounding paymaster loss from repeated execution failures. Once the limit is reached, the user can still submit the vault call while paying gas themselves. ## Sponsored Selectors diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md index fbbaf228..fe36f615 100644 --- a/src/envelope/doc/README.md +++ b/src/envelope/doc/README.md @@ -78,9 +78,9 @@ The vault no longer contains an internal paymaster callback, and the EIP-3009 ga ## Deploy -| Script | Purpose | -| ---------------------------------- | ----------------------------------------------------------- | -| `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeLinks` and optionally `EnvelopePaymaster`. | +| Script | Purpose | +| ----------------------------------- | ------------------------------------------------------------------------------------------- | +| `hardhat-deploy/DeployEnvelope.ts` | Deploys `EnvelopeLinks` and optionally `EnvelopePaymaster`. | | `script/DeployEnvelopeZkSync.s.sol` | Forge deployment script for `EnvelopeLinks` and optional `EnvelopePaymaster` on ZkSync Era. | Important environment variables: diff --git a/src/paymasters/EnvelopePaymaster.sol b/src/paymasters/EnvelopePaymaster.sol index 8e814bbb..ea7c45db 100644 --- a/src/paymasters/EnvelopePaymaster.sol +++ b/src/paymasters/EnvelopePaymaster.sol @@ -9,11 +9,18 @@ import {IEnvelopeGaslessValidator} from "../envelope/IEnvelopeGaslessValidator.s /// @dev The EnvelopeLinks remains the source of truth for whether a call is valid and prepaid or sponsored. /// This paymaster only accepts general-flow transactions targeting that vault. contract EnvelopePaymaster is BasePaymaster { + uint256 public constant MAX_GASLESS_ATTEMPTS_PER_LINK = 3; + IEnvelopeGaslessValidator public immutable envelopeLinks; + mapping(uint256 => uint256) public gaslessAttemptsByLink; + error DestinationIsNotEnvelopeLinks(); error EnvelopeGaslessOperationNotApproved(); error PaymasterBalanceTooLow(); + error GaslessAttemptLimitReached(uint256 index); + + event GaslessAttemptRecorded(uint256 indexed index, uint256 indexed attempts); constructor(address admin, address withdrawer, address envelopeLinks_) BasePaymaster(admin, withdrawer) { envelopeLinks = IEnvelopeGaslessValidator(envelopeLinks_); @@ -21,7 +28,6 @@ contract EnvelopePaymaster is BasePaymaster { function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH, bytes memory transactionData) internal - view override { if (to != address(envelopeLinks)) revert DestinationIsNotEnvelopeLinks(); @@ -35,6 +41,32 @@ contract EnvelopePaymaster is BasePaymaster { if (!approved) revert EnvelopeGaslessOperationNotApproved(); if (address(this).balance < requiredETH) revert PaymasterBalanceTooLow(); + + _recordGaslessAttempt(transactionData); + } + + function _recordGaslessAttempt(bytes memory transactionData) internal { + uint256 index = _decodeGaslessLinkIndex(transactionData); + uint256 attempts = gaslessAttemptsByLink[index]; + if (attempts == MAX_GASLESS_ATTEMPTS_PER_LINK) revert GaslessAttemptLimitReached(index); + + unchecked { + ++attempts; + } + gaslessAttemptsByLink[index] = attempts; + emit GaslessAttemptRecorded(index, attempts); + } + + /// @dev Reads the first uint256 calldata argument out of `transactionData` (the link index). + /// Safe because this is only called after `isValidGaslessOperation` matched one of the + /// claim/reclaim selectors, all of which have `uint256 _index` as their first parameter. + /// Offset 36 = 32 (bytes-memory length prefix) + 4 (function selector). + function _decodeGaslessLinkIndex(bytes memory transactionData) internal pure returns (uint256 index) { + if (transactionData.length < 36) revert EnvelopeGaslessOperationNotApproved(); + + assembly { + index := mload(add(transactionData, 36)) + } } function _validateAndPayApprovalBasedFlow(address, address, address, uint256, bytes memory, uint256) diff --git a/test/envelope/Coverage.t.sol b/test/envelope/Coverage.t.sol index 32d2651b..557bf36d 100644 --- a/test/envelope/Coverage.t.sol +++ b/test/envelope/Coverage.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../../src/envelope/EnvelopeLinks.sol"; import {EnvelopeFeeAuthTestUtils} from "./EnvelopeFeeAuthTestUtils.sol"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.sol"; import "./mocks/ERC20Mock.sol"; import "./mocks/ERC721Mock.sol"; import "./mocks/ERC1155Mock.sol"; @@ -71,21 +72,13 @@ contract EnvelopeCoverageTest is Test { view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked(vault.ENVELOPE_SALT(), block.chainid, address(vault), index, recipient, mode) - ) - ); + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), index, recipient, mode); (uint8 v, bytes32 r, bytes32 s) = vm.sign(linkPrivKey, digest); return abi.encodePacked(r, s, v); } function _signMfa(uint256 index, address recipient, uint256 deadline) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked(vault.ENVELOPE_SALT(), block.chainid, address(vault), index, recipient, deadline) - ) - ); + bytes32 digest = EnvelopeEIP712Utils.mfaDigest(address(vault), index, recipient, deadline); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); } @@ -111,7 +104,7 @@ contract EnvelopeCoverageTest is Test { uint256 deadline ) internal view returns (bytes memory) { bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( - vault.ENVELOPE_SALT(), vaultAddr, req, feePayer, serviceFee, gaslessFee, gaslessSponsored, deadline + vaultAddr, req, feePayer, serviceFee, gaslessFee, gaslessSponsored, deadline ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); @@ -224,24 +217,12 @@ contract EnvelopeCoverageTest is Test { } // ══════════════════════════════════════════════════════════════════════════════ - // withdrawFees — ETH accumulated fees + // withdrawFees — ERC-20 (feeToken) only; ETH fees are not supported // ══════════════════════════════════════════════════════════════════════════════ - function test_withdrawFees_eth() public { - // Seed accumulatedFees[address(0)] with ETH balance - bytes32 slot = keccak256(abi.encode(address(0), uint256(5))); - vm.store(address(vault), slot, bytes32(uint256(0.5 ether))); - vm.deal(address(vault), 0.5 ether); - - uint256 ownerBalBefore = address(this).balance; - vault.withdrawFees(address(0)); - assertEq(address(this).balance, ownerBalBefore + 0.5 ether); - assertEq(vault.accumulatedFees(address(0)), 0); - } - function test_RevertIf_withdrawFees_noFees() public { vm.expectRevert(EnvelopeLinks.NoFeesToWithdraw.selector); - vault.withdrawFees(address(feeToken)); + vault.withdrawFees(); } function test_withdrawFees_erc20() public { @@ -259,26 +240,22 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0.1 ether, 0.05 ether, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0.1 ether, - gaslessFee: 0.05 ether, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0.1 ether, gaslessFee: 0.05 ether, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); vault.createLinkWithFees{value: 1 ether}(req, auth); uint256 ownerBalBefore = feeToken.balanceOf(address(this)); - vault.withdrawFees(address(feeToken)); + vault.withdrawFees(); assertEq(feeToken.balanceOf(address(this)), ownerBalBefore + 0.15 ether); - assertEq(vault.accumulatedFees(address(feeToken)), 0); + assertEq(vault.accumulatedFees(), 0); } function test_RevertIf_withdrawFees_nonOwner() public { vm.prank(OTHER); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, OTHER)); - vault.withdrawFees(address(feeToken)); + vault.withdrawFees(); } // ══════════════════════════════════════════════════════════════════════════════ @@ -310,7 +287,11 @@ contract EnvelopeCoverageTest is Test { } function test_isValidGaslessOperation_unknownSelector() public view { - assertFalse(vault.isValidGaslessOperation(RECIPIENT, hex"deadbeef0000000000000000000000000000000000000000000000000000000000000000")); + assertFalse( + vault.isValidGaslessOperation( + RECIPIENT, hex"deadbeef0000000000000000000000000000000000000000000000000000000000000000" + ) + ); } function test_isValidGaslessOperation_reclaim_indexOutOfBounds() public view { @@ -349,11 +330,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0, - gaslessFee: 0.01 ether, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0, gaslessFee: 0.01 ether, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); vault.createLinkWithFees{value: 1 ether}(req, auth); @@ -408,11 +385,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0, - gaslessFee: 0.01 ether, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0, gaslessFee: 0.01 ether, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); uint256 idx = vault.createLinkWithFees{value: 1 ether}(req, auth); @@ -469,7 +442,8 @@ contract EnvelopeCoverageTest is Test { bool[] memory mfas = new bool[](2); vm.prank(SENDER); - uint256[] memory indexes = vault.createCustomLinks{value: 0.5 ether}(tokens, types, amounts, tokenIds, keys, mfas); + uint256[] memory indexes = + vault.createCustomLinks{value: 0.5 ether}(tokens, types, amounts, tokenIds, keys, mfas); assertEq(indexes.length, 2); assertEq(vault.getLinkAsset(indexes[0]).amount, 0.5 ether); @@ -545,7 +519,7 @@ contract EnvelopeCoverageTest is Test { assertEq(vault.getLinkAsset(indexes[0]).amount, 1 ether); assertEq(vault.getLinkAsset(indexes[1]).amount, 50); assertEq(vault.getLinkParties(indexes[1]).recipient, RECIPIENT); - assertEq(vault.accumulatedFees(address(feeToken)), 0.04 ether); + assertEq(vault.accumulatedFees(), 0.04 ether); } function test_RevertIf_createCustomLinksWithFees_lengthMismatch() public { @@ -572,11 +546,7 @@ contract EnvelopeCoverageTest is Test { }); EnvelopeLinks.FeeAuthorization[] memory auths = new EnvelopeLinks.FeeAuthorization[](1); auths[0] = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0, - gaslessFee: 0, - gaslessSponsored: false, - deadline: 0, - signature: "" + serviceFee: 0, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: "" }); vm.prank(SENDER); @@ -599,11 +569,7 @@ contract EnvelopeCoverageTest is Test { }); EnvelopeLinks.FeeAuthorization[] memory auths = new EnvelopeLinks.FeeAuthorization[](1); auths[0] = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0, - gaslessFee: 0, - gaslessSponsored: false, - deadline: 0, - signature: "" + serviceFee: 0, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: "" }); vm.prank(SENDER); @@ -755,11 +721,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0.01 ether, 0, false, deadline); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0.01 ether, - gaslessFee: 0, - gaslessSponsored: false, - deadline: deadline, - signature: authSig + serviceFee: 0.01 ether, gaslessFee: 0, gaslessSponsored: false, deadline: deadline, signature: authSig }); vm.warp(deadline + 1); @@ -822,17 +784,13 @@ contract EnvelopeCoverageTest is Test { } // ══════════════════════════════════════════════════════════════════════════════ - // Claim with empty signature (claimKey == address(0)) + // H-1: createLink with zero claimKey must revert // ══════════════════════════════════════════════════════════════════════════════ - function test_claim_noClaimKey() public { - // Create a link with claimKey = address(0) — anyone can claim without signature + function test_RevertIf_createWithZeroClaimKey() public { vm.prank(SENDER); - uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, address(0)); - - uint256 balBefore = RECIPIENT.balance; - vault.claim(idx, RECIPIENT, ""); - assertEq(RECIPIENT.balance, balBefore + 1 ether); + vm.expectRevert(EnvelopeLinks.ZeroClaimKey.selector); + vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, address(0)); } // ══════════════════════════════════════════════════════════════════════════════ @@ -953,11 +911,7 @@ contract EnvelopeCoverageTest is Test { bytes memory authSig = _signFeeAuthForVault(address(vaultNoFeeToken), req, SENDER, 0.01 ether, 0, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0.01 ether, - gaslessFee: 0, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0.01 ether, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); @@ -1029,11 +983,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0, - gaslessFee: 0.01 ether, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0, gaslessFee: 0.01 ether, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); return vault.createLinkWithFees{value: amount}(req, auth); @@ -1053,11 +1003,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0, - gaslessFee: 0.01 ether, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0, gaslessFee: 0.01 ether, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); return vault.createLinkWithFees{value: amount}(req, auth); @@ -1077,41 +1023,12 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0, true, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0, - gaslessFee: 0, - gaslessSponsored: true, - deadline: 0, - signature: authSig + serviceFee: 0, gaslessFee: 0, gaslessSponsored: true, deadline: 0, signature: authSig }); vm.prank(SENDER); return vault.createLinkWithFees{value: amount}(req, auth); } - // ══════════════════════════════════════════════════════════════════════════════ - // withdrawFees — ETH path where owner contract rejects the transfer - // Scenario: Owner is a multisig/governance contract that cannot receive ETH - // ══════════════════════════════════════════════════════════════════════════════ - - function test_RevertIf_withdrawFees_ethRejected() public { - // Deploy a vault owned by a contract that rejects ETH - EthRejecter rejecter = new EthRejecter(); - EnvelopeLinks rejVault = new EnvelopeLinks(BACKEND_AUTHORIZER, address(rejecter), address(feeToken)); - - // Create a link with ETH service fees so that ETH accumulates in the vault - // Since fees are ERC-20 (feeToken), we need to get ETH into accumulatedFees. - // withdrawFees(address(0)) withdraws ETH accumulated fees. - // Seed directly: we can call withdrawFees with ETH balance. - vm.deal(address(rejVault), 1 ether); - // Write to the accumulatedFees[address(0)] storage slot - // accumulatedFees is at storage slot 5 in the contract layout - bytes32 slot = keccak256(abi.encode(address(0), uint256(5))); - vm.store(address(rejVault), slot, bytes32(uint256(1 ether))); - - vm.prank(address(rejecter)); - vm.expectRevert(EnvelopeLinks.EthTransferFailed.selector); - rejVault.withdrawFees(address(0)); - } - // ══════════════════════════════════════════════════════════════════════════════ // Claim ERC-721 and ERC-1155 via createCustomLinksWithFees // Scenario: Backend-authorized fee links for NFTs — full lifecycle @@ -1131,11 +1048,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0.05 ether, 0, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0.05 ether, - gaslessFee: 0, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0.05 ether, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); @@ -1162,11 +1075,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0.02 ether, 0, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0.02 ether, - gaslessFee: 0, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0.02 ether, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); @@ -1280,13 +1189,12 @@ contract EnvelopeCoverageTest is Test { uint256 deadline = block.timestamp + 1 hours; bytes memory claimSig = _signClaim(LINK_PRIVKEY, idx, RECIPIENT, vault.OPEN_CLAIM_MODE()); // Use a wrong signature (signed by LINK_PRIVKEY instead of BACKEND) - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256(abi.encodePacked(vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, RECIPIENT, deadline)) - ); + bytes32 digest = EnvelopeEIP712Utils.mfaDigest(address(vault), idx, RECIPIENT, deadline); (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); bytes memory wrongMfaSig = abi.encodePacked(r, s, v); - bytes memory data = abi.encodeCall(EnvelopeLinks.claimWithMFA, (idx, RECIPIENT, claimSig, wrongMfaSig, deadline)); + bytes memory data = + abi.encodeCall(EnvelopeLinks.claimWithMFA, (idx, RECIPIENT, claimSig, wrongMfaSig, deadline)); assertFalse(vault.isValidGaslessOperation(RECIPIENT, data)); } @@ -1353,11 +1261,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0.01 ether, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0, - gaslessFee: 0.01 ether, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0, gaslessFee: 0.01 ether, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); uint256 idx = vault.createLinkWithFees{value: 1 ether}(req, auth); @@ -1431,11 +1335,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0, 0, true, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0, - gaslessFee: 0, - gaslessSponsored: true, - deadline: 0, - signature: authSig + serviceFee: 0, gaslessFee: 0, gaslessSponsored: true, deadline: 0, signature: authSig }); vm.prank(SENDER); uint256 idx = vault.createLinkWithFees{value: 1 ether}(req, auth); @@ -1468,11 +1368,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0.01 ether, 0, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0.01 ether, - gaslessFee: 0, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0.01 ether, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); @@ -1498,11 +1394,7 @@ contract EnvelopeCoverageTest is Test { }); bytes memory authSig = _signFeeAuth(req, SENDER, 0.01 ether, 0, false, 0); EnvelopeLinks.FeeAuthorization memory auth = EnvelopeLinks.FeeAuthorization({ - serviceFee: 0.01 ether, - gaslessFee: 0, - gaslessSponsored: false, - deadline: 0, - signature: authSig + serviceFee: 0.01 ether, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: authSig }); vm.prank(SENDER); @@ -1677,9 +1569,8 @@ contract EnvelopeCoverageTest is Test { mfas[1] = false; vm.prank(SENDER); - uint256[] memory indexes = vault.createCustomLinks{value: 0.5 ether}( - tokenAddresses, types, amounts, tokenIds, keys, mfas - ); + uint256[] memory indexes = + vault.createCustomLinks{value: 0.5 ether}(tokenAddresses, types, amounts, tokenIds, keys, mfas); assertEq(indexes.length, 2); assertEq(vault.getLinkAsset(indexes[0]).contractType, 0); @@ -1695,9 +1586,7 @@ contract EnvelopeCoverageTest is Test { function test_onERC1155BatchReceived_internalTransfer() public { // The onERC1155BatchReceived success path is only reachable when operator == vault address. // We can call it directly to verify the selector is returned. - bytes4 result = vault.onERC1155BatchReceived( - address(vault), SENDER, new uint256[](1), new uint256[](1), "" - ); + bytes4 result = vault.onERC1155BatchReceived(address(vault), SENDER, new uint256[](1), new uint256[](1), ""); assertEq(result, vault.onERC1155BatchReceived.selector); } } diff --git a/test/envelope/Deposit.t.sol b/test/envelope/Deposit.t.sol index c8b9f23d..a6a2e07d 100644 --- a/test/envelope/Deposit.t.sol +++ b/test/envelope/Deposit.t.sol @@ -25,7 +25,7 @@ contract EnvelopeLinksDepositTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/EnvelopeBatching.t.sol b/test/envelope/EnvelopeBatching.t.sol index 96cd30cd..7da81fcf 100644 --- a/test/envelope/EnvelopeBatching.t.sol +++ b/test/envelope/EnvelopeBatching.t.sol @@ -4,12 +4,12 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; import {EnvelopeFeeAuthTestUtils} from "./EnvelopeFeeAuthTestUtils.sol"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.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"; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { EnvelopeLinks public vault; @@ -30,7 +30,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { linkPubKey = vm.addr(LINK_PRIVKEY); backendAuthorizer = vm.addr(BACKEND_PRIVKEY); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); testToken = new ERC20Mock(); feeToken = new ERC20Mock(); feeVault = new EnvelopeLinks(backendAuthorizer, address(this), address(feeToken)); @@ -182,7 +182,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { assertEq(secondStatus.requiresMFA, true); assertEq(secondParties.recipient, RECIPIENT); assertEq(feeToken.balanceOf(address(feeVault)), 0.1 ether); - assertEq(feeVault.accumulatedFees(address(feeToken)), 0.1 ether); + assertEq(feeVault.accumulatedFees(), 0.1 ether); bytes memory withdrawalSig = _signWithdrawal(feeVault, depositIndexes[0], RECIPIENT, feeVault.OPEN_CLAIM_MODE()); bytes memory callData = abi.encodeCall(EnvelopeLinks.claim, (depositIndexes[0], RECIPIENT, withdrawalSig)); @@ -411,14 +411,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { uint256 deadline ) internal view returns (bytes memory) { bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( - targetVault.ENVELOPE_SALT(), - address(targetVault), - request, - feePayer, - serviceFee, - gaslessFee, - gaslessSponsored, - deadline + address(targetVault), request, feePayer, serviceFee, gaslessFee, gaslessSponsored, deadline ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); @@ -429,13 +422,7 @@ contract EnvelopeBatchingTest is Test, ERC1155Holder, ERC721Holder { view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - targetVault.ENVELOPE_SALT(), block.chainid, address(targetVault), depositIndex, recipient, mode - ) - ) - ); + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(targetVault), depositIndex, recipient, mode); (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); return abi.encodePacked(r, s, v); } diff --git a/test/envelope/EnvelopeEIP712.t.sol b/test/envelope/EnvelopeEIP712.t.sol new file mode 100644 index 00000000..f10cae61 --- /dev/null +++ b/test/envelope/EnvelopeEIP712.t.sol @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +// Thorough EIP-712 tests for EnvelopeLinks: +// - Domain separator correctness +// - Cross-chain replay protection (domain separator includes chainId) +// - Cross-contract replay protection (domain separator includes verifyingContract) +// - Typehash verification +// - Structured data correctness for all three signed message types +// - eip712Domain() getter (EIP-5267) + +import {Test} from "forge-std/Test.sol"; +import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.sol"; +import {EnvelopeFeeAuthTestUtils} from "./EnvelopeFeeAuthTestUtils.sol"; +import {ERC20Mock} from "./mocks/ERC20Mock.sol"; + +contract EnvelopeEIP712Test is Test { + EnvelopeLinks public vault; + EnvelopeLinks public vault2; // second instance for cross-contract tests + ERC20Mock public feeToken; + + uint256 constant LINK_PRIV = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + uint256 constant MFA_PRIV = uint256(keccak256("eip712-test-mfa-signer")); + address linkPubKey; + address mfaSigner; + + address constant ALICE = address(0xA11CE); + address constant BOB = address(0xB0B); + + function setUp() public { + linkPubKey = vm.addr(LINK_PRIV); + mfaSigner = vm.addr(MFA_PRIV); + feeToken = new ERC20Mock(); + + vault = new EnvelopeLinks(mfaSigner, address(this), address(feeToken)); + vault2 = new EnvelopeLinks(mfaSigner, address(this), address(feeToken)); + } + + receive() external payable {} + + // ══════════════════════════════════════════════════════════════════════════════ + // Domain separator correctness + // ══════════════════════════════════════════════════════════════════════════════ + + function test_domainSeparator_matchesExpected() public view { + bytes32 expected = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("EnvelopeLinks"), + keccak256("5"), + block.chainid, + address(vault) + ) + ); + assertEq(EnvelopeEIP712Utils.domainSeparator(address(vault)), expected); + } + + function test_domainSeparator_differsBetweenInstances() public view { + bytes32 ds1 = EnvelopeEIP712Utils.domainSeparator(address(vault)); + bytes32 ds2 = EnvelopeEIP712Utils.domainSeparator(address(vault2)); + assertTrue(ds1 != ds2, "Different contract addresses must have different domain separators"); + } + + function test_domainSeparator_includesChainId() public { + bytes32 ds1 = EnvelopeEIP712Utils.domainSeparator(address(vault)); + + // Fork to a different chain ID + vm.chainId(999); + bytes32 ds2 = EnvelopeEIP712Utils.domainSeparator(address(vault)); + + assertTrue(ds1 != ds2, "Different chain IDs must produce different domain separators"); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // EIP-5267: eip712Domain() getter + // ══════════════════════════════════════════════════════════════════════════════ + + function test_eip712Domain_returnsCorrectValues() public view { + ( + bytes1 fields, + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ) = vault.eip712Domain(); + + assertEq(uint8(fields), 0x0f, "Fields should indicate name, version, chainId, verifyingContract"); + assertEq(keccak256(bytes(name)), keccak256("EnvelopeLinks")); + assertEq(keccak256(bytes(version)), keccak256("5")); + assertEq(chainId, block.chainid); + assertEq(verifyingContract, address(vault)); + assertEq(salt, bytes32(0)); + assertEq(extensions.length, 0); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Typehash verification + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimTypehash() public view { + bytes32 expected = keccak256("Claim(uint256 index,address recipient,bytes32 mode)"); + assertEq(vault.CLAIM_TYPEHASH(), expected); + } + + function test_mfaApprovalTypehash() public view { + bytes32 expected = keccak256("MfaApproval(uint256 index,address recipient,uint256 deadline)"); + assertEq(vault.MFA_APPROVAL_TYPEHASH(), expected); + } + + function test_feeAuthorizationTypehash() public view { + bytes32 expected = keccak256( + "FeeAuthorization(address feePayer,address tokenAddress,uint8 contractType,uint256 amount,uint256 tokenId,address claimKey,address onBehalfOf,bool withMFA,address recipient,uint40 reclaimableAfter,uint256 serviceFee,uint256 gaslessFee,bool gaslessSponsored,uint256 deadline)" + ); + assertEq(vault.FEE_AUTHORIZATION_TYPEHASH(), expected); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Cross-chain replay protection + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimSignature_invalidOnDifferentChain() public { + // Create a link on the original chain + uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + + // Sign the claim on chain 31337 (default foundry chain id) + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), idx, ALICE, vault.OPEN_CLAIM_MODE()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + // Switch to a different chain and verify the signature fails + vm.chainId(42161); // Arbitrum chain ID + + vm.expectRevert(EnvelopeLinks.WrongSignature.selector); + vault.claim(idx, ALICE, sig); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Cross-contract replay protection + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimSignature_invalidOnDifferentContract() public { + // Create identical links on both vaults + uint256 idx1 = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + uint256 idx2 = vault2.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + assertEq(idx1, idx2, "Both should be index 0"); + + // Sign for vault1 + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), idx1, ALICE, vault.OPEN_CLAIM_MODE()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + // Claim on vault1 works + vault.claim(idx1, ALICE, sig); + + // Same signature on vault2 fails + vm.expectRevert(EnvelopeLinks.WrongSignature.selector); + vault2.claim(idx2, ALICE, sig); + } + + function test_mfaSignature_invalidOnDifferentContract() public { + // Create MFA links on both vaults + uint256 idx1 = vault.createMFALink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + uint256 idx2 = vault2.createMFALink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + + // Sign MFA for vault1 + bytes32 mfaDigest = EnvelopeEIP712Utils.mfaDigest(address(vault), idx1, ALICE, 0); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIV, mfaDigest); + bytes memory mfaSig = abi.encodePacked(r, s, v); + + bytes32 claimDigest1 = EnvelopeEIP712Utils.claimDigest(address(vault), idx1, ALICE, vault.OPEN_CLAIM_MODE()); + (v, r, s) = vm.sign(LINK_PRIV, claimDigest1); + bytes memory claimSig1 = abi.encodePacked(r, s, v); + + // Works on vault1 + vault.claimWithMFA(idx1, ALICE, claimSig1, mfaSig, 0); + + // MFA sig from vault1 fails on vault2 + bytes32 claimDigest2 = EnvelopeEIP712Utils.claimDigest(address(vault2), idx2, ALICE, vault2.OPEN_CLAIM_MODE()); + (v, r, s) = vm.sign(LINK_PRIV, claimDigest2); + bytes memory claimSig2 = abi.encodePacked(r, s, v); + + vm.expectRevert(EnvelopeLinks.WrongMfaSignature.selector); + vault2.claimWithMFA(idx2, ALICE, claimSig2, mfaSig, 0); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Fee authorization cross-contract replay + // ══════════════════════════════════════════════════════════════════════════════ + + function test_feeAuthorization_invalidOnDifferentContract() public { + feeToken.mint(address(this), 10 ether); + feeToken.approve(address(vault), type(uint256).max); + feeToken.approve(address(vault2), type(uint256).max); + + EnvelopeLinks.LinkRequest memory request = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 0.1 ether, + tokenId: 0, + claimKey: linkPubKey, + onBehalfOf: address(this), + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + + uint256 serviceFee = 100; + uint256 deadline = block.timestamp + 1 hours; + + // Sign fee auth for vault1 + bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( + address(vault), request, address(this), serviceFee, 0, false, deadline + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIV, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + EnvelopeLinks.FeeAuthorization memory feeAuth = EnvelopeLinks.FeeAuthorization({ + serviceFee: serviceFee, gaslessFee: 0, gaslessSponsored: false, deadline: deadline, signature: sig + }); + + // Works on vault1 + vault.createLinkWithFees{value: 0.1 ether}(request, feeAuth); + + // Fails on vault2 (different verifyingContract in domain separator) + vm.expectRevert(EnvelopeLinks.WrongFeeAuthorizationSignature.selector); + vault2.createLinkWithFees{value: 0.1 ether}(request, feeAuth); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Claim mode discrimination + // ══════════════════════════════════════════════════════════════════════════════ + + function test_openClaimSignature_cannotBeUsedAsBound() public { + // Create an address-bound link + uint256 idx = vault.createCustomLink{value: 1 ether}( + address(0), 0, 1 ether, 0, linkPubKey, address(this), false, ALICE, 0 + ); + + // Sign with OPEN_CLAIM_MODE + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), idx, ALICE, vault.OPEN_CLAIM_MODE()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + bytes memory openSig = abi.encodePacked(r, s, v); + + // Using open mode sig in bound recipient claim should fail + // (claimAsBoundRecipient passes BOUND_CLAIM_MODE as extraData) + vm.prank(ALICE); + vm.expectRevert(EnvelopeLinks.WrongSignature.selector); + vault.claimAsBoundRecipient(idx, ALICE, openSig); + } + + function test_boundClaimSignature_cannotBeUsedAsOpen() public { + // Create an open link (recipient = address(0)) + uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + + // Sign with BOUND_CLAIM_MODE + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), idx, ALICE, vault.BOUND_CLAIM_MODE()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + bytes memory boundSig = abi.encodePacked(r, s, v); + + // Trying to use bound mode sig in an open claim (which uses OPEN_CLAIM_MODE internally) + vm.expectRevert(EnvelopeLinks.WrongSignature.selector); + vault.claim(idx, ALICE, boundSig); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Structured data fields completeness + // ══════════════════════════════════════════════════════════════════════════════ + + function test_claimDigest_changesWithIndex() public { + bytes32 d1 = EnvelopeEIP712Utils.claimDigest(address(vault), 0, ALICE, vault.OPEN_CLAIM_MODE()); + bytes32 d2 = EnvelopeEIP712Utils.claimDigest(address(vault), 1, ALICE, vault.OPEN_CLAIM_MODE()); + assertTrue(d1 != d2, "Different index must produce different digest"); + } + + function test_claimDigest_changesWithRecipient() public { + bytes32 d1 = EnvelopeEIP712Utils.claimDigest(address(vault), 0, ALICE, vault.OPEN_CLAIM_MODE()); + bytes32 d2 = EnvelopeEIP712Utils.claimDigest(address(vault), 0, BOB, vault.OPEN_CLAIM_MODE()); + assertTrue(d1 != d2, "Different recipient must produce different digest"); + } + + function test_claimDigest_changesWithMode() public { + bytes32 d1 = EnvelopeEIP712Utils.claimDigest(address(vault), 0, ALICE, vault.OPEN_CLAIM_MODE()); + bytes32 d2 = EnvelopeEIP712Utils.claimDigest(address(vault), 0, ALICE, vault.BOUND_CLAIM_MODE()); + assertTrue(d1 != d2, "Different mode must produce different digest"); + } + + function test_mfaDigest_changesWithDeadline() public { + bytes32 d1 = EnvelopeEIP712Utils.mfaDigest(address(vault), 0, ALICE, 0); + bytes32 d2 = EnvelopeEIP712Utils.mfaDigest(address(vault), 0, ALICE, 1000); + assertTrue(d1 != d2, "Different deadline must produce different digest"); + } + + function testFuzz_claimSignature_worksForAnyRecipient(address recipient) public { + vm.assume(recipient != address(0)); + vm.assume(recipient.code.length == 0); // avoid contracts that reject ETH + + uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), idx, recipient, vault.OPEN_CLAIM_MODE()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + vm.deal(recipient, 0); // start at 0 to verify receipt + vault.claim(idx, recipient, sig); + assertEq(recipient.balance, 1 ether); + } + + function testFuzz_mfaSignature_worksWithAnyDeadline(uint256 deadline) public { + // Ensure deadline is either 0 (no expiry) or in the future + vm.assume(deadline == 0 || deadline > block.timestamp); + + uint256 idx = vault.createMFALink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + + bytes32 mfaDigest = EnvelopeEIP712Utils.mfaDigest(address(vault), idx, ALICE, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIV, mfaDigest); + bytes memory mfaSig = abi.encodePacked(r, s, v); + + bytes32 claimDigest_ = EnvelopeEIP712Utils.claimDigest(address(vault), idx, ALICE, vault.OPEN_CLAIM_MODE()); + (v, r, s) = vm.sign(LINK_PRIV, claimDigest_); + bytes memory claimSig = abi.encodePacked(r, s, v); + + vault.claimWithMFA(idx, ALICE, claimSig, mfaSig, deadline); + } +} diff --git a/test/envelope/EnvelopeEIP712Utils.sol b/test/envelope/EnvelopeEIP712Utils.sol new file mode 100644 index 00000000..e2a5eafb --- /dev/null +++ b/test/envelope/EnvelopeEIP712Utils.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.20; + +import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; + +/// @dev Shared EIP-712 digest helpers for EnvelopeLinks test suites. +library EnvelopeEIP712Utils { + bytes32 internal constant DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + bytes32 internal constant NAME_HASH = keccak256("EnvelopeLinks"); + bytes32 internal constant VERSION_HASH = keccak256("5"); + + function domainSeparator(address vaultAddr) internal view returns (bytes32) { + return keccak256(abi.encode(DOMAIN_TYPEHASH, NAME_HASH, VERSION_HASH, block.chainid, vaultAddr)); + } + + function claimDigest(address vaultAddr, uint256 index, address recipient, bytes32 mode) + internal + view + returns (bytes32) + { + bytes32 structHash = + keccak256(abi.encode(EnvelopeLinks(payable(vaultAddr)).CLAIM_TYPEHASH(), index, recipient, mode)); + return _hashTypedData(vaultAddr, structHash); + } + + function mfaDigest(address vaultAddr, uint256 index, address recipient, uint256 deadline) + internal + view + returns (bytes32) + { + bytes32 structHash = keccak256( + abi.encode(EnvelopeLinks(payable(vaultAddr)).MFA_APPROVAL_TYPEHASH(), index, recipient, deadline) + ); + return _hashTypedData(vaultAddr, structHash); + } + + function feeAuthDigest( + address vaultAddr, + address feePayer, + EnvelopeLinks.LinkRequest memory request, + uint256 serviceFee, + uint256 gaslessFee, + bool gaslessSponsored, + uint256 deadline + ) internal view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + EnvelopeLinks(payable(vaultAddr)).FEE_AUTHORIZATION_TYPEHASH(), + feePayer, + request.tokenAddress, + request.contractType, + request.amount, + request.tokenId, + request.claimKey, + request.onBehalfOf, + request.withMFA, + request.recipient, + request.reclaimableAfter, + serviceFee, + gaslessFee, + gaslessSponsored, + deadline + ) + ); + return _hashTypedData(vaultAddr, structHash); + } + + function _hashTypedData(address vaultAddr, bytes32 structHash) private view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", domainSeparator(vaultAddr), structHash)); + } +} diff --git a/test/envelope/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol index 0f9ca7f9..7f491df7 100644 --- a/test/envelope/EnvelopeEdgeCases.t.sol +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -10,7 +10,7 @@ import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; @@ -61,7 +61,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { function setUp() public { LINK_PUBKEY20 = vm.addr(LINK_PRIV); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); erc20 = new ERC20Mock(); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); @@ -72,13 +72,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { // ── helpers ──────────────────────────────────────────────────────────── function _signWithdrawal(uint256 idx, address recipient, uint256 privKey) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, recipient, vault.OPEN_CLAIM_MODE() - ) - ) - ); + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), idx, recipient, vault.OPEN_CLAIM_MODE()); (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, digest); return abi.encodePacked(r, s, v); } @@ -109,6 +103,53 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { vault.createLink(address(erc721), 2, 2, 1, LINK_PUBKEY20); } + function test_RevertWhen_SingleErc20DepositReceivesEth() public { + erc20.mint(address(this), 100); + erc20.approve(address(vault), 100); + + vm.expectRevert(EnvelopeLinks.EthNotAcceptedForNonEthLink.selector); + vault.createLink{value: 1 wei}(address(erc20), 1, 100, 0, LINK_PUBKEY20); + } + + function test_RevertWhen_SingleErc721DepositReceivesEth() public { + erc721.mint(address(this), 1); + erc721.approve(address(vault), 1); + + vm.expectRevert(EnvelopeLinks.EthNotAcceptedForNonEthLink.selector); + vault.createLink{value: 1 wei}(address(erc721), 2, 1, 1, LINK_PUBKEY20); + } + + function test_RevertWhen_SingleErc1155DepositReceivesEth() public { + erc1155.mint(address(this), 1, 100, ""); + erc1155.setApprovalForAll(address(vault), true); + + vm.expectRevert(EnvelopeLinks.EthNotAcceptedForNonEthLink.selector); + vault.createLink{value: 1 wei}(address(erc1155), 3, 100, 1, LINK_PUBKEY20); + } + + function test_RevertWhen_CreateLinkWithFeesNonEthDepositReceivesEth() public { + erc20.mint(address(this), 100); + erc20.approve(address(vault), 100); + + EnvelopeLinks.LinkRequest memory request = EnvelopeLinks.LinkRequest({ + tokenAddress: address(erc20), + contractType: 1, + amount: 100, + tokenId: 0, + claimKey: LINK_PUBKEY20, + onBehalfOf: address(this), + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + EnvelopeLinks.FeeAuthorization memory authorization = EnvelopeLinks.FeeAuthorization({ + serviceFee: 0, gaslessFee: 0, gaslessSponsored: false, deadline: 0, signature: "" + }); + + vm.expectRevert(EnvelopeLinks.EthNotAcceptedForNonEthLink.selector); + vault.createLinkWithFees{value: 1 wei}(request, authorization); + } + // ── EnvelopeLinks withdraw input validation ───────────────────────────────── function test_RevertWhen_WithdrawIndexOutOfBounds() public { @@ -139,13 +180,7 @@ contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { 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( - vault.ENVELOPE_SALT(), block.chainid, address(vault), idx, ALICE, vault.BOUND_CLAIM_MODE() - ) - ) - ); + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), idx, ALICE, vault.BOUND_CLAIM_MODE()); (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); bytes memory sig = abi.encodePacked(r, s, v); diff --git a/test/envelope/EnvelopeFeeAuthTestUtils.sol b/test/envelope/EnvelopeFeeAuthTestUtils.sol index f13d576f..04a65ff6 100644 --- a/test/envelope/EnvelopeFeeAuthTestUtils.sol +++ b/test/envelope/EnvelopeFeeAuthTestUtils.sol @@ -1,12 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.20; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.sol"; library EnvelopeFeeAuthTestUtils { function feeAuthorizationDigest( - bytes32 salt, address vaultAddr, EnvelopeLinks.LinkRequest memory request, address feePayer, @@ -15,29 +14,8 @@ library EnvelopeFeeAuthTestUtils { bool gaslessSponsored, uint256 deadline ) internal view returns (bytes32) { - bytes32 digest; - assembly ("memory-safe") { - let ptr := mload(0x40) - mstore(0x40, add(ptr, 544)) - mstore(ptr, salt) - mstore(add(ptr, 32), chainid()) - mstore(add(ptr, 64), vaultAddr) - mstore(add(ptr, 96), feePayer) - mstore(add(ptr, 128), mload(request)) - mstore(add(ptr, 160), mload(add(request, 32))) - mstore(add(ptr, 192), mload(add(request, 64))) - mstore(add(ptr, 224), mload(add(request, 96))) - mstore(add(ptr, 256), mload(add(request, 128))) - mstore(add(ptr, 288), mload(add(request, 160))) - mstore(add(ptr, 320), mload(add(request, 192))) - mstore(add(ptr, 352), mload(add(request, 224))) - mstore(add(ptr, 384), mload(add(request, 256))) - mstore(add(ptr, 416), serviceFee) - mstore(add(ptr, 448), gaslessFee) - mstore(add(ptr, 480), gaslessSponsored) - mstore(add(ptr, 512), deadline) - digest := keccak256(ptr, 544) - } - return MessageHashUtils.toEthSignedMessageHash(digest); + return EnvelopeEIP712Utils.feeAuthDigest( + vaultAddr, feePayer, request, serviceFee, gaslessFee, gaslessSponsored, deadline + ); } -} \ No newline at end of file +} diff --git a/test/envelope/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol index 8a36f18c..9f1cb1f2 100644 --- a/test/envelope/EnvelopeHardening.t.sol +++ b/test/envelope/EnvelopeHardening.t.sol @@ -11,6 +11,7 @@ import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; import {ERC20Mock} from "./mocks/ERC20Mock.sol"; import {ERC721Mock} from "./mocks/ERC721Mock.sol"; import {ERC1155Mock} from "./mocks/ERC1155Mock.sol"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; @@ -23,7 +24,7 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); function setUp() public { - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); erc721 = new ERC721Mock(); erc1155 = new ERC1155Mock(); } @@ -78,30 +79,14 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { uint256 idx = nodleVault.createMFALinkFor{value: 1 wei}(address(0), 0, 1, 0, depositSigner, address(this)); // withdrawal signature (signed by deposit pubkey) - bytes32 wdHash = MessageHashUtilsLite.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - nodleVault.ENVELOPE_SALT(), - block.chainid, - address(nodleVault), - idx, - address(this), - nodleVault.OPEN_CLAIM_MODE() - ) - ) - ); + bytes32 wdHash = + EnvelopeEIP712Utils.claimDigest(address(nodleVault), idx, address(this), nodleVault.OPEN_CLAIM_MODE()); (uint8 wv, bytes32 wr, bytes32 ws) = vm.sign(depositPrivKey, wdHash); bytes memory wdSig = abi.encodePacked(wr, ws, wv); // MFA signature (signed by configured mfaAuthorizer, includes deadline) uint256 deadline = 0; // no expiry - bytes32 mfaHash = MessageHashUtilsLite.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - nodleVault.ENVELOPE_SALT(), block.chainid, address(nodleVault), idx, address(this), deadline - ) - ) - ); + bytes32 mfaHash = EnvelopeEIP712Utils.mfaDigest(address(nodleVault), idx, address(this), deadline); (uint8 mv, bytes32 mr, bytes32 ms) = vm.sign(mfaPrivKey, mfaHash); bytes memory mfaSig = abi.encodePacked(mr, ms, mv); @@ -122,15 +107,3 @@ contract EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { vault.claimWithMFA(idx, address(this), wdSig, mfaSig, 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/envelope/EnvelopeLinks.t.sol b/test/envelope/EnvelopeLinks.t.sol index d695bed0..fd7f225e 100644 --- a/test/envelope/EnvelopeLinks.t.sol +++ b/test/envelope/EnvelopeLinks.t.sol @@ -30,7 +30,7 @@ contract EnvelopeLinksTest is Test { testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); // Mint tokens for test accounts testToken.mint(address(this), 1000); diff --git a/test/envelope/EnvelopeSecurity.t.sol b/test/envelope/EnvelopeSecurity.t.sol new file mode 100644 index 00000000..162dfd34 --- /dev/null +++ b/test/envelope/EnvelopeSecurity.t.sol @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.26; + +// Tests for security hardening findings: +// H-1 — Balance-delta measurement for fee-on-transfer tokens +// H-2 — Mutable mfaAuthorizer (key rotation) +// M-1 — Guard _isMfaSignatureValid against address(0) +// M-2 — Reject unbound links in claimAsBoundRecipient +// M-3 — Reject recipientAddress == address(0) in claims +// M-4 — Fee-authorization replay protection + +import {Test} from "forge-std/Test.sol"; +import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; +import {EnvelopeFeeAuthTestUtils} from "./EnvelopeFeeAuthTestUtils.sol"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.sol"; +import {ERC20Mock} from "./mocks/ERC20Mock.sol"; +import {FeeOnTransferERC20Mock} from "./mocks/FeeOnTransferERC20Mock.sol"; + +contract EnvelopeSecurityTest is Test { + EnvelopeLinks public vault; + EnvelopeLinks public mfaVault; + ERC20Mock public feeToken; + FeeOnTransferERC20Mock public fotToken; + + uint256 constant LINK_PRIV = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + uint256 constant MFA_PRIV = uint256(keccak256("security-test-mfa-signer")); + address linkPubKey; + address mfaSigner; + + address constant ALICE = address(0xA11CE); + address constant BOB = address(0xB0B); + + function setUp() public { + linkPubKey = vm.addr(LINK_PRIV); + mfaSigner = vm.addr(MFA_PRIV); + + feeToken = new ERC20Mock(); + fotToken = new FeeOnTransferERC20Mock(); + + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); + mfaVault = new EnvelopeLinks(mfaSigner, address(this), address(feeToken)); + } + + receive() external payable {} + + // ══════════════════════════════════════════════════════════════════════════════ + // H-1: Balance-delta measurement for fee-on-transfer tokens + // ══════════════════════════════════════════════════════════════════════════════ + + function test_H1_feeOnTransferRecordsActualAmount() public { + fotToken.mint(address(this), 10000); + fotToken.approve(address(vault), 10000); + + // Deposit 1000 tokens; FOT takes 1% → vault receives 990. + uint256 idx = vault.createLink(address(fotToken), 1, 1000, 0, linkPubKey); + EnvelopeLinks.LinkAsset memory asset = vault.getLinkAsset(idx); + // The stored amount must reflect the actual received amount (990), not requested (1000). + assertEq(asset.amount, 990, "Should store balance-delta, not requested amount"); + } + + function test_H1_batchFeeOnTransferRecordsActualPerLinkAmount() public { + fotToken.mint(address(this), 100000); + fotToken.approve(address(vault), 100000); + + address[] memory keys = new address[](5); + for (uint256 i = 0; i < 5; i++) { + keys[i] = linkPubKey; + } + + uint256[] memory indexes = vault.createLinks(address(fotToken), 1, 1000, 0, keys); + + // Each link: requested 1000 * 5 = 5000, received 4950, per-link = 990. + for (uint256 i = 0; i < indexes.length; i++) { + EnvelopeLinks.LinkAsset memory asset = vault.getLinkAsset(indexes[i]); + assertEq(asset.amount, 990, "Batch per-link should use balance delta"); + } + } + + function test_H1_raffleFeeOnTransferReverts() public { + fotToken.mint(address(this), 100000); + fotToken.approve(address(vault), 100000); + + uint256[] memory amounts = new uint256[](3); + amounts[0] = 1000; + amounts[1] = 2000; + amounts[2] = 3000; + + // Raffle links with FOT token should revert because received < expected. + vm.expectRevert(EnvelopeLinks.InsufficientTokensReceived.selector); + vault.createRaffleLinks(address(fotToken), 1, amounts, linkPubKey); + } + + function test_H1_feeTokenMustTransferExactAmount() public { + EnvelopeLinks fotFeeVault = new EnvelopeLinks(mfaSigner, address(this), address(fotToken)); + fotToken.mint(address(this), 10000); + fotToken.approve(address(fotFeeVault), 10000); + + EnvelopeLinks.LinkRequest memory request = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 0.1 ether, + tokenId: 0, + claimKey: linkPubKey, + onBehalfOf: address(this), + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + + uint256 serviceFee = 1000; + bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( + address(fotFeeVault), request, address(this), serviceFee, 0, false, 0 + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIV, digest); + EnvelopeLinks.FeeAuthorization memory feeAuth = EnvelopeLinks.FeeAuthorization({ + serviceFee: serviceFee, + gaslessFee: 0, + gaslessSponsored: false, + deadline: 0, + signature: abi.encodePacked(r, s, v) + }); + + vm.expectRevert(EnvelopeLinks.FeeTokenTransferAmountMismatch.selector); + fotFeeVault.createLinkWithFees{value: 0.1 ether}(request, feeAuth); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // H-2: Mutable mfaAuthorizer (key rotation) + // ══════════════════════════════════════════════════════════════════════════════ + + function test_H2_ownerCanRotateMfaAuthorizer() public { + assertEq(mfaVault.mfaAuthorizer(), mfaSigner); + + address newSigner = address(0x1234); + mfaVault.setMfaAuthorizer(newSigner); + assertEq(mfaVault.mfaAuthorizer(), newSigner); + } + + function test_H2_nonOwnerCannotRotateMfaAuthorizer() public { + vm.prank(ALICE); + vm.expectRevert(); + mfaVault.setMfaAuthorizer(address(0x9999)); + } + + function test_H2_rotationInvalidatesOldSignatures() public { + // Create an MFA-gated link. + uint256 idx = mfaVault.createMFALink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + + // Sign MFA with the old key. + bytes memory mfaSig = _signMfa(address(mfaVault), idx, ALICE, 0, MFA_PRIV); + bytes memory claimSig = _signOpen(address(mfaVault), idx, ALICE); + + // Rotate key. + uint256 newMfaPriv = uint256(keccak256("new-mfa-key")); + mfaVault.setMfaAuthorizer(vm.addr(newMfaPriv)); + + // Old MFA signature should now fail. + vm.expectRevert(EnvelopeLinks.WrongMfaSignature.selector); + mfaVault.claimWithMFA(idx, ALICE, claimSig, mfaSig, 0); + + // New signature works. + bytes memory newMfaSig = _signMfa(address(mfaVault), idx, ALICE, 0, newMfaPriv); + mfaVault.claimWithMFA(idx, ALICE, claimSig, newMfaSig, 0); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // M-1: Guard against mfaAuthorizer == address(0) + // ══════════════════════════════════════════════════════════════════════════════ + + function test_M1_constructorRejectsZeroAuthorizer() public { + vm.expectRevert(EnvelopeLinks.ZeroMfaAuthorizer.selector); + new EnvelopeLinks(address(0), address(this), address(0)); + } + + function test_M1_setMfaAuthorizerRejectsZero() public { + vm.expectRevert(EnvelopeLinks.ZeroMfaAuthorizer.selector); + mfaVault.setMfaAuthorizer(address(0)); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // M-2: Reject unbound links in claimAsBoundRecipient + // ══════════════════════════════════════════════════════════════════════════════ + + function test_M2_claimAsBoundRecipientRevertsOnUnboundLink() public { + uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + bytes memory sig = _signBound(address(vault), idx, ALICE); + + vm.prank(ALICE); + vm.expectRevert(EnvelopeLinks.LinkNotRecipientBound.selector); + vault.claimAsBoundRecipient(idx, ALICE, sig); + } + + function test_M2_claimAsBoundRecipientWorksOnBoundLink() public { + uint256 idx = vault.createCustomLink{value: 1 ether}( + address(0), 0, 1 ether, 0, linkPubKey, address(this), false, ALICE, 0 + ); + bytes memory sig = _signBound(address(vault), idx, ALICE); + + vm.prank(ALICE); + vault.claimAsBoundRecipient(idx, ALICE, sig); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // M-3: Reject recipientAddress == address(0) in claims + // ══════════════════════════════════════════════════════════════════════════════ + + function test_M3_claimRevertsWithZeroRecipient() public { + uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + bytes memory sig = _signOpen(address(vault), idx, address(0)); + + vm.expectRevert(EnvelopeLinks.ZeroRecipientAddress.selector); + vault.claim(idx, address(0), sig); + } + + function test_M3_isValidGaslessReturnsFalseForZeroRecipient() public { + uint256 idx = vault.createLink{value: 1 ether}(address(0), 0, 1 ether, 0, linkPubKey); + bytes memory sig = _signOpen(address(vault), idx, address(0)); + + bytes memory callData = abi.encodeCall(EnvelopeLinks.claim, (idx, address(0), sig)); + bool valid = vault.isValidGaslessOperation(address(0), callData); + assertFalse(valid, "Should reject zero recipient in gasless validation"); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // M-4: Fee-authorization replay protection + // ══════════════════════════════════════════════════════════════════════════════ + + function test_M4_feeAuthorizationCannotBeReused() public { + feeToken.mint(address(this), 1 ether); + feeToken.approve(address(mfaVault), 1 ether); + + EnvelopeLinks.LinkRequest memory request = EnvelopeLinks.LinkRequest({ + tokenAddress: address(0), + contractType: 0, + amount: 0.1 ether, + tokenId: 0, + claimKey: linkPubKey, + onBehalfOf: address(this), + withMFA: false, + recipient: address(0), + reclaimableAfter: 0 + }); + + uint256 serviceFee = 100; + uint256 gaslessFee = 0; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( + address(mfaVault), request, address(this), serviceFee, gaslessFee, false, deadline + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIV, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + EnvelopeLinks.FeeAuthorization memory feeAuth = EnvelopeLinks.FeeAuthorization({ + serviceFee: serviceFee, gaslessFee: gaslessFee, gaslessSponsored: false, deadline: deadline, signature: sig + }); + + // First use succeeds. + mfaVault.createLinkWithFees{value: 0.1 ether}(request, feeAuth); + + // Second use with the same authorization reverts. + vm.expectRevert(EnvelopeLinks.FeeAuthorizationAlreadyUsed.selector); + mfaVault.createLinkWithFees{value: 0.1 ether}(request, feeAuth); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════════════ + + function _signOpen(address vaultAddr, uint256 idx, address recipient) internal view returns (bytes memory) { + bytes32 digest = EnvelopeEIP712Utils.claimDigest( + vaultAddr, idx, recipient, EnvelopeLinks(payable(vaultAddr)).OPEN_CLAIM_MODE() + ); + (uint8 vv, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + return abi.encodePacked(r, s, vv); + } + + function _signBound(address vaultAddr, uint256 idx, address recipient) internal view returns (bytes memory) { + bytes32 digest = EnvelopeEIP712Utils.claimDigest( + vaultAddr, idx, recipient, EnvelopeLinks(payable(vaultAddr)).BOUND_CLAIM_MODE() + ); + (uint8 vv, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + return abi.encodePacked(r, s, vv); + } + + function _signMfa(address vaultAddr, uint256 idx, address recipient, uint256 deadline, uint256 privKey) + internal + view + returns (bytes memory) + { + bytes32 digest = EnvelopeEIP712Utils.mfaDigest(vaultAddr, idx, recipient, deadline); + (uint8 vv, bytes32 r, bytes32 s) = vm.sign(privKey, digest); + return abi.encodePacked(r, s, vv); + } +} diff --git a/test/envelope/Gasless.t.sol b/test/envelope/Gasless.t.sol index c59098c5..f0ecbd94 100644 --- a/test/envelope/Gasless.t.sol +++ b/test/envelope/Gasless.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../../src/envelope/EnvelopeLinks.sol"; import {EnvelopeFeeAuthTestUtils} from "./EnvelopeFeeAuthTestUtils.sol"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.sol"; import "./mocks/ERC20Mock.sol"; contract EnvelopeLinksGaslessTest is Test { @@ -70,14 +71,7 @@ contract EnvelopeLinksGaslessTest is Test { uint256 deadline ) internal view returns (bytes memory) { bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( - vault.ENVELOPE_SALT(), - address(vault), - request, - feePayer, - serviceFee, - gaslessFee, - gaslessSponsored, - deadline + address(vault), request, feePayer, serviceFee, gaslessFee, gaslessSponsored, deadline ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); @@ -113,23 +107,13 @@ contract EnvelopeLinksGaslessTest is Test { view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked(vault.ENVELOPE_SALT(), block.chainid, address(vault), depositIndex, recipient, mode) - ) - ); + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), depositIndex, recipient, mode); (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); return abi.encodePacked(r, s, v); } function _signMfa(uint256 depositIndex, address recipient, uint256 deadline) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), block.chainid, address(vault), depositIndex, recipient, deadline - ) - ) - ); + bytes32 digest = EnvelopeEIP712Utils.mfaDigest(address(vault), depositIndex, recipient, deadline); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); } @@ -163,7 +147,7 @@ contract EnvelopeLinksGaslessTest is Test { assertEq(fees.gaslessFee, gaslessFee); assertFalse(status.gaslessSponsored); assertEq(feeToken.balanceOf(address(vault)), serviceFee + gaslessFee); - assertEq(vault.accumulatedFees(address(feeToken)), serviceFee + gaslessFee); + assertEq(vault.accumulatedFees(), serviceFee + gaslessFee); } function test_SponsoredGaslessAuthorizationApprovesPaymasterWithoutGaslessFee() public { @@ -214,7 +198,7 @@ contract EnvelopeLinksGaslessTest is Test { assertEq(fees.gaslessFee, 0); assertFalse(status.gaslessSponsored); assertEq(feeToken.balanceOf(address(vault)), 0); - assertEq(vault.accumulatedFees(address(feeToken)), 0); + assertEq(vault.accumulatedFees(), 0); } function test_ZeroFeeAuthorizationWithoutSignatureRemainsOpen() public { diff --git a/test/envelope/Integration.t.sol b/test/envelope/Integration.t.sol index c2d52fab..ce844d09 100644 --- a/test/envelope/Integration.t.sol +++ b/test/envelope/Integration.t.sol @@ -25,7 +25,7 @@ contract EnvelopeLinksIntegrationTest is Test, ERC1155Holder, ERC721Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); testToken = new ERC20Mock(); testToken721 = new ERC721Mock(); testToken1155 = new ERC1155Mock(); diff --git a/test/envelope/MFA.t.sol b/test/envelope/MFA.t.sol index 51052d2e..e1d192e6 100644 --- a/test/envelope/MFA.t.sol +++ b/test/envelope/MFA.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../../src/envelope/EnvelopeLinks.sol"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.sol"; contract EnvelopeLinksMFATest is Test { EnvelopeLinks public vault; @@ -19,30 +20,14 @@ contract EnvelopeLinksMFATest is Test { } function _signMfa(uint256 depositIndex, address recipient, uint256 deadline) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), block.chainid, address(vault), depositIndex, recipient, deadline - ) - ) - ); + bytes32 digest = EnvelopeEIP712Utils.mfaDigest(address(vault), depositIndex, recipient, deadline); (uint8 v, bytes32 r, bytes32 s) = vm.sign(MFA_PRIVKEY, digest); return abi.encodePacked(r, s, v); } function _signWithdrawal(uint256 depositIndex, address recipient) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - depositIndex, - recipient, - vault.OPEN_CLAIM_MODE() - ) - ) - ); + bytes32 digest = + EnvelopeEIP712Utils.claimDigest(address(vault), depositIndex, recipient, vault.OPEN_CLAIM_MODE()); (uint8 v, bytes32 r, bytes32 s) = vm.sign(uint256(SAMPLE_PRIVKEY), digest); return abi.encodePacked(r, s, v); } diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol index 9eebb6c9..1c32131f 100644 --- a/test/envelope/RecipientBound.t.sol +++ b/test/envelope/RecipientBound.t.sol @@ -22,7 +22,7 @@ contract RecipientBoundTest is Test { function setUp() public { console.log("Setting up test"); testToken = new ERC20Mock(); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); testToken.mint(address(this), 1000); testToken.approve(address(vault), 1000); } diff --git a/test/envelope/SenderWithdraw.t.sol b/test/envelope/SenderWithdraw.t.sol index ae2e9104..eccb0655 100644 --- a/test/envelope/SenderWithdraw.t.sol +++ b/test/envelope/SenderWithdraw.t.sol @@ -19,7 +19,7 @@ contract TestSenderWithdrawEther is Test { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), 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"); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); testToken = new ERC20Mock(); // contractType 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"); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); testToken = new ERC721Mock(); // contractType 2 // Mint token for test @@ -110,7 +110,7 @@ contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { function setUp() public { console.log("Setting up test"); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); testToken = new ERC1155Mock(); // Mint tokens diff --git a/test/envelope/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol index 8ae71a59..4c67d1ac 100644 --- a/test/envelope/SigWithdraw.t.sol +++ b/test/envelope/SigWithdraw.t.sol @@ -3,57 +3,64 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../../src/envelope/EnvelopeLinks.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"; +import {EnvelopeEIP712Utils} from "./EnvelopeEIP712Utils.sol"; contract TestSigWithdrawEther is Test { EnvelopeLinks public vault; - // sample inputs - address _pubkey20 = 0x8fd379246834eac74B8419FfdA202CF8051F7A03; + uint256 constant LINK_PRIV = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; + address _pubkey20; address _recipientAddress = 0x6B3751c5b04Aa818EA90115AA06a4D9A36A16f02; - bytes public signatureAnybody = - hex"02a37d0548c14c6b07eba4ef1438eb946cdada4f481164755129eb3725f7e8c13d7c052308e73314338f4d484a5f4aef20c7519a1dbc283e4826253b742817241c"; - bytes public signatureRecipient = - hex"364c17bca8823977b29b7646c954353996f363549f08ce3943969171c050f0d74006eabb597df680e9e4229631f473bfbedf995336a03d2fd3be7f1fff22d2511b"; - receive() external payable {} // necessary to receive ether + receive() external payable {} function setUp() public { - console.log("Setting up test"); - vault = new EnvelopeLinks(address(0), address(this), address(0)); + vault = new EnvelopeLinks(address(0xBA), address(this), address(0)); + _pubkey20 = vm.addr(LINK_PRIV); + } + + function _signOpen(uint256 idx, address recipient) internal view returns (bytes memory) { + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), idx, recipient, vault.OPEN_CLAIM_MODE()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + return abi.encodePacked(r, s, v); + } + + function _signBound(uint256 idx, address recipient) internal view returns (bytes memory) { + bytes32 digest = EnvelopeEIP712Utils.claimDigest(address(vault), idx, recipient, vault.BOUND_CLAIM_MODE()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIV, digest); + return abi.encodePacked(r, s, v); } - // test sender withdrawal of ETH function testSigWithdrawEther(uint64 amount) public { vm.assume(amount > 0); uint256 depositIdx = vault.createLink{value: amount}(address(0), 0, amount, 0, _pubkey20); + bytes memory sigAnybody = _signOpen(depositIdx, _recipientAddress); - // Can't use withdrawDepositAsRecipient - vm.expectRevert(EnvelopeLinks.NotTheRecipient.selector); - vault.claimAsBoundRecipient(depositIdx, _recipientAddress, signatureAnybody); + // Can't use claimAsBoundRecipient on unbound link + vm.prank(_recipientAddress); + vm.expectRevert(EnvelopeLinks.LinkNotRecipientBound.selector); + vault.claimAsBoundRecipient(depositIdx, _recipientAddress, sigAnybody); - // Anybody can withdraw - vault.claim(depositIdx, _recipientAddress, signatureAnybody); + // Anybody can withdraw with open-mode signature + vault.claim(depositIdx, _recipientAddress, sigAnybody); } function testWithdrawDepositAsRecipient(uint64 amount) public { vm.assume(amount > 0); - uint256 depositIdx = vault.createLink{value: amount}(address(0), 0, amount, 0, _pubkey20); + uint256 depositIdx = vault.createCustomLink{value: amount}( + address(0), 0, amount, 0, _pubkey20, address(this), false, _recipientAddress, 0 + ); + bytes memory sigBound = _signBound(depositIdx, _recipientAddress); - // Can't use pure withdrawDeposit + // Can't use open claim with bound-mode signature vm.expectRevert(EnvelopeLinks.WrongSignature.selector); - vault.claim(depositIdx, _recipientAddress, signatureRecipient); + vault.claim(depositIdx, _recipientAddress, sigBound); - // Only the recipient is able to withdraw via withdrawDepositAsRecipient + // Non-recipient caller is rejected vm.expectRevert(EnvelopeLinks.NotTheRecipient.selector); - vault.claimAsBoundRecipient(depositIdx, _recipientAddress, signatureRecipient); + vault.claimAsBoundRecipient(depositIdx, _recipientAddress, sigBound); - vm.prank(_recipientAddress); // Withdraw! - vault.claimAsBoundRecipient(depositIdx, _recipientAddress, signatureRecipient); + vm.prank(_recipientAddress); + vault.claimAsBoundRecipient(depositIdx, _recipientAddress, sigBound); } } diff --git a/test/envelope/mocks/FeeOnTransferERC20Mock.sol b/test/envelope/mocks/FeeOnTransferERC20Mock.sol new file mode 100644 index 00000000..0c0a5378 --- /dev/null +++ b/test/envelope/mocks/FeeOnTransferERC20Mock.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @dev ERC-20 mock that takes a 1% fee on every transfer (simulates fee-on-transfer tokens). +contract FeeOnTransferERC20Mock is ERC20 { + constructor() ERC20("FeeOnTransfer", "FOT") {} + + function mint(address account, uint256 amount) external { + _mint(account, amount); + } + + function _update(address from, address to, uint256 value) internal override { + if (from != address(0) && to != address(0)) { + // Burn 1% as a fee + uint256 fee = value / 100; + super._update(from, address(0), fee); + super._update(from, to, value - fee); + } else { + super._update(from, to, value); + } + } +} diff --git a/test/paymasters/EnvelopePaymaster.t.sol b/test/paymasters/EnvelopePaymaster.t.sol index 154a3e96..2fe55c12 100644 --- a/test/paymasters/EnvelopePaymaster.t.sol +++ b/test/paymasters/EnvelopePaymaster.t.sol @@ -10,7 +10,7 @@ import {EnvelopePaymaster} from "../../src/paymasters/EnvelopePaymaster.sol"; import {EnvelopeLinks} from "../../src/envelope/EnvelopeLinks.sol"; import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; import {EnvelopeFeeAuthTestUtils} from "../envelope/EnvelopeFeeAuthTestUtils.sol"; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {EnvelopeEIP712Utils} from "../envelope/EnvelopeEIP712Utils.sol"; contract EnvelopePaymasterTest is Test { EnvelopeLinks public vault; @@ -75,14 +75,7 @@ contract EnvelopePaymasterTest is Test { uint256 deadline ) internal view returns (bytes memory) { bytes32 digest = EnvelopeFeeAuthTestUtils.feeAuthorizationDigest( - vault.ENVELOPE_SALT(), - address(vault), - request, - SENDER, - serviceFee, - gaslessFee, - gaslessSponsored, - deadline + address(vault), request, SENDER, serviceFee, gaslessFee, gaslessSponsored, deadline ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(BACKEND_PRIVKEY, digest); return abi.encodePacked(r, s, v); @@ -103,18 +96,15 @@ contract EnvelopePaymasterTest is Test { } function _signWithdrawal(uint256 depositIndex, address recipient) internal view returns (bytes memory) { - bytes32 digest = MessageHashUtils.toEthSignedMessageHash( - keccak256( - abi.encodePacked( - vault.ENVELOPE_SALT(), - block.chainid, - address(vault), - depositIndex, - recipient, - vault.OPEN_CLAIM_MODE() - ) - ) - ); + bytes32 digest = + EnvelopeEIP712Utils.claimDigest(address(vault), depositIndex, recipient, vault.OPEN_CLAIM_MODE()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); + return abi.encodePacked(r, s, v); + } + + function _signBoundWithdrawal(uint256 depositIndex, address recipient) internal view returns (bytes memory) { + bytes32 digest = + EnvelopeEIP712Utils.claimDigest(address(vault), depositIndex, recipient, vault.BOUND_CLAIM_MODE()); (uint8 v, bytes32 r, bytes32 s) = vm.sign(LINK_PRIVKEY, digest); return abi.encodePacked(r, s, v); } @@ -151,6 +141,39 @@ contract EnvelopePaymasterTest is Test { assertEq(magic, paymaster.validateAndPayForPaymasterTransaction.selector); assertEq(BOOTLOADER_FORMAL_ADDRESS.balance, bootloaderBalBefore + requiredETH); + assertEq(paymaster.gaslessAttemptsByLink(index), 1); + } + + function test_RevertIf_GaslessAttemptLimitReached() public { + uint256 index = _makeGaslessDeposit(1 ether); + bytes memory withdrawalSig = _signWithdrawal(index, RECIPIENT); + bytes memory data = abi.encodeCall(EnvelopeLinks.claim, (index, RECIPIENT, withdrawalSig)); + Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), data, 100_000, 1 gwei); + + // Exhaust all 3 allowed attempts + for (uint256 i = 0; i < paymaster.MAX_GASLESS_ATTEMPTS_PER_LINK(); i++) { + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + assertEq(paymaster.gaslessAttemptsByLink(index), paymaster.MAX_GASLESS_ATTEMPTS_PER_LINK()); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(abi.encodeWithSelector(EnvelopePaymaster.GaslessAttemptLimitReached.selector, index)); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_UnboundBoundRecipientGaslessOperationNotApproved() public { + uint256 index = _makeGaslessDeposit(1 ether); + bytes memory withdrawalSig = _signBoundWithdrawal(index, RECIPIENT); + bytes memory data = abi.encodeCall(EnvelopeLinks.claimAsBoundRecipient, (index, RECIPIENT, withdrawalSig)); + Transaction memory txn = _buildTransaction(RECIPIENT, address(vault), data, 100_000, 1 gwei); + + assertFalse(vault.isValidGaslessOperation(RECIPIENT, data)); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(EnvelopePaymaster.EnvelopeGaslessOperationNotApproved.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + assertEq(paymaster.gaslessAttemptsByLink(index), 0); } function test_RevertIf_DestinationIsNotEnvelopeLinks() public {