Skip to content

feat(peanut): vendor Peanut V4.4 + EnvelopeApprovalPaymaster, deploy to ZkSync Sepolia#115

Open
Douglasacost wants to merge 17 commits into
mainfrom
feat/peanut-protocol
Open

feat(peanut): vendor Peanut V4.4 + EnvelopeApprovalPaymaster, deploy to ZkSync Sepolia#115
Douglasacost wants to merge 17 commits into
mainfrom
feat/peanut-protocol

Conversation

@Douglasacost
Copy link
Copy Markdown
Collaborator

@Douglasacost Douglasacost commented May 13, 2026

Summary

  • Vendors Peanut Protocol V4.4 (vault + batcher + router) from peanutprotocol/peanut-contracts@main under src/peanut/ with OZ-v5 / ZkSync-aligned patches.
  • Adds EnvelopeApprovalPaymaster — a Path-C gas sponsor that lets users submit gasless approve(envelope, …) / setApprovalForAll(envelope, …) txs, gated by an EIP-712 grant signed off-chain by the operator.
  • Wires Hardhat-zksync deploy scripts and ships the full stack to ZkSync Sepolia, all contracts verified on the explorer.
  • Adds src/peanut/doc/ with one spec markdown per contract.
  • Tests adapted to repo style (*.t.sol, named imports, serious-only comments) and significantly extended with 20 new edge-case + reentrancy tests on top of the vendored upstream suite.

What's in this branch

Commit Scope
34180c0 Vendor Peanut V4.4 under OZ v5 (vault, batcher, router, mocks, EIP-3009 chain, test suite)
1f677da <address>.transfer().call{value:}("") in router test (EraVM-safe)
12a77ce Security hardening + ZkSync alignment (S1–S4 + Z1–Z4 + M1–M3 + T1–T4)
bc2ae42 Initial Foundry deploy script (superseded)
e15a351 Switch to Hardhat-zksync deploy (canonical for this repo)
265c5c8 Move mocks out of src/ into test/peanut/mocks/
051edcf PeanutApprovalPaymaster first cut
040626c Paymaster inherits BasePaymaster (drop duplication)
cc12351 Split paymaster validation into helpers (fix zksolc stack-too-deep at verify)
2b2f0c6 Rename Peanut → Envelope, drop token allowlist, add per-tx ETH cap
15599a0 Spec sheet per contract under src/peanut/doc/
d2b2c12 T5 regression — pin the L2ECO withdrawal bug fix with two new tests
149e192 Spellcheck whitelist + fix own typos
09812ee Fix 12 upstream typos in vendored copy (comments + one filename)
f25eca5 Tests adapted to repo style + 20 new edge-case tests

Deployed on ZkSync Sepolia (chain 300)

Contract Address
PeanutV4 0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44
PeanutBatcherV4 0x1676cD8B90e2E4388C032ae5Eb4BA50166Bb3426
EnvelopeApprovalPaymaster 0xEE95bFF2240652e0f57aE3fcd57F87d85593c191

All verified on the ZkSync Sepolia explorer.

Security hardening applied at vendoring

  • S1onERC{721,1155,1155Batch}Received revert on non-self operator (upstream silently dropped direct transfers, stranding tokens)
  • S2PeanutRouter.withdrawFees uses SafeERC20.safeTransfer (USDT-compatible)
  • S3MFA_AUTHORIZER: hardcoded constant → immutable constructor arg
  • S4_storeDeposit rejects pubKey20 == 0 && recipient == 0 (would otherwise be claimable by anyone)
  • T5 upstream bug fix_withdrawDeposit L2ECO branch was sending to _deposit.senderAddress instead of _recipientAddress; recipients claiming L2ECO links would silently receive nothing while the deposit was marked claimed. The other four contractType branches (ETH, ERC-20, ERC-721, ERC-1155) all correctly target _recipientAddress — this was a copy-paste from the parallel _withdrawDepositSender function. Fix: route to _recipientAddress and use SafeERC20.safeTransfer (consistent with the ERC-20 branch above it). Pinned by two regression tests in test/peanut/PeanutHardening.t.sol:
    • test_T5_L2ECOWithdrawGoesToRecipientNotSender — confirmed to FAIL against the upstream-bug code path
    • test_T5_L2ECOSenderReclaimStillGoesToSender — sanity check that _withdrawDepositSender (separate function, correct upstream) wasn't over-corrected
  • OwnershipPeanutRouter upgraded OwnableOwnable2Step

ZkSync alignment

  • All peanut pragmas pinned to 0.8.26
  • <address>.transfer().call{value:}("") everywhere (EraVM rejects the 2300-gas stipend)
  • PeanutBatcherV4 dropped public peanut storage var → local per call (saves pubdata)
  • All raw IL2ECO.transfer* switched to SafeERC20
  • Hardhat config skips L1-only / Anvil-only sources (SwarmRegistryL1Upgradeable, FleetIdentity.t.sol, TestUpgradeOnAnvil.s.sol) so yarn hardhat compile works
  • deployPaths: ["hardhat-deploy"] added so deploy-zksync finds the scripts

