Skip to content

feat(envelope): EnvelopeLinks — link-based gift escrow with gasless claims#115

Merged
aliXsed merged 49 commits into
mainfrom
feat/peanut-protocol
May 21, 2026
Merged

feat(envelope): EnvelopeLinks — link-based gift escrow with gasless claims#115
aliXsed merged 49 commits into
mainfrom
feat/peanut-protocol

Conversation

@Douglasacost
Copy link
Copy Markdown
Collaborator

@Douglasacost Douglasacost commented May 13, 2026

Envelope Gift Links — Full Rework

This PR reworks the envelope/gift system for ZkSync L2, building on top of the vendored Peanut Protocol V4.4 codebase.

What changed

Area Change
Contract rename EnvelopeVaultEnvelopeLinks — matches the Link struct and createLink/claim/reclaim API
API rename All public functions renamed for clarity: deposit→link, withdraw→claim/reclaim (see commit log for full map)
Fee system Backend-signed FeeAuthorization for service + gasless fees collected in NODL at link creation
Gasless claims gaslessFee > 0 or gaslessSponsored == true enables EnvelopePaymaster-sponsored claims and reclaims
AA batching ZkSync smart-account batch UX: one user confirmation for approve + createLink
Recipient binding recipient field + reclaimableAfter for address-bound links
Structure Flat src/envelope/ layout, removed V4/ and util/ subdirs, stripped Peanut ASCII header
Docs Comprehensive Mermaid diagrams covering all flows, post-deployment cast smoke test recipes
Tests 207 focused tests covering all link types, gasless eligibility, batching, edge cases
CI Fixed cspell dictionary for new words (Reown, CBOR, Remy, konlet)

Key types

struct Link { address claimKey; uint256 amount; address tokenAddress; uint8 contractType; bool redeemed; bool withMFA; bool gaslessEligible; uint40 reclaimableAfter; uint256 tokenId; address recipient; address creator; uint40 timestamp; uint256 serviceFee; uint256 gaslessFee; }
struct LinkRequest { address tokenAddress; uint8 contractType; uint256 amount; uint256 tokenId; address claimKey; bool withMFA; address recipient; uint40 reclaimableAfter; }
struct FeeAuthorization { uint256 serviceFee; uint256 gaslessFee; bool gaslessSponsored; uint256 deadline; bytes signature; }

API surface

Create:

  • createLink(token, type, amount, id, claimKey) — simple P2P gift, no backend needed
  • createLinkWithFees(LinkRequest, FeeAuthorization) — full-featured with fees/gasless
  • createCustomLink(...) — all options without fees
  • createLinks(...) / createCustomLinksWithFees(...) — batch variants
  • createMFALink(...) / createMFALinkFor(...) — MFA-gated

Claim:

  • claim(index, recipient, signature) — open claim
  • claimWithMFA(index, recipient, sig, mfaSig, deadline) — MFA claim
  • claimAsBoundRecipient(index, recipient, sig) — recipient-only claim

Reclaim:

  • reclaim(index) — creator takes back unclaimed link

Views:

  • getLinkCount(), getLink(i), getAllLinks(), getLinksCreatedBy(addr)
  • isValidGaslessOperation(caller, calldata) — paymaster validation

Flows

  1. P2P (no backend): App generates keypair → createLink → share link → recipient claims
  2. With fees: App → Atlas backend → FeeAuthorizationcreateLinkWithFees
  3. Gasless claim: Sender prepays or backend sponsors → recipient uses EnvelopePaymaster
  4. MFA: Backend signs both fee auth (creation) and MFA auth (claim time)
  5. Recipient-bound: Only the named recipient can claim; creator reclaims after timeout

License

EnvelopeLinks.sol and IEnvelopeGaslessValidator.sol remain GPL-3.0-or-later (derived from Peanut Protocol). All other files retain their existing licenses.

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 81d40fa during checks #711

Summary coverage rate:
  lines......: 25.7% (771 of 3001 lines)
  functions..: 22.3% (105 of 470 functions)
  branches...: 27.5% (140 of 509 branches)

