Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 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
1d58c43
feat(envelope): add makeCustomDepositFrom for operator-orchestrated d…
Douglasacost May 14, 2026
b9a3937
chore(spellcheck): whitelist 'funder' (used in EnvelopeVault docs + t…
Douglasacost May 18, 2026
1b55826
revert: drop EnvelopeApprovalPaymaster + makeCustomDepositFrom
Douglasacost May 18, 2026
ff861c4
docs(envelope): refresh Sepolia vault address after redeploy
Douglasacost May 18, 2026
6f5a7ed
refactor(envelope): remove ECO logic, custom errors, consistent naming
aliXsed May 19, 2026
24263a6
feat(envelope): add Ownable2Step + backend-signed fee model
aliXsed May 19, 2026
1fbd6a2
feat(envelope): replace IEIP3009 gasless with sponsored claim/reclaim…
aliXsed May 19, 2026
2da9908
feat(envelope): add deadline to MFA signatures
aliXsed May 19, 2026
927783a
feat(envelope): move gasless flow to paymaster
aliXsed May 19, 2026
17ccc38
feat(envelope): move batching into vault
aliXsed May 19, 2026
77ce0bd
feat(envelope): support sponsored gasless claims
aliXsed May 20, 2026
3331036
refactor(envelope): flatten directory structure, strip Peanut ASCII h…
aliXsed May 20, 2026
5c08dc8
refactor(envelope): rename API for clarity — deposit→link, withdraw→c…
aliXsed May 20, 2026
f177323
refactor(envelope): rename contract EnvelopeVault → EnvelopeLinks
aliXsed May 20, 2026
f2bfe38
test(envelope): add 61 coverage tests for EnvelopeLinks and EnvelopeP…
aliXsed May 20, 2026
0d384d8
refactor(envelope): split Link struct to reduce stack pressure; expan…
aliXsed May 20, 2026
8617f38
feat(envelope): add forge zkSync deployment path
aliXsed May 20, 2026
94fdceb
docs(envelope): document approve-once-use-forever pattern for EOA users
aliXsed May 20, 2026
81d40fa
Security/envelope hardening (#116)
aliXsed May 21, 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
34 changes: 33 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
"src/swarms/doc/iso3166-2"
],
"ignoreWords": [
"AMPL",
"NODL",
"Nodle",
"Typehashes",
"depin",
"contentsign",
"matterlabs",
Expand Down Expand Up @@ -74,6 +76,7 @@
"SLOAD",
"Bitmask",
"mstore",
"mload",
"MBOND",
"USCA",
"USNY",
Expand Down Expand Up @@ -101,6 +104,35 @@
"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",
"Reown",
"konlet",
"CBOR",
"Remy",
"remy",
"aabbcc",
"mfas",
"reqs"
]
}
15 changes: 15 additions & 0 deletions .solhintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Vendored Envelope (Peanut V4.4) sources — kept close to upstream

# (peanutprotocol/peanut-contracts@main) for diff parity. Upstream uses

# require-string style; converting to custom errors would diverge

# significantly without any security/correctness benefit.

#

# Our own code (EnvelopeApprovalPaymaster, anything authored in this repo)

# is NOT in this list and remains lint-clean.

src/envelope/EnvelopeLinks.sol
121 changes: 121 additions & 0 deletions hardhat-deploy/DeployEnvelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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_MFA_AUTHORIZER: Address authorized to sign MFA withdraw approvals.
* Defaults to 0x0 (MFA disabled — claimWithMFA reverts).
* Set to your backend signer for production MFA/fee authorizations.
* - ENVELOPE_OWNER: Owner/fee withdrawer. Defaults to deployer.
* - ENVELOPE_FEE_TOKEN: ERC20 token used for service/gasless fees (e.g. NODL).
* Defaults to 0x0 (non-zero fee authorizations disabled).
* - ENVELOPE_DEPLOY_PAYMASTER: "true"|"false". Default "false". Deploys EnvelopePaymaster.
* - ENVELOPE_PAYMASTER_ADMIN: Admin for EnvelopePaymaster. Defaults to deployer.
* - ENVELOPE_PAYMASTER_WITHDRAWER: ETH withdrawer for EnvelopePaymaster. Defaults to deployer.
*
* Usage:
* yarn hardhat deploy-zksync \
* --script DeployEnvelope.ts \
* --network zkSyncSepoliaTestnet
*/
module.exports = async function (hre: HardhatRuntimeEnvironment) {
const ZERO = "0x0000000000000000000000000000000000000000";

const rpcUrl = hre.network.config.url!;
const provider = new Provider(rpcUrl);
const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider);
const deployer = new Deployer(hre, wallet);

const mfaAuthorizer = process.env.ENVELOPE_MFA_AUTHORIZER ?? ZERO;
const envelopeOwner = process.env.ENVELOPE_OWNER ?? wallet.address;
const feeToken = process.env.ENVELOPE_FEE_TOKEN ?? ZERO;
const deployPaymaster =
(process.env.ENVELOPE_DEPLOY_PAYMASTER ?? "false").toLowerCase() === "true";
const paymasterAdmin = process.env.ENVELOPE_PAYMASTER_ADMIN ?? wallet.address;
const paymasterWithdrawer =
process.env.ENVELOPE_PAYMASTER_WITHDRAWER ?? wallet.address;

