Design note · clear signing

Clear signing upgradeable proxies without reading the chain

The hardware wallet stays offline, proxies and implementations stay untouched, and a descriptor stops working on chain the moment the code behind it changes.

TL;DR

  1. Attesters publish an on-chain registry (an oracle) that says which ERC-7730 descriptors are valid for each proxy right now. They revoke a descriptor the moment its proxy is upgraded.
  2. The user delegates their EOA once, via EIP-7702, to a small audited delegate contract.
  3. To clear-sign, the user's account calls itself. The delegate code checks the registry for the descriptor the device displayed, then calls the proxy as the user.
  4. If the proxy was upgraded, the transaction reverts on chain, even if the device still holds the old descriptor.
  5. Users without EIP-7702 get the same check through an ERC-2771 forwarder, for protocols that upgrade their implementation to trust it. Both routes can ship together.

1The problem

ERC-7730 lets a hardware wallet show a human-readable intent for a contract call ("Withdraw 100 USDC to you"), using a descriptor file bound to the contract's address. Upgradeable proxies break the assumption behind it: the address stays the same while the code behind it can change at any time.

The device cannot read the chain, and it refreshes its descriptors only periodically (for example every hour). Between an upgrade and the next refresh it would keep showing an intent that may no longer be true.

2What's needed

The idea: the user's own account checks, at execution time, an on-chain list of descriptors that are valid right now. An attester keeps that list up to date in real time, so the device never needs fresh data.

3Standards involved

ERC-7730JSON descriptor format for clear signing: maps a function's arguments to labels and formats. Its calldata field format displays a call nested inside another call.
EIP-7702Lets an EOA (externally owned account) point its address at contract code with a signed authorization. The account keeps its key, address and balances, and the calls it makes still come from its own address.
ERC-2771Meta-transactions: a trusted forwarder calls a contract with the original sender's address appended to the calldata, and the contract reads it with _msgSender(). Used here as the alternative to EIP-7702.
EIP-1967 · 1822 · 2535Standard proxy layouts: implementation and beacon storage slots (1967), UUPS (1822), diamonds with a facet per function (2535). The watcher reads them to know which code is live.
RFC 8785 (JCS)JSON Canonicalization Scheme: one exact byte form for a JSON file, so everyone computes the same hash of a descriptor.

4Entities

trusted

Hardware wallet

Holds the key. Checks the descriptor against the transaction locally and never reads the chain. Signs EIP-7702 authorizations only for a whitelisted delegate.

untrusted

Companion app

The host software (desktop or browser wallet). Builds the transaction, fetches nonce and fees, broadcasts.

unchanged

User account (EOA)

Holds the funds. Delegated once, through EIP-7702, to the delegate contract below.

unchanged

Protocol

Its proxy and implementation, as deployed today. Publishes an ERC-7730 file for the proxy, as today.

trusted

Attester

Audits the implementation, signs the descriptor, and runs a watcher that detects implementation changes every block.

new contract

Registry

On-chain list of descriptor hashes that are valid right now, one per chain. Only the attester writes to it.

new contract

Delegate

The code the user's account runs. Checks the registry, then calls the proxy from the user's own address.

5How a call flows

Hardware wallet offline, signs 1 tx User account EOA + delegate code Proxy unchanged Implementation can be upgraded Registry valid descriptor hashes Attester watcher off chain, real time signed tx to itself 1. isValid(proxy, descriptorHash)? 2. call as user delegate- call watches which code is live set
Accent: the new pieces and the check that makes a stale descriptor fail on chain.
  1. The companion app builds the transaction. It is sent from the user's account to the user's account, with data execute(proxy, descriptorHash, withdrawCalldata).
  2. The device checks it locally. The transaction is a call to its own address; descriptorHash is the hash of the descriptor it holds; that descriptor is signed by a trusted attester and bound to this proxy and chain. Then it displays the inner call as usual.
  3. On chain, the delegate code checks the registry. If registry.isValid(proxy, descriptorHash) is false, the transaction reverts before the proxy is ever called.
  4. Otherwise the user's account calls the proxy. The proxy sees msg.sender = the user and delegatecalls the implementation, exactly as a direct call would.

6Two entry points, one check

The check has to run outside the implementation, and the proxy still has to see the user as msg.sender. There are two ways to get both, and they can ship together: the same registry check behind two entry points.

EIP-7702 delegate

Every proxy, for users on 7702

The route described above. The user's account runs the delegate code and calls the proxy itself. Proxies and implementations stay exactly as they are. Needs the user to delegate their account, on a chain that supports EIP-7702.

ERC-2771 forwarder

Every user, for protocols that opt in

