Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
34180c0
feat: vendor Peanut Protocol V4.4 under OpenZeppelin v5
Douglasacost May 12, 2026
1f677da
fix(peanut): use call instead of transfer for ETH seeding in router test
Douglasacost May 12, 2026
12a77ce
refactor(peanut): security hardening + ZkSync-aligned modernization
Douglasacost May 12, 2026
bc2ae42
feat(peanut): add ZkSync Era deploy script
Douglasacost May 12, 2026
e15a351
feat(peanut): switch deploy to Hardhat-zksync (canonical for this repo)
Douglasacost May 13, 2026
265c5c8
refactor(peanut): move mocks out of src/ into test/peanut/mocks/
Douglasacost May 13, 2026
051edcf
feat(paymasters): PeanutApprovalPaymaster — sponsor approve/setApprov…
Douglasacost May 13, 2026
040626c
refactor(paymasters): PeanutApprovalPaymaster inherits BasePaymaster
Douglasacost May 13, 2026
cc12351
refactor(paymasters): split PeanutApprovalPaymaster validation into s…
Douglasacost May 13, 2026
2b2f0c6
refactor(paymasters): rename Peanut→Envelope, drop token allowlist, a…
Douglasacost May 13, 2026
15599a0
docs(peanut): spec sheet per contract under src/peanut/doc/
Douglasacost May 13, 2026
d2b2c12
test(peanut): regression for upstream L2ECO withdrawal bug (T5)
Douglasacost May 13, 2026
149e192
chore(spellcheck): whitelist peanut/envelope vocabulary, fix own typos
Douglasacost May 13, 2026
09812ee
chore(peanut): fix upstream typos in vendored copy
Douglasacost May 13, 2026
f25eca5
test(peanut): adapt to repo style + add edge-case coverage
Douglasacost May 13, 2026
8fe7adb
chore(lint): exclude vendored peanut sources from solhint
Douglasacost May 13, 2026
fb450f5
fix(peanut): address PR review findings
Douglasacost May 13, 2026
3a76b01
feat(paymasters): add Mode B — operator-EOA + allowlisted-target spon…
Douglasacost May 14, 2026
0db2d21
docs(peanut): catch up specs to Mode B + earlier hardening
Douglasacost May 14, 2026
db71727
feat(deploy): seed Mode B from DeployEnvelopePaymaster + update Sepol…
Douglasacost May 14, 2026
1fcbcf0
chore(peanut): remove unused PeanutV4Router
Douglasacost May 14, 2026
c47e402
docs(peanut): drop residual router note from README
Douglasacost May 14, 2026
be97cd1
chore(license): GPL-3.0-or-later compliance for vendored Peanut sources
Douglasacost May 14, 2026
9989954
refactor(peanut): rename contract symbols Peanut → Envelope (trademar…
Douglasacost May 14, 2026
8cf63eb
chore(peanut): cosmetic Peanut → Envelope cleanup (env vars, test + d…
Douglasacost May 14, 2026
74db895
chore(envelope): rename directories src/peanut → src/envelope, test/p…
Douglasacost May 14, 2026
d0af357
chore(envelope): full Peanut → Envelope sweep (rename DeployPeanut, t…
Douglasacost May 14, 2026
9bca40a
chore(envelope): rename source files PeanutV4.4.sol → EnvelopeVault.s…
Douglasacost May 14, 2026
7988c82
chore(envelope): update Solidity pragma version to ^0.8.26 in Envelop…
Douglasacost May 14, 2026
5f9f8ca
docs(envelope): refresh Sepolia addresses after post-rebrand redeploy
Douglasacost May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
9 changes: 9 additions & 0 deletions .solhintignore
Original file line number Diff line number Diff line change
@@ -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
103 changes: 103 additions & 0 deletions hardhat-deploy/DeployEnvelope.ts
Original file line number Diff line number Diff line change
@@ -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.");
}
};
183 changes: 183 additions & 0 deletions hardhat-deploy/DeployEnvelopePaymaster.ts
Original file line number Diff line number Diff line change
@@ -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} }`,
);
};
25 changes: 24 additions & 1 deletion hardhat.config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: {
Expand Down Expand Up @@ -54,6 +76,7 @@ const config: HardhatUserConfig = {
},
paths: {
sources: "src",
deployPaths: ["hardhat-deploy"],
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
Expand Down
Loading
Loading