EnvelopeApprovalPaymaster (Path-C support)

The user-side approval tx flow needs gas sponsorship. The new paymaster gates every sponsored tx with:

  1. Caller == bootloader
  2. paymasterInput flow == general
  3. EIP-712 grant from operator (single-use nonce, deadline, recovers to operatorSigner)
  4. Inner selector ∈ {approve, setApprovalForAll}
  5. Inner spender == envelopeVault
  6. requiredETH ≤ maxEthPerTx (per-tx cap)
  7. Daily quota not exhausted (QuotaControl)

No on-chain user whitelist. No per-token allowlist. The operator's grant is the only auth surface — defense-in-depth comes from per-tx cap + daily quota.

Test suite (961 / 961 pass)

Peanut tests — 93 tests

  • 60 vendored (PeanutV4, PeanutBatcher, PeanutRouter, MFA, SenderWithdraw, SigWithdraw, RecipientBound, Deposit, Integration, Gasless)
  • 13 hardening (S1–S5 + T5 L2ECO regression) — PeanutHardening.t.sol
  • 20 new edge casesPeanutEdgeCases.t.sol:
    • Deposit input validation: INVALID CONTRACT TYPE, WRONG ETH AMOUNT, AMOUNT MUST BE 1 FOR ERC721, ECO-via-plain-ERC20 rejected
    • Withdraw input validation: index out of bounds, double-claim, wrong signer, recipient-mode caller mismatch, wrong recipient on address-bound, too-early sender reclaim, non-sender reclaim, MFA gate when authorizer is zero
    • Views: getDepositCount, getAllDepositsForAddress filtering
    • Reentrancy: malicious ERC-20 reentering withdrawDeposit during safeTransfer is caught by nonReentrant (proves the guard works end-to-end)
    • Batcher input validation: INVALID TOTAL ETHER SENT, PARAMETERS LENGTH MISMATCH, ERC-721 raffle rejection, zero-length pubKeys no-op
    • L2ECO inflation accounting: withdraw at a higher multiplier returns proportionally less (the inflation-invariant share is what the depositor banked)

Paymaster tests — 19 tests

EnvelopeApprovalPaymaster.t.sol — per-gate revert + happy paths + quota period rollover + admin role gates

Rest of repo — 849 tests, no regressions

Test plan checklist

  • forge test — 961 / 961 pass
  • T5 regression verified end-to-end: temporarily reintroduced the bug, confirmed test_T5_L2ECOWithdrawGoesToRecipientNotSender fails with recipient must receive the L2ECO tokens: 0 != 100; restored the fix → green again
  • yarn hardhat compile — clean (126 files)
  • forge build --zksync — peanut clean (only cosmetic warnings)
  • yarn spellcheck — 0 issues / 246 files
  • Sepolia deploy verified end-to-end
  • Reviewer to confirm SPDX license posture: peanut sources are GPL-3.0-or-later, rest of repo is BSD-3-Clause-Clear
  • Reviewer to decide if IL2ECO / contractType==4 rebasing branches should be kept (Nodle has no rebasing token today)

Test style alignment with repo

  • All vendored test files renamed to *.t.sol (testDeposit.sol → Deposit.t.sol, etc.)
  • Dead stubs deleted (testBatch.sol, Batch/ dir, hardhat/PeanutV4.1.spec.ts)
  • Casual comments replaced with serious technical ones; cspell whitelist trimmed correspondingly
  • 17 upstream comment typos fixed in our copy (no bytecode impact)

Docs

src/peanut/doc/ (mirrors src/swarms/doc/ convention):

  • README.md — overview, deployed addresses, three deposit paths
  • PeanutV4.md — full vault spec (174 lines)
  • PeanutBatcherV4.md — batcher spec (92 lines)
  • PeanutRouter.md — router spec (138 lines)
  • EnvelopeApprovalPaymaster.md — paymaster spec with backend signing skeleton (266 lines)

Follow-ups (not in this PR)

  • No script/DeployPeanut*.s.sol Foundry script (deferred to Hardhat-zksync canonical path; orphan was deleted)
  • PeanutV4Router not deployed on Sepolia (deploy when Squid integration is wired)
  • License harmonization (GPL ↔ BSD) — legal call

Imports peanutprotocol/peanut-contracts V4.4 (vault + batcher + router)
plus EIP-3009 mocks, sample SCW, and Squid mock into src/peanut/, with
the squirrel-labs test suite under test/peanut/.

OZ v5 patches applied during vendoring:
- ReentrancyGuard moved from security/ to utils/
- ECDSA.toEthSignedMessageHash -> MessageHashUtils
- SafeERC20.safeApprove -> forceApprove
- Ownable constructor takes initial owner explicitly
- EIP3009Implementation marks interface fns override

