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");
+ }
+}