From f0ffbfeabe66acc03a1987c3aa61526dd7b03415 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 30 Apr 2026 16:54:36 -0400 Subject: [PATCH] docs(envelopes): add envelopes technical specification and integration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specifies a bearer-link asset transfer system: a sender wraps assets in an on-chain envelope, gets a shareable URL containing a fresh ECDSA private key in the URL fragment, and anyone holding the URL can claim the contents through the app. Front-run-safe by construction — claim signature commits to the claimer's address, so a third party who observes a claim transaction cannot hijack it. Architecture: one BaseEnvelope abstract + four concrete contracts (EthEnvelope, Erc20Envelope, Erc721Envelope, Erc1155Envelope), each multi-tenant and immutable. Sender chooses single-claim or multi-claim per envelope; equal-share slots; on-chain dedup by claimer address. Optional expiration with sender reclaim post-expiry. Configurable flat per-envelope fee in ETH (default 0). Permissionless creation; no operator role. Two documents under src/envelopes/doc/: - spec/envelopes-specification.md — full technical specification (architecture, roles, interfaces, flows, storage, security model, testing strategy, deployment & ops, file layout, deferred items). - integration-guide.md — operational runbook mirroring src/swarms/doc/upgradeable-contracts.md: TypeScript/ethers.js v6 examples for sender create flows across all four asset classes, URL parsing and EIP-712 claim signing, recipient claim and reclaim flows, admin operations (setFee, setTreasury, pause/ unpause, role rotation), bug-fix rollout (replace, don't upgrade) with pre-replace and post-replace checklists, monitoring (events to index, cast event-streaming, health signals), and security considerations covering the bearer model's limits, front-run safety, reentrancy, and pause semantics. Operational sections describe the actual zkSync flow: deployment via a Forge script + ops/ shell wrapper mirroring the swarms pattern, source-code verification via ops/verify_zksync_contracts.py. Adds Linkdrop, keypair, typedata, autonumber, dedup, exfiltrated, and runbook to the project cspell ignoreWords list. --- .cspell.json | 9 +- src/envelopes/doc/README.md | 10 + src/envelopes/doc/integration-guide.md | 831 ++++++++++++++++ .../doc/spec/envelopes-specification.md | 936 ++++++++++++++++++ 4 files changed, 1785 insertions(+), 1 deletion(-) create mode 100644 src/envelopes/doc/README.md create mode 100644 src/envelopes/doc/integration-guide.md create mode 100644 src/envelopes/doc/spec/envelopes-specification.md diff --git a/.cspell.json b/.cspell.json index 77c5ae9..c905c4b 100644 --- a/.cspell.json +++ b/.cspell.json @@ -101,6 +101,13 @@ "hexlify", "repoint", "repointed", - "cutover" + "cutover", + "autonumber", + "dedup", + "Linkdrop", + "keypair", + "typedata", + "exfiltrated", + "runbook" ] } diff --git a/src/envelopes/doc/README.md b/src/envelopes/doc/README.md new file mode 100644 index 0000000..479b0a9 --- /dev/null +++ b/src/envelopes/doc/README.md @@ -0,0 +1,10 @@ +# Envelopes — Documentation + +Bearer-link asset transfer: a sender wraps ETH, ERC-20, ERC-721, or ERC-1155 assets in an on-chain envelope, gets a shareable URL, and anyone holding the URL can claim the contents through the app. Front-run-safe by construction (claim signature commits to the claimer's address). + +## Contents + +| Document | Description | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| [spec/envelopes-specification.md](spec/envelopes-specification.md) | Full technical specification (architecture, roles, interfaces, flows, storage, security, testing, ops) | +| [integration-guide.md](integration-guide.md) | Operational runbook — TypeScript/ethers.js integration, Cast CLI, sender + app + recipient flows, admin operations, bug-fix rollout | diff --git a/src/envelopes/doc/integration-guide.md b/src/envelopes/doc/integration-guide.md new file mode 100644 index 0000000..ec243e5 --- /dev/null +++ b/src/envelopes/doc/integration-guide.md @@ -0,0 +1,831 @@ +# Envelopes — Integration & Operations Guide + +This document covers how senders, apps, and operators interact with the deployed Envelopes contracts. The full technical specification lives in [`spec/envelopes-specification.md`](spec/envelopes-specification.md). + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Deployment](#deployment) +4. [Sender Integration](#sender-integration) +5. [App Integration (URL & Claim Signing)](#app-integration-url--claim-signing) +6. [Recipient / Claim Flow](#recipient--claim-flow) +7. [Reclaim Flow](#reclaim-flow) +8. [Admin Operations](#admin-operations) +9. [Bug-Fix Rollout (Replace, Don't Upgrade)](#bug-fix-rollout-replace-dont-upgrade) +10. [Monitoring & Observability](#monitoring--observability) +11. [Security Considerations](#security-considerations) + +--- + +## Overview + +Envelopes are bearer-link asset transfers: a sender wraps ETH, ERC-20, ERC-721, or ERC-1155 assets in an on-chain envelope, the app produces a shareable URL containing a fresh ECDSA private key in its fragment, and anyone holding the URL can claim the contents through the app. + +Four contracts are deployed independently, one per asset class: + +| Contract | Asset class | Deployed once, immutable | +| :----------------- | :---------------- | :------------------------------------------------------ | +| `EthEnvelope` | Native ETH | `msg.value` carries both the asset payload and the fee | +| `Erc20Envelope` | ERC-20 | Sender approves token amount; fee in ETH | +| `Erc721Envelope` | ERC-721 | Sender approves NFTs; provides `tokenIds[]`; fee in ETH | +| `Erc1155Envelope` | ERC-1155 | Sender approves the 1155 token; fee in ETH | + +All four extend a shared `BaseEnvelope` abstract that owns claim verification, slot bookkeeping, expiry, reclaim, fee accounting, and pause control. The full design is in the spec. + +--- + +## Architecture + +### Contract Layout + +Unlike `src/swarms/`, envelopes are **not** UUPS-upgradeable. Each contract is deployed once and is fixed in behavior for its lifetime. Bug fixes ship as new deployments — see [Bug-Fix Rollout](#bug-fix-rollout-replace-dont-upgrade). + +``` +┌─────────────────────────────────────────────┐ +│ BaseEnvelope (abstract) │ +│ ─ claim(envelopeId, claimer, signature) │ +│ ─ reclaim(envelopeId) │ +│ ─ EIP-712 domain + signature recovery │ +│ ─ envelopes mapping (per envelope state) │ +│ ─ claimed mapping (per-claimer dedup) │ +│ ─ fee + treasury, pause, access control │ +│ ─ _payout / _refund / _clearSubclassStorage │ +│ (abstract) │ +└──────────────┬──────────────────────────────┘ + │ extends + ┌───────────┼───────────────┐ + ▼ ▼ ▼ ▼ +EthEnvelope Erc20Envelope Erc721Envelope Erc1155Envelope +``` + +### Envelope Identity + +Each envelope is identified by the **public address** of a fresh ECDSA keypair generated by the app at creation time. The corresponding private key lives in the URL fragment shared with recipients. There is no envelope counter and no factory storage of issued IDs — the contract's `mapping(address => Envelope)` is the registry. + +### Lifecycle States + +| State | Condition | What works | +| :----------- | :---------------------------------------------------------------------------------- | :---------------------- | +| `ACTIVE` | `slotsClaimed < slotsTotal` AND `(expiresAt == 0 OR block.timestamp < expiresAt)` | claim | +| `EXHAUSTED` | `slotsClaimed == slotsTotal` | (nothing — fully drained) | +| `EXPIRED` | `expiresAt != 0 AND block.timestamp >= expiresAt` AND slots still remaining | reclaim | +| `RECLAIMED` | sender called `reclaim` | (nothing — fully cleared) | + +--- + +## Deployment + +### Fresh Deployment on zkSync + +Use the orchestration script (mirrors `ops/deploy_swarm_contracts_zksync.sh`): + +```bash +# Testnet (dry run) +./ops/deploy_envelopes_zksync.sh testnet + +# Testnet (broadcast) +./ops/deploy_envelopes_zksync.sh testnet --broadcast + +# Mainnet (broadcast) +./ops/deploy_envelopes_zksync.sh mainnet --broadcast +``` + +Required environment variables (loaded from `.env-test` or `.env-prod`): + +| Variable | Description | +| :------------------------------ | :------------------------------------------------------------------- | +| `DEPLOYER_PRIVATE_KEY` | Funded with ETH for gas | +| `N_ENVELOPES_ADMIN` | Multisig holding `DEFAULT_ADMIN_ROLE` on all four contracts | +| `N_ENVELOPES_PAUSER` | Address holding `PAUSER_ROLE` | +| `N_ENVELOPES_FEE_TREASURY` | Address that receives protocol fees | +| `N_ENVELOPES_INITIAL_FEE_WEI` | Initial fee in wei. Default `0` | + +The script: + +1. Compiles with `forge build --zksync`. +2. Calls `script/DeployEnvelopesZkSync.s.sol`, which deploys all four contracts in any order (no inter-contract dependencies). +3. Sanity-checks each deployment via `cast` (admin role, pauser role, treasury, fee). +4. Verifies source code on the zkSync block explorer via `python3 ops/verify_zksync_contracts.py` (see [Repo Convention on zkSync Verification](#repo-convention-on-zksync-verification)). +5. Appends addresses to the env file. + +### Output + +Save these addresses; they are the user-facing addresses (no proxies): + +``` +EthEnvelope: 0x... +Erc20Envelope: 0x... +Erc721Envelope: 0x... +Erc1155Envelope: 0x... +``` + +### Repo Convention on zkSync Verification + +Do **not** use `forge script --verify` — the zkSync verifier rejects the absolute paths it sends. Use the project's Python helper: + +```bash +python3 ops/verify_zksync_contracts.py \ + --broadcast broadcast/DeployEnvelopesZkSync.s.sol//run-latest.json \ + --verifier-url $VERIFIER_URL \ + --compiler-version 0.8.26 \ + --zksolc-version v1.5.15 \ + --project-root "$(pwd)" +``` + +Verifier URLs: + +- **Mainnet**: `https://zksync2-mainnet-explorer.zksync.io/contract_verification` (explorer at `https://explorer.zksync.io`) +- **Testnet**: `https://explorer.sepolia.era.zksync.dev/contract_verification` (explorer at `https://sepolia.explorer.zksync.io`) + +With `bytecode_hash = "none"` already set in `foundry.toml`, this achieves full verification. + +--- + +## Sender Integration + +### TypeScript / ethers.js v6 — Setup + +```typescript +import { ethers } from "ethers"; + +import EthEnvelopeABI from "./artifacts/EthEnvelope.json"; +import Erc20EnvelopeABI from "./artifacts/Erc20Envelope.json"; +import Erc721EnvelopeABI from "./artifacts/Erc721Envelope.json"; +import Erc1155EnvelopeABI from "./artifacts/Erc1155Envelope.json"; + +const ADDRESSES = { + ethEnvelope: process.env.ETH_ENVELOPE!, + erc20Envelope: process.env.ERC20_ENVELOPE!, + erc721Envelope: process.env.ERC721_ENVELOPE!, + erc1155Envelope: process.env.ERC1155_ENVELOPE!, +}; + +const provider = new ethers.JsonRpcProvider(process.env.RPC_URL); +const sender = new ethers.Wallet(process.env.SENDER_PRIVATE_KEY!, provider); + +const ethEnvelope = new ethers.Contract(ADDRESSES.ethEnvelope, EthEnvelopeABI.abi, sender); +const erc20Envelope = new ethers.Contract(ADDRESSES.erc20Envelope, Erc20EnvelopeABI.abi, sender); +const erc721Envelope = new ethers.Contract(ADDRESSES.erc721Envelope, Erc721EnvelopeABI.abi, sender); +const erc1155Envelope = new ethers.Contract(ADDRESSES.erc1155Envelope, Erc1155EnvelopeABI.abi, sender); +``` + +### Generating the Envelope Keypair + +The keypair is fresh per envelope. The private key never leaves the user's device. + +```typescript +function freshEnvelope(): { envelopeId: string; privateKey: string } { + const wallet = ethers.Wallet.createRandom(); + return { envelopeId: wallet.address, privateKey: wallet.privateKey }; +} +``` + +### Create — ETH + +```typescript +async function createEthEnvelope(opts: { + slotAmountWei: bigint; + slotsTotal: number; + expiresAtSeconds: number; // 0 = never expires +}): Promise<{ envelopeId: string; privateKey: string; txHash: string }> { + const { envelopeId, privateKey } = freshEnvelope(); + + const fee: bigint = await ethEnvelope.feeWei(); + const total = opts.slotAmountWei * BigInt(opts.slotsTotal); + const msgValue = fee + total; + + const tx = await ethEnvelope.createEnvelope( + envelopeId, + opts.slotAmountWei, + opts.slotsTotal, + opts.expiresAtSeconds, + { value: msgValue }, + ); + const receipt = await tx.wait(); + + return { envelopeId, privateKey, txHash: receipt.hash }; +} +``` + +### Create — ERC-20 + +```typescript +async function createErc20Envelope(opts: { + token: string; + slotAmount: bigint; + slotsTotal: number; + expiresAtSeconds: number; +}): Promise<{ envelopeId: string; privateKey: string; txHash: string }> { + const { envelopeId, privateKey } = freshEnvelope(); + + const fee: bigint = await erc20Envelope.feeWei(); + const total = opts.slotAmount * BigInt(opts.slotsTotal); + + // 1. Approve the envelope contract for the asset payload. + const erc20 = new ethers.Contract( + opts.token, + ["function approve(address,uint256) returns (bool)"], + sender, + ); + await (await erc20.approve(ADDRESSES.erc20Envelope, total)).wait(); + + // 2. Create the envelope. msg.value carries the fee only. + const tx = await erc20Envelope.createEnvelope( + envelopeId, + opts.token, + opts.slotAmount, + opts.slotsTotal, + opts.expiresAtSeconds, + { value: fee }, + ); + const receipt = await tx.wait(); + + return { envelopeId, privateKey, txHash: receipt.hash }; +} +``` + +### Create — ERC-721 + +```typescript +async function createErc721Envelope(opts: { + token: string; + tokenIds: bigint[]; // length = slotsTotal + expiresAtSeconds: number; +}): Promise<{ envelopeId: string; privateKey: string; txHash: string }> { + const { envelopeId, privateKey } = freshEnvelope(); + + const fee: bigint = await erc721Envelope.feeWei(); + + // 1. Approve. setApprovalForAll is gas-cheaper than per-token approve when N > 1. + const nft = new ethers.Contract( + opts.token, + ["function setApprovalForAll(address,bool)"], + sender, + ); + await (await nft.setApprovalForAll(ADDRESSES.erc721Envelope, true)).wait(); + + // 2. Create. + const tx = await erc721Envelope.createEnvelope( + envelopeId, + opts.token, + opts.tokenIds, + opts.expiresAtSeconds, + { value: fee }, + ); + const receipt = await tx.wait(); + + return { envelopeId, privateKey, txHash: receipt.hash }; +} +``` + +### Create — ERC-1155 + +```typescript +async function createErc1155Envelope(opts: { + token: string; + id: bigint; + slotAmount: bigint; + slotsTotal: number; + expiresAtSeconds: number; +}): Promise<{ envelopeId: string; privateKey: string; txHash: string }> { + const { envelopeId, privateKey } = freshEnvelope(); + + const fee: bigint = await erc1155Envelope.feeWei(); + + const erc1155 = new ethers.Contract( + opts.token, + ["function setApprovalForAll(address,bool)"], + sender, + ); + await (await erc1155.setApprovalForAll(ADDRESSES.erc1155Envelope, true)).wait(); + + const tx = await erc1155Envelope.createEnvelope( + envelopeId, + opts.token, + opts.id, + opts.slotAmount, + opts.slotsTotal, + opts.expiresAtSeconds, + { value: fee }, + ); + const receipt = await tx.wait(); + + return { envelopeId, privateKey, txHash: receipt.hash }; +} +``` + +### Cast Examples — Sender + +```bash +export ETH_ENVELOPE=0x... +export RPC_URL=https://mainnet.era.zksync.io +export SENDER_KEY=0x... + +# Generate a fresh keypair off-chain +cast wallet new + +# Read the current fee +FEE=$(cast call $ETH_ENVELOPE "feeWei()(uint256)" --rpc-url $RPC_URL) + +# Create a single-claim ETH envelope (1 ETH, no expiry) +ENVELOPE_ID=0xbeef... +SLOT_AMOUNT=1000000000000000000 # 1 ETH in wei +SLOTS=1 +EXPIRES=0 +TOTAL_VALUE=$(cast --to-dec $(echo "$FEE + $SLOT_AMOUNT * $SLOTS" | bc)) + +cast send $ETH_ENVELOPE \ + "createEnvelope(address,uint128,uint16,uint96)" \ + $ENVELOPE_ID $SLOT_AMOUNT $SLOTS $EXPIRES \ + --value $TOTAL_VALUE \ + --rpc-url $RPC_URL --private-key $SENDER_KEY +``` + +--- + +## App Integration (URL & Claim Signing) + +The app is the bridge between the on-chain envelope and the recipient. It is responsible for: + +1. Generating the keypair at create time. +2. Building the share URL. +3. Parsing an incoming URL and reconstructing the keypair. +4. Producing a valid EIP-712 claim signature with the URL's private key. +5. Submitting the claim transaction (or relaying it). + +### URL Format + +Use the **fragment** (everything after `#`) to carry the private key. Browsers do **not** send the fragment to servers, so the key never appears in HTTP request lines, server logs, referrers, or analytics. + +``` +https://app.example.com/envelope//#k= +``` + +Recommended path layout: + +``` +asset = "eth" | "erc20" | "erc721" | "erc1155" +envelopeId = the public address of the keypair (lowercase, 0x-prefixed, 42 chars) +privateKey = the keypair's private key (lowercase, 0x-prefixed, 66 chars) +``` + +The `` segment lets the app pick the correct contract address before reading on-chain state. + +### Parsing an Incoming URL + +```typescript +import { ethers } from "ethers"; + +type Parsed = { + asset: "eth" | "erc20" | "erc721" | "erc1155"; + envelopeId: string; + wallet: ethers.Wallet; +}; + +function parseEnvelopeUrl(url: string): Parsed { + const u = new URL(url); + + // Path: /envelope// + const parts = u.pathname.split("/").filter(Boolean); + if (parts.length !== 3 || parts[0] !== "envelope") { + throw new Error("malformed envelope URL"); + } + const asset = parts[1] as Parsed["asset"]; + const envelopeId = ethers.getAddress(parts[2]); + + // Fragment: #k= + const frag = new URLSearchParams(u.hash.slice(1)); + const k = frag.get("k"); + if (!k) throw new Error("missing private key in URL fragment"); + + const wallet = new ethers.Wallet(k); + if (wallet.address.toLowerCase() !== envelopeId.toLowerCase()) { + throw new Error("envelopeId does not match private key"); + } + + return { asset, envelopeId, wallet }; +} +``` + +### Producing the EIP-712 Claim Signature + +The claim signature commits to the claimer's address. This is what makes the protocol front-run-safe — a third party who observes a claim transaction in the mempool cannot redirect the asset to their own address, because they cannot regenerate a valid signature for a different `claimer`. + +```typescript +type ContractDomain = { + name: string; // "NodleEnvelopes-ETH" / "-ERC20" / "-ERC721" / "-ERC1155" + address: string; // verifyingContract +}; + +const CONTRACT_DOMAINS: Record = { + eth: { name: "NodleEnvelopes-ETH", address: ADDRESSES.ethEnvelope }, + erc20: { name: "NodleEnvelopes-ERC20", address: ADDRESSES.erc20Envelope }, + erc721: { name: "NodleEnvelopes-ERC721", address: ADDRESSES.erc721Envelope }, + erc1155:{ name: "NodleEnvelopes-ERC1155",address: ADDRESSES.erc1155Envelope }, +}; + +async function signClaim(parsed: Parsed, claimer: string, chainId: number): Promise { + const cd = CONTRACT_DOMAINS[parsed.asset]; + + const domain = { + name: cd.name, + version: "1", + chainId, + verifyingContract: cd.address, + }; + + const types = { + Claim: [ + { name: "envelopeId", type: "address" }, + { name: "claimer", type: "address" }, + ], + }; + + const value = { + envelopeId: parsed.envelopeId, + claimer: ethers.getAddress(claimer), + }; + + return await parsed.wallet.signTypedData(domain, types, value); +} +``` + +### Submitting the Claim Transaction + +The submitting account does **not** have to be the claimer. Anyone (the app's relayer, a paymaster, a third party) can broadcast the claim — the signature commits to the claimer. + +```typescript +async function submitClaim(parsed: Parsed, claimer: string, signature: string) { + const contractByAsset = { + eth: ethEnvelope, + erc20: erc20Envelope, + erc721: erc721Envelope, + erc1155: erc1155Envelope, + } as const; + + const c = contractByAsset[parsed.asset]; + const tx = await c.claim(parsed.envelopeId, claimer, signature); + return await tx.wait(); +} +``` + +End-to-end: + +```typescript +async function claimFromUrl(url: string, claimer: string) { + const parsed = parseEnvelopeUrl(url); + const chainId = Number((await provider.getNetwork()).chainId); + const signature = await signClaim(parsed, claimer, chainId); + return await submitClaim(parsed, claimer, signature); +} +``` + +--- + +## Recipient / Claim Flow + +The recipient pastes the URL into the app. The app drives the rest. From the user's perspective: + +1. **Open URL** in the app. +2. App reads on-chain state (`envelopes(envelopeId)`) and shows: asset class, slot amount, slots remaining, expiry. +3. App asks the recipient for a destination wallet address (or auto-fills the connected wallet). +4. App signs the EIP-712 claim with the URL's private key (off-chain, in-app). +5. App broadcasts the claim transaction (or relays it through a sponsor). +6. Asset lands in the recipient's wallet. + +### Read Envelope State + +```typescript +async function getEnvelopeState(asset: Parsed["asset"], envelopeId: string) { + const c = { + eth: ethEnvelope, + erc20: erc20Envelope, + erc721: erc721Envelope, + erc1155: erc1155Envelope, + }[asset]; + + const e = await c.envelopes(envelopeId); + // tuple: (sender, expiresAt, slotAmount, slotsTotal, slotsClaimed) + const slotsRemaining = Number(e[3]) - Number(e[4]); + const expired = + e[1] !== 0n && BigInt(Math.floor(Date.now() / 1000)) >= e[1]; + + return { + sender: e[0] as string, + expiresAt: Number(e[1]), + slotAmount: e[2] as bigint, + slotsTotal: Number(e[3]), + slotsClaimed: Number(e[4]), + slotsRemaining, + state: + e[0] === ethers.ZeroAddress + ? "RECLAIMED" + : slotsRemaining === 0 + ? "EXHAUSTED" + : expired + ? "EXPIRED" + : "ACTIVE", + }; +} +``` + +### Has This Address Already Claimed? + +```typescript +async function hasClaimed( + asset: Parsed["asset"], + envelopeId: string, + candidate: string, +): Promise { + const c = { + eth: ethEnvelope, + erc20: erc20Envelope, + erc721: erc721Envelope, + erc1155: erc1155Envelope, + }[asset]; + return await c.claimed(envelopeId, candidate); +} +``` + +### Cast — Manual Claim + +If a recipient cannot use the app (no JS, raw cast user), they can still claim if they have a tool that produces an EIP-712 signature: + +```bash +# Recover the envelope state +cast call $ETH_ENVELOPE "envelopes(address)" $ENVELOPE_ID --rpc-url $RPC_URL + +# Sign EIP-712 with the URL's private key (cast wallet sign-typed-data) +SIG=$(cast wallet sign-typed-data \ + --private-key $URL_PRIVATE_KEY \ + '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Claim":[{"name":"envelopeId","type":"address"},{"name":"claimer","type":"address"}]},"primaryType":"Claim","domain":{"name":"NodleEnvelopes-ETH","version":"1","chainId":324,"verifyingContract":"'$ETH_ENVELOPE'"},"message":{"envelopeId":"'$ENVELOPE_ID'","claimer":"'$CLAIMER'"}}') + +# Submit +cast send $ETH_ENVELOPE \ + "claim(address,address,bytes)" \ + $ENVELOPE_ID $CLAIMER $SIG \ + --rpc-url $RPC_URL --private-key $RELAYER_KEY +``` + +--- + +## Reclaim Flow + +The original sender can reclaim any unclaimed slots **only after `expiresAt`**. Envelopes with `expiresAt == 0` are non-reclaimable by design. + +### TypeScript + +```typescript +async function reclaim(asset: Parsed["asset"], envelopeId: string) { + const c = { + eth: ethEnvelope, + erc20: erc20Envelope, + erc721: erc721Envelope, + erc1155: erc1155Envelope, + }[asset]; + + const tx = await c.reclaim(envelopeId); + return await tx.wait(); +} +``` + +### Cast + +```bash +cast send $ETH_ENVELOPE "reclaim(address)" $ENVELOPE_ID \ + --rpc-url $RPC_URL --private-key $SENDER_KEY +``` + +### Reverts + +| Error | When | +| :---------------- | :---------------------------------------------------------------- | +| `NotSender` | Caller is not the original `sender` (or sender already zeroed) | +| `NotYetExpired` | Before `expiresAt`, OR `expiresAt == 0` (never expires) | +| `EnvelopeNotFound` | Envelope was already reclaimed | + +The fee paid at creation is **not** refunded. + +--- + +## Admin Operations + +All admin operations are restricted to addresses holding `DEFAULT_ADMIN_ROLE` on the affected contract. Role rotation goes through `grantRole` / `revokeRole`. + +### Set the Protocol Fee + +```bash +# 0.001 ETH +cast send $ETH_ENVELOPE "setFee(uint256)" 1000000000000000 \ + --rpc-url $RPC_URL --private-key $ADMIN_KEY + +# Apply to the other three contracts the same way +cast send $ERC20_ENVELOPE "setFee(uint256)" 1000000000000000 ... +cast send $ERC721_ENVELOPE "setFee(uint256)" 1000000000000000 ... +cast send $ERC1155_ENVELOPE "setFee(uint256)" 1000000000000000 ... +``` + +Each contract holds its own `feeWei`. Keep the four values in sync with an ops script if you want a uniform protocol fee. + +### Set the Treasury + +```bash +cast send $ETH_ENVELOPE "setTreasury(address)" $NEW_TREASURY \ + --rpc-url $RPC_URL --private-key $ADMIN_KEY +``` + +### Pause / Unpause New Creations + +```bash +# Pause (anyone with PAUSER_ROLE) +cast send $ETH_ENVELOPE "pause()" \ + --rpc-url $RPC_URL --private-key $PAUSER_KEY + +# Unpause (admin only) +cast send $ETH_ENVELOPE "unpause()" \ + --rpc-url $RPC_URL --private-key $ADMIN_KEY +``` + +When paused: + +- `createEnvelope` reverts `EnforcedPause`. +- `claim` and `reclaim` continue to work normally — existing senders are not trapped. + +### Rotate Roles + +```bash +# Grant DEFAULT_ADMIN_ROLE to a new multisig +ADMIN_ROLE=0x0000000000000000000000000000000000000000000000000000000000000000 # = bytes32(0) +cast send $ETH_ENVELOPE "grantRole(bytes32,address)" $ADMIN_ROLE $NEW_ADMIN \ + --rpc-url $RPC_URL --private-key $CURRENT_ADMIN_KEY + +# Revoke from the old multisig +cast send $ETH_ENVELOPE "revokeRole(bytes32,address)" $ADMIN_ROLE $OLD_ADMIN \ + --rpc-url $RPC_URL --private-key $NEW_ADMIN_KEY +``` + +`PAUSER_ROLE` rotation is identical with the corresponding role hash: + +```bash +PAUSER_ROLE=$(cast keccak "PAUSER_ROLE") +cast send $ETH_ENVELOPE "grantRole(bytes32,address)" $PAUSER_ROLE $NEW_PAUSER \ + --rpc-url $RPC_URL --private-key $ADMIN_KEY +``` + +--- + +## Bug-Fix Rollout (Replace, Don't Upgrade) + +Envelopes are immutable. To ship a fix: + +### Step-by-Step + +1. **Pause `create*` on the affected v1 contract.** Holders of in-flight envelopes can still claim and reclaim. + ```bash + cast send $ETH_ENVELOPE_V1 "pause()" --rpc-url $RPC_URL --private-key $PAUSER_KEY + ``` + +2. **Deploy the v2 contract.** Use the same deployment script with bumped sources. + ```bash + ./ops/deploy_envelopes_zksync.sh mainnet --broadcast + ``` + +3. **Update the app's contract addresses** to point new envelope creations at v2. The v1 address remains the lookup target for envelopes created before the cutover. + +4. **Drain v1 naturally.** Pre-existing envelopes either: + - Get claimed by their recipients. + - Hit `expiresAt` and get reclaimed by their senders. + - Never get claimed and (if `expiresAt == 0`) remain locked forever (these were already trusted-recipient bearers and the funds belong to whoever held the URL). + +5. **Once v1 is fully drained**, leave it dormant. There is no on-chain decommissioning step. + +### Limits of This Approach + +- A bug that allows draining funds from existing envelopes has **no on-chain rescue**. You can pause `create*` to stop new exposure, but in-flight envelopes are at the mercy of whoever has the URLs. +- The `paused` state of a v1 contract does not affect any v2 contract; they are fully independent deployments with their own role registries. +- Subquery indexers must be updated to track both v1 and v2 addresses for the duration of the v1 drain. + +### Pre-Replace Checklist + +Before broadcasting v2: + +1. **Run all envelope tests:** + ```bash + forge test --match-path "test/envelopes/**" + ``` + +2. **Run on a fork:** spin up a fork of mainnet, deploy v2, exercise the full create/claim/reclaim path against fresh envelopes. + +3. **Compare gas profile** of v2 against v1 for typical create/claim flows to catch regressions. + +4. **Compare `selectors`** to confirm no unintended ABI breakage: + ```bash + forge inspect EthEnvelope methods + forge inspect EthEnvelope errors + ``` + +### Post-Replace Verification + +```bash +# Confirm v2 admin / pauser / treasury / fee match expectations +ADMIN_ROLE=0x0000000000000000000000000000000000000000000000000000000000000000 +cast call $ETH_ENVELOPE_V2 "hasRole(bytes32,address)(bool)" $ADMIN_ROLE $ADMIN --rpc-url $RPC_URL +cast call $ETH_ENVELOPE_V2 "treasury()(address)" --rpc-url $RPC_URL +cast call $ETH_ENVELOPE_V2 "feeWei()(uint256)" --rpc-url $RPC_URL + +# Confirm v1 is paused +cast call $ETH_ENVELOPE_V1 "paused()(bool)" --rpc-url $RPC_URL +``` + +--- + +## Monitoring & Observability + +### Events to Index + +| Event | Emitted from | Purpose | +| :--------------------- | :------------------- | :-------------------------------------------------------------- | +| `EnvelopeCreated` | All four contracts | New envelope; populates the off-chain envelope registry | +| `EnvelopeClaimed` | All four contracts | Slot consumed; updates `slotsRemaining` for the app | +| `EnvelopeReclaimed` | All four contracts | Sender recovered remaining; envelope clears | +| `FeeUpdated` | All four contracts | Admin changed protocol fee | +| `TreasuryUpdated` | All four contracts | Admin changed treasury sink | +| `Paused` / `Unpaused` | All four contracts | Operational state transitions | + +### Cast — Stream Events + +```bash +# Recent envelope creations +cast logs \ + --address $ETH_ENVELOPE \ + "EnvelopeCreated(address,address,uint128,uint16,uint96)" \ + --from-block latest-1000 \ + --rpc-url $RPC_URL + +# Recent claims +cast logs \ + --address $ETH_ENVELOPE \ + "EnvelopeClaimed(address,address,uint16)" \ + --from-block latest-1000 \ + --rpc-url $RPC_URL +``` + +### Health Signals + +| Signal | Threshold / interpretation | +| :-------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | +| Sudden spike in `EnvelopeCreated` rate | Possible abuse (storage-spam); raise fee if needed | +| `EnvelopeClaimed` rate dropping to ~0 with non-zero TVL | App / URL distribution may be broken | +| Reclaim rate climbing | Recipients are not finding URLs in time — investigate distribution / expiry settings | +| `paused == true` | Should be intentional and tracked in ops journal | +| Treasury balance not increasing while fees > 0 | Treasury setter may have been redirected; cross-check `treasury()` against the expected address | + +--- + +## Security Considerations + +### Bearer Model — What the Contract Cannot Defend Against + +- **Anyone with the URL can claim.** Sharing the URL with the wrong audience leaks the funds. The contract has no knowledge of "intended" claimers. +- **URLs in shared screens, screenshots, server-side logs (when not in fragment)** can be exfiltrated. The repo's recommendation is to put the private key in the URL **fragment** so it never reaches a server log. +- **Phishing pages that mimic the app** can intercept the private key via a malicious paste handler. Defending against this is the app's job. + +### Front-Run Safety + +Claim signatures commit to the claimer's address. A third party watching the mempool sees: + +``` +claim(envelopeId, claimer, signature) +``` + +The signature recovers to `envelopeId` (the public address) when paired with `(envelopeId, claimer)` as the EIP-712 message. A front-runner who tries to substitute a different claimer fails verification — they would need a signature over `(envelopeId, theirOwnAddress)`, which requires the URL's private key, which they don't have. + +### Reentrancy + +`claim` and `reclaim` are guarded by `nonReentrant`. State writes always precede external transfers (checks-effects-interactions). ERC-721 / ERC-1155 receiver callbacks executed during create/payout/refund cannot manipulate envelope state. + +### Pause Semantics + +Pause **only** blocks `create*`. Claims and reclaims always work. This is deliberate — a discovered bug must not trap funds already in escrow. + +### Fee & Treasury + +- The fee is paid in ETH at create time and forwarded immediately to `treasury`. +- The fee is **not** refundable on reclaim — it is the cost of the service. +- Admin sets `treasury`. Trust assumption: admin is the multisig. Each fee is small and per-envelope, so a malicious treasury redirection has bounded blast radius and can be reverted by rotating the admin key and pointing `treasury` back to a legitimate address. + +### Immutable Contracts + +There is no upgrade path for deployed contracts. Bug fixes ship as new deployments — see [Bug-Fix Rollout](#bug-fix-rollout-replace-dont-upgrade). The trade-off is explicit: in-flight envelopes are not protected against newly-discovered bugs in the contracts they live in. + +### Out of Scope + +- Royalty enforcement on transferred NFTs (ERC-2981 is informational; on-chain enforcement is widely abandoned). +- KYC / sanctions screening of senders or claimers. +- Cross-chain envelopes. +- Enumeration of all envelopes by sender (not exposed on-chain — use indexer events). + +--- + +For the full technical specification, including storage layout, EIP-712 type details, and risk-by-risk mitigations, see [`spec/envelopes-specification.md`](spec/envelopes-specification.md). diff --git a/src/envelopes/doc/spec/envelopes-specification.md b/src/envelopes/doc/spec/envelopes-specification.md new file mode 100644 index 0000000..f490226 --- /dev/null +++ b/src/envelopes/doc/spec/envelopes-specification.md @@ -0,0 +1,936 @@ +--- +title: "Nodle Envelopes — Technical Specification" +subtitle: "Bearer-Link Asset Transfer with Fresh-Keypair Claim Authorization" +date: "April 2026" +version: "1.0" +--- + +
+ +# Nodle Envelopes + +## Technical Specification + +**Bearer-Link Asset Transfer with Fresh-Keypair Claim Authorization** + +Version 1.0 — April 2026 + +
+ +
+ +## Table of Contents + +1. [Introduction & Architecture](#1-introduction--architecture) +2. [Roles & Access Control](#2-roles--access-control) +3. [Contract Interfaces](#3-contract-interfaces) +4. [Envelope Creation Flow](#4-envelope-creation-flow) +5. [Claim & Reclaim Flows](#5-claim--reclaim-flows) +6. [Storage Layout](#6-storage-layout) +7. [Security Model](#7-security-model) +8. [Testing Strategy](#8-testing-strategy) +9. [Deployment & Operations](#9-deployment--operations) +10. [File Layout](#10-file-layout) +11. [Open Considerations](#11-open-considerations) + +
+ +## 1. Introduction & Architecture + +### 1.1 System Overview + +The Envelopes system lets a sender wrap on-chain assets in a bearer instrument that anyone holding a shareable URL can claim. The URL carries a fresh ECDSA private key in its fragment; the corresponding public address acts as the envelope's on-chain identifier. Claiming requires producing a signature from that private key bound to the claimer's wallet address — so anyone who has the URL can claim, but no observer of an in-flight claim transaction can hijack it. + +The on-chain layer provides: + +- A multi-tenant contract per asset class (ETH, ERC-20, ERC-721, ERC-1155) holding many envelopes in a single deployment. +- A shared `BaseEnvelope` abstract for envelope storage, EIP-712 claim verification, slot bookkeeping, expiry, reclaim, fee accounting, and pause control. +- Front-run-safe claim semantics by construction: the claim signature commits to the claimer's address, so a third party who observes a claim transaction cannot reuse the signature for a different recipient. + +### 1.2 Architecture + +```mermaid +graph TB + subgraph Senders["Senders"] + S(("Sender Wallet")) + end + + subgraph App["App Layer (off-chain)"] + APP(("App / Frontend")) + end + + subgraph Recipients["Recipients"] + R(("Recipient
(URL holder)")) + R2(("Recipient 2
(multi-claim)")) + end + + subgraph EnvelopeContracts["Envelope Contracts (multi-tenant, immutable)"] + ETH["EthEnvelope"] + E20["Erc20Envelope"] + E721["Erc721Envelope"] + E1155["Erc1155Envelope"] + BASE["BaseEnvelope
(abstract: storage, EIP-712,
expiry, reclaim, fee, pause)"] + end + + subgraph Treasury["Protocol"] + TR(("Fee Treasury")) + end + + S -- "create*(...)" --> ETH + S -- "create*(...)" --> E20 + S -- "create*(...)" --> E721 + S -- "create*(...)" --> E1155 + APP -- "URL with privKey
(fragment, off-chain only)" --> R + APP -- "URL with privKey" --> R2 + R -- "claim(envelopeId, claimer, sig)" --> ETH + R2 -- "claim(envelopeId, claimer, sig)" --> ETH + ETH -- "fee" --> TR + E20 -- "fee" --> TR + E721 -- "fee" --> TR + E1155 -- "fee" --> TR + + ETH -. "extends" .-> BASE + E20 -. "extends" .-> BASE + E721 -. "extends" .-> BASE + E1155 -. "extends" .-> BASE + + style ETH fill:#4a9eff,color:#fff + style E20 fill:#4a9eff,color:#fff + style E721 fill:#4a9eff,color:#fff + style E1155 fill:#4a9eff,color:#fff + style BASE fill:#ff9f43,color:#fff + style S fill:#2ecc71,color:#fff + style R fill:#2ecc71,color:#fff + style R2 fill:#2ecc71,color:#fff + style APP fill:#95a5a6,color:#fff + style TR fill:#95a5a6,color:#fff +``` + +### 1.3 Core Components + +| Contract | Role | Pattern | Upgradeability | +| :----------------- | :---------------------------------------------------- | :--------------------------------- | :------------- | +| `BaseEnvelope` | Abstract base: shared storage, signature verification, expiry, fee accounting, pause | Abstract, not deployed standalone | — | +| `EthEnvelope` | Wraps native ETH | Multi-tenant, deployed once | Immutable | +| `Erc20Envelope` | Wraps ERC-20 (any approved token) | Multi-tenant, deployed once | Immutable | +| `Erc721Envelope` | Wraps ERC-721 (sender provides `tokenIds[]`) | Multi-tenant, deployed once | Immutable | +| `Erc1155Envelope` | Wraps ERC-1155 (single `id` per envelope, all slots equal) | Multi-tenant, deployed once | Immutable | + +Each concrete contract is **deployed once and is immutable**. Bug fixes are shipped by deploying a new version; the app routes new envelopes to the new deployment while old envelopes drain via claim, expiry, or reclaim. There is no factory and no clone pattern — envelopes are short-lived bearer instruments and per-envelope clones would be wasteful. + +### 1.4 Design Decisions + +| # | Decision | Choice | +| :- | :-------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | URL authorization pattern | Linkdrop-style: fresh ECDSA keypair per envelope; private key in URL fragment; on-chain envelope keyed by the public address | +| 2 | Front-run safety | EIP-712 claim signature commits to the claimer's address; the claim payload is `Claim(address envelopeId, address claimer)` | +| 3 | Claim model | Sender chooses single-claim or multi-claim per envelope; one URL per envelope; equal-share slots; on-chain dedup keyed by claimer address | +| 4 | Asset payload | One asset class per envelope (ETH or ERC-20 or ERC-721 or ERC-1155). Multi-asset bundles are out of scope | +| 5 | Contract organization | One abstract base + four concrete contracts (per asset class), deployed independently | +| 6 | Lifecycle | Optional `expiresAt` (uint96, 0 = never expires). Sender can `reclaim` only after expiry. No mid-flight cancel | +| 7 | Creation policy | Permissionless — any wallet can call `create*` | +| 8 | Fee model | Configurable flat per-envelope fee in **ETH**, taken at creation, forwarded to a configurable treasury. Default 0 at deployment | +| 9 | Upgradeability | Contracts are immutable. Bug fixes ship as new deployments; old contracts drain via claim/expiry/reclaim. Ops can pause `create*` while a v2 rolls out | +| 10 | Reentrancy protection | OpenZeppelin `ReentrancyGuard` on `claim` and `reclaim`. Asset transfers always follow checks-effects-interactions | +| 11 | Inheritance | Direct from OpenZeppelin (non-upgradeable) primitives — `AccessControl`, `Pausable`, `ReentrancyGuard`, plus standard ERC token interfaces | + +### 1.5 Non-Goals + +- Multi-asset bundles in a single envelope. +- Non-bearer claim authorization (e.g., per-recipient allowlists, KYC). Recipients are anonymous-by-URL. +- Royalty enforcement on transferred NFTs (ERC-2981 is informational; see §1.6). +- App / frontend, URL parsing, key generation, or messaging integration. +- Bridging or cross-chain envelopes. +- On-chain envelope discovery or search (envelopes are intentionally pseudonymous; the URL is the lookup key). + +### 1.6 Lifecycle States + +For any envelope `e`: + +``` +ACTIVE e.slotsClaimed < e.slotsTotal + AND (e.expiresAt == 0 OR block.timestamp < e.expiresAt) + AND e.sender != address(0) + +EXHAUSTED e.slotsClaimed == e.slotsTotal + → claim reverts (no slots), reclaim reverts (nothing to refund) + +EXPIRED e.expiresAt != 0 AND block.timestamp >= e.expiresAt + AND e.slotsClaimed < e.slotsTotal + → claim reverts (expired), reclaim by sender refunds remaining + +RECLAIMED sender called reclaim + → e.sender zeroed, all subclass refs cleared + → claim reverts (no envelope), reclaim reverts (already reclaimed) +``` + +
+ +## 2. Roles & Access Control + +### 2.1 Role Map + +| Role | Held by | Scope | Capabilities | +| :-------------------- | :---------------------------- | :----------- | :-------------------------------------------------------------------------------------------------------- | +| `DEFAULT_ADMIN_ROLE` | L2 admin Safe (multisig) | Each contract | Set protocol fee; set treasury address; grant/revoke `PAUSER_ROLE` | +| `PAUSER_ROLE` | L2 admin Safe + ops key | Each contract | Pause new envelope creation only. Existing claims and reclaims always work even when paused | + +There is **no operator role**, **no whitelist**, and **no creator role** — envelope creation is permissionless. The only privileged operations are administrative (fee/treasury) and emergency (pause-create). + +### 2.2 Role Admin Hierarchy + +```mermaid +graph LR + DAR["DEFAULT_ADMIN_ROLE
(per contract)"] --> PAUSE["PAUSER_ROLE
(per contract)"] + DAR --> FEE["fee + treasury setters
(admin-only)"] + + style DAR fill:#ff9f43,color:#fff + style PAUSE fill:#ff9f43,color:#fff + style FEE fill:#ff9f43,color:#fff +``` + +`DEFAULT_ADMIN_ROLE` administers `PAUSER_ROLE` and is the only role that can adjust fee/treasury. Each of the four contracts has its own role registry; admin manages all four (typically the same multisig address). + +### 2.3 Pause Semantics + +When `paused == true`: + +- `create*` reverts with `EnforcedPause`. +- `claim` and `reclaim` continue to function normally. + +Rationale: a discovered bug should not trap funds in flight. Senders who already deposited assets can still see them claimed or reclaimed. Pause only prevents new exposure. + +
+ +## 3. Contract Interfaces + +### 3.1 Public Interfaces + +| Interface | Description | +| :------------------------------------- | :--------------------------------------------------------------------- | +| `interfaces/IBaseEnvelope.sol` | Shared envelope storage, claim, reclaim, view APIs | +| `interfaces/IEthEnvelope.sol` | ETH-specific `create` overload | +| `interfaces/IErc20Envelope.sol` | ERC-20-specific `create` overload | +| `interfaces/IErc721Envelope.sol` | ERC-721-specific `create` overload | +| `interfaces/IErc1155Envelope.sol` | ERC-1155-specific `create` overload | +| `interfaces/EnvelopeTypes.sol` | Shared enums and structs (`Envelope`, `ClaimSig` typedata) | + +### 3.2 Contract Classes + +```mermaid +classDiagram + class BaseEnvelope { + <> + +bytes32 CLAIM_TYPEHASH + +mapping envelopes : address → Envelope + +mapping claimed : address → mapping(address → bool) + +uint256 feeWei + +address treasury + -- + +claim(envelopeId, claimer, signature) + +reclaim(envelopeId) + +setFee(newFeeWei) onlyAdmin + +setTreasury(newTreasury) onlyAdmin + +pause() onlyPauser + +unpause() onlyAdmin + #_initEnvelope(envelopeId, sender, slots, slotAmount, expiresAt) + #_consumeSlot(envelopeId, claimer) + #_payout(envelopeId, claimer)* + #_refund(envelopeId, sender, slotsRemaining)* + #_clearSubclassStorage(envelopeId)* + } + + class EthEnvelope { + +createEnvelope(envelopeId, slotAmount, slotsTotal, expiresAt) payable + #_payout(envelopeId, claimer) + #_refund(envelopeId, sender, slotsRemaining) + #_clearSubclassStorage(envelopeId) + } + + class Erc20Envelope { + +mapping tokenOf : address → address + -- + +createEnvelope(envelopeId, token, slotAmount, slotsTotal, expiresAt) payable + #_payout(envelopeId, claimer) + #_refund(envelopeId, sender, slotsRemaining) + #_clearSubclassStorage(envelopeId) + } + + class Erc721Envelope { + +mapping tokenOf : address → address + +mapping tokenIdsOf : address → uint256[] + -- + +createEnvelope(envelopeId, token, tokenIds, expiresAt) payable + #_payout(envelopeId, claimer) + #_refund(envelopeId, sender, slotsRemaining) + #_clearSubclassStorage(envelopeId) + } + + class Erc1155Envelope { + +mapping tokenOf : address → address + +mapping idOf : address → uint256 + -- + +createEnvelope(envelopeId, token, id, slotAmount, slotsTotal, expiresAt) payable + #_payout(envelopeId, claimer) + #_refund(envelopeId, sender, slotsRemaining) + #_clearSubclassStorage(envelopeId) + } + + BaseEnvelope <|-- EthEnvelope + BaseEnvelope <|-- Erc20Envelope + BaseEnvelope <|-- Erc721Envelope + BaseEnvelope <|-- Erc1155Envelope +``` + +### 3.3 Shared Types + +```solidity +struct Envelope { + address sender; // creator; zero after reclaim + uint96 expiresAt; // 0 = never expires + uint128 slotAmount; // amount per slot (1 for ERC-721) + uint16 slotsTotal; // total slots at creation + uint16 slotsClaimed; // running count + // 12 bytes free for subclass packing (kept reserved) +} +``` + +Layout fits in two 32-byte slots: + +- Slot 0: `sender` (20) | `expiresAt` (12) = 32 bytes +- Slot 1: `slotAmount` (16) | `slotsTotal` (2) | `slotsClaimed` (2) | reserved (12) + +### 3.4 EIP-712 Claim Payload + +All four contracts share the same EIP-712 type and a per-deployment domain separator: + +```solidity +// Domain (per deployed contract) +EIP712Domain { + string name; // e.g., "NodleEnvelopes-ETH" / "-ERC20" / "-ERC721" / "-ERC1155" + string version; // "1" + uint256 chainId; + address verifyingContract; +} + +// Claim type +Claim { + address envelopeId; // public address of the envelope keypair + address claimer; // wallet receiving the assets +} +bytes32 constant CLAIM_TYPEHASH = keccak256("Claim(address envelopeId,address claimer)"); +``` + +Front-run safety: the signature commits to `claimer`. Replay-by-different-recipient is impossible because the signature would not recover to `envelopeId`. Replay for the same `claimer` is rejected by the on-chain `claimed[envelopeId][claimer]` map. + +The `name` field differs per contract so a signature for an ERC-20 envelope cannot be replayed against the ETH envelope contract (or any other class) at the same `envelopeId`. (`envelopeId` collisions across contracts are cryptographically improbable, but per-contract domains add belt-and-braces.) + +### 3.5 `BaseEnvelope` (abstract) + +```solidity +interface IBaseEnvelope { + event EnvelopeCreated( + address indexed envelopeId, + address indexed sender, + uint128 slotAmount, + uint16 slotsTotal, + uint96 expiresAt + ); + event EnvelopeClaimed( + address indexed envelopeId, + address indexed claimer, + uint16 slotsRemaining + ); + event EnvelopeReclaimed( + address indexed envelopeId, + address indexed sender, + uint16 slotsRemainingRefunded + ); + event FeeUpdated(uint256 oldFee, uint256 newFee); + event TreasuryUpdated(address oldTreasury, address newTreasury); + + error EnvelopeAlreadyExists(address envelopeId); + error EnvelopeNotFound(address envelopeId); + error EnvelopeExpired(address envelopeId); + error EnvelopeExhausted(address envelopeId); + error AlreadyClaimed(address envelopeId, address claimer); + error InvalidSignature(); + error NotSender(); + error NotYetExpired(); + error ZeroSlots(); + error InsufficientFee(uint256 required, uint256 provided); + error ZeroAddress(); + + function claim( + address envelopeId, + address claimer, + bytes calldata signature + ) external; + + function reclaim(address envelopeId) external; + + function setFee(uint256 newFeeWei) external; + function setTreasury(address newTreasury) external; + function pause() external; + function unpause() external; + + function feeWei() external view returns (uint256); + function treasury() external view returns (address); + + function envelopes(address envelopeId) external view returns ( + address sender, + uint96 expiresAt, + uint128 slotAmount, + uint16 slotsTotal, + uint16 slotsClaimed + ); + function claimed(address envelopeId, address candidate) external view returns (bool); +} +``` + +### 3.6 `EthEnvelope` + +```solidity +interface IEthEnvelope is IBaseEnvelope { + error WrongMsgValue(uint256 required, uint256 provided); + + function createEnvelope( + address envelopeId, + uint128 slotAmount, + uint16 slotsTotal, + uint96 expiresAt + ) external payable; +} +``` + +Behavior: + +- `msg.value == feeWei + uint256(slotAmount) * uint256(slotsTotal)` exactly. Reverts `WrongMsgValue` otherwise. +- Reverts `ZeroSlots` if `slotsTotal == 0` or `slotAmount == 0`. +- Reverts `EnvelopeAlreadyExists` if `envelopes[envelopeId].sender != address(0)`. +- Forwards `feeWei` to `treasury` via `call{value:}`. Reverts on failure. +- `_payout`: `call{value: slotAmount}(claimer)`. +- `_refund`: `call{value: slotsRemaining * slotAmount}(sender)`. +- `_clearSubclassStorage`: no-op (no subclass storage). + +### 3.7 `Erc20Envelope` + +```solidity +interface IErc20Envelope is IBaseEnvelope { + function createEnvelope( + address envelopeId, + address token, + uint128 slotAmount, + uint16 slotsTotal, + uint96 expiresAt + ) external payable; + + function tokenOf(address envelopeId) external view returns (address); +} +``` + +Behavior: + +- `msg.value == feeWei` exactly (asset payload travels via `transferFrom`, not `msg.value`). +- Sender must have approved this contract for `slotAmount * slotsTotal` of `token` before calling `createEnvelope`. +- Pulls `slotAmount * slotsTotal` via `SafeERC20.safeTransferFrom(token, msg.sender, address(this), total)`. +- Stores `tokenOf[envelopeId] = token`. +- `_payout`: `SafeERC20.safeTransfer(token, claimer, slotAmount)`. +- `_refund`: `SafeERC20.safeTransfer(token, sender, slotsRemaining * slotAmount)`. +- `_clearSubclassStorage`: `delete tokenOf[envelopeId]`. + +### 3.8 `Erc721Envelope` + +```solidity +interface IErc721Envelope is IBaseEnvelope { + error TokenIdsLengthMismatch(uint256 expected, uint256 provided); + + function createEnvelope( + address envelopeId, + address token, + uint256[] calldata tokenIds, + uint96 expiresAt + ) external payable; + + function tokenOf(address envelopeId) external view returns (address); + function tokenIdsOf(address envelopeId) external view returns (uint256[] memory); + function remainingTokenIds(address envelopeId) external view returns (uint256[] memory); +} +``` + +Behavior: + +- `slotsTotal = tokenIds.length`. `slotAmount = 1` (one NFT per slot). +- `msg.value == feeWei` exactly. +- Sender must have approved this contract per token (or `setApprovalForAll`). Pulls each `tokenId` via `IERC721.safeTransferFrom(msg.sender, address(this), tokenId)` in a loop. +- Stores `tokenOf[envelopeId] = token` and `tokenIdsOf[envelopeId] = tokenIds` (full array). +- `_payout`: pops the last element of `tokenIdsOf[envelopeId]` (LIFO order is gas-cheaper than shifting; semantics from claimer's perspective don't depend on order) and `IERC721.safeTransferFrom(address(this), claimer, tokenId)`. +- `_refund`: loop the remaining `tokenIdsOf[envelopeId]` and transfer each back to `sender`. +- `_clearSubclassStorage`: `delete tokenOf[envelopeId]` and `delete tokenIdsOf[envelopeId]`. + +`Erc721Envelope` implements `IERC721Receiver` (returning `IERC721Receiver.onERC721Received.selector`) so `safeTransferFrom` accepts the contract as recipient during `createEnvelope`. + +### 3.9 `Erc1155Envelope` + +```solidity +interface IErc1155Envelope is IBaseEnvelope { + function createEnvelope( + address envelopeId, + address token, + uint256 id, + uint128 slotAmount, + uint16 slotsTotal, + uint96 expiresAt + ) external payable; + + function tokenOf(address envelopeId) external view returns (address); + function idOf(address envelopeId) external view returns (uint256); +} +``` + +Behavior: + +- All slots wrap `slotAmount` of the same `id` (one ERC-1155 id per envelope). +- `msg.value == feeWei` exactly. +- Sender must have called `setApprovalForAll(envelopeContract, true)` on the ERC-1155 token. Pulls `slotAmount * slotsTotal` of `id` via `IERC1155.safeTransferFrom(msg.sender, address(this), id, total, "")`. +- Stores `tokenOf[envelopeId] = token` and `idOf[envelopeId] = id`. +- `_payout`: `IERC1155.safeTransferFrom(address(this), claimer, id, slotAmount, "")`. +- `_refund`: `IERC1155.safeTransferFrom(address(this), sender, id, slotsRemaining * slotAmount, "")`. +- `_clearSubclassStorage`: `delete tokenOf[envelopeId]` and `delete idOf[envelopeId]`. + +`Erc1155Envelope` implements `IERC1155Receiver` (returning the standard selectors) so `safeTransferFrom` accepts the contract as recipient. + +
+ +## 4. Envelope Creation Flow + +### 4.1 ETH Single-Claim Sequence + +```mermaid +sequenceDiagram + autonumber + participant S as Sender Wallet + participant App as App + participant ETH as EthEnvelope + participant TR as Treasury + + App->>App: Generate fresh keypair (k, K) + App->>S: Construct create tx
envelopeId=K, slotAmount=X, slotsTotal=1, expiresAt=T + S->>ETH: createEnvelope(K, X, 1, T) payable msg.value=X+fee + ETH->>ETH: require sender of envelope[K] == 0 + ETH->>ETH: require msg.value == feeWei + X + ETH->>ETH: store envelopes[K] = {msg.sender, T, X, 1, 0} + ETH->>TR: forward feeWei + ETH-->>App: emit EnvelopeCreated(K, sender, X, 1, T) + App-->>S: Build URL: https://app/.../envelope#k= + S->>S: Share URL via any messenger +``` + +### 4.2 ERC-20 Multi-Claim Sequence + +```mermaid +sequenceDiagram + autonumber + participant S as Sender Wallet + participant App as App + participant T20 as ERC-20 Token + participant E20 as Erc20Envelope + participant TR as Treasury + + App->>App: Generate fresh keypair (k, K) + S->>T20: approve(E20, slotAmount * slotsTotal) + T20-->>S: ok + S->>E20: createEnvelope(K, T20, slotAmount, slotsTotal, expiresAt) msg.value=fee + E20->>E20: require msg.value == feeWei + E20->>T20: safeTransferFrom(S, E20, slotAmount * slotsTotal) + E20->>TR: forward feeWei + E20-->>App: emit EnvelopeCreated(K, S, slotAmount, slotsTotal, expiresAt) + App-->>S: Build URL with privKey k +``` + +### 4.3 ERC-721 Sequence + +```mermaid +sequenceDiagram + autonumber + participant S as Sender Wallet + participant App as App + participant T721 as ERC-721 Token + participant E721 as Erc721Envelope + + App->>App: Generate fresh keypair (k, K) + S->>T721: setApprovalForAll(E721, true)
or per-token approve() + S->>E721: createEnvelope(K, T721, [id1, id2, ..., idN], expiresAt) msg.value=fee + E721->>E721: require msg.value == feeWei + loop For each tokenId + E721->>T721: safeTransferFrom(S, E721, tokenId) + end + E721->>E721: store tokenOf[K] = T721, tokenIdsOf[K] = [id1..idN] + E721->>E721: store envelopes[K] = {S, expiresAt, 1, N, 0} + E721-->>App: emit EnvelopeCreated(K, S, 1, N, expiresAt) +``` + +ERC-1155 mirrors §4.2 with a single `safeTransferFrom` call moving `slotAmount * slotsTotal` of the chosen `id`. + +### 4.4 Atomicity & Race Conditions + +`createEnvelope` is single-transaction-atomic: the caller cannot observe partial state. Two senders race-creating with the same `envelopeId` would result in one tx reverting via `EnvelopeAlreadyExists` — but `envelopeId` is the public address of a fresh ECDSA keypair, so collisions are cryptographically improbable. + +
+ +## 5. Claim & Reclaim Flows + +### 5.1 Claim — Single or Multi + +```mermaid +sequenceDiagram + autonumber + participant R as Recipient + participant App as App + participant ENV as Envelope + + R->>App: Open URL with privKey k + App->>App: Parse URL fragment → privKey k → derive envelopeId K + App->>ENV: read envelopes[K] (slots, expiry, sender) + ENV-->>App: envelope state + App->>App: Check ACTIVE: claimable, not expired, slot available, claimer not in claimed[K] + App->>R: Prompt for claimer wallet address + R->>App: Provide claimer address C + App->>App: Sign EIP-712 Claim(K, C) with privKey k → signature + App->>ENV: claim(K, C, signature) [tx from R or sponsored] + ENV->>ENV: Verify ecrecover(signature) == K + ENV->>ENV: Check envelopes[K].sender != 0 (exists) + ENV->>ENV: Check expiresAt == 0 OR block.timestamp < expiresAt + ENV->>ENV: Check slotsClaimed < slotsTotal + ENV->>ENV: Check claimed[K][C] == false + ENV->>ENV: claimed[K][C] = true + ENV->>ENV: envelopes[K].slotsClaimed++ + ENV->>ENV: _payout(K, C) — asset transfer + ENV-->>App: emit EnvelopeClaimed(K, C, slotsRemaining) +``` + +The submitting tx (`claim`) does not have to come from `claimer` — anyone can relay it (e.g., the app's paymaster, a meta-tx relayer). The signature commits to `claimer`, so a relayer cannot redirect the asset. + +### 5.2 Reclaim + +```mermaid +sequenceDiagram + autonumber + participant S as Sender + participant ENV as Envelope + + S->>ENV: reclaim(envelopeId K) + ENV->>ENV: Check envelopes[K].sender == msg.sender (NotSender) + ENV->>ENV: Check expiresAt != 0 AND block.timestamp >= expiresAt (NotYetExpired) + ENV->>ENV: Compute slotsRemaining = slotsTotal - slotsClaimed + ENV->>ENV: Mark envelope as reclaimed (sender = 0, slotsRemaining absorbed) + ENV->>ENV: _refund(K, S, slotsRemaining) + ENV->>ENV: _clearSubclassStorage(K) + ENV-->>S: emit EnvelopeReclaimed(K, S, slotsRemaining) +``` + +`reclaim` is idempotent-resistant: after success, `envelopes[K].sender == 0`, so a repeat call reverts `EnvelopeNotFound` (or `NotSender` because `sender` is now zero and `msg.sender` cannot be zero). + +The fee is **not refunded** — it was paid to treasury at creation time. This is consistent with a "service fee for hosting the envelope" model. + +### 5.3 Edge Cases + +| Scenario | Outcome | +| :------------------------------------------ | :------------------------------------------------------------------- | +| Claim on exhausted envelope | Reverts `EnvelopeExhausted` | +| Claim past expiry | Reverts `EnvelopeExpired` | +| Claim with reused claimer address | Reverts `AlreadyClaimed` | +| Claim with signature for different envelope | Reverts `InvalidSignature` (recovered address mismatch) | +| Claim while paused | Succeeds (pause only blocks `create*`) | +| Reclaim before expiry | Reverts `NotYetExpired` | +| Reclaim with `expiresAt == 0` | Reverts `NotYetExpired` (never expires → cannot be reclaimed) | +| Reclaim by non-sender | Reverts `NotSender` | +| Reclaim of fully-claimed envelope | Succeeds with `slotsRemaining == 0`; no asset transfer; envelope cleared | +| Reclaim already reclaimed | Reverts `EnvelopeNotFound` / `NotSender` (sender slot zeroed) | + +
+ +## 6. Storage Layout + +### 6.1 Per-Contract Storage + +Inherited: + +- `AccessControl` — role registry (one slot for `_roles` mapping head) +- `Pausable` — `_paused` bool +- `ReentrancyGuard` — `_status` uint256 +- `EIP712` — cached domain separator + chain id + +Contract-specific: + +``` +[OZ AccessControl + Pausable + ReentrancyGuard + EIP712 storage] +slot N+0 : envelopes (mapping address → Envelope) +slot N+1 : claimed (mapping address → mapping(address → bool)) +slot N+2 : feeWei (uint256) +slot N+3 : treasury (address) +``` + +Subclass storage extends with one or two extra mappings (see §3.7–§3.9). + +Contracts are immutable so there is no storage gap; storage layout discipline applies only when shipping a v2 contract that needs to be deployed alongside v1 (no in-place migration). + +### 6.2 Per-Envelope Footprint + +Base: + +- `envelopes[envelopeId]` — 2 storage slots (struct packed per §3.3). +- `claimed[envelopeId]` — one slot per claimer that ever claimed (mapping head consumes no slot until written). + +Subclass extras: + +- `Erc20Envelope`: 1 slot for `tokenOf[envelopeId]`. +- `Erc721Envelope`: 1 slot for `tokenOf[envelopeId]` + ⌈N/1⌉ slots for `tokenIdsOf[envelopeId]` (Solidity dynamic array; one slot for length, N slots for elements). +- `Erc1155Envelope`: 2 slots for `tokenOf[envelopeId]` and `idOf[envelopeId]`. + +After full claim or reclaim, subclass extras are explicitly cleared via `delete` so that completed envelopes return their storage and refund gas to the caller. + +
+ +## 7. Security Model + +### 7.1 Trust Assumptions + +| Principal | Trusted to | Compromise impact | +| :------------------ | :---------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------- | +| Admin (Safe) | Set fee/treasury benignly; rotate pauser; not pause maliciously | Can redirect future fees to a malicious treasury. Cannot move existing envelope assets | +| Pauser | Pause only in genuine emergencies | Can DoS new envelope creation. Cannot affect existing claims or reclaims | +| Senders | Hold the assets they wrap and approve transfer | A malicious sender wraps and immediately reclaims → slots-remaining = 0; fee already collected | +| URL holders | Treat the URL as bearer credential | Anyone who obtains the URL can claim. Sharing the URL outside the intended audience leaks funds | + +The protocol does not, and cannot, distinguish "intended" claimers from anyone else with the URL. That is the whole point of the bearer model. + +### 7.2 Risks & Mitigations + +| # | Risk | Mitigation | +| :- | :-------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | +| 1 | Front-run of claim by extracting URL key from tx | Tx contains `(envelopeId, claimer, signature)` — signature reveals only `envelopeId` (public address), not the private key. Front-runners cannot derive a signature for a different claimer | +| 2 | Replay of a captured claim signature | Signature commits to `claimer`; replay just re-fills `claimer`'s claim, which is rejected by `claimed[K][C]` | +| 3 | Replay of a signature across asset classes | Per-contract EIP-712 domain `name` differs (e.g., `NodleEnvelopes-ETH` vs `-ERC20`); same `envelopeId` yields different digests across contracts | +| 4 | Reentrancy via `safeTransferFrom` callback (claimer's contract code) | `nonReentrant` guard on `claim` and `reclaim`; checks-effects-interactions pattern (state writes precede transfers) | +| 5 | Large `tokenIds[]` DoS on ERC-721 create or reclaim | Cap `tokenIds.length` to `MAX_SLOTS = 256` (representable in `uint16` and bounded loop) | +| 6 | Sender provides duplicate `tokenIds` in ERC-721 | First `safeTransferFrom` succeeds; second on the same id reverts (sender no longer owns it). Atomic creation reverts as a whole | +| 7 | ETH refund failure (claimer is a contract that rejects ETH) | `_payout` uses `call{value:}` and reverts on failure; multi-claim envelopes can have other claims continue. Documented: contracts that reject ETH cannot claim ETH envelopes | +| 8 | Treasury is a contract that reverts on receive | Fee transfer at creation reverts the entire `createEnvelope`; admin must set treasury to a payable address | +| 9 | Admin sets treasury to attacker address | Trust assumption: admin is multisig. Each fee is small and per-envelope. Not retroactive — assets already in escrow are safe | +| 10 | `expiresAt` set to a time well in the past | Envelope is created in the EXPIRED state immediately. Sender can `reclaim` and recover; fee still paid. Documented as user error | +| 11 | Sender wraps and immediately reclaims (fee griefing of treasury) | By design: fee is the cost of using the service; treasury keeps it. Not exploitable as DoS | +| 12 | Storage exhaustion from many tiny envelopes | Each envelope has a one-time fee (when configured); zero fee at default. If this becomes abuse vector, raise fee | +| 13 | Reclaim of an already-claimed-out envelope | `slotsRemaining == 0` → no asset transfer, just clear sender. No funds lost, just emits an event | +| 14 | Pause used to trap fees | Pause cannot affect existing claims or reclaims; only `create*`. New senders simply cannot create until unpaused | + +### 7.3 Out of Scope + +- Royalty enforcement on transferred NFTs. Envelopes use OpenZeppelin's standard `safeTransferFrom`; royalty hooks (if any) on the token contract apply normally during these transfers. +- Front-end / wallet UX for URL parsing and key handling. The contract layer assumes the app correctly parses the URL fragment and never transmits the private key off-device. +- Bridging or cross-chain envelopes. +- KYC / sanctions screening of claimers. + +### 7.4 Audit Posture + +- The four concrete contracts share the entire claim/reclaim/expiry flow via `BaseEnvelope`. Audit attention should concentrate on `BaseEnvelope`'s signature verification and slot bookkeeping; subclass overrides are small and asset-specific. +- All asset transfers go through OpenZeppelin's `SafeERC20`, `IERC721`, `IERC1155` standard safe-transfer primitives. +- Recommended: focused audit on `BaseEnvelope.claim` and `BaseEnvelope.reclaim` plus each subclass's `_payout`/`_refund` before mainnet deployment. + +
+ +## 8. Testing Strategy + +Unit tests live under `test/envelopes/`: + +### 8.1 `BaseEnvelopeShared.t.sol` + +A shared harness exercising lifecycle invariants against a minimal concrete subclass (`MockEnvelope`) so the shared logic is tested in isolation. + +- `claim` succeeds with a valid signature; emits `EnvelopeClaimed`; updates `slotsClaimed`; populates `claimed[envelopeId][claimer]`. +- `claim` reverts `InvalidSignature` for a signature that recovers to a different address. +- `claim` reverts `AlreadyClaimed` when the same claimer claims twice. +- `claim` reverts `EnvelopeExhausted` after all slots are taken. +- `claim` reverts `EnvelopeExpired` after `expiresAt`. +- `claim` succeeds while paused (pause only affects `create*`). +- `reclaim` reverts `NotSender` for non-sender callers. +- `reclaim` reverts `NotYetExpired` before `expiresAt` (and always for `expiresAt == 0`). +- `reclaim` clears subclass storage and zeros `sender`. +- `setFee` and `setTreasury` are admin-only; emit corresponding events. +- `pause`/`unpause` permissions and effects. +- ReentrancyGuard prevents reentrant `claim` / `reclaim`. + +### 8.2 `EthEnvelope.t.sol` + +- `createEnvelope` requires `msg.value == feeWei + slotAmount * slotsTotal`. +- Reverts `EnvelopeAlreadyExists` on duplicate `envelopeId`. +- Reverts `ZeroSlots` for `slotsTotal == 0` or `slotAmount == 0`. +- Fee is forwarded to treasury at creation; fee = 0 path skips the transfer. +- Multi-claim ETH: each claim transfers `slotAmount` to claimer. +- Reclaim refunds `(slotsTotal - slotsClaimed) * slotAmount` to sender; fee not refunded. +- Reclaim of fully-claimed envelope succeeds with no asset transfer. +- Claimer that rejects ETH (`receive` reverts) → claim reverts; envelope state unchanged. + +### 8.3 `Erc20Envelope.t.sol` + +- Sender approval flow; reverts on insufficient allowance. +- `msg.value == feeWei` strict. +- Pulls `slotAmount * slotsTotal` of `token` at creation. +- Multi-claim transfers the right amount to each claimer. +- Reclaim returns remaining tokens to sender. +- Token with `transfer` returning false (non-standard ERC-20) → handled correctly via `SafeERC20`. + +### 8.4 `Erc721Envelope.t.sol` + +- Sender provides `tokenIds[]`; contract pulls each via `safeTransferFrom`. +- Reverts on duplicate `tokenIds` (second pull reverts ownership check). +- Reverts on `tokenIds.length > MAX_SLOTS`. +- Multi-claim distributes one NFT per slot in LIFO order. +- Reclaim returns all remaining NFTs to sender. +- `IERC721Receiver` is implemented and returns the correct selector during creation. + +### 8.5 `Erc1155Envelope.t.sol` + +- Sender approves via `setApprovalForAll`. +- Single `safeTransferFrom` pulls `slotAmount * slotsTotal` at creation. +- Multi-claim transfers `slotAmount` per claim. +- Reclaim returns remaining balance. +- `IERC1155Receiver` is implemented and returns correct selectors. + +### 8.6 `Envelopes.integration.t.sol` + +End-to-end: deploy all four contracts, set fees, exercise creation + claim + reclaim across all asset classes, verify treasury accounting and event emission. + +### 8.7 Coverage Target + +≥ 95% line coverage on the new contracts, measured locally via `forge coverage`. + +
+ +## 9. Deployment & Operations + +### 9.1 Deployment Script + +Two artifacts mirror the swarms ZkSync pattern: + +- `script/DeployEnvelopesZkSync.s.sol` — Forge script that deploys all four contracts (or a configurable subset). +- `ops/deploy_envelopes_zksync.sh` — orchestration shell script analogous to `ops/deploy_swarm_contracts_zksync.sh`: runs preflight checks, compiles with `forge build --zksync`, calls the Forge script, performs post-deploy `cast` checks, and invokes `ops/verify_zksync_contracts.py` for source-code verification. + +Environment variables (prefixed `N_` per repo convention): + +| Variable | Description | +| :------------------------------ | :------------------------------------------------------------------- | +| `N_ENVELOPES_ADMIN` | Multisig that will hold `DEFAULT_ADMIN_ROLE` on all four contracts | +| `N_ENVELOPES_PAUSER` | Address (typically a Safe or ops key) that will hold `PAUSER_ROLE` | +| `N_ENVELOPES_FEE_TREASURY` | Address that receives protocol fees | +| `N_ENVELOPES_INITIAL_FEE_WEI` | Initial fee in wei. Default `0` | + +Steps performed by the Forge script: + +1. Deploy `EthEnvelope(admin, pauser, treasury, initialFeeWei)`. +2. Deploy `Erc20Envelope(admin, pauser, treasury, initialFeeWei)`. +3. Deploy `Erc721Envelope(admin, pauser, treasury, initialFeeWei)`. +4. Deploy `Erc1155Envelope(admin, pauser, treasury, initialFeeWei)`. +5. Log all four addresses in the same `: 0x...` format the orchestration script greps for. + +Steps performed by the orchestration shell script: + +6. Sanity-check each deployment with `cast` (admin role granted, pauser role granted, treasury and fee values correct). +7. Verify source code on the zkSync block explorer via `python3 ops/verify_zksync_contracts.py --broadcast broadcast/DeployEnvelopesZkSync.s.sol//run-latest.json --verifier-url $VERIFIER_URL --compiler-version 0.8.26 --zksolc-version v1.5.15 --project-root "$PROJECT_ROOT"`. Verifier URLs follow the swarms convention: + - **Mainnet**: `https://zksync2-mainnet-explorer.zksync.io/contract_verification` (explorer at `https://explorer.zksync.io`) + - **Testnet**: `https://explorer.sepolia.era.zksync.dev/contract_verification` (explorer at `https://sepolia.explorer.zksync.io`) +8. Append the deployed addresses to the appropriate `.env-test` / `.env-prod` file. +9. Add a usage example to `README.md`. + +> **Note on tooling.** Do **not** use `forge script --verify` (sends absolute paths the zkSync verifier rejects). Use `ops/verify_zksync_contracts.py`, which generates standard JSON, rewrites relative imports to project-rooted paths, and submits to the zkSync verifier API. With `bytecode_hash = "none"` already set in `foundry.toml`, this achieves full verification. + +### 9.2 Indexing + +Subquery extension: + +- Four top-level sources, one per contract address. Handlers on `EnvelopeCreated`, `EnvelopeClaimed`, and `EnvelopeReclaimed` write `Envelope`, `EnvelopeClaim`, and `EnvelopeReclaim` entities respectively. +- Treasury accounting reconstructed from `feeWei` at `EnvelopeCreated` time and `FeeUpdated` events. + +Indexer wiring is out of this repo's contract scope but is referenced here so the implementation plan can include a tracking task for the subquery package. + +### 9.3 Paymaster Integration + +`BondTreasuryPaymaster` is not used by envelopes by default — creation is expected to be paid by the sender, since the sender is the asset owner. If the platform wants to sponsor sender gas for trusted users, the paymaster's existing whitelist mechanism can wrap the `create*` call sites without changes to envelope contracts. + +Claim transactions are typically relayed by the app on behalf of the recipient (so recipients don't need ETH for gas). Any meta-tx relayer or paymaster works because the claim's authorization is the EIP-712 signature, not `tx.origin`. + +### 9.4 Bug-Fix Rollout (Replace, Don't Upgrade) + +Envelopes are immutable. To ship a fix: + +1. Pause `create*` on the affected v1 contract (`PAUSER_ROLE`). +2. Deploy the v2 contract. +3. Update the app to route new envelopes to v2. +4. Existing v1 envelopes continue to be claimable / reclaimable until they are exhausted, expired, or reclaimed. +5. Once v1 has fully drained (no active envelopes), it can be left dormant — there is no on-chain decommissioning step. + +If a critical bug allows draining funds from existing v1 envelopes, there is no on-chain rescue path. Mitigations are: + +- Monitor `EnvelopeClaimed` event rates and assets-at-rest balances; alert on anomalies. +- Pause `create*` immediately to stop new exposure. +- Coordinate with affected senders off-chain. + +This is the explicit trade-off accepted in §1.4 row 9. + +
+ +## 10. File Layout + +``` +src/envelopes/ + BaseEnvelope.sol + EthEnvelope.sol + Erc20Envelope.sol + Erc721Envelope.sol + Erc1155Envelope.sol + interfaces/ + IBaseEnvelope.sol + IEthEnvelope.sol + IErc20Envelope.sol + IErc721Envelope.sol + IErc1155Envelope.sol + EnvelopeTypes.sol + doc/ + README.md + spec/ + envelopes-specification.md +test/envelopes/ + BaseEnvelopeShared.t.sol + EthEnvelope.t.sol + Erc20Envelope.t.sol + Erc721Envelope.t.sol + Erc1155Envelope.t.sol + Envelopes.integration.t.sol + mocks/ + MockEnvelope.sol (minimal subclass for shared tests) + MockReentrantClaimer.sol (test helper for reentrancy) +script/ + DeployEnvelopesZkSync.s.sol +ops/ + deploy_envelopes_zksync.sh (mirrors deploy_swarm_contracts_zksync.sh) +``` + +License header on every Solidity file: `// SPDX-License-Identifier: BSD-3-Clause-Clear`. + +Solidity pragma: `^0.8.26` (matches existing contracts). + +
+ +## 11. Open Considerations + +These are not blocking for v1; recorded for future iteration. + +| Item | Status | Notes | +| :---------------------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Multi-asset bundle envelope | Deferred | Add a 5th `BundleEnvelope` template later if real demand arises; existing four contracts are unchanged | +| NODL-denominated fees | Deferred | Currently fee is in ETH; can add per-contract NODL-fee variants if desired without breaking the ETH path | +| Per-recipient allowlist | Deferred | Out of scope by design (bearer model). If future use cases demand it, ship as a parallel `GatedEnvelope` contract — does not affect base | +| Sender cancellation before expiry | Deferred | Out of scope per §1.4 row 6. Could be added as a flag at creation but breaks recipient guarantees; revisit only with strong UX justification | +| CI storage-layout diff for v2 → v3 deploys | Not applicable | Contracts are immutable; storage compatibility between deployments is not required. v2 simply lives at a new address | +| Envelope expiry indexing for app "expiring soon" UX | Deferred | Subquery can index `expiresAt` and surface a sender's pending envelopes; not a contract change | +| Cross-chain envelopes | Deferred | Out of scope; would require a bridge or LayerZero-style integration |