60/60 peanut tests pass. Open follow-ups: MFA_AUTHORIZER hardcoded to
upstream key, no deploy script yet, IL2ECO branches kept (unused on Nodle).
ZkSync rejects <address>.transfer() under the sendtransfer error policy
because the 2300 gas stipend isn't safe under EraVM pubdata costs.
This was the only native .transfer() in the peanut suite — IERC20.transfer
calls elsewhere are fine.
Security fixes:
- ERC721/1155 receivers now revert on direct (non-self) transfers instead
  of silently dropping (was: implicit return bytes4(0); some tokens accepted
  it and the assets got stuck with no recovery path)
- PeanutRouter.withdrawFees uses SafeERC20.safeTransfer (works with USDT
  and other non-bool-returning ERC20s)
- MFA_AUTHORIZER promoted from hardcoded constant to immutable constructor
  arg, so each deploy can pick its own signer (or address(0) to disable)
- _storeDeposit rejects deposits with both pubKey20 == 0 and recipient == 0
  (would otherwise be claimable by anyone)
- Fixed upstream bug: _withdrawDeposit's L2ECO branch was sending tokens to
  senderAddress instead of recipientAddress; now correct
- PeanutRouter switched to Ownable2Step (safer ownership handoff)

ZkSync-aligned patterns:
- Pragma pinned to 0.8.26 (matches repo, aligns with zksolc)
- Batcher dropped public PeanutV4 storage var; uses local in each call so
  EraVM doesn't charge pubdata for every batch invocation
- Explicit override(IERC165) on supportsInterface for stricter solc/zksolc
- All raw IL2ECO transfer/transferFrom calls replaced with SafeERC20

Modernization:
- Named imports throughout
- Cleaner NatSpec on constructors and public methods
- Removed unused parameter names from receiver hooks (silences zksolc warns)

Tests:
- Updated all `new PeanutV4(address(0))` call sites to the 2-arg constructor
- testMFA pins LEGACY_MFA_AUTHORIZER (the upstream Squirrel address) so its
  pre-baked authorization signature still verifies
- New PeanutHardening.t.sol with 11 tests covering each fix above

71/71 peanut tests pass (60 vendored + 11 hardening).
849/849 rest-of-repo tests still pass — no regressions.
zksolc compiles peanut clean (only cosmetic warnings; pre-existing repo-level
zksync errors in SwarmRegistryL1Upgradeable / FleetIdentity.t.sol /
TestUpgradeOnAnvil.s.sol are unrelated).
Three-step deploy: PeanutV4 (always), PeanutBatcherV4 (default on),
PeanutV4Router (default off — only useful for cross-chain via Squid).

Env-driven config:
- ECO_TOKEN:      gates contractType==1 deposits from a rebasing token (default 0)
- MFA_AUTHORIZER: per-deploy MFA signer; 0 disables MFA (default 0)
- DEPLOY_BATCHER: skip the batcher if not needed (default true)
- DEPLOY_ROUTER:  enable the cross-chain router (default false)
- SQUID_ADDRESS:  required when DEPLOY_ROUTER=true
- ROUTER_OWNER:   if set, initiates Ownable2Step handoff to this address;
                  the new owner must call acceptOwnership() in a follow-up tx

Header documents the workaround for the repo's pre-existing zksolc errors
(SwarmRegistryL1 / FleetIdentity.t.sol / TestUpgradeOnAnvil) so users know
to pass --skip flags until those are wired into [profile.zksync].
The Foundry script never had a clean path on ZkSync because the repo's
zksolc compile graph picks up L1-only files (SwarmRegistryL1Upgradeable
uses EXTCODECOPY) that no per-script --skip flag can fully suppress.
Hardhat-zksync is what the team actually uses to deploy
(hardhat-deploy/DeployS*.ts), so mirror that pattern.

Changes:
- Drop script/DeployPeanutZkSync.s.sol — Foundry path was a dead end.
- Add hardhat-deploy/DeployPeanut.ts following the canonical
  DeploySwarmUpgradeable.ts pattern: zksync-ethers + Deployer +
  estimateDeployFee + verify:verify per contract. Same env-var surface as
  before (PEANUT_* prefix to avoid colliding with existing scripts).
- hardhat.config.ts:
  * Add a TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS subtask that filters out
    SwarmRegistryL1Upgradeable.sol, FleetIdentity.t.sol, and
    TestUpgradeOnAnvil.s.sol — files that can't compile under zksolc.
    All three are L1-only or Anvil-only test/script artifacts; excluding
    them from the zksync compile graph is a no-op for the L1 toolchain
    but unblocks every Hardhat-zksync command.
  * Add deployPaths: ["hardhat-deploy"] so deploy-zksync can locate scripts.

Verified:
- yarn hardhat compile: clean (141 files, peanut included)
- yarn hardhat deploy-zksync --script DeployPeanut.ts: runs end-to-end
  through config + estimate; only fails at the actual RPC connect when
  no zksync node is running locally (expected).
- forge test: 71/71 peanut + 849/849 rest-of-repo, no regressions.
Mock contracts have no business in the production source tree. They were
only there because the upstream peanut repo kept them in src/util/.

