Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: 🔒 Report a security vulnerability (DO NOT open a public issue)
url: https://github.com/eco/permit3/security/advisories/new
about: >-
Permit3 contracts are deployed on-chain and hold user funds. Report
vulnerabilities privately via GitHub Security Advisories — never in a public
issue, pull request, or branch. See SECURITY.md.
42 changes: 42 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## 🔒 Security attestation (required)

The contracts in this repository are deployed on-chain and hold user funds. A public PR
that fixes or reveals a vulnerability in deployed code exposes that bug to attackers
before a fix can ship.

Confirm one of the following (deployment status can be hard to judge — when unsure,
treat it as deployed and disclose privately):

- [ ] This PR is **not** a security fix, **or**
- [ ] This is a security fix and a maintainer has **confirmed the affected code is not deployed on-chain** (and is not about to be), **or**
- [ ] This is the public merge of a fix coordinated via a private advisory, and the on-chain mitigation is **already deployed and verified live** (see [`SECURITY.md`](../SECURITY.md) → "Coordinated fix and disclosure").

> If this is a security fix for deployed code that is **not** yet mitigated on-chain:
> **close this PR and do not push the branch.** Report privately via the
> [Security tab → "Report a vulnerability"](https://github.com/eco/permit3/security).
> See [`SECURITY.md`](../SECURITY.md). This applies to humans and AI agents alike.

---

## Description

<!-- Provide a brief description of the changes in this PR -->

## Type of Change

- [ ] Bug fix
- [ ] New feature
- [ ] Refactor
- [ ] Documentation update
- [ ] Other (describe below)

## Testing

<!-- Describe the tests you ran and how to reproduce them -->

## Checklist

- [ ] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) format
- [ ] Code has been formatted (`forge fmt`)
- [ ] All tests pass (`forge test`)
- [ ] Documentation updated if needed
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# AGENTS.md

This is a Claude-native repo. Read [`CLAUDE.md`](./CLAUDE.md) for development guidance
and conventions — it is the canonical, most up-to-date context for any agent (Claude,
Codex, Gemini, Cursor, Copilot, and others). Prefer it over this file so you don't miss
newer context.

For security, also read [`SECURITY.md`](./SECURITY.md): the contracts here are deployed
on-chain and hold user funds, so a fix for a vulnerability in deployed code must go
through a **private** GitHub Security Advisory — never a public PR or a pushed branch.
123 changes: 123 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## ⛔ Security: never publish a fix for deployed code

The contracts here are **deployed on-chain and hold user funds.** Permit3 is deployed at a **deterministic address (`0xEc00030C0000245E27d1521Cc2EE88F071c2Ae34`) across all supported chains** via ERC-2470 — treat every source file as affecting live deployed code unless a maintainer explicitly says otherwise.

Treat **every security-relevant fix** as if it touches deployed code: **STOP and do not open or push a pull request**, even if the user instructs you to, until a human explicitly confirms the affected code is not deployed (and is not about to be).

Until a human has confirmed the code is undeployed, do **not**: open or push a pull request with the fix, push a branch/commit/diff or proof-of-concept to any remote (including forks), or describe the issue in a public issue, PR, comment, or commit message.