The user calls a canonical forwarder contract. It runs the same registry check, then calls the proxy with the user's address appended to the calldata, and the implementation reads the user with _msgSender(). Works for plain EOAs and smart accounts, but only for protocols whose implementation trusts the forwarder: one implementation upgrade, the proxy stays in place.

Shipped together, the coverage is the union. The wallet picks the route per transaction:

Protocol trusts the forwarderProtocol unchanged
User account on 7702Clear signing (either route)Clear signing (delegate)
Plain EOAClear signing (forwarder)Blind signing

The forwarder gets its own ERC-7730 file, identical to the delegate's except that it is bound to the forwarder's address. Two hazards come with the ERC-2771 route:

The bottom-right cell cannot be closed. For a plain EOA to stay msg.sender without EIP-7702, the transaction has to go straight to the proxy, and then the only code that runs is the proxy and the implementation that just changed. Nothing outside the upgrader's control is left to perform the check.

7Descriptor lifecycle

The registry stores one flag per proxy and descriptor hash:

mapping(address target => mapping(bytes32 descriptorHash => bool)) public isValid;
Attestonce per audit

After the audit, the attester's watcher calls registry.set(proxy, [hash], true) and records a fingerprint of the live implementation: the implementation slot, the beacon or the diamond facets, and their code hashes.

Useevery transaction

The delegate reads registry.isValid(proxy, hash). If it is false, the transaction reverts with DescriptorNotValid before the proxy is called.

Invalidatereal time

The watcher recomputes the fingerprint every block. As soon as it differs (an upgrade happened), it sends registry.set(proxy, [hash], false). From that block on, every transaction built from that descriptor reverts, whatever the devices have cached.

Re-attestafter re-audit

After auditing the new implementation, the attester sets the new file's hash to true. The old hash stays false, so a device still holding the old file can never pass again.

Only the attester's address can call set. It can also revoke a hash by hand, for example if a descriptor turns out to be wrong.

8Minimal example

The protocol's descriptor: plain ERC-7730, unchanged

{
  "context": { "contract": { "deployments": [{ "chainId": 1, "address": "0xVaultProxy" }] } },
  "metadata": { "owner": "Example Vault" },
  "display": { "formats": {
    "withdraw(address token,uint256 amount,address to)": {
      "intent": "Withdraw from vault",
      "fields": [
        { "path": "amount", "label": "Amount", "format": "tokenAmount", "params": { "tokenPath": "token" } },
        { "path": "to", "label": "Recipient", "format": "addressName" }
      ]
    }
  }}
}

Its hash is keccak256(JCS(file)). That value goes into the registry and into the transaction.

The delegate's descriptor: one new parameter

{
  "context": { "contract": { "deployments": [{ "chainId": 1, "address": "0xClearSignDelegate" }] } },
  "display": { "formats": {
    "execute(address target,bytes32 descriptorHash,bytes data)": {
      "intent": "Verified call",
      "fields": [{
        "path": "data", "label": "Action", "format": "calldata",
        "params": {
          "calleePath": "target",
          "amountPath": "@.value",
          "descriptorHashPath": "descriptorHash"
        }
      }]
    }
  }}
}

calldata, calleePath and amountPath are standard ERC-7730. The highlighted parameter is the only addition: the file used to display the inner call must hash to that argument.

The transaction

from: 0xUser
to:   0xUser                         // the user's own account, running the delegate code
data: execute(
        target         = 0xVaultProxy,
        descriptorHash = keccak256(JCS(vault descriptor)),
        data           = withdraw(USDC, 100000000, 0xUser))

What the device shows

Withdraw from vault
Amount100 USDC
Recipient0xUser…

9Limits and cost

Watcher latency

A transaction that lands after an upgrade but before the revocation still executes. The exposure window is the watcher's reaction time (a block or two), not the device's hourly refresh.

Same-block upgrade

Someone holding the upgrade key who controls transaction ordering can place the upgrade right before a victim's transaction in the same block. Beacon and diamond proxies can close this with a live check in the delegate (implementation(), facetAddress()). EIP-1967 proxies need a short upgrade delay, because another contract cannot read their implementation slot.

Gas

About 10.5k extra gas per transaction (measured): roughly +7% on a typical 150k gas DeFi call. Merging the registry into the delegate contract would save about 2.6k of it.

Delegation

The account delegates to the delegate contract, so it must be audited and whitelisted by wallet vendors. An EOA has one EIP-7702 delegation at a time.

Scope of the watcher

It sees code changes. Behaviour changes through admin setters with the same code (fees, oracles, recipients) need monitoring too.

Key management

The attester key that writes the registry is used on every chain in real time, so it is a hot key.