Moved to test/peanut/mocks/:
  - Mocks: ERC20Mock, ERC721Mock, ERC1155Mock, SampleSCW, SquidMock
  - EIP-3009 chain (only used by ERC20Mock to support gasless tests):
    EIP3009Implementation, EIP3009Internals, EIP712, EIP712Domain, ECRecover

Kept in src/peanut/util/ (used by production peanut code):
  - IEIP3009: interface PeanutV4 calls for receiveWithAuthorization
  - IL2ECO:   interface PeanutV4 calls for rebasing-token deposits

Updated imports:
  - Test files: ../../src/peanut/util/X.sol -> ./mocks/X.sol
  - EIP3009Internals + EIP3009Implementation: ./IEIP3009.sol ->
    ../../../src/peanut/util/IEIP3009.sol (still need the production interface)

Verified:
  - forge build: clean
  - forge test peanut: 71/71 pass
  - hardhat compile: 125 files (was 141 - mocks no longer in production
    compile path, leaner zksolc graph)
…alForAll for the peanut vault

Existing WhitelistPaymaster only inspects (from, to); it can't safely sponsor
token approval txs because the inner selector and spender argument are
invisible to it. This paymaster checks every layer:

  - tx.to must be on a per-token allowlist (admin-curated)
  - inner selector must be approve(address,uint256) or
    setApprovalForAll(address,bool) — same selectors cover ERC-20/721/1155
  - inner first arg (spender/operator) must equal the configured peanutVault
  - tx.from must hold an unexpired EIP-712 grant signed by operatorSigner
    (signature passed in paymasterInput; nonce single-use; no per-user
    onchain whitelist tx needed)
  - global wei-per-period quota via QuotaControl (existing repo pattern)

Doesn't extend BasePaymaster because that base hides transaction.data
behind a (from, to, requiredETH) hook. Instead inherits IPaymaster +
QuotaControl directly and re-implements the bootloader gate inline (~5
lines).

EraVM rules permit writes to paymaster's own storage during validation
(used here for nonce + quota state).

Tests: 19/19 covering happy paths (approve, setApprovalForAll), all 9
revert paths (non-bootloader, wrong flow, expired grant, reused nonce,
wrong signer, wrong user, disallowed token, unsupported selector, wrong
spender, exceeded quota, insufficient balance), quota period rollover,
and admin role gates.
The previous standalone version duplicated bootloader-check logic,
WITHDRAWER_ROLE, Withdrawn event, withdraw(), postTransaction(),
receive(), and the BOOTLOADER_FORMAL_ADDRESS constant.

One-keyword change to BasePaymaster: mark
validateAndPayForPaymasterTransaction as `virtual` so subclasses can
override it when they need access to the full Transaction calldata
(the existing `_validateAndPayGeneralFlow` hook hides `transaction.data`
and `transaction.paymasterInput` by design).

WhitelistPaymaster and BondTreasuryPaymaster are untouched — they
continue to override the internal hook through BasePaymaster's default
outer-function implementation.

PeanutApprovalPaymaster now:
  - is BasePaymaster, QuotaControl
  - overrides validateAndPayForPaymasterTransaction with full peanut-
    specific validation
  - implements the two abstract internal hooks as reverts (general:
    Unused; approvalBased: PaymasterFlowNotSupported)
  - drops 9 lines net of duplication (37 deleted, 28 added)
  - inherits withdraw / postTransaction / receive / Withdrawn /
    AccessRestrictedToBootloader / WITHDRAWER_ROLE / BOOTLOADER_FORMAL_ADDRESS

Tests: 939/939 (19 paymaster-specific + 102 other paymaster tests
including WhitelistPaymaster/BondTreasuryPaymaster suites untouched +
all peanut and rest-of-repo tests). Behavior unchanged externally.

Also adds hardhat-deploy/DeployPeanutPaymaster.ts (Hardhat-zksync
deploy script that matches existing patterns; takes PEANUT_V4 and
operator signer from env, optionally funds + seeds token allowlist).
…maller helpers

validateAndPayForPaymasterTransaction was too dense for zksolc's legacy
codegen — 17 active locals tripped stack-too-deep at the explorer's
verification compile (zksolc doesn't accept Solidity's viaIR flag because
it translates legacy IR to EraVM directly).

Split the validation into 4 internal helpers, each scope <16 locals:
  - _requireGeneralFlow(paymasterInput) — flow selector check
  - _verifyAndConsumeGrant(user, paymasterInput) — EIP-712 grant decode,
    expiry/nonce check, signature recover, nonce mark-used
  - _requireApprovalCallToPeanut(data) — inner selector + spender check
  - _payBootloader(requiredETH) — balance, quota, transfer

Same behavior, just structurally lighter. All 19 paymaster tests pass.

Deployed + verified on ZkSync Sepolia at
0x301DB88e0AdD434CBac07ef3F4207C16E4dEb6a0 (operator signer
0xc1F2A7b888e4837aFACfc5E914AB647476ceCD46, vault
0xC241FE8Af12Cf35Eb346eA8eC3AECFCF6F6c2C44, quota 0.1 ETH/day).
…dd per-tx ETH cap

