Search projects, hackathons, sponsors and tech

    ← All projects

    Cross-permit

    One EIP-712 signature arms token allowances on every chain.

    Demo

    About

    CROSSPERMIT One signature. Every chain. Nothing bridged. An allowance isn't money. It's a number in a token's storage that says "this spender may pull this much from me," and that number lives on one chain only. Permit2 made granting it gasless, but a Permit2 signature can't leave its chain: the EIP-712 domain includes chainId, so a signature valid on Base is invalid on Arbitrum. Four chains means four signatures. CrossPermit makes the permission portable. One signed message sets allowances on many chains. Each chain keeps its own ledger, but a single signature authorises all of them. No token moves and nothing is bridged. Bridges solve "my money is on the wrong chain." CrossPermit solves "my authority is on the wrong chain." WHO NEEDS THIS -------------- Funds managing client capital on-chain face two constraints that pull against each other. They can't take custody, because the client's tokens must stay in the client's wallet. And they can't ask for a signature per chain per rebalance, because every wallet prompt adds friction, approval risk and another audit line. Today a four-chain move takes four signatures, four wallet sessions, and four audit records that a risk committee has to trust describe one decision. With CrossPermit it takes one signature and produces one audit record. The desk never touches the tokens. HOW ONE SIGNATURE TRAVELS ------------------------- 1. Pin the domain. Set chainId = 1 everywhere, so the digest names no chain. verifyingContract stays in the domain, so the contract address must be identical on every chain. 2. Prove the address. The ERC-2470 singleton factory gives the same address on every chain for the same init code and salt. 3. Merkle the rest. Each chain's permit bundle becomes a leaf, and the user signs the root. Anyone can submit a chain's bundle with its proof. CrossPermit rebuilds the root, recovers the signer and applies the bundle. No chain can apply another chain's leaf. Revocation works the same way: one signed LOCK covers every chain. The test: Optimism Sepolia was added after the first two chains were live. With the same salt, CrossPermit landed at the same address and returned a byte-identical DOMAIN_SEPARATOR, with zero coordination. DOMAIN_SEPARATOR() 0x4ce820a58ffb00fe1b6cb52f083bdd1cfd176732c34db34a84517668750aa5e4 It's identical on all three chains. Read it yourself. LIVE TODAY ---------- One 65-byte signature goes to a relayer, which fans it out to Ethereum Sepolia, Base Sepolia and Optimism Sepolia from a single HTTP request. A Uniswap Universal Router deployed with permit2 := CrossPermit then spends the allowance, including a real v4 swap on each chain. The desk flow is: add a client -> send one link -> they sign once -> you allocate TRADE What it proves: v4 swap through the Universal Router's real V4_SWAP settle path Status: Live, 3 testnets INVEST What it proves: v4 LP position funded by CrossPermit.transferFrom, held in the client's name Status: Live, 3 testnets EARN What it proves: 10,000 USDC in, 10,032.65 out after 30 days on Aave v4 Core Hub Status: Mainnet fork HOLD RWAs What it proves: Compliance-gated equity desk reading Ondo's live NVDAon Status: Mainnet fork, venue mocked Before the wallet opens, the client sees every per-chain grant in plain language. That includes allowances already outstanding, so the grant is never understated. The client can leave at any time: one signature locks the desk on every chain, and a spend that worked a second earlier reverts. WHY A RISK COMMITTEE SIGNS OFF ------------------------------ Every grant, lock and burned salt is a decoded row in MultiBaas. The dashboard shows four sources side by side and never merges them: - Chain storage: what the allowance is now. - MultiBaas: how it got there, including grants that have lapsed. - PoolManager extsload: what the venue holds. - The desk's own records: what was requested. These are never treated as proof that anything was granted. Where the sources disagree, the dashboard shows the disagreement. Any spender that isn't the router or the liquidity desk is flagged. WHY NOW ------- v4 made the venue programmable, and the Universal Router made settlement uniform. The remaining barrier to one-click multichain allocation is the number of signatures. Removing it requires no change to the router's payment path, only a router deployed with permit2 := CrossPermit. LIVE ---- Address: 0x659C6F027FC4F6b2fF7A18dF1e3C3ec78a99de1B Chains: Ethereum Sepolia, Base Sepolia, Optimism Sepolia Salt: keccak256("CrossPermit v1") Compiler: solc 0.8.27, optimizer 1e6 runs

    How it's made

    CROSSPERMIT - ONE SIGNATURE, EVERY CHAIN ======================================== A Permit2 signature is locked to one chain because its EIP-712 domain includes block.chainid. CrossPermit removes that lock without making the signature replayable across chains. HOW IT WORKS ------------ 1. Pin the domain. contracts/src/lib/EIP712.sol is OpenZeppelin's EIP712 with one change: uint256 private constant CROSS_CHAIN_ID = 1; // not block.chainid The domain still includes verifyingContract, so the separator only matches if the address matches. ERC-2470 guarantees that: same init code + same salt = same address on every chain. Both halves are needed. DOMAIN_SEPARATOR() 0x4ce820a58ffb00fe1b6cb52f083bdd1cfd176732c34db34a84517668750aa5e4 (identical on all three live deployments) 2. One bundle per chain, one leaf per bundle. Each bundle is a list of entries: grant, decrease, lock, unlock, or transfer. The chain id is hashed inside the leaf, and the contract also reverts with WrongChainId if it doesn't match block.chainid. That makes two independent guards at the one place a mix-up would be silent. 3. Leaves are computed by the client. Always. The SDK hashes leaves locally. The on-chain hashChainPermits is called only to assert equality. If the leaf came from an RPC, a hostile endpoint could make the user sign a bundle they never saw. 4. Leaves fold into a Merkle root. Pairs use OpenZeppelin sorted-pair hashing, so client and contract fold identically. The tree is left-leaning, so the last leaf has a one-element proof. Put the most expensive chain last and it pays the least gas. 5. The user signs the root once. CrossPermit(address owner,bytes32 salt,uint48 deadline, uint48 timestamp,bytes32 merkleRoot) No chain id appears here, so the digest is byte-identical everywhere. One wallet prompt, one 65-byte signature. 6. Each chain redeems only its own leaf. Anyone submits a chain's bundle plus its proof. The contract rebuilds the leaf, the root and the digest, then recovers the signer. A wrong bundle recovers to the wrong signer, so nothing needs checking separately. Submission is permissionless because the bundle only moves the signer's own tokens. 7. Replay protection is per chain. Each salt is burned in per-chain storage, so an intent applies exactly once on each chain. deadline bounds validity. timestamp orders competing intents. 8. Revocation has the same shape. One signed cancel root burns the salt on every chain. One signed LOCK disables a spender on every chain. Remove any piece and the scheme breaks: - pinned chainId makes the digest portable - CREATE2 address makes the domain portable - Merkle root lets one digest carry different grants per chain - chain id in the leaf stops grants being swapped between chains - client-side leaf stops an RPC choosing what the user signs THE STACK --------- Contracts (Solidity 0.8.27, Foundry) CrossPermit.sol's transferFrom overloads are selector-identical to Permit2's. The Universal Router's payment path is untouched; only the PERMIT2 immutable changes at construction. Because it has no getter, the swap is confirmed three ways: deploy log, broadcast constructor args, and the CrossPermit address appearing at all four use sites in each router's runtime bytecode. Also included: LiquidityDesk.sol (v4 adapter), YieldRouter.sol (Aave v4), and EquityDesk.sol (compliance gate that defaults to deny). SDK (TypeScript + viem) Builds bundles, computes leaves, folds the tree, signs, and cancels. Relayer (Bun) One POST fans out to N chains. It simulates before every broadcast and streams per-chain status over SSE. It can pay gas and refuse to submit. It cannot change a recipient, spender or amount. Every flow still works without it. Web (Next.js, wagmi/viem, Reown AppKit) A multi-manager desk, one screen per client, and mandate links that can't be double-claimed. Pool state is read directly from the v4 PoolManager. MultiBaas (control plane) Every Permit is a decoded row. It also provides the allowance ledger feed, audit trail, address aliases and custody signer. docs/multibaas.md explains each feature used or skipped. DECISIONS WORTH NAMING ---------------------- Timestamps are signed behind wall clock. Head blocks lag by seconds, so signing with Date.now() fails on slow chains and never on fast ones. A constant TIMESTAMP_LAG fixes it without changing the order of intents. LiquidityDesk can put tokens in but never take them out. Anyone can call add, but only for an owner who signed a writ naming the desk, and only into the pool. remove and collect are msg.sender-scoped. Unichain Sepolia was dropped. MultiBaas doesn't support it, and a chain without an audit trail isn't one this product can stand on. TESTING ------- - Fuzzes leafOf and the Merkle builder against the contract for 1-32 leaves. - Invariants: an allowance never exceeds what was signed, a LOCK cannot be bypassed by any ordering, and a bundle never applies twice on one chain. - Fork tests: real v4 swaps on three testnets, the full LiquidityDesk sequence including what it must not be able to do, and Aave v4 + NVDAon on mainnet. - Live runs: lifecycle.ts covers eight real state transitions on testnets, and onboard.ts runs twenty-two checks on the mandate loop.