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..10c4685d --- /dev/null +++ b/src/envelope/V4/EnvelopeVault.sol @@ -0,0 +1,881 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Modified by Nodle (2026-05-12) — see src/envelope/doc/PeanutV4.md ("Vendoring patches +// applied at import") and the git history of this file for the full patch set. The +// upstream source is peanutprotocol/peanut-contracts@main; the full GNU GPL v3 license +// text is bundled at src/envelope/V4/LICENSE-GPL. +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 + ); + } + + 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; + } + + /** + * 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..677e25f7 --- /dev/null +++ b/src/envelope/doc/EnvelopeApprovalPaymaster.md @@ -0,0 +1,339 @@ +# 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.makeCustomDeposit`, `vault.withdrawDeposit`, etc. | + +Mode B is the "single point we top up" pattern: instead of funding the operator's hot wallet directly, fund the paymaster and let the operator submit txs gaslessly. Bounded daily spend (QuotaControl), bounded per-tx spend (`maxEthPerTx`), and rotation just means flipping `isOperator` on a new EOA — no balance migration. + +## Deployment scope + +- **Authorization model** — signed grants from the operator. No on-chain user whitelist; the backend gates per request. +- **No token allowlist** — the operator's grant is the only auth surface. Defense-in-depth comes from a hard per-tx ETH cap and a global daily quota. +- **Operator-driven UX** — the user never sees the EIP-712 grant; only the operator's backend does. + +Deployed on ZkSync Sepolia at [`0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268`](https://sepolia.explorer.zksync.io/address/0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268#contract). + +## 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 = "0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268"; +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..b7b79999 --- /dev/null +++ b/src/envelope/doc/EnvelopeVault.md @@ -0,0 +1,174 @@ +# 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 | +| `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. + +## Receiver hooks (S1 hardening) + +The vault implements `IERC721Receiver` + `IERC1155Receiver` because withdrawing NFTs goes through `safeTransferFrom` and the **recipient** may be a contract that needs the receiver-check; for the vault itself, the only legitimate calls to its own receiver hooks are when the vault itself is the operator (i.e. during withdraw). Direct deposits via `safeTransferFrom(user → vault, ...)` from outside this contract are explicitly rejected: + +```solidity +require(_operator == address(this), "DIRECT TRANSFERS NOT ALLOWED"); +``` + +This closes the upstream footgun where the hooks silently returned `bytes4(0)`, causing some tokens to accept the transfer and strand the asset in the vault. + +## EIP-3009 path + +For tokens that implement EIP-3009 (USDC and forks), the user signs `ReceiveWithAuthorization(...)` off-chain; the relayer submits to the vault via `makeDepositWithAuthorization` (or `makeCustomDeposit` with `_isGasless3009=true`). No pre-approval is needed — this is Path B. + +The vault re-derives the nonce as `keccak256(pubKey20, _nonce)` before calling the token's `receiveWithAuthorization` — this binds the EIP-3009 signature to the specific link, preventing front-running where another link's owner steals the deposit. + +## Events + +```solidity +event DepositEvent(uint256 indexed _index, uint8 indexed _contractType, + uint256 _amount, address indexed _senderAddress); +event WithdrawEvent(uint256 indexed _index, uint8 indexed _contractType, + uint256 _amount, address indexed _recipientAddress); +event MessageEvent(string message); // emitted once at deploy ("Hello World, have a nutty day!") +``` + +## Views + +```solidity +function getDepositCount() external view returns (uint256); +function getDeposit(uint256 _index) external view returns (Deposit memory); +function getAllDeposits() external view returns (Deposit[] memory); +function getAllDepositsForAddress(address _address) external view returns (Deposit[] memory); +function getSigner(bytes32 messageHash, bytes memory signature) public pure returns (address); +``` + +Note that `getAllDeposits` / `getAllDepositsForAddress` scale linearly with array length. Indexing services should listen to events instead. + +## Vendoring patches applied at import + +| | Patch | +|---|---| +| OZ v5 | `security/ReentrancyGuard.sol` → `utils/ReentrancyGuard.sol` | +| OZ v5 | `ECDSA.toEthSignedMessageHash` → `MessageHashUtils.toEthSignedMessageHash` | +| OZ v5 | `IL2ECO.transfer/transferFrom` → `SafeERC20.safeTransfer/safeTransferFrom` (cast IL2ECO → IERC20) | +| Hardening (S1) | `onERC{721,1155,1155Batch}Received` revert on non-self operator | +| Hardening (S3) | `MFA_AUTHORIZER` from `constant` to `immutable` constructor arg | +| Hardening (S4) | `_storeDeposit` rejects dual-zero pubKey20 + recipient | +| Bug fix | `_withdrawDeposit` L2ECO branch was sending to `senderAddress`; fixed to `_recipientAddress` | +| ZkSync | All raw IL2ECO calls switched to SafeERC20 | +| ZkSync | Explicit `override(IERC165)` on `supportsInterface` | +| Modern | Named imports throughout | +| Modern | Pragma pinned to `0.8.26` | + +## Test coverage + +| Suite | File | +|---|---| +| Vendored upstream tests | `test/envelope/EnvelopeVault.t.sol`, `Deposit.t.sol`, `SigWithdraw.t.sol`, `SenderWithdraw.t.sol`, `MFA.t.sol`, `RecipientBound.t.sol`, `Integration.t.sol`, `EnvelopeGasless.t.sol` | +| Hardening (S1–S4 + T1–T4) | `test/envelope/EnvelopeHardening.t.sol` | + +71 tests pass. diff --git a/src/envelope/doc/README.md b/src/envelope/doc/README.md new file mode 100644 index 00000000..9f96d26c --- /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` | [`0x37dbCC12784727AdE2A78AFbcb686b0eb915574f`](https://sepolia.explorer.zksync.io/address/0x37dbCC12784727AdE2A78AFbcb686b0eb915574f#contract) | +| `EnvelopeBatcher` | [`0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1`](https://sepolia.explorer.zksync.io/address/0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1#contract) | +| `EnvelopeApprovalPaymaster` | [`0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268`](https://sepolia.explorer.zksync.io/address/0x842fe6fC8358c5eeBf5b7dA4E8546DB3d8ADA268#contract) | + +## Three deposit paths + +The vault itself supports three ways a sender can fund a link: + +| Path | Trigger | Approval | Gas sponsor needed | +|---|---|---|---| +| **A** — ETH | `msg.value` directly | n/a | no | +| **B** — EIP-2612 / EIP-3009 token | `makeDepositWithAuthorization` (EIP-3009) | embedded in signature | no | +| **C** — anything else (ERC-20 w/o permit, ERC-721, ERC-1155) | `makeCustomDeposit` after user calls `token.approve` / `setApprovalForAll` | separate approval tx | **yes** — see [EnvelopeApprovalPaymaster](./EnvelopeApprovalPaymaster.md) | + +## Deploy + +| Script | Purpose | +|---|---| +| `hardhat-deploy/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/`) | **90** (56 vendored + 11 hardening + 23 edge cases) | +| `EnvelopeApprovalPaymaster` (`test/paymasters/EnvelopeApprovalPaymaster.t.sol`) | **27** (19 Mode A + 7 Mode B + 1 EIP-1271 contract signer) | +| Other paymasters (unchanged) | 102 | +| Rest of repo | 747 | +| **Total** | **966** | 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/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"); + } +}