The per-token allowlist was operator-side ceremony with little marginal safety:
the operator already curates which tokens get grants (by deciding what tx the
backend builds in step 2 of Path C). Removing it cuts an admin workflow.

Replaced with a per-tx ETH cap (`maxEthPerTx`, immutable, constructor-set) so
the worst-case drain under operator-key compromise is bounded per tx, not per
token. Combined with the existing daily QuotaControl cap, the security envelope
is equivalent for honest operation, tighter under compromise.

Renames (paymaster surface only; the vault keeps the upstream PeanutV4 name):
  - PeanutApprovalPaymaster        → EnvelopeApprovalPaymaster
  - peanutVault state              → envelopeVault
  - SpenderNotPeanut error         → SpenderNotEnvelope
  - EIP-712 domain name string     → "EnvelopeApprovalPaymaster"
  - GRANT_TYPEHASH                 → keccak256("EnvelopeApprovalGrant(...)")
  - file + test + deploy script names
  - all NatSpec and comments

NOTE: changing the EIP-712 domain name invalidates the signatures that would
verify against the previously-deployed paymaster at 0x301D...b6a0. That
contract is functionally orphaned now — needs a redeploy of the new bytecode
to a fresh address.

Tests: 19/19 envelope-paymaster (covers per-tx-cap, exceeded-quota via
constructor-tightened paymaster instance, sponsorship works on any token,
all the per-gate reverts). Full repo: 939/939, no regressions.
Mirrors src/swarms/doc/ convention. One markdown file per deployable contract:

  README.md                       — overview, deployed addresses, file map
  PeanutV4.md                     — vault: deposit + withdraw paths, signature
                                    scheme, dual-zero invariant, vendoring
                                    patches, threat model
  PeanutBatcherV4.md              — batcher: stateless design, per-asset pull
                                    pattern, ERC-721-not-implemented rationale
  PeanutRouter.md                 — router: EIP-191 v0x00 routing sig, fee
                                    paths, Ownable2Step note
  EnvelopeApprovalPaymaster.md    — paymaster: 5-gate validation, EIP-712
                                    grant schema, backend signing skeleton,
                                    deliberate drops vs. earlier iterations

735 lines total. Lives in src/peanut/doc/ even though the paymaster source
is at src/paymasters/ — the Envelope product spans both directories.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 13, 2026

LCOV of commit fb450f5 during checks #676

Summary coverage rate:
  lines......: 25.9% (771 of 2978 lines)
  functions..: 22.2% (105 of 473 functions)
  branches...: 24.3% (140 of 577 branches)

Files changed coverage rate:
                                                  |Lines       |Functions  |Branches    
  Filename                                        |Rate     Num|Rate    Num|Rate     Num
  ======================================================================================
  src/paymasters/BasePaymaster.sol                | 0.0%     33| 0.0%     5| 0.0%      8
  src/paymasters/EnvelopeApprovalPaymaster.sol    | 0.0%     54| 0.0%     9| 0.0%     13
  src/peanut/V4/PeanutBatcherV4.4.sol             | 0.0%     95| 0.0%    10| 0.0%     39
  src/peanut/V4/PeanutRouter.sol                  | 0.0%     26| 0.0%     3| 0.0%     18
  src/peanut/V4/PeanutV4.4.sol                    | 0.0%    177| 0.0%    29| 0.0%     83
  test/peanut/PeanutEdgeCases.t.sol               | 0.0%     11| 0.0%     2| 0.0%      2
  test/peanut/PeanutHardening.t.sol               | 0.0%     19| 0.0%     5| 0.0%      6
  test/peanut/mocks/ECRecover.sol                 | 0.0%      8| 0.0%     1| 0.0%      4
  test/peanut/mocks/EIP3009Implementation.sol     | 0.0%      6| 0.0%     3|    -      0
  test/peanut/mocks/EIP3009Internals.sol          | 0.0%     32| 0.0%     7| 0.0%     14
  test/peanut/mocks/EIP712.sol                    | 0.0%      7| 0.0%     2|    -      0
  test/peanut/mocks/ERC1155Mock.sol               | 0.0%      4| 0.0%     2|    -      0
  test/peanut/mocks/ERC20Mock.sol                 | 0.0%      3| 0.0%     2|    -      0
  test/peanut/mocks/ERC721Mock.sol                | 0.0%      3| 0.0%     2|    -      0
  test/peanut/mocks/L2ECOMock.sol                 | 0.0%      8| 0.0%     4|    -      0
  test/peanut/mocks/SampleSCW.sol                 | 0.0%      3| 0.0%     1|    -      0
  test/peanut/mocks/SquidMock.sol                 | 0.0%      5| 0.0%     1| 0.0%      4

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR vendors Peanut Protocol V4.4 (vault, batcher, router) into src/peanut/, adds an operator-gated EnvelopeApprovalPaymaster for zkSync “Path-C” sponsored approval transactions, and wires Hardhat-zksync deploy + verification scripts plus contract specs.