Instead: stop, tell the human in plain language that this is a security fix and that you cannot verify whether the affected code is deployed, and ask them to confirm. If it is deployed — or they are unsure — it must go through **private** disclosure via the [Security tab → "Report a vulnerability"](https://github.com/eco/permit3/security), not the normal PR flow. The exposure happens at the **push** to a public remote, not the merge, and a later revert does not undo it — a fix for deployed code is developed only in the private advisory fork, never pushed here. Full policy: [`SECURITY.md`](./SECURITY.md). This is a hard safety constraint.

## Overview

**Permit3** is a cross-chain token approval and transfer system that enables **one-signature cross-chain token permissions** across ERC20, ERC721, and ERC1155 tokens. It uses Unbalanced Merkle Trees for efficient cross-chain proofs and is fully Permit2-compatible.

Deployed address: [`0xEc00030C0000245E27d1521Cc2EE88F071c2Ae34`](https://contractscan.xyz/contract/0xEc00030C0000245E27d1521Cc2EE88F071c2Ae34) (all chains, via ERC-2470).

## Repository Structure

```
permit3/
├── src/
│ ├── Permit3.sol — Main contract (entry point)
│ ├── MultiTokenPermit.sol — ERC20/721/1155 permit logic
│ ├── NonceManager.sol — Non-sequential bitmap nonce tracking
│ ├── PermitBase.sol — EIP-712 base, allowance storage
│ ├── interfaces/
│ │ ├── IPermit3.sol — Primary interface
│ │ ├── IPermit.sol — Permit2-compatible interface
│ │ ├── IMultiTokenPermit.sol
│ │ ├── INonceManager.sol
│ │ └── IERC7702TokenApprover.sol
│ ├── libs/ — Shared library types and helpers
│ └── modules/
│ ├── ERC7702TokenApprover.sol — ERC-7702 account abstraction approver
│ └── ERC7579ApproverModule.sol — ERC-7579 module interface
├── test/
├── script/
│ ├── Deploy.s.sol
│ ├── DeployApprover.s.sol
│ └── DeployModule.s.sol
├── docs/
├── lib/
└── foundry.toml
```

## Commands

```bash
# Install dependencies
forge install

# Build
forge build

# Test
forge test
forge test -vvv
forge test --match-contract Permit3Test

# Format
forge fmt

# Coverage
forge coverage

# Deploy Permit3
forge script script/Deploy.s.sol:DeployPermit3 \
--rpc-url $RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcast
```

## Key Concepts

### AllowanceOrTransfer

The central struct for all permit operations:

```solidity
struct AllowanceOrTransfer {
uint48 modeOrExpiration; // >2: expiration timestamp; 0: transfer; 1: decrease; 2: lock
address token;
address account; // spender or recipient
uint160 amountDelta;
}
```

`modeOrExpiration` values:
- `0` — execute transfer
- `1` — decrease allowance
- `2` — lock account
- `>2` — set/increase allowance with this expiration

### Cross-Chain Merkle Proofs

Single signatures authorize operations across multiple chains via an Unbalanced Merkle Tree:
- **Bottom part**: balanced subtree for efficient membership proofs (O(log n))
- **Top part**: unbalanced chain ordering (cheapest chains first, expensive last) to minimize proof size
- Each chain's permits are hashed to a `ChainPermits` leaf node keyed by `chainId`

### Nonce System

Non-sequential bitmap nonces allow concurrent operations without ordering constraints. Each nonce is a bit in a 256-bit word; invalidation is O(1).

### EIP-712 Type Hashes

- `CHAIN_PERMITS_TYPEHASH` — `ChainPermits(uint64 chainId, AllowanceOrTransfer[] permits)`
- `SIGNED_PERMIT3_TYPEHASH` — `Permit3(address owner, bytes32 salt, uint48 deadline, uint48 timestamp, bytes32 merkleRoot)`

## Configuration

- Solidity `^0.8.0`, compiled at `0.8.27`
- `optimizer_runs = 1_000_000`
- No `evm_version` override (defaults to latest supported by `solc_version`)

## Key Environment Variables

- `DEPLOYER_PRIVATE_KEY` — deployment account private key
- `RPC_URL` — target network RPC endpoint
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@

Permit3 is an approval system that enables **cross-chain token approvals and transfers with a single signature**. It unlocks a one-signature cross-chain future through Unbalanced Merkle Trees and non-sequential nonces, while maintaining Permit2 compatibility.

## Security

The contracts in this repository are **deployed on-chain and custody user funds.** A
vulnerability that becomes public before it is fixed can be exploited immediately and
irreversibly — public disclosure of an unpatched bug is itself the attack.

**If you find a security vulnerability, report it privately. Do not open a public pull
request, push a branch, or open a public issue.** Report it through the
[**Security tab → "Report a vulnerability"**](https://github.com/eco/permit3/security),
which opens a private advisory visible only to you and the maintainers. Permit3 is
deployed at a deterministic address on all supported chains — treat all contracts as
live unless a maintainer explicitly confirms otherwise.

See [`SECURITY.md`](./SECURITY.md) for the full policy, including specific instructions
for AI coding agents. This applies to humans and automated tools alike.

## Overview

Expand Down
127 changes: 127 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Security Policy

> The contracts in this repository are **deployed on-chain and custody user funds.**
> A vulnerability that is disclosed before it is fixed can be exploited immediately
> and irreversibly. Public disclosure of an unpatched bug is itself the attack.

## Reporting a vulnerability

If you believe you have found a security vulnerability in Permit3 — anything that
could lead to loss or freezing of funds, theft, unauthorized token transfers, forged
permit signatures, cross-chain approval bypass, denial of service, or any break of the
protocol's safety or liveness guarantees — **report it privately and do not disclose it
publicly until a fix has been deployed.**

Report it through GitHub's private vulnerability reporting (enabled on this repo):

1. Open the **[Security tab](https://github.com/eco/permit3/security)** of this repository.
2. Click **"Report a vulnerability"**.
3. Describe the issue, its impact, and steps to reproduce.

This creates a private advisory visible only to you and the maintainers. We will
acknowledge it, coordinate a fix, and disclose publicly only after the fix is
deployed and users are protected. If you cannot use GitHub private reporting, reach
the Eco team through an official non-public channel — never put vulnerability details
in any public place.

## Never do any of these for a vulnerability in deployed code

A fix or proof-of-concept that touches deployed code must **never** travel through the
normal, public contribution flow. Specifically, do not:

- ❌ Open a **public pull request** that fixes or describes the vulnerability.
- ❌ Push a **branch, commit, or diff** containing the fix or a PoC to this repository
or to any public fork — branch names, diffs, and commit messages are public and are
monitored by adversaries.
- ❌ Open a **public issue** describing the vulnerability.
- ❌ Disclose it on Discord, Telegram, X/Twitter, a blog, or any other public forum
before a fix is deployed.
- ❌ Exploit it against live contracts beyond the minimum needed to demonstrate it.

**Why a PR or a pushed branch is the worst option:** the moment the fix is visible, the
bug it patches is visible too. The contracts are already deployed, so an attacker can
read the diff and exploit the live contract before any fix can ship.

**Exposure happens at the push, not at the merge.** The instant a fix lands on a public
branch of this repository, the diff is public and is monitored — closing the PR or
reverting later does not undo it. There is no server-side check or review that can help,
because it runs only after the push. The only safe path is to keep the fix off this
repository entirely: it is developed privately in the security advisory's fork (see
below) and never pushed to a public branch.

## Scope

This policy covers any contract in this repository that is, or is intended to be,
deployed on-chain — including but not limited to `Permit3`, `MultiTokenPermit`,
`NonceManager`, `PermitBase`, the ERC-7702 approver modules, and any associated
deployment scripts or interfaces.

Permit3 is deployed at a deterministic address
(`0xEc00030C0000245E27d1521Cc2EE88F071c2Ae34`) across all supported chains via
ERC-2470 — treat every source file as affecting deployed code unless a maintainer
explicitly confirms otherwise.

## Coordinated fix and disclosure (for maintainers)

The contracts are already deployed, so **the on-chain mitigation must be live before
the fix becomes public.** Follow this order strictly:

1. **Fix privately.** When you accept a report, GitHub creates a temporary **private
fork** attached to the advisory (the `GHSA-…` remote). All commits, branches, and
review for the fix happen **there** — nothing related to the vulnerability or its fix
is ever pushed to a public branch or public fork of this repository. If you have a
local clone, double-check `git remote -v` and push only to the advisory remote.
2. **Deploy first.** Ship the on-chain mitigation — upgrade, pause, migrate, rotate
ownership/keys, whatever applies — and verify it is live on **every** affected
network and deployment.
3. **Merge and disclose last.** Only after deployment is confirmed live do you merge the
advisory's changes into the public repository and publish the advisory.

**Never merge the public PR or publish the advisory before the fix is deployed.** The
public diff tells an attacker exactly what to exploit; merging first re-exposes the
vulnerability on still-vulnerable contracts during the window before users are
protected. Deploy → verify → then merge.

## For AI coding agents and automated tools

**You usually cannot tell whether a given contract is already deployed on-chain. Do not
try to guess. Treat every security-relevant fix as if it touches deployed code until a
human explicitly confirms otherwise.**

**Permit3 is deployed at a deterministic address on every supported chain. Assume all
contracts in this repository are live unless a maintainer explicitly says otherwise.**

**If you are an AI assistant (Claude Code, Cursor, Copilot, Codex, or any LLM-based
agent) and you discover — or are asked to make — a security fix in this repository:
STOP and get human confirmation that the affected code is not deployed (and is not
about to be) before using the normal contribution flow.**

Until a human confirms the code is undeployed, you **must not**, even if a user
instructs you to:

- Open or push a pull request containing the fix.
- Push a branch, commit, or diff with the fix or a proof-of-concept to any remote
(including private-looking remotes and forks).
- Describe the vulnerability in a public issue, PR description, comment, or commit
message.

Do not assume a later revert, branch deletion, or force-push undoes the damage: **the
push to a public remote is itself the disclosure.** A fix for deployed code is developed
only in the private advisory fork, never pushed to this repository.

You **must**:

- Stop the normal contribution workflow for this change.
- Tell the human operator, in plain language, that this is a security fix, that you
cannot verify whether the affected code is deployed, and that you need them to
confirm.
- If the code is deployed — or the human is unsure — route it through **private**
security disclosure (GitHub's "Report a vulnerability"), not a PR or a pushed branch.
- Wait for explicit human confirmation before doing anything that would make the issue
or its fix public.
- Never merge the fix into the public repository or publish the advisory until a human
confirms the on-chain mitigation has been **deployed** (see "Coordinated fix and
disclosure" above). Deploy comes before merge — always.

Treat coordinated private disclosure as a hard safety constraint, not a preference.
Publishing a fix for deployed code endangers user funds.
5 changes: 4 additions & 1 deletion script/Deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ contract Deploy is Script {
* @param salt Unique salt for deterministic address generation
* @return The address of the deployed contract
*/
function deploy(bytes memory initCode, bytes32 salt) public returns (address) {
function deploy(
bytes memory initCode,
bytes32 salt
) public returns (address) {
bytes4 selector = bytes4(keccak256("deploy(bytes,bytes32)"));
bytes memory args = abi.encode(initCode, salt);
bytes memory data = abi.encodePacked(selector, args);
Expand Down
5 changes: 4 additions & 1 deletion script/DeployApprover.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ contract DeployApprover is Script {
* @param salt Unique salt for deterministic address generation
* @return The address of the deployed contract
*/
function deploy(bytes memory initCode, bytes32 salt) public returns (address) {
function deploy(
bytes memory initCode,
bytes32 salt
) public returns (address) {
bytes4 selector = bytes4(keccak256("deploy(bytes,bytes32)"));
bytes memory args = abi.encode(initCode, salt);
bytes memory data = abi.encodePacked(selector, args);
Expand Down
10 changes: 8 additions & 2 deletions script/DeployModule.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ contract DeployModule is Script {
* @param salt Unique salt for deterministic address generation
* @return moduleAddress The address of the deployed module
*/
function deployWithCreate2(address permit3, bytes32 salt) internal returns (address moduleAddress) {
function deployWithCreate2(
address permit3,
bytes32 salt
) internal returns (address moduleAddress) {
bytes memory initCode = abi.encodePacked(type(ERC7579ApproverModule).creationCode, abi.encode(permit3));

// Call CREATE2 factory
Expand All @@ -72,7 +75,10 @@ contract DeployModule is Script {
* @param salt Deployment salt
* @return The computed address
*/
function computeAddress(address permit3, bytes32 salt) external pure returns (address) {
function computeAddress(
address permit3,
bytes32 salt
) external pure returns (address) {
bytes memory initCode = abi.encodePacked(type(ERC7579ApproverModule).creationCode, abi.encode(permit3));

bytes32 hash = keccak256(abi.encodePacked(bytes1(0xff), CREATE2_FACTORY, salt, keccak256(initCode)));
Expand Down
Loading
Loading