console.log("=== Deploying Envelope on ZkSync ===");
console.log("Network: ", hre.network.name);
console.log("Deployer: ", wallet.address);
console.log("MFA Authorizer: ", mfaAuthorizer);
console.log("Owner: ", envelopeOwner);
console.log("Fee Token: ", feeToken);
console.log("Deploy Paymaster:", deployPaymaster);
console.log("");

// 1. Vault — required.
const vault = await deployContract(deployer, "EnvelopeLinks", [
mfaAuthorizer,
envelopeOwner,
feeToken,
]);
const vaultAddr = await vault.getAddress();

// 2. Paymaster — optional. Must be funded with ETH after deployment.
let paymasterAddr: string | undefined;
if (deployPaymaster) {
const envelopePaymaster = await deployContract(
deployer,
"EnvelopePaymaster",
[paymasterAdmin, paymasterWithdrawer, vaultAddr],
);
paymasterAddr = await envelopePaymaster.getAddress();
}

console.log("");
console.log("=== Deployment Complete ===");
console.log("EnvelopeLinks: ", vaultAddr);
if (paymasterAddr) console.log("EnvelopePaymaster: ", paymasterAddr);
console.log("");

// Verification
console.log("=== Verifying Contracts ===");
try {
console.log("Verifying EnvelopeLinks...");
await hre.run("verify:verify", {
address: vaultAddr,
contract: "src/envelope/EnvelopeLinks.sol:EnvelopeLinks",
constructorArguments: [mfaAuthorizer, envelopeOwner, feeToken],
});
} catch (e: any) {
console.log("Verification failed or already verified:", e.message);
}

if (paymasterAddr) {
try {
console.log("Verifying EnvelopePaymaster...");
await hre.run("verify:verify", {
address: paymasterAddr,
contract: "src/paymasters/EnvelopePaymaster.sol:EnvelopePaymaster",
constructorArguments: [paymasterAdmin, paymasterWithdrawer, vaultAddr],
});
} catch (e: any) {
console.log("Verification failed or already verified:", e.message);
}
}

console.log("");
console.log("=== Add these to .env-test: ===");
console.log(`ENVELOPE_VAULT=${vaultAddr}`);
if (paymasterAddr) console.log(`ENVELOPE_PAYMASTER=${paymasterAddr}`);

if (mfaAuthorizer === ZERO) {
console.log("");
console.log(
"NOTE: ENVELOPE_MFA_AUTHORIZER is 0x0 — claimWithMFA will always revert. Set it before allowing MFA-flagged links in production.",
);
}
};
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
35 changes: 31 additions & 4 deletions ops/verify_zksync_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,34 @@
# Used by --broadcast mode to map broadcast JSON entries to verifiable contracts.
# Extend this when adding new contract types to the deploy script.
CONTRACT_SOURCE_MAP = {
"EnvelopeLinks": "src/envelope/EnvelopeLinks.sol:EnvelopeLinks",
"EnvelopePaymaster": "src/paymasters/EnvelopePaymaster.sol:EnvelopePaymaster",
"ServiceProviderUpgradeable": "src/swarms/ServiceProviderUpgradeable.sol:ServiceProviderUpgradeable",
"FleetIdentityUpgradeable": "src/swarms/FleetIdentityUpgradeable.sol:FleetIdentityUpgradeable",
"SwarmRegistryUniversalUpgradeable": "src/swarms/SwarmRegistryUniversalUpgradeable.sol:SwarmRegistryUniversalUpgradeable",
"ERC1967Proxy": "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy",
"BondTreasuryPaymaster": "src/paymasters/BondTreasuryPaymaster.sol:BondTreasuryPaymaster",
}

# Some zkSync forge broadcasts record deployments as calls to ContractDeployer
# with unnamed `additionalContracts`. For those scripts, recover the deployed
# contract type by the deterministic deployment order inside the script.
BROADCAST_CONTRACT_SEQUENCE = {
"DeployEnvelopeZkSync.s.sol": [
"EnvelopeLinks",
"EnvelopePaymaster",
],
"DeploySwarmUpgradeableZkSync.s.sol": [
"ServiceProviderUpgradeable",
"ERC1967Proxy",
"FleetIdentityUpgradeable",
"ERC1967Proxy",
"SwarmRegistryUniversalUpgradeable",
"ERC1967Proxy",
"BondTreasuryPaymaster",
],
}


# ---------------------------------------------------------------------------
# Core logic
Expand Down Expand Up @@ -258,14 +279,20 @@ def parse_broadcast(broadcast_path: str) -> list:
with open(broadcast_path) as f:
data = json.load(f)

script_name = os.path.basename(os.path.dirname(os.path.dirname(broadcast_path)))
deployment_sequence = BROADCAST_CONTRACT_SEQUENCE.get(script_name, [])
unnamed_index = 0