Changes:

  • Added Peanut V4.4 core contracts (vault + batcher + router) with zkSync/OZ v5 alignment and hardening.
  • Added EnvelopeApprovalPaymaster (EIP-712 operator grant gating + per-tx ETH cap + quota control) and Foundry tests for it.
  • Added Hardhat-zksync deployment scripts, updated Hardhat compilation settings, and added per-contract markdown specs under src/peanut/doc/.

Reviewed changes

Copilot reviewed 42 out of 42 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
hardhat.config.ts Filters out zksolc-incompatible sources and configures deploy script discovery via deployPaths.
hardhat-deploy/DeployPeanut.ts Hardhat-zksync deployment + verification script for Peanut vault/batcher/router.
hardhat-deploy/DeployEnvelopePaymaster.ts Hardhat-zksync deployment + verification script for EnvelopeApprovalPaymaster.
src/paymasters/BasePaymaster.sol Makes validateAndPayForPaymasterTransaction virtual to allow specialization.
src/paymasters/EnvelopeApprovalPaymaster.sol Implements operator-signed EIP-712 grant gating for sponsored approve / setApprovalForAll.
src/QuotaControl.sol (Context) quota mechanism consumed by the new paymaster for daily caps.
src/peanut/V4/PeanutV4.4.sol Vendored Peanut V4.4 vault with hardening + zkSync/OZ v5 patches.
src/peanut/V4/PeanutBatcherV4.4.sol Stateless batch deposit helper for Peanut V4.4 with zkSync alignment.
src/peanut/V4/PeanutRouter.sol Cross-chain router via Squid; Ownable2Step + SafeERC20 fee withdrawal.
src/peanut/util/IEIP3009.sol EIP-3009 interface for gasless token deposits.
src/peanut/util/IL2ECO.sol Interface for ECO-like rebasing multiplier reads.
src/peanut/doc/README.md High-level Envelope/Peanut layout, deposit paths, deploy pointers, and test counts.
src/peanut/doc/PeanutV4.md Vault spec detailing storage, flows, and hardening notes.
src/peanut/doc/PeanutBatcherV4.md Batcher spec and supported batch paths.
src/peanut/doc/PeanutRouter.md Router spec describing signature scheme and bridge flow.
src/peanut/doc/EnvelopeApprovalPaymaster.md Paymaster spec, grant format, validation gates, and backend signing skeleton.
test/paymasters/EnvelopeApprovalPaymaster.t.sol Foundry suite validating all paymaster gates, quota rollover, and admin/withdraw behavior.
test/peanut/PeanutHardening.t.sol Hardening tests for S1–S4/T1–T4 behaviors introduced during vendoring.
test/peanut/PeanutRouter.t.sol Router withdrawal + bridging tests, including fee-tampering resistance.
test/peanut/PeanutBatcher.t.sol Batcher tests for ETH/ERC20/ERC1155 batches and raffle flows.
test/peanut/PeanutV4.t.sol Core PeanutV4 behavior tests (deposits, withdrawals, ECO guard).
test/peanut/PeanutV4Gasless.t.sol Gasless reclaim + EIP-3009 style deposit tests.
test/peanut/RecipeintBound.t.sol Recipient-bound deposit and reclaim tests.
test/peanut/testDeposit.sol Deposit-path tests for ETH/ERC20/ERC721/ERC1155.
test/peanut/testIntegration.sol Integration tests verifying basic invariants across deposit/withdraw paths.
test/peanut/testMFA.sol MFA deposit + withdrawal flow tests.
test/peanut/testSenderWithdraw.sol Sender reclaim tests for ETH/ERC20/ERC721/ERC1155.
test/peanut/testSigWithdraw.sol Signature-based withdrawal tests for ETH.
test/peanut/testBatch.sol (Commented) legacy batch test scaffolding kept in-tree.
test/peanut/Batch/testBatchDeposit.sol (Commented) legacy batch deposit scaffolding.
test/peanut/Batch/testBatchDepositEther.sol (Commented) legacy ETH batch deposit scaffolding.
test/peanut/Batch/testBatchDepositEtherOptimized.sol (Commented) legacy optimized ETH batch scaffolding.
test/peanut/hardhat/PeanutV4.1.spec.ts Vendored Hardhat test for older Peanut behavior (smock-based).
test/peanut/mocks/ERC20Mock.sol ERC20 mock with EIP-3009 test implementation.
test/peanut/mocks/ERC721Mock.sol ERC721 mock used in Peanut tests.
test/peanut/mocks/ERC1155Mock.sol ERC1155 mock used in Peanut tests.
test/peanut/mocks/SquidMock.sol Squid mock used for router bridge call tests.
test/peanut/mocks/SampleSCW.sol Minimal EIP-1271-like sample SCW for gasless reclaim tests.
test/peanut/mocks/EIP712Domain.sol Fixed domain separator helper for EIP-3009 tests.
test/peanut/mocks/EIP712.sol EIP-712 helper library for EIP-3009 tests.
test/peanut/mocks/EIP3009Internals.sol Internal EIP-3009 logic used by mocks.
test/peanut/mocks/EIP3009Implementation.sol EIP-3009 surface implementation used by ERC20Mock.
test/peanut/mocks/ECRecover.sol ECDSA recover helper used by EIP-712/EIP-3009 helpers.

