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
- 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.
- The user delegates their EOA once, via EIP-7702, to a small audited delegate contract.
- 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.
- If the proxy was upgraded, the transaction reverts on chain, even if the device still holds the old descriptor.
- 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 hardware wallet makes no chain call. It sees only the descriptor and the transaction to sign.
- One transaction, one signature.
- Existing proxies and implementations work unchanged, and the proxy still sees the user as
msg.sender. - Any proxy type: transparent, UUPS, beacon, diamond.
- A descriptor must stop working on chain as soon as the code behind it changes, even if a device still holds it.
3Standards involved
calldata field format displays a call nested inside another call._msgSender(). Used here as the alternative to EIP-7702.4Entities
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.
Companion app
The host software (desktop or browser wallet). Builds the transaction, fetches nonce and fees, broadcasts.
User account (EOA)
Holds the funds. Delegated once, through EIP-7702, to the delegate contract below.
Protocol
Its proxy and implementation, as deployed today. Publishes an ERC-7730 file for the proxy, as today.
Attester
Audits the implementation, signs the descriptor, and runs a watcher that detects implementation changes every block.
Registry
On-chain list of descriptor hashes that are valid right now, one per chain. Only the attester writes to it.
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
- 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). - The device checks it locally. The transaction is a call to its own address;
descriptorHashis 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. - On chain, the delegate code checks the registry. If
registry.isValid(proxy, descriptorHash)is false, the transaction reverts before the proxy is ever called. - 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.
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.
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 forwarder | Protocol unchanged | |
|---|---|---|
| User account on 7702 | Clear signing (either route) | Clear signing (delegate) |
| Plain EOA | Clear 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:
- One forwarder, many protocols. A bug in the canonical forwarder would expose every protocol that trusts it, so it must be tiny, immutable and audited.
- ERC-2771 combined with multicall. Together they let callers spoof
msg.sender(the December 2023 incident, fixed in OpenZeppelin 4.9.4 and 5.0.1). A protocol adopting the forwarder must use a patched multicall or none.
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;
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.
The delegate reads registry.isValid(proxy, hash). If it is false, the transaction reverts with DescriptorNotValid before the proxy is called.
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.
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
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.