Files changed coverage rate:
                                                  |Lines       |Functions  |Branches    
  Filename                                        |Rate     Num|Rate    Num|Rate     Num
  ======================================================================================
  script/DeployEnvelopeZkSync.s.sol               | 0.0%     40| 0.0%     1| 0.0%      4
  src/envelope/EnvelopeLinks.sol                  | 0.0%    355| 0.0%    53| 0.0%     96
  src/paymasters/BasePaymaster.sol                | 0.0%     33| 0.0%     5| 0.0%      8
  src/paymasters/EnvelopePaymaster.sol            | 0.0%     24| 0.0%     5| 0.0%      7
  src/paymasters/WhitelistPaymaster.sol           | 0.0%     31| 0.0%     7| 0.0%      3
  test/envelope/Coverage.t.sol                    | 0.0%      2| 0.0%     1|    -      0
  test/envelope/EnvelopeEIP712Utils.sol           | 0.0%     14| 0.0%     5|    -      0
  test/envelope/EnvelopeEdgeCases.t.sol           | 0.0%     11| 0.0%     2| 0.0%      2
  test/envelope/EnvelopeFeeAuthTestUtils.sol      | 0.0%      2| 0.0%     1|    -      0
  test/envelope/mocks/ECRecover.sol               | 0.0%      8| 0.0%     1| 0.0%      4
  test/envelope/mocks/EIP712.sol                  | 0.0%      7| 0.0%     2|    -      0
  test/envelope/mocks/ERC1155Mock.sol             | 0.0%      4| 0.0%     2|    -      0
  test/envelope/mocks/ERC20Mock.sol               | 0.0%      3| 0.0%     2|    -      0
  test/envelope/mocks/ERC721Mock.sol              | 0.0%      3| 0.0%     2|    -      0
  test/envelope/mocks/FeeOnTransferERC20Mock.sol  | 0.0%      8| 0.0%     2| 0.0%      2
  test/envelope/mocks/SampleSCW.sol               | 0.0%      3| 0.0%     1|    -      0
  test/paymasters/BasePaymaster.t.sol             | 0.0%      6| 0.0%     3|    -      0
  test/paymasters/BondTreasuryPaymaster.t.sol     |16.7%     12|20.0%     5|    -      0
  test/paymasters/WhitelistPaymaster.t.sol        | 0.0%      4| 0.0%     2|    -      0

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 Outdated
Comment thread src/envelope/V4/EnvelopeBatcher.sol Outdated
Comment thread test/peanut/SigWithdraw.t.sol Outdated
Comment thread test/envelope/RecipientBound.t.sol
Comment thread test/envelope/MFA.t.sol Outdated
Comment thread src/envelope/doc/EnvelopeVault.md Outdated
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.
…sorship

Single paymaster, two modes, one ETH pool:

  Mode A (existing): user-side approve / setApprovalForAll, gated by an
  EIP-712 grant signed off-chain by operatorSigner. Single-use nonce,
  deadline, selector + spender checks.

  Mode B (new): caller is on isOperator allowlist + tx.to is on
  isAllowedTarget allowlist. No grant required (operator is a trusted
  persistent identity). Lets the operator call any function on the
  envelope vault — typically makeCustomDeposit, withdrawDeposit — without
  holding ETH itself.

Both modes share maxEthPerTx and the QuotaControl daily counter, so a
single ETH top-up funds both flows. Revoking an operator is a tx — no
balance migration needed when rotating relayers.

New state:
  - mapping(address => bool) public isOperator
  - mapping(address => bool) public isAllowedTarget

New events:
  - OperatorSet(operator, allowed)
  - AllowedTargetSet(target, allowed)
  - OperatorCallSponsored(operator, target, gasPaid)   — distinct from
    ApprovalSponsored so indexers can filter

New admin functions (DEFAULT_ADMIN_ROLE):
  - setOperator(address, bool)
  - setAllowedTarget(address, bool)

New error:
  - TargetNotAllowed

Validation flow:
  if isOperator[tx.from]:
      Mode B — verify isAllowedTarget[tx.to], then per-tx cap + quota + pay
  else:
      Mode A — existing grant + selector + spender flow, then per-tx cap + quota + pay

Tests: 7 new in test/paymasters/EnvelopeApprovalPaymaster.t.sol covering
Mode B happy path, target-not-allowed, non-operator falls through to
Mode A, per-tx cap shared, QuotaControl shared between modes, admin role
gates on setOperator/setAllowedTarget, operator revocation.

forge test 972/972 (was 965; +7). lint clean. spellcheck clean.

Doc updated: src/peanut/doc/EnvelopeApprovalPaymaster.md describes both
modes, gates per mode, post-deploy Mode B seeding.

NOTE: Sepolia paymaster at 0xEE95bFF... is now stale bytecode (still
functions for Mode A but doesn't have Mode B). Drain + redeploy needed.
Five gaps in src/peanut/doc/EnvelopeApprovalPaymaster.md:
  - Storage section was missing isOperator and isAllowedTarget mappings
  - Mode A grant gate said ECDSA.recover; switched to
    SignatureChecker.isValidSignatureNow (was changed in fb450f5 for
    EIP-1271 support, doc didn't follow)
  - Events / Errors section missing OperatorSet, AllowedTargetSet,
    OperatorCallSponsored, TargetNotAllowed
  - Threat model missing Mode B specifics (random EOA, malicious target,
    operator key compromise, allowlist multiple operators)
  - Test coverage said 19; now 27 (Mode A + Mode B + EIP-1271)

src/peanut/doc/README.md updates:
  - Paymaster description: "Path-C gas sponsor + operator gas pool"
  - Test totals refreshed to current numbers (peanut 96, paymaster 27,
    total 972) — earlier numbers were from before the hardening + edge
    case suites + Mode B added their tests
…ia address

Deploy script:
  - Two new env vars: ENVELOPE_PAYMASTER_INITIAL_OPERATORS (comma-list,
    default empty) and ENVELOPE_PAYMASTER_INITIAL_TARGETS (comma-list,
    default = PEANUT_V4). After deploy + funding, if the deployer is
    the admin, calls setOperator(...) and setAllowedTarget(...) per entry.
  - If admin != deployer, prints an instruction and skips (admin must
    seed themselves).

Docs:
  - README + EnvelopeApprovalPaymaster.md updated with the new Sepolia
    address: 0x80EA078d599Bc63BB921Cf96CC6861731446e268 (Mode A + Mode B
    bytecode, verified, funded with 0.0015 ETH, deployer seeded as both
    operatorSigner AND Mode-B operator, peanut vault seeded as Mode-B
    target).

Old paymaster (0xEE95bFF…) and a duplicate from a re-run (0x5E44c478…)
were both drained back to deployer; only the new 0x80EA078d… remains.
The router wraps a peanut withdraw with a Squid bridge call for
cross-chain claims. Nodle's deployment doesn't currently use it (no
Squid integration), and it's not deployed on Sepolia. Removing it now
shrinks the audit surface and the test/build matrix; if cross-chain
support becomes a requirement later, re-vendor the upstream router
contract and add it back.

Removed:
  src/peanut/V4/PeanutRouter.sol           (vendored upstream router)
  test/peanut/PeanutRouter.t.sol           (4 tests; no longer applicable)
  test/peanut/mocks/SquidMock.sol          (only the router test used it)
  src/peanut/doc/PeanutRouter.md           (138-line spec)

Updated:
  test/peanut/PeanutHardening.t.sol — drop T3 (withdrawFees safeTransfer
    proof) since the router is gone. Also remove the NonReturningERC20
    inline mock + Ownable + PeanutV4Router + SquidMock imports + router
    state in setUp. T1, T2, T4, T5 unchanged.

  hardhat-deploy/DeployPeanut.ts — drop PEANUT_DEPLOY_ROUTER /
    PEANUT_SQUID_ADDRESS / PEANUT_ROUTER_OWNER env vars and the third
    deploy + verification block.

  src/peanut/doc/README.md — drop router row from the layout / deployed
    addresses tables. Naming convention updated. Test totals refreshed
    (peanut 90, total 966).

  .solhintignore — drop the now-deleted PeanutRouter.sol entry.

Test deltas:
  peanut suite: 96 → 90 (-4 router happy-path, -2 T3)
  repo total:   972 → 966

forge test: 966/966 pass. yarn lint: 0 errors. yarn spellcheck: 0 issues.
Three gaps closed:

1. Bundled the full GNU GPL v3 license text at src/peanut/V4/LICENSE-GPL
   (copied verbatim from peanutprotocol/peanut-contracts@main/LICENSE.md,
   673 lines). GPL §4 wants a copyright notice distributed with the work;
   the SPDX header was the only marker before.

2. Added top-of-file modification notice on the two modified GPL files
   (PeanutV4.4.sol, PeanutBatcherV4.4.sol). GPL §5(a): "carry prominent
   notices stating that you modified it, and giving a relevant date."
   Notice points to the per-file vendoring-patches list in the spec docs
   and the git history for the full patch set.

3. Relicensed test files that import GPL-licensed sources from
   UNLICENSED / BSD-3-Clause-Clear → GPL-3.0-or-later. Strict reading
   of the GPL: a file that imports GPL code becomes a derivative work
   and must itself be GPL. Affected: 11 test files
   (Deposit, Integration, MFA, PeanutBatcher, PeanutEdgeCases,
   PeanutHardening, PeanutV4, PeanutV4Gasless, RecipientBound,
   SenderWithdraw, SigWithdraw).

Plus added a "License notice" section to src/peanut/doc/README.md
documenting the mixed-license layout (GPL parts + BSD parts + MIT parts)
so future contributors don't have to reverse-engineer it.

What stayed BSD-3-Clause-Clear: EnvelopeApprovalPaymaster (doesn't
import any GPL source — references the vault by address only). Repo
root LICENSE is unchanged. The OSI's "mere aggregation" interpretation
covers everything else in the repo.

Tests: 966/966. Lint: clean. Spellcheck: clean.

This is a technical compliance pass, not legal advice — Nodle counsel
should sign off before mainnet.
…k hygiene)

Renames the on-chain-visible contract symbols + EIP-712 domain string from
Squirrel Labs' "Peanut" brand to Nodle's "Envelope" brand. Source file paths
keep their upstream names so the audit lineage to peanutprotocol/peanut-contracts@main
stays grep-friendly via path + git history + LICENSE-GPL + modification notice.

  contract PeanutV4         → EnvelopeVault
  contract PeanutBatcherV4  → EnvelopeBatcher
  EIP712Domain.name "Peanut" → "Envelope" (in PeanutV4.4.sol constructor)

Why: GPL gives us the right to fork the code; it doesn't grant use of the
upstream brand. Renaming the visible symbols closes a trademark vector
independent of license. The previously-renamed paymaster (EnvelopeApprovalPaymaster)
already followed this convention.

What stayed:
  - File paths (PeanutV4.4.sol, PeanutBatcherV4.4.sol) — preserves upstream-diff
  - PEANUT_SALT constant — its on-chain hash is baked into every signature; changing
    the value would break compatibility with anything using the salt convention
  - Author attribution (Squirrel Labs) — kept per GPL §5(d)
  - LICENSE-GPL, top-of-file modification notices — kept

Updated:
  - src/peanut/V4/PeanutV4.4.sol — contract name, EIP-712 domain string
  - src/peanut/V4/PeanutBatcherV4.4.sol — contract name + 5 type refs
  - 11 test files — type refs in imports + state vars + new() calls
  - test/peanut/mocks/L2ECOMock.sol — 1 type ref in comment
  - hardhat-deploy/DeployPeanut.ts — contract name strings for deploy + verify
  - src/peanut/doc/* — symbol references throughout, naming-convention section

Test contract names also updated for consistency:
  PeanutV4Test → EnvelopeVaultTest, PeanutV4DepositTest → EnvelopeVaultDepositTest, etc.

Sepolia redeployed (old addresses orphaned; old paymaster drained ~0.0015 ETH back):
  EnvelopeVault              0x32D02E54EaE5F8Bba75129e9306e0b8b70f05f6a
  EnvelopeBatcher            0x5DAe00DDFA1F96Aaf75d21F49B6FF5C756174816
  EnvelopeApprovalPaymaster  0xc160C8F6faC916De00B55aA0a630eBdce43CD532
All three verified. Paymaster funded with 0.0015 ETH and seeded with the
deployer EOA as Mode B operator + the new vault as Mode B target.

The vault's EIP-712 domain change ("Peanut" → "Envelope") invalidates any
gasless-reclaim signatures produced under the old domain. We had none in
production, so nothing breaks.

forge test 966/966. yarn lint 0 errors. yarn spellcheck 0 issues.
…oc filenames)

Aligns the rest of the developer-facing surface with the Envelope brand.
Pure cosmetic — no behavior change, no redeploy.

Env var renames (deploy scripts read the new names):
  PEANUT_V4              → ENVELOPE_VAULT
  PEANUT_BATCHER         → ENVELOPE_BATCHER
  PEANUT_MFA_AUTHORIZER  → ENVELOPE_MFA_AUTHORIZER
  PEANUT_ECO_TOKEN       → ENVELOPE_ECO_TOKEN
  PEANUT_DEPLOY_BATCHER  → ENVELOPE_DEPLOY_BATCHER

Test file renames (no class names changed — those were already done in 9989954):
  test/peanut/PeanutV4.t.sol         → EnvelopeVault.t.sol
  test/peanut/PeanutBatcher.t.sol    → EnvelopeBatcher.t.sol
  test/peanut/PeanutHardening.t.sol  → EnvelopeHardening.t.sol
  test/peanut/PeanutEdgeCases.t.sol  → EnvelopeEdgeCases.t.sol
  test/peanut/PeanutV4Gasless.t.sol  → EnvelopeGasless.t.sol

Doc file renames:
  src/peanut/doc/PeanutV4.md         → EnvelopeVault.md
  src/peanut/doc/PeanutBatcherV4.md  → EnvelopeBatcher.md

What stays "Peanut" (intentional):
  - File paths src/peanut/V4/PeanutV4.4.sol etc. — preserves upstream-diff lineage
  - PEANUT_SALT constant — its hash is in every signature digest
  - GPL §5(d) attribution comments (`// @author Squirrel Labs`,
    `peanutprotocol/peanut-contracts@main`) — required by the license
  - README mentions "Peanut Protocol V4.4" as the upstream — that's a fact

ACTION REQUIRED for users with .env-test:
  - Rename PEANUT_V4              → ENVELOPE_VAULT
  - Rename PEANUT_BATCHER         → ENVELOPE_BATCHER
  - Rename PEANUT_MFA_AUTHORIZER  → ENVELOPE_MFA_AUTHORIZER
  (PEANUT_ECO_TOKEN and PEANUT_DEPLOY_BATCHER probably weren't set anyway.)

forge test 966/966. yarn lint 0 errors. yarn spellcheck 0 issues.
Douglasacost and others added 19 commits May 13, 2026 22:32
…eanut → test/envelope

Final cosmetic cleanup. No behavior change, no redeploy.

Path rewrites covered:
  - test imports: ../../src/peanut/V4/PeanutV4.4.sol → ../../src/envelope/V4/PeanutV4.4.sol
  - mock imports: ../../../src/peanut/util/IEIP3009.sol → ../../../src/envelope/util/IEIP3009.sol
  - paymaster test: ../peanut/mocks/SampleSCW.sol → ../envelope/mocks/SampleSCW.sol
  - .solhintignore: src/peanut/V4/* → src/envelope/V4/*
  - hardhat-deploy verify args: contract: "src/peanut/V4/PeanutV4.4.sol:EnvelopeVault"
                                       → "src/envelope/V4/PeanutV4.4.sol:EnvelopeVault"
  - all spec docs (README + EnvelopeVault.md + EnvelopeBatcher.md + EnvelopeApprovalPaymaster.md)
  - LICENSE-GPL stays where it is (now at src/envelope/V4/LICENSE-GPL); modification
    notices in PeanutV4.4.sol / PeanutBatcherV4.4.sol point at the new path.

What stays "Peanut" in the tree (all intentional):
  - File names PeanutV4.4.sol, PeanutBatcherV4.4.sol — preserves per-file diff to upstream
  - PEANUT_SALT constant — its hash is in every signature
  - GPL §5(d) attribution comments — required by the license

forge test 966/966. yarn lint 0 errors. yarn spellcheck 0 issues.
…est classes, locals, PEANUT_SALT, comments)

Final cleanup pass after the directory rename. No on-chain bytecode change.

Renames:
  - hardhat-deploy/DeployPeanut.ts → DeployEnvelope.ts
  - PEANUT_SALT constant → ENVELOPE_SALT (value unchanged at
    0x70adbbeb…d94e0; preimage comment kept for auditor clarity).
    Per user decision (Option 2): symbol-only rename preserves signature-
    scheme interop with the upstream Peanut SDK.
  - test contract classes:
      PeanutHardeningTest → EnvelopeHardeningTest
      PeanutEdgeCasesTest → EnvelopeEdgeCasesTest
      PeanutBatcherTest   → EnvelopeBatcherTest
  - local variables in source + tests + deploy script:
      peanut, peanutV4, nodlePeanut, peanutV4ECO → vault, nodleVault, vaultECO
  - function parameters in batcher source:
      _peanutAddress → _vaultAddress
  - test hash preimages (just nonces, safe to change):
      nodle.peanut.* → nodle.envelope.*
  - prose comments mentioning "Peanut" where it wasn't required attribution:
      "the new peanut instance" → "the new envelope vault"
      "peanut depositor" → "envelope depositor"
      "different Peanut deployment" → "different Envelope deployment"
      DeployPeanut.ts header "Peanut Protocol suite" → "Envelope (vendored Peanut V4.4) suite"

What still says "Peanut" (every occurrence is intentional):
  - File names PeanutV4.4.sol, PeanutBatcherV4.4.sol — preserves
    per-file diff to upstream
  - GPL §5(d) attribution: `// @author Squirrel Labs`,
    `// @title Peanut Protocol`, `peanutprotocol/peanut-contracts@main`
  - ENVELOPE_SALT keccak preimage: "Konrad makes tokens go woosh tadam"
    (kept as documentation of how the constant value was derived)
  - Factual upstream identification in README + .solhintignore + deploy
    script header (e.g., "Vendored Envelope (Peanut V4.4) sources")

Verified: forge test 966/966. yarn lint 0 errors. yarn spellcheck 0
issues / 242 files. Local build artifacts in deployments-zk/ remain
gitignored.
…ol, PeanutBatcherV4.4.sol → EnvelopeBatcher.sol

Final consistency pass after the directory rename + contract symbol rename
+ env var rename + deploy script rename. The "V4.4" suffix is upstream's
versioning, not Envelope's; dropped.

What changed (path-string rewrites only):
  - Batcher's `import {EnvelopeVault} from "./PeanutV4.4.sol"` → `EnvelopeVault.sol`
  - 11 test imports + 2 mock cross-tree imports
  - DeployEnvelope.ts verify args:
      contract: "src/envelope/V4/PeanutV4.4.sol:EnvelopeVault"
        → "src/envelope/V4/EnvelopeVault.sol:EnvelopeVault"
  - Same for batcher
  - .solhintignore (2 entries)
  - Doc spec references throughout

Audit lineage to upstream peanutprotocol/peanut-contracts@main is still
preserved by:
  - GPL §5(d) attribution (`// @title Peanut Protocol`, `// @author Squirrel Labs`)
  - `// Modified by Nodle (2026-05-12)` notice atop each file
  - LICENSE-GPL bundled at src/envelope/V4/LICENSE-GPL
  - Git history (rename detected as 99% similarity)
  - README mention of "vendored Peanut Protocol V4.4"

forge test 966/966. yarn lint 0 errors. yarn spellcheck 0 issues.
…eBatcher and EnvelopeVault

This change modifies the Solidity pragma directive in both EnvelopeBatcher.sol and EnvelopeVault.sol to ensure compatibility with newer compiler versions while maintaining the existing functionality.
Vault, batcher, and paymaster were redeployed on 2026-05-13 because the
EIP-712 vault domain `name` flipped from "Peanut" to "Envelope" — the
prior on-chain instances were inconsistent with the renamed source.
…eposits

The existing makeCustomDeposit pulls tokens from msg.sender, which means
an operator submitting the deposit tx (e.g. via paymaster Mode B) can
only fund deposits from the operator's own balance — even when the user
has approved the vault. makeCustomDepositFrom takes a `_from` parameter
and pulls via standard transferFrom against `_from`'s allowance, so the
operator can submit while the user funds.

Native ETH (contractType 0) is rejected: there is no allowance model
for native ETH, so an operator cannot pull from a third party. ETH
deposits must continue to use makeCustomDeposit directly from the funder.

The authorization model is the standard ERC-20 / 721 / 1155 allowance
semantic — granting allowance to the vault is consent for any caller
to invoke transferFrom up to that allowance. Same trust model as DEX
routers, etc.

Combined with paymaster Mode A (sponsoring approve) + Mode B (sponsoring
makeCustomDepositFrom), this gives the canonical Path C UX: user signs
one EIP-712 grant, sends one tx (approve), operator handles the rest.

13 tests covering all four token contract types, allowance/balance
failures, ECO gating, ETH rejection, dual-zero auth-field rejection,
and a regression that the original makeCustomDeposit semantics are
unchanged. Full repo: 979/979 pass (was 966).

Redeployed on ZkSync Sepolia:
  EnvelopeVault     0xed414522b1Fbe08EEfd156f912a57CF345A55735
  EnvelopeApprovalPaymaster  0xbA6a646B316f27fF5b2CE4B504da49Ebe400d5AD
  EnvelopeBatcher (unchanged source) 0xe8c0aEC0F90f99968B2bf517ECa2BBd41A4926c1
The operator-relayed deposit flow has been retired:
  - The operator/controller submits its own txs and pays its own gas;
    no paymaster needed for that path.
  - The user pays for their own approve and their own deposit.

So both pieces of the operator-orchestration stack come out:

EnvelopeApprovalPaymaster
  - src/paymasters/EnvelopeApprovalPaymaster.sol (deleted)
  - test/paymasters/EnvelopeApprovalPaymaster.t.sol (deleted, was 27 tests)
  - hardhat-deploy/DeployEnvelopePaymaster.ts (deleted)
  - src/envelope/doc/EnvelopeApprovalPaymaster.md (deleted)
  - On-chain (Sepolia): paymaster 0xbA6a646B drained (0.00121 ETH → deployer),
    no longer referenced in .env-test.

makeCustomDepositFrom (and its _pullTokensFromViaApproval helper)
  - Removed from src/envelope/V4/EnvelopeVault.sol; vault modification notice
    reverted to its pre-2026-05-14 form.
  - test/envelope/MakeCustomDepositFrom.t.sol deleted (was 13 tests).
  - The deployed vault 0xed414522 still has this function as inert dead code;
    no on-chain redeploy bundled with this commit.

Knock-on cleanup:
  - .cspell.json: drop 'funder' (only use was in the removed code)
  - src/envelope/doc/README.md: drop paymaster row, simplify Path C, refresh
    test counts (979 → 939)
  - src/envelope/doc/EnvelopeVault.md: remove makeCustomDepositFrom row,
    drop the Operator-orchestrated deposits section, refresh test counts

Full repo: 939/939 tests pass. cspell: 0/238 issues. hardhat compile: clean.
Vault redeployed cleanly without makeCustomDepositFrom in the bytecode.
Old: 0xed414522b1Fbe08EEfd156f912a57CF345A55735 (had inert dead code)
New: 0x5cf96a5db415801E52a63f216AEE601FAB6B8b11 (chain == source)

Batcher unchanged. Paymaster gone (removed in 1b55826).
- Remove contractType 4 (ECO) and all IL2ECO/ecoAddress references
- Delete IL2ECO.sol and L2ECOMock.sol
- Convert all require() strings to custom errors
- Remove 'DEPOSIT MUST HAVE AUTH' check from _storeDeposit
- Rename MFA_AUTHORIZER to mfaAuthorizer (immutable, lowercase)
- Constructor now takes single arg: address _mfaAuthorizer
- Update all test files to match new interface
- Inherit Ownable2Step; owner passed in constructor alongside mfaAuthorizer
- withdrawMFADeposit now accepts serviceFee + gasAbsorptionFee params
- Both fee amounts are included in MFA signature (tamper-proof, backend-determined)
- Fees deducted from deposit amount at MFA withdrawal, accumulated per-token
- withdrawFees(address token) onlyOwner (pull pattern)
- No on-chain percentage/cap config — backend has full fee discretion
- New errors: FeeExceedsDepositAmount, NoFeesToWithdraw
- New events: FeeCollected, FeesWithdrawn
- MFA test rewritten to use vm.sign (old hardcoded sig incompatible with new payload)
… via IPaymaster

- Remove all EIP-3009 based gasless deposit and withdrawal code
- Add IPaymaster interface for treasury/paymaster validation
- Add withdrawMFADepositSponsored: sponsored claim with serviceFee + gasAbsorptionFee
- Add withdrawDepositSenderSponsored: sponsored reclaim via EIP-712 sender auth
- gasAbsorptionFee goes directly to treasury, serviceFee accumulates for owner
- Delete IEIP3009.sol, EIP3009 mocks, and EnvelopeGasless tests
- Add Sponsored.t.sol with 6 tests covering happy path and reverts
- Update EnvelopeBatcher, RecipientBound, EdgeCases, ERC20Mock for new signatures
- Backend-signed MFA messages now include a deadline (block.timestamp)
- deadline=0 means no expiry (signature never expires)
- Add MfaSignatureExpired error, checked before signature verification
- Deadline included in both withdrawMFADeposit/Sponsored and
  withdrawDepositSenderSponsored MFA hash preimages
- Add 4 deadline-specific tests (valid deadline, expired claim,
  expired reclaim, zero=no-expiry)
…eader

Move EnvelopeVault.sol from V4/ and IEnvelopeGaslessValidator.sol from
util/ directly into src/envelope/. Remove the large Peanut Protocol
ASCII art header; retain one-line GPL attribution as legally required.

Prior work acknowledgment: the core deposit/claim/signature scheme in
EnvelopeVault.sol originated from peanutprotocol/peanut-contracts V4.4
by Squirrel Labs (GPL-3.0-or-later). The GPL license and SPDX
identifier are preserved.
…laim/reclaim

Comprehensive rename of EnvelopeVault's public surface to make the
contract's mental model explicit for AI agents and human readers:

Structs:
  Deposit → Link, DepositRequest → LinkRequest

Fields:
  pubKey20 → claimKey, claimed → redeemed, senderAddress → creator

Constants:
  ANYONE_WITHDRAWAL_MODE → OPEN_CLAIM_MODE
  RECIPIENT_WITHDRAWAL_MODE → BOUND_CLAIM_MODE

Link creation (formerly 'deposit'):
  makeDeposit → createLink
  makeMFADeposit → createMFALink
  makeSelflessDeposit → createLinkFor
  makeSelflessMFADeposit → createMFALinkFor
  makeCustomDeposit → createCustomLink
  makeCustomDepositWithFees → createLinkWithFees
  makeBatch* → createLinks / createCustomLinks / createRaffleLinks etc.

Claiming (formerly 'withdraw'):
  withdrawDeposit → claim
  withdrawMFADeposit → claimWithMFA
  withdrawDepositAsRecipient → claimAsBoundRecipient
  withdrawDepositSender → reclaim

Views:
  getDepositCount → getLinkCount
  getDeposit → getLink
  getAllDeposits → getAllLinks
  getAllDepositsForAddress → getLinksCreatedBy

Errors:
  DepositIndexOutOfBounds → LinkIndexOutOfBounds
  DepositAlreadyClaimed → LinkAlreadyRedeemed
  NotTheSender → NotTheCreator

Events:
  DepositEvent → LinkCreated
  WithdrawEvent → LinkRedeemed

All 207 tests pass.
The contract custodies link-based gifts, not a vault in the DeFi sense.
EnvelopeLinks better describes the mental model: create links,
recipients claim them, creators reclaim them.

Also:
- Fix cspell dictionary (Reown, CBOR, Remy, konlet)
- Update docs: fix stale function names in README and flow tables
- Add post-deployment cast smoke test recipes to EnvelopeLinks.md
- Rename paymaster internals: envelopeVault → envelopeLinks
@aliXsed aliXsed changed the title feat(peanut): vendor Peanut V4.4 + EnvelopeApprovalPaymaster, deploy to ZkSync Sepolia feat(envelope): EnvelopeLinks — link-based gift escrow with gasless claims May 20, 2026
aliXsed added 5 commits May 20, 2026 20:21
…aymaster

Targets gaps from spec-driven analysis (avoiding reading function bodies):
- ERC-721/ERC-1155 claim paths
- createLinkFor (selfless non-MFA)
- createCustomLink with recipient binding
- claimWithMFA on recipient-bound links
- withdrawFees (ERC-20 + no-fees revert + non-owner revert)
- supportsInterface (ERC165, ERC721Receiver, ERC1155Receiver, unknown)
- isValidGaslessOperation edge cases (short data, unknown selector,
  index OOB, wrong creator, already redeemed, before reclaimableAfter,
  caller≠recipient, no gasless eligibility, expired MFA, bound claim)
- createCustomLinks mixed ETH+ERC20, ERC-721, ERC-1155 batches
- createCustomLinksWithFees heterogeneous batch + error paths
- createLinks ERC-1155, ERC-721 revert, invalid type, wrong ETH
- createLinksNoReturn
- createMFARaffleLinks, raffle error paths
- Claim with no claimKey (address(0))
- ETH transfer failure on claim and reclaim
- Reclaim ERC-721 and ERC-1155
- Reclaim recipient-bound after deadline
- View function tests (getAllLinks, getLinksCreatedBy, getLinkCount)
- getSigner utility

Also:
- Make links array internal (public getter with 17-field struct causes
  stack-too-deep in forge coverage; explicit getLink() already exists)
- Add coverage-related words to cspell dictionary

Total: 268 tests pass (207 existing + 61 new)
…d coverage

- Split monolithic Link struct into LinkStatus, LinkAsset, LinkParties, LinkFees
  sub-structs to resolve stack-too-deep errors during coverage instrumentation.
  This fixes the CI coverage job that was failing with a Yul stack overflow.

- Add 25 coverage tests targeting realistic flows:
  - withdrawFees ETH path (success + revert on rejection)
  - Claim/reclaim ERC-721 and ERC-1155 links created with fees
  - createCustomLinksWithFees heterogeneous batches including NFTs
  - Gasless validation: sponsored links, MFA-gated links, wrong signatures,
    already-redeemed links, out-of-bounds indexes, bound-recipient mismatches
  - createLinksNoReturn batch, createRaffleLinks ERC-20, onERC1155BatchReceived
  - getLinkIndexesCreatedBy with multiple creators
  - EnvelopePaymaster catch branch (malformed calldata)

Coverage results (--ir-minimum):
  EnvelopeLinks:    90.65% lines, 91.65% stmts, 82% branches, 100% funcs
  EnvelopePaymaster: 100% across all metrics

Remaining uncovered lines are assembly blocks (fee authorization encoding)
and IR source-mapping artifacts for return statements — all logically exercised.
- add DeployEnvelopeZkSync.s.sol for EnvelopeLinks and optional EnvelopePaymaster
- validate Hardhat and Forge deployment routes on a local node-zksync instance
- document the tested forge command, including zkSync-incompatible file skips
- extend verify_zksync_contracts.py to recover unnamed zkSync broadcast deployments by script sequence
- register Envelope contracts in the verifier source map
- fix cspell issues introduced by the new Envelope coverage tests
Explains why a Router/Forwarder is unnecessary: direct vault approval
gives the same UX (2 one-time approvals + 1 tx per link) without
extra gas overhead or contract complexity.
* feat(envelope): security hardening for production deployment

Implements findings from final security review:

H-1: Balance-delta measurement for ERC-20 deposits (fee-on-transfer safety)
     - _pullTokensViaApprovalFrom measures balanceOf delta, not requested amount
     - Batch functions use actual received / count for per-link amounts
     - Raffle links revert with InsufficientTokensReceived for FOT tokens

H-2: Make mfaAuthorizer mutable for key rotation
     - Removed immutable, added setMfaAuthorizer(address) onlyOwner
     - Emits MfaAuthorizerUpdated event

M-1: Guard _isMfaSignatureValid and _verifyMfaSignature against address(0)
     - claimWithMFA reverts with MfaAuthorizerIsZero when authorizer is unset
     - Paymaster validation returns false instead of matching address(0)

M-2: Reject unbound links in claimAsBoundRecipient
     - Reverts with LinkNotRecipientBound if link.parties.recipient == address(0)

M-3: Reject recipientAddress == address(0) in all claim paths
     - _executeClaim reverts with ZeroRecipientAddress
     - _isValidClaim returns false for zero recipient (paymaster path)

M-4: Fee-authorization replay protection
     - usedFeeAuthorizations mapping tracks consumed signature hashes
     - Reverts with FeeAuthorizationAlreadyUsed on replay

L-1: Remove dead DOMAIN_SEPARATOR state and EIP712Domain struct/hash function
L-8: Explicit InvalidContractType revert in _pullUniformBatchAssets
L-9: Remove constructor debug MessageEvent emit

Also adds EnvelopeSecurity.t.sol with 13 targeted tests covering all findings,
updates documentation with security properties section, and fixes spellcheck.

* fix(envelope): finalize security hardening follow-up changes

* feat(envelope): migrate signatures to EIP-712 typed structured data

Replace all custom EIP-191 signature schemes with EIP-712:
- Inherit OpenZeppelin EIP712 (domain: 'EnvelopeLinks', version '5')
- Define CLAIM_TYPEHASH, MFA_APPROVAL_TYPEHASH, FEE_AUTHORIZATION_TYPEHASH
- Remove ENVELOPE_SALT constant and assembly-based fee auth digest helpers
- All three signature sites now use _hashTypedDataV4(keccak256(abi.encode(...)))

Benefits:
- Domain separator includes chainId + verifyingContract (cross-chain/contract replay protection)
- Wallet-readable signing prompts (EIP-712 structured data)
- Compliant with EIP-5267 (eip712Domain() getter)
- Removes ~80 lines of hand-rolled assembly for fee auth digest

Adds EnvelopeEIP712.t.sol with 19 dedicated tests covering:
- Domain separator correctness and uniqueness
- Cross-chain and cross-contract replay protection
- Typehash verification
- Claim mode discrimination
- Fuzz tests for arbitrary recipients and deadlines
- EIP-5267 getter validation

Updates all existing test helpers to use shared EnvelopeEIP712Utils library.
All 1072 tests pass. Spellcheck clean.

* paymaster: raise gasless attempt limit to 3

One attempt was too strict — legitimate retries (wrong gas limit,
receiver contract not deployed, token paused then unpaused) would
permanently consume the user's gasless quota on the first failure.

MAX_GASLESS_ATTEMPTS_PER_LINK = 3 gives users room for honest retries
while keeping paymaster sponsorship liability bounded.  The test for
GaslessAttemptLimitReached now exhausts all 3 allowed attempts before
asserting the revert.

* envelope: finalize pre-prod security hardening (H-1, M-1..M-4, L-1, L-2, L-6, L-7)

Security fixes:
- H-1: Reject zero claimKey only when also recipient-bound (allows recipient-only links)
- M-1: Constructor + setMfaAuthorizer reject zero authorizer
- M-2: Enforce no ETH fees (accumulatedFees scalar, withdrawFees no-arg)
- M-3: claimWithMFA bounds-checks index, rejects non-MFA links
- M-4: Batch functions early-return on empty list, revert on uneven division
- L-1: Fee replay protection uses EIP712 digest
- L-2/L-6/L-7: Consolidate signature recovery via _recoverSigner, add NatSpec

All 220 envelope tests pass.
@aliXsed aliXsed merged commit 3409cdb into main May 21, 2026
3 checks passed
@aliXsed aliXsed deleted the feat/peanut-protocol branch May 21, 2026 05:05
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.

3 participants