Comment thread src/paymasters/EnvelopeApprovalPaymaster.sol
Comment thread src/peanut/V4/PeanutBatcherV4.4.sol
Comment thread test/peanut/SigWithdraw.t.sol Outdated
Comment thread test/peanut/RecipientBound.t.sol
Comment thread test/peanut/MFA.t.sol
Comment thread src/peanut/doc/PeanutV4.md
Comment thread test/peanut/PeanutV4Gasless.t.sol Outdated
Upstream PeanutV4.4 had a copy-paste error in _withdrawDeposit's
contractType==4 branch: it transferred to _deposit.senderAddress instead
of _recipientAddress, so a recipient claiming an L2ECO link with a valid
signature would receive nothing — the tokens went back to the sender —
while the deposit was still marked claimed=true.

Two new tests pin the fix:

  test_T5_L2ECOWithdrawGoesToRecipientNotSender
    - sender deposits 100 L2ECO (multiplier=2 → 200 stored inflation-invariant)
    - recipient (not sender) claims with a valid signature
    - asserts: recipient gets 100, sender stays at 0, vault drained
    Confirmed to FAIL against the upstream-bug code path (verified by
    temporarily reintroducing the bug; test failed with
    'recipient must receive the L2ECO tokens: 0 != 100').

  test_T5_L2ECOSenderReclaimStillGoesToSender
    - sanity check: _withdrawDepositSender (separate function) still
      legitimately routes to senderAddress; the fix to _withdrawDeposit
      did not over-correct the parallel reclaim path

Adds test/peanut/mocks/L2ECOMock.sol — minimal ERC20 with a settable
linearInflationMultiplier(). No production code changes; bug fix itself
is in commit 12a77ce.

941/941 repo tests pass.
CI spell check reported 103 issues in 29 files (38 unique words) across
the vendored Peanut suite + my new code. Cleanup:

1. Fixed two typos I introduced:
   - test/paymasters/...: 'nonce-pertx' -> 'nonce-per-tx' (nonce string)
   - src/paymasters/...: 'EraVM's paymaster-validation rules' -> 'EraVM
     paymaster-validation rules' (apostrophe-s tripped cspell)

2. Whitelisted 38 words in .cspell.json:
   - Legitimate domain terms: Axelar, IEIP, calldataload, SECZ, secp,
     tadam, footgun, peanutprotocol, rollup, PRIVKEY, keypair, scwallet,
     gaslessly, Customisable, authorisation, arrayify, nomiclabs, defi,
     MAGICVALUE, unhashed, Hashbinary
   - Vendored upstream typos kept for diff parity (would be a real fix to
     pull from upstream later if they ever clean it up): contractype,
     Recipeint, DOESNT, Suuuuper, talkin, wooooooosh, pretent, Depost,
     alwasy, auhorisation, authorizattion, funfction, gsalessly, provied,
     fuceted

CI passes: 0 issues, 250 files checked. Repo tests unchanged.
All in comments, error strings, function names, or one filename — no
bytecode changes. With these fixed, the cspell whitelist shrinks by 12
entries; only intentional stylistic words remain (Suuuuper, talkin,
wooooooosh — all Peanut Protocol's "nutty" branding).

Source comments:
  src/peanut/V4/PeanutV4.4.sol
    - alwasy → always
    - auhorisation → authorisation
    - funfction → function

Test comments / strings / identifiers:
  test/peanut/PeanutV4.t.sol             pretent → pretend
  test/peanut/PeanutV4Gasless.t.sol      provied → provided, gsalessly → gaslessly
  test/peanut/PeanutV4Gasless.t.sol      testMakeDepost… → testMakeDeposit…
  test/peanut/PeanutRouter.t.sol         fuceted → faucet
  test/peanut/testMFA.sol                authorizattion → authorization
  test/peanut/testSenderWithdraw.sol     contractype → contractType
  test/peanut/mocks/SquidMock.sol        DOESNT → DOES NOT
  test/peanut/RecipeintBound.t.sol → RecipientBound.t.sol (file rename)
  src/peanut/doc/PeanutV4.md             doc reference updated to new filename

941/941 tests pass. Spellcheck: 0 issues / 250 files.
Style alignment with the rest of the repo:
  - File rename: testFoo.sol → Foo.t.sol (matches *.t.sol forge convention)
      testDeposit       → Deposit.t.sol
      testIntegration   → Integration.t.sol
      testMFA           → MFA.t.sol
      testSenderWithdraw → SenderWithdraw.t.sol
      testSigWithdraw   → SigWithdraw.t.sol
  - Delete dead stubs (all entirely commented out / unused):
      testBatch.sol
      test/peanut/Batch/{testBatchDeposit, testBatchDepositEther,
                         testBatchDepositEtherOptimized}.sol
      test/peanut/hardhat/PeanutV4.1.spec.ts  (Hardhat-ts test; repo is Foundry-primary)
  - Cleaned three casual comments to match the repo's serious tone (kept all
    serious/technical comments):
      "Suuuuper dumb squid mock"        → real NatSpec
      "Now we talkin'!"                  → "selfless deposit's owner can reclaim"
      "wooooooosh! Controlling the time" → "advance past reclaimableAfter"
  - Dropped {Suuuuper, talkin, wooooooosh} from .cspell.json whitelist.

