diff --git a/.cspell.json b/.cspell.json index 77c5ae9b..e29af002 100644 --- a/.cspell.json +++ b/.cspell.json @@ -101,6 +101,27 @@ "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" ] } diff --git a/.solhintignore b/.solhintignore new file mode 100644 index 00000000..f139a58c --- /dev/null +++ b/.solhintignore @@ -0,0 +1,9 @@ +# 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/V4/EnvelopeBatcher.sol diff --git a/hardhat-deploy/DeployEnvelope.ts b/hardhat-deploy/DeployEnvelope.ts new file mode 100644 index 00000000..7fd63abe --- /dev/null +++ b/hardhat-deploy/DeployEnvelope.ts @@ -0,0 +1,103 @@ +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 Envelope (vendored Peanut V4.4) suite on ZkSync Era. + * + * Required environment variables: + * - 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. + * - ENVELOPE_DEPLOY_BATCHER: "true"|"false". Default "true". Deploys EnvelopeBatcher. + * + * Usage: + * yarn hardhat deploy-zksync \ + * --script DeployEnvelope.ts \ + * --network zkSyncSepoliaTestnet + */ +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 deployBatcher = (process.env.ENVELOPE_DEPLOY_BATCHER ?? "true").toLowerCase() === "true"; + + 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("ECO Token: ", ecoToken); + console.log("MFA Authorizer: ", mfaAuthorizer); + console.log("Deploy Batcher: ", deployBatcher); + console.log(""); + + // 1. Vault — required. + const vault = await deployContract(deployer, "EnvelopeVault", [ecoToken, mfaAuthorizer]); + const vaultAddr = await vault.getAddress(); + + // 2. Batcher — optional. + let batcherAddr: string | undefined; + if (deployBatcher) { + const batcher = await deployContract(deployer, "EnvelopeBatcher", []); + batcherAddr = await batcher.getAddress(); + } + + console.log(""); + console.log("=== Deployment Complete ==="); + console.log("EnvelopeVault: ", vaultAddr); + if (batcherAddr) console.log("EnvelopeBatcher: ", batcherAddr); + console.log(""); + + // Verification + console.log("=== Verifying Contracts ==="); + try { + console.log("Verifying EnvelopeVault..."); + await hre.run("verify:verify", { + address: vaultAddr, + contract: "src/envelope/V4/EnvelopeVault.sol:EnvelopeVault", + constructorArguments: [ecoToken, mfaAuthorizer], + }); + } catch (e: any) { + 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); + } + } + + console.log(""); + console.log("=== Add these to .env-test: ==="); + console.log(`ENVELOPE_VAULT=${vaultAddr}`); + if (batcherAddr) console.log(`ENVELOPE_BATCHER=${batcherAddr}`); + + 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."); + } +}; diff --git a/hardhat-deploy/DeployEnvelopePaymaster.ts b/hardhat-deploy/DeployEnvelopePaymaster.ts new file mode 100644 index 00000000..88a99722 --- /dev/null +++ b/hardhat-deploy/DeployEnvelopePaymaster.ts @@ -0,0 +1,183 @@ +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/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/src/envelope/V4/EnvelopeBatcher.sol b/src/envelope/V4/EnvelopeBatcher.sol new file mode 100644 index 00000000..7097dd76 --- /dev/null +++ b/src/envelope/V4/EnvelopeBatcher.sol @@ -0,0 +1,251 @@ +// 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), + false, // not EIP-3009 + "" // not EIP-3009 + ); + } + 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 new file mode 100644 index 00000000..0a5b9d71 --- /dev/null +++ b/src/envelope/V4/EnvelopeVault.sol @@ -0,0 +1,965 @@ +// 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. +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"; +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 {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; + + 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 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 DOMAIN_SEPARATOR; // initialized in the constructor + + 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. + address public immutable MFA_AUTHORIZER; + + 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 immutable ecoAddress; // address of the ECO token (set at deploy, never changes) + + // 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); + + /// @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: "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 + ) + ); + } + + 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(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 + */ + 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 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). + * @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 + ); + } + + /** + * 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, + uint256 _amount, + uint256 _tokenId, + address _pubKey20, + address _onBehalfOf, + bool _requiresMFA, + 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({ + 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 + // 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; + } + + /** + * 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. + */ + 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 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 + ); + } + + /// @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) + { + require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + 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) { + require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + 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) { + require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); + 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( + ENVELOPE_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( + ENVELOPE_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 + 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 + * @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 + uint256 scaledAmount = _deposit.amount / IL2ECO(_deposit.tokenAddress).linearInflationMultiplier(); + IERC20(_deposit.tokenAddress).safeTransfer(_deposit.senderAddress, scaledAmount); + } + + 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/envelope/V4/LICENSE-GPL b/src/envelope/V4/LICENSE-GPL new file mode 100644 index 00000000..96bd6eda --- /dev/null +++ b/src/envelope/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/envelope/doc/EnvelopeApprovalPaymaster.md b/src/envelope/doc/EnvelopeApprovalPaymaster.md new file mode 100644 index 00000000..298a9ee1 --- /dev/null +++ b/src/envelope/doc/EnvelopeApprovalPaymaster.md @@ -0,0 +1,350 @@ +# 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/EnvelopeBatcher.md b/src/envelope/doc/EnvelopeBatcher.md new file mode 100644 index 00000000..75995839 --- /dev/null +++ b/src/envelope/doc/EnvelopeBatcher.md @@ -0,0 +1,92 @@ +# 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 new file mode 100644 index 00000000..50acfe0a --- /dev/null +++ b/src/envelope/doc/EnvelopeVault.md @@ -0,0 +1,194 @@ +# EnvelopeVault — link-based asset vault + +`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. + +## 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 | +|---|---|---| +| `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 + +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. 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`. + +### `_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(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)` | + +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. + +## 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: + +```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` | +| 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 + T5) | `test/envelope/EnvelopeHardening.t.sol` | +| Edge cases | `test/envelope/EnvelopeEdgeCases.t.sol` | +| `makeCustomDepositFrom` | `test/envelope/MakeCustomDepositFrom.t.sol` | + +103 tests pass. diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md new file mode 100644 index 00000000..26383755 --- /dev/null +++ b/src/envelope/doc/README.md @@ -0,0 +1,79 @@ +# 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. + +## 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) | +| `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/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 + +This subtree mixes licenses; the repo-root `LICENSE` (Clear BSD) doesn't 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 | +| `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 | + +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 + +- **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. + +## Deployed on ZkSync Sepolia (chain 300) + +| | Address | +|---|---| +| `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) | + +## 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. + +## 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) | +| Other paymasters (unchanged) | 102 | +| Rest of repo | 747 | +| **Total** | **979** | diff --git a/src/envelope/util/IEIP3009.sol b/src/envelope/util/IEIP3009.sol new file mode 100644 index 00000000..dd3d362a --- /dev/null +++ b/src/envelope/util/IEIP3009.sol @@ -0,0 +1,65 @@ +// 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/IL2ECO.sol b/src/envelope/util/IL2ECO.sol new file mode 100644 index 00000000..cdb3dd24 --- /dev/null +++ b/src/envelope/util/IL2ECO.sol @@ -0,0 +1,8 @@ +// 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/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/EnvelopeApprovalPaymaster.sol b/src/paymasters/EnvelopeApprovalPaymaster.sol new file mode 100644 index 00000000..1d66eb83 --- /dev/null +++ b/src/paymasters/EnvelopeApprovalPaymaster.sol @@ -0,0 +1,234 @@ +// 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/Deposit.t.sol b/test/envelope/Deposit.t.sol new file mode 100644 index 00000000..1edf8e42 --- /dev/null +++ b/test/envelope/Deposit.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +////////////////////////////// +// A few integration tests for the EnvelopeVault contract +////////////////////////////// + +import "forge-std/Test.sol"; +import "../../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"; + +contract EnvelopeVaultDepositTest is Test, ERC1155Holder, ERC721Holder { + EnvelopeVault public vault; + 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"); + vault = new EnvelopeVault(address(0), 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); + vault.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(vault), amount); + // 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); + } + + // 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(vault), tokenId); + vault.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(vault), true); + vault.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); + } +} diff --git a/test/envelope/EnvelopeBatcher.t.sol b/test/envelope/EnvelopeBatcher.t.sol new file mode 100644 index 00000000..bf9f856b --- /dev/null +++ b/test/envelope/EnvelopeBatcher.t.sol @@ -0,0 +1,230 @@ +// 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(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/EnvelopeEdgeCases.t.sol b/test/envelope/EnvelopeEdgeCases.t.sol new file mode 100644 index 00000000..fc2b8fd2 --- /dev/null +++ b/test/envelope/EnvelopeEdgeCases.t.sol @@ -0,0 +1,362 @@ +// 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_* +// 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"; +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 `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 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 { + vault = 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(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) { + revert("REENTRANCY GUARD MISSING"); + } catch { + // expected — guard caught it + } + } + } +} + +contract EnvelopeEdgeCasesTest is Test, ERC721Holder, ERC1155Holder { + EnvelopeVault public vault; + EnvelopeBatcher 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); + vault = new EnvelopeVault(address(0), address(0)); + batcher = new EnvelopeBatcher(); + 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( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + idx, + recipient, + vault.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 vault.makeDeposit{value: amount}(address(0), 0, amount, 0, LINK_PUBKEY20); + } + + // ── EnvelopeVault deposit input validation ────────────────────────────────── + + function test_RevertWhen_DepositInvalidContractType() public { + // _pullTokensViaApproval rejects contractType >= 5. + vm.expectRevert("INVALID CONTRACT TYPE"); + 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"); + 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(vault), 1); + vm.expectRevert("AMOUNT MUST BE 1 FOR ERC721"); + 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"); + vault.withdrawDeposit(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); + + vm.expectRevert("DEPOSIT ALREADY WITHDRAWN"); + vault.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"); + vault.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( + vault.ENVELOPE_SALT(), + block.chainid, + address(vault), + idx, + ALICE, + vault.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"); + vault.withdrawDepositAsRecipient(idx, ALICE, sig); + } + + 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, "" + ); + // 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"); + vault.withdrawDeposit(idx, BOB, sig); + } + + 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, "" + ); + vm.expectRevert("TOO EARLY TO RECLAIM"); + vault.withdrawDepositSender(idx); + + vm.warp(reclaimAfter + 1); + 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"); + vault.withdrawDepositSender(idx); + } + + function test_RevertWhen_MFADepositWithoutMFASignature() public { + // vault is deployed with MFA_AUTHORIZER == 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"); + vault.withdrawDeposit(idx, ALICE, sig); + } + + // ── EnvelopeVault views ───────────────────────────────────────────────────── + + function test_GetAllDepositsForAddressFiltersBySender() public { + _depositEth(1); + _depositEth(1); + // Same sender (address(this)) made both deposits. + EnvelopeVault.Deposit[] memory mine = vault.getAllDepositsForAddress(address(this)); + assertEq(mine.length, 2); + + // Different sender → empty. + EnvelopeVault.Deposit[] memory aliceDeposits = vault.getAllDepositsForAddress(ALICE); + assertEq(aliceDeposits.length, 0); + } + + function test_DepositCountTracksArrayLength() public { + assertEq(vault.getDepositCount(), 0); + _depositEth(1); + _depositEth(1); + _depositEth(1); + assertEq(vault.getDepositCount(), 3); + } + + // ── EnvelopeVault reentrancy ──────────────────────────────────────────────── + + function test_NonReentrantBlocksReentryFromMaliciousToken() public { + ReentrantToken evil = new ReentrantToken(); + evil.mint(address(this), 100); + 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); + bytes memory sig = _signWithdrawal(idx, ALICE, LINK_PRIV); + + // Arm the token to reenter inside its _update during the outgoing safeTransfer. + 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. + vault.withdrawDeposit(idx, ALICE, sig); + assertEq(evil.balanceOf(ALICE), 100); + assertTrue(evil.attempted(), "reentrancy attempt should have run"); + } + + // ── EnvelopeBatcher 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); + // 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(vault), 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(vault), 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 + ); + } + + 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(vault), 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); + } + + function test_BatchZeroLengthDepositsIsNoop() public { + address[] memory pubKeys = new address[](0); + uint256[] memory ids = batcher.batchMakeDeposit(address(vault), address(0), 0, 0, 0, pubKeys); + 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 new file mode 100644 index 00000000..949bb2d3 --- /dev/null +++ b/test/envelope/EnvelopeGasless.t.sol @@ -0,0 +1,214 @@ +// 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(0)); + } + + 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, + 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); + + EnvelopeVault.GaslessReclaim memory reclaimRequest = EnvelopeVault.GaslessReclaim(depositIndex); + + if (bytes(expectRevert).length > 0) { + vm.expectRevert(bytes(expectRevert)); + } + + 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, ""); + + // 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 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. + _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 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("INVALID SIGNATURE"); + 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"); + + // 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 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 & 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/EnvelopeHardening.t.sol b/test/envelope/EnvelopeHardening.t.sol new file mode 100644 index 00000000..241f9929 --- /dev/null +++ b/test/envelope/EnvelopeHardening.t.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +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) + +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 EnvelopeHardeningTest is Test, ERC721Holder, ERC1155Holder { + EnvelopeVault public vault; + ERC721Mock public erc721; + ERC1155Mock public erc1155; + + address constant ALICE = address(0x8fd379246834eac74B8419FfdA202CF8051F7A03); + address constant PUBKEY20 = address(0xaBC5211D86a01c2dD50797ba7B5b32e3C1167F9f); + + function setUp() public { + vault = new EnvelopeVault(address(0), address(0)); + erc721 = new ERC721Mock(); + erc1155 = new ERC1155Mock(); + } + + receive() external payable {} + + // ── T1 ───────────────────────────────────────────────────────────────── + // 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. + + function test_T1_directERC721TransferReverts() public { + erc721.mint(address(this), 42); + vm.expectRevert("DIRECT TRANSFERS NOT ALLOWED"); + 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(vault), 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(vault), ids, amounts, ""); + } + + // ── T2 ───────────────────────────────────────────────────────────────── + // 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.vault.mfa-test-signer")); + address mfaSigner = vm.addr(mfaPrivKey); + + 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.vault.deposit-key")); + address depositSigner = vm.addr(depositPrivKey); + + 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( + keccak256( + abi.encodePacked( + nodleVault.ENVELOPE_SALT(), + block.chainid, + address(nodleVault), + idx, + address(this), + nodleVault.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( + nodleVault.ENVELOPE_SALT(), + block.chainid, + address(nodleVault), + idx, + address(this) + ) + ) + ); + (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); + } + + function test_T2_zeroMfaAuthorizerRejectsAllMfaWithdrawals() public { + // vault deployed with mfaAuthorizer = address(0). Any MFA withdrawal must fail. + 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) + ); + + // 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); + } + + // ── 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 +/// 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/EnvelopeVault.t.sol b/test/envelope/EnvelopeVault.t.sol new file mode 100644 index 00000000..717d4e90 --- /dev/null +++ b/test/envelope/EnvelopeVault.t.sol @@ -0,0 +1,137 @@ +// 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/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; + +contract EnvelopeVaultTest is Test { + EnvelopeVault public vault; + 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(); + vault = 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 EnvelopeVault to spend tokens + testToken.approve(address(vault), 1000); + testToken721.setApprovalForAll(address(vault), true); + // testToken1155.setApprovalForAll(address(vault), true); + } + + function testContractCreation() public { + assertTrue(address(vault) != address(0), "Contract creation failed"); + } + + 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"); + assertEq(vault.getDepositCount(), 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); + + // Deposit was made on behalf of other address, so we can't withdraw :((( + vm.expectRevert("NOT THE SENDER"); + 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"); + 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 + vault.withdrawDepositSender(depositIndex); + + // Check that the contract has the correct balance + 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 new file mode 100644 index 00000000..985cda1c --- /dev/null +++ b/test/envelope/Integration.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +////////////////////////////// +// A few integration tests for the EnvelopeVault contract +////////////////////////////// + +import "forge-std/Test.sol"; +import "../../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"; + +contract EnvelopeVaultIntegrationTest is Test, ERC1155Holder, ERC721Holder { + EnvelopeVault public vault; + 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"); + vault = new EnvelopeVault(address(0), 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(vault.getDepositCount(), 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); + assertEq(depositIdx, 0); // deposit index 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 + 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 + } + + 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(vault), amount); + assertEq(testToken.balanceOf(address(this)), amount); // contract token balance invariant + uint256 depositIdx = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + assertEq(depositIdx, 0); // deposit index 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 + 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 + } + + // Test for ERC721 Token + function testIntegrationERC721SenderWithdraw(uint64 tokenId) public { + // setup + testToken721.mint(address(this), tokenId); + testToken721.approve(address(vault), tokenId); + + // invariant checks + assertEq(vault.getDepositCount(), 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); + + // invariant checks + assertEq(depositIdx, 0); + 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 + vault.withdrawDepositSender(depositIdx); + + // invariant checks + assertEq(vault.getDepositCount(), 1); + assertEq(testToken721.ownerOf(tokenId), address(this)); + assertEq(testToken721.balanceOf(address(vault)), 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(vault), true); + assertEq(testToken1155.balanceOf(address(this), tokenId), amount); // contract token balance invariant + uint256 depositIdx = vault.makeDeposit(address(testToken1155), 3, amount, tokenId, PUBKEY20); + assertEq(depositIdx, 0); // deposit index 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 + 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 new file mode 100644 index 00000000..e1da7ff0 --- /dev/null +++ b/test/envelope/MFA.t.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +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; + + // 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 { + vault = new EnvelopeVault(address(0), LEGACY_MFA_AUTHORIZER); + } + + function testMFADeposit() public { + uint256 depositIndex = vault.makeSelflessMFADeposit{value: 1}( + 0x0000000000000000000000000000000000000000, + 0, + 1, + 0, + SAMPLE_ADDRESS, + 0x0000000000000000000000000000000000001234); + + 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), digest); + bytes memory signature = abi.encodePacked(r, s, v); + + // Withdrawing without authorization, so should fail + vm.expectRevert("REQUIRES AUTHORIZATION"); + vault.withdrawDeposit(depositIndex, address(this), signature); + + // Withdrawing with incorrect authorization signature + vm.expectRevert("WRONG MFA SIGNATURE"); + vault.withdrawMFADeposit(depositIndex, address(this), signature, signature); + + // Authorization is correct! Withdrawal has to be successful! + bytes memory authorization = hex"41caae599d693a31ea45aab95c8d166e9709cb450f1c76a2b06306ee61cb28b37ed0cad0d47d055580ce204ac9973b671a0970d02f9ee6572a9234f3130707321c"; + vault.withdrawMFADeposit(depositIndex, address(this), signature, authorization); + } + + receive () payable external {} +} \ No newline at end of file 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); + } +} diff --git a/test/envelope/RecipientBound.t.sol b/test/envelope/RecipientBound.t.sol new file mode 100644 index 00000000..d49c9514 --- /dev/null +++ b/test/envelope/RecipientBound.t.sol @@ -0,0 +1,82 @@ +// 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/ERC721Mock.sol"; +import "./mocks/ERC1155Mock.sol"; + +contract RecipientBoundTest is Test { + EnvelopeVault public vault; + 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(); + vault = new EnvelopeVault(address(0), address(0)); + testToken.mint(address(this), 1000); + testToken.approve(address(vault), 1000); + } + + function testRecipientBoundDeposit() public { + uint256 depositIndex = vault.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"); + vault.withdrawDeposit(depositIndex, address(this), bytes("")); + + 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 { + uint256 depositIndex = vault.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"); + vault.withdrawDepositSender(depositIndex); + + 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 new file mode 100644 index 00000000..a289ed3c --- /dev/null +++ b/test/envelope/SenderWithdraw.t.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../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"; + +contract TestSenderWithdrawEther is Test { + EnvelopeVault public vault; + // 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"); + vault = new EnvelopeVault(address(0), address(0)); + } + + function testSenderWithdrawEther(uint64 amount) public { + vm.assume(amount > 0); + uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, PUBKEY20); + + // Withdraw the deposit + vault.withdrawDepositSender(depositIdx); + } +} + +contract TestSenderWithdrawErc20 is Test { + EnvelopeVault public vault; + 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"); + 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(vault), 2 ** 130); + + // Make a deposit + uint256 amount = 2 ** 128; + _depositIdx = vault.makeDeposit(address(testToken), 1, amount, 0, PUBKEY20); + } + + function testSenderWithdrawErc20() public { + // Withdraw the deposit + vault.withdrawDepositSender(_depositIdx); + } +} + +contract TestSenderWithdrawErc721 is Test, ERC721Holder { + EnvelopeVault public vault; + 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"); + 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(vault), _tokenId); + + // Make a deposit + _depositIdx = vault.makeDeposit(address(testToken), 2, 1, _tokenId, PUBKEY20); + } + + function testSenderWithdrawErc721() public { + // Withdraw the deposit + vault.withdrawDepositSender(_depositIdx); + } +} + +contract TestSenderWithdrawErc1155 is Test, ERC1155Holder { + EnvelopeVault public vault; + 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"); + 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(vault), true); + + // Make a deposit + _depositIdx = vault.makeDeposit(address(testToken), 3, _tokenAmount, _tokenId, PUBKEY20); + } + + function testSenderWithdrawErc1155() public { + // Withdraw the deposit + vault.withdrawDepositSender(_depositIdx); + } +} diff --git a/test/envelope/SigWithdraw.t.sol b/test/envelope/SigWithdraw.t.sol new file mode 100644 index 00000000..ba551091 --- /dev/null +++ b/test/envelope/SigWithdraw.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../src/envelope/V4/EnvelopeVault.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"; + +contract TestSigWithdrawEther is Test { + EnvelopeVault public vault; + + // 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"); + vault = new EnvelopeVault(address(0), address(0)); + } + + // 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); + + // Can't use withdrawDepositAsRecipient + vm.expectRevert("NOT THE RECIPIENT"); + vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureAnybody); + + // Anybody can withdraw + vault.withdrawDeposit(depositIdx, _recipientAddress, signatureAnybody); + } + + function testWithdrawDepositAsRecipient(uint64 amount) public { + vm.assume(amount > 0); + uint256 depositIdx = vault.makeDeposit{value: amount}(address(0), 0, amount, 0, _pubkey20); + + // Can't use pure withdrawDeposit + vm.expectRevert("WRONG SIGNATURE"); + vault.withdrawDeposit(depositIdx, _recipientAddress, signatureRecipient); + + // Only the recipient is able to withdraw via withdrawDepositAsRecipient + vm.expectRevert("NOT THE RECIPIENT"); + vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); + + vm.prank(_recipientAddress); // Withdraw! + vault.withdrawDepositAsRecipient(depositIdx, _recipientAddress, signatureRecipient); + } +} diff --git a/test/envelope/mocks/ECRecover.sol b/test/envelope/mocks/ECRecover.sol new file mode 100644 index 00000000..7cba128f --- /dev/null +++ b/test/envelope/mocks/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.26; + +/** + * @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/test/envelope/mocks/EIP3009Implementation.sol b/test/envelope/mocks/EIP3009Implementation.sol new file mode 100644 index 00000000..4165a392 --- /dev/null +++ b/test/envelope/mocks/EIP3009Implementation.sol @@ -0,0 +1,42 @@ +// 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 new file mode 100644 index 00000000..9eda8ab9 --- /dev/null +++ b/test/envelope/mocks/EIP3009Internals.sol @@ -0,0 +1,101 @@ +/** + * 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/EIP712.sol b/test/envelope/mocks/EIP712.sol new file mode 100644 index 00000000..c023ca75 --- /dev/null +++ b/test/envelope/mocks/EIP712.sol @@ -0,0 +1,37 @@ +/** + * SPDX-License-Identifier: MIT + * + * Copyright (c) 2018-2020 CENTRE SECZ + */ + +pragma solidity 0.8.26; + +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/test/envelope/mocks/EIP712Domain.sol b/test/envelope/mocks/EIP712Domain.sol new file mode 100644 index 00000000..5bee7047 --- /dev/null +++ b/test/envelope/mocks/EIP712Domain.sol @@ -0,0 +1,15 @@ +/** + * SPDX-License-Identifier: MIT + * + * Copyright (c) 2018-2020 CENTRE SECZ + */ + +pragma solidity 0.8.26; + +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/test/envelope/mocks/ERC1155Mock.sol b/test/envelope/mocks/ERC1155Mock.sol new file mode 100644 index 00000000..e6a0890c --- /dev/null +++ b/test/envelope/mocks/ERC1155Mock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +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/test/envelope/mocks/ERC20Mock.sol b/test/envelope/mocks/ERC20Mock.sol new file mode 100644 index 00000000..8e08306f --- /dev/null +++ b/test/envelope/mocks/ERC20Mock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +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 { + constructor() ERC20("ERC20Mock", "20MOCK") { + this; + } + + function mint(address account, uint256 amount) external { + _mint(account, amount); + } +} diff --git a/test/envelope/mocks/ERC721Mock.sol b/test/envelope/mocks/ERC721Mock.sol new file mode 100644 index 00000000..394799fa --- /dev/null +++ b/test/envelope/mocks/ERC721Mock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +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/test/envelope/mocks/L2ECOMock.sol b/test/envelope/mocks/L2ECOMock.sol new file mode 100644 index 00000000..d920e767 --- /dev/null +++ b/test/envelope/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 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); + } +} diff --git a/test/envelope/mocks/SampleSCW.sol b/test/envelope/mocks/SampleSCW.sol new file mode 100644 index 00000000..48a069cd --- /dev/null +++ b/test/envelope/mocks/SampleSCW.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.26; + +// 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/test/paymasters/EnvelopeApprovalPaymaster.t.sol b/test/paymasters/EnvelopeApprovalPaymaster.t.sol new file mode 100644 index 00000000..ce79ce0c --- /dev/null +++ b/test/paymasters/EnvelopeApprovalPaymaster.t.sol @@ -0,0 +1,590 @@ +// 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"); + } +}