results = []
for tx in data["transactions"]:
contract_name = tx.get("contractName", "")
address = tx.get("contractAddress", "")
if not address:
additional = tx.get("additionalContracts") or []
if additional:
address = additional[0].get("address", "")
additional = tx.get("additionalContracts") or []
if additional:
address = additional[0].get("address", "")
if not contract_name and additional and unnamed_index < len(deployment_sequence):
contract_name = deployment_sequence[unnamed_index]
unnamed_index += 1
if not address or not contract_name:
continue

Expand Down
98 changes: 98 additions & 0 deletions script/DeployEnvelopeZkSync.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: BSD-3-Clause-Clear

pragma solidity ^0.8.26;

import {Script, console} from "forge-std/Script.sol";

import {EnvelopeLinks} from "../src/envelope/EnvelopeLinks.sol";
import {EnvelopePaymaster} from "../src/paymasters/EnvelopePaymaster.sol";

/**
* @title DeployEnvelopeZkSync
* @notice Deploys EnvelopeLinks and, optionally, EnvelopePaymaster on ZkSync Era.
*
* @dev Do NOT use `forge script --verify` on ZkSync for these contracts.
* Deploy with forge, then run `ops/verify_zksync_contracts.py` against the
* generated broadcast JSON. See the Usage section below.
*
* Usage:
* forge script script/DeployEnvelopeZkSync.s.sol --rpc-url $L2_RPC --broadcast --zksync \
* --skip src/swarms/SwarmRegistryL1Upgradeable.sol \
* --skip test/SwarmRegistryL1.t.sol \
* --skip test/upgrade-demo/TestUpgradeOnAnvil.s.sol \
* --skip script/DeploySwarmUpgradeable.s.sol \
* --skip script/UpgradeSwarm.s.sol
*
* # After deployment, verify via the custom helper instead of forge --verify:
* python3 ops/verify_zksync_contracts.py \
* --broadcast broadcast/DeployEnvelopeZkSync.s.sol/324/run-latest.json \
* --verifier-url https://zksync2-mainnet-explorer.zksync.io/contract_verification
*
* Required environment variables:
* - DEPLOYER_PRIVATE_KEY
*
* Optional environment variables:
* - ENVELOPE_MFA_AUTHORIZER
* - ENVELOPE_OWNER
* - ENVELOPE_FEE_TOKEN
* - ENVELOPE_DEPLOY_PAYMASTER (true|false, default false)
* - ENVELOPE_PAYMASTER_ADMIN
* - ENVELOPE_PAYMASTER_WITHDRAWER
*/
contract DeployEnvelopeZkSync is Script {
address public vaultAddr;
address public paymasterAddr;

function run() external {
uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY");
address deployer = vm.addr(deployerPrivateKey);

address mfaAuthorizer = vm.envOr("ENVELOPE_MFA_AUTHORIZER", address(0));
address envelopeOwner = vm.envOr("ENVELOPE_OWNER", deployer);
address feeToken = vm.envOr("ENVELOPE_FEE_TOKEN", address(0));
bool deployPaymaster = vm.envOr("ENVELOPE_DEPLOY_PAYMASTER", false);
address paymasterAdmin = vm.envOr("ENVELOPE_PAYMASTER_ADMIN", deployer);
address paymasterWithdrawer = vm.envOr("ENVELOPE_PAYMASTER_WITHDRAWER", deployer);

console.log("=== Deploying Envelope ===");
console.log("Deployer:", deployer);
console.log("MFA Authorizer:", mfaAuthorizer);
console.log("Owner:", envelopeOwner);
console.log("Fee Token:", feeToken);
console.log("Deploy Paymaster:", deployPaymaster);
console.log("");

vm.startBroadcast(deployerPrivateKey);

EnvelopeLinks vault = new EnvelopeLinks(mfaAuthorizer, envelopeOwner, feeToken);
vaultAddr = address(vault);

if (deployPaymaster) {
EnvelopePaymaster paymaster = new EnvelopePaymaster(paymasterAdmin, paymasterWithdrawer, vaultAddr);
paymasterAddr = address(paymaster);
}

vm.stopBroadcast();

console.log("=== Deployment Complete ===");
console.log("EnvelopeLinks:", vaultAddr);
if (paymasterAddr != address(0)) {
console.log("EnvelopePaymaster:", paymasterAddr);
}
console.log("");
console.log("=== Env vars ===");
console.log("ENVELOPE_VAULT=%s", vaultAddr);
if (paymasterAddr != address(0)) {
console.log("ENVELOPE_PAYMASTER=%s", paymasterAddr);
}
if (mfaAuthorizer == address(0)) {
console.log("");
console.log("NOTE: MFA auth is 0x0.");
console.log("claimWithMFA stays disabled.");
}
console.log("");
console.log("=== Verification Note ===");
console.log("Use verify_zksync_contracts.py");
console.log("No forge script --verify");
}
}
Loading
Loading