New edge-case suite — test/peanut/PeanutEdgeCases.t.sol — 20 tests:
  PeanutV4 deposit input validation:
    - INVALID CONTRACT TYPE (contractType >= 5)
    - WRONG ETH AMOUNT (msg.value mismatch)
    - AMOUNT MUST BE 1 FOR ERC721
    - ECO via plain ERC-20 path rejected
  PeanutV4 withdraw input validation:
    - DEPOSIT INDEX DOES NOT EXIST
    - DEPOSIT ALREADY WITHDRAWN (double-claim)
    - WRONG SIGNATURE (signer mismatch)
    - NOT THE RECIPIENT (withdrawDepositAsRecipient caller mismatch)
    - WRONG RECIPIENT (address-bound deposit claimed by other)
    - TOO EARLY TO RECLAIM (recipient-bound sender reclaim before deadline)
    - NOT THE SENDER (non-sender reclaim)
    - REQUIRES AUTHORIZATION (MFA deposit, MFA_AUTHORIZER == 0)
  Views:
    - getDepositCount tracks length
    - getAllDepositsForAddress filters by sender
  Reentrancy:
    - Malicious ERC-20 reentering withdrawDeposit during safeTransfer is
      caught by nonReentrant (proves the guard works end-to-end).
  PeanutBatcherV4 input validation:
    - INVALID TOTAL ETHER SENT
    - PARAMETERS LENGTH MISMATCH (arbitrary batch)
    - ONLY ETH AND ERC20 RAFFLES ARE SUPPORTED (ERC-721 raffle path)
    - Zero-length pubKeys is a no-op
  L2ECO inflation accounting:
    - Withdraw at higher multiplier returns proportionally less (the
      inflation-invariant share is what the depositor banked).

961/961 repo tests pass (was 941; +20 new edge cases). Spellcheck: 0
issues / 246 files.
The repo's solhint config treats gas-custom-errors as an error (not a
warning). The vendored Peanut V4.4 / Batcher / Router use require-string
patterns extensively (~40 instances). Converting them to custom errors
would diverge significantly from upstream
(peanutprotocol/peanut-contracts@main) without any security or
correctness benefit — only a style change.

Add the three vendored Solidity files to .solhintignore so CI's lint job
passes. The new code in this PR (EnvelopeApprovalPaymaster, hardening
tests, edge-case tests, deploy scripts) already uses custom errors and
is NOT in the ignore list — it remains lint-clean.

Local: yarn lint → 0 errors / 175 warnings (warnings are non-blocking
and all pre-existing in non-peanut code).
Four issues raised by the Copilot review on PR #115:

1. EnvelopeApprovalPaymaster: switch operator-signature verification from
   ECDSA.recover to SignatureChecker.isValidSignatureNow, matching the
   constructor docstring's promise of EOA-or-contract signers. Now
   accepts EIP-1271 smart-contract operatorSigners (multisigs etc.).

2. PeanutBatcherV4.batchMakeDepositNoReturn: latent upstream bug — the
   inner call forwarded {value: msg.value} per loop iteration but the
   batcher only received msg.value once. For ETH batches with N > 1, the
   second iteration would revert with insufficient balance. Now requires
   msg.value == _amount * N for ETH and msg.value == 0 for non-ETH
   (prevents stuck dust in the vault too).

3. test/peanut/SigWithdraw.t.sol: SPDX `BUSL-1.1` → `UNLICENSED` to
   match the rest of the vendored test suite.

4. PeanutV4: `address public ecoAddress` → `immutable` (matches the
   doc + small gas saving; the value is set in constructor and never
   mutated).

New tests:
  - test_acceptsEip1271ContractSigner — proves SignatureChecker path
    accepts a SampleWallet (EIP-1271) as operatorSigner
  - test_BatchNoReturnEth_HappyPath — 3-deposit ETH batch round-trips
  - test_RevertWhen_BatchNoReturnEthAmountMismatch — total mismatch
  - test_RevertWhen_BatchNoReturnEthSentForErc20 — msg.value > 0 with
    ERC-20 path is rejected

forge test: 965/965 (was 961; +4 new). yarn lint: 0 errors. yarn
spellcheck: 0 issues.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants