Attestation and proof formats
An attestation always travels as one envelope: ABI-encoded attestation data plus an array of 65-byte ECDSA signatures. An update takes that envelope directly; a verify call carries it in its proof field.attestationData field holds abi.encode(StateAttestation) for an update, and abi.encode(PacketAttestation) for both verify calls.
- A zero commitment in a packet attestation claims the path is empty at that height. That positive claim is what a non-membership proof needs.
- The contract sets no limit on how many packets one attestation covers, so sizing a proof against the block gas limit should be considered by the submitter.
How signatures are verified
When a proof arrives, the client rebuilds the digest the attestors signed and checks the signatures against it. The digest issha256(typeTag || sha256(attestationData)), where the tag is 0x01 for a state attestation and 0x02 for a packet attestation.
The check is strict. At least minRequiredSigs signatures must be present, and no two may recover to the same address. Each one must be exactly 65 bytes and must recover to an address in the configured attestor set.
One bad or unknown signature reverts the whole call, so a submitter prunes signatures off chain rather than padding the array.
Interface
This client implements the standard light client interface:Client updates
Of the five interface functions, onlyupdateClient writes state. Its return value says which of three things happened.
An update message is a proof envelope carrying a state attestation. The attestation needs a quorum over the state-tagged digest, a nonzero height, and a nonzero timestamp.
An update at a lower height is accepted and its timestamp stored, but
latestHeight advances only when the new height is higher.
Evidence of misbehaviour reaches this client one way only, through the conflicting timestamp in the table above.
Membership proofs
Both verify functions re-check quorum over a packet attestation, then look the path up in the attested list.
Each call requires a stored consensus timestamp at the proof height, a quorum over the packet-tagged digest, and an attested height equal to the requested proof height.
Both are view functions, so neither writes anything.
The path is exactly one element, hashed with
keccak256 before the lookup. A membership call also takes a nonempty value that decodes as a bytes32 commitment.
Membership then succeeds on an attested entry whose path hash and commitment both match. It reverts when no entry does.
Non-membership needs the path to appear in the attested list with an explicitly zero commitment. A path missing from the list reverts NotMember.
When the proof height is new, the client has to be updated before the verify call reaches it. The relayer’s EVM builder does that by putting the updateClient call first in its IBCMulticall batch, so a verify call later in that transaction finds a stored timestamp at the proof height.
Views
Three getters expose the whole trust configuration and every stored timestamp, and they keep answering after a freeze.
A zero from
getConsensusTimestamp means no usable timestamp is stored for that height, and no proof at that height verifies while it reads zero.
Two more getters come with the router binding: ICS26_ROUTER() returns the router the client is bound to, and paused() whether the client is paused.
State and storage
The client’s own storage is its trust configuration, the highest height it has seen, a frozen flag, and one timestamp per attested height. The router it is bound to is an immutable in its code, and the pause flag lives in the inherited OpenZeppelin Pausable storage.
The contract has no delete path, so every attested height stays queryable for the life of the deployment.
Progress is read through the getters rather than from events:
getClientState for latestHeight and the frozen flag, getConsensusTimestamp for one height at a time. The only events it emits are OpenZeppelin Pausable’s Paused and Unpaused.
Freezing
One quorum-signed contradiction freezes the client, and the freeze is permanent. The client freezes when a state attestation passes the quorum check but carries a timestamp different from the one already stored for its height. That call setsisFrozen and returns Misbehaviour.
A frozen client stops verifying. Calls to updateClient, verifyMembership, verifyNonMembership, and misbehaviour revert on the frozen guard, while the getters and the pause switch keep working.
The contract carries no unfreeze function, so recovery runs through a new deployment and a client migration, described in Deployment and replacement below.
Errors
The table lists every error the client declares.
A call can also revert on the OpenZeppelin code the client inherits. An update or verify call on a paused client gets
EnforcedPause, pausing a paused client gets EnforcedPause, and unpausing an unpaused one gets ExpectedPause. The compiled ABI lists the inherited errors in full.
A proof envelope that fails to decode reverts with no named error.
Permissions and pausing
The client takes no role configuration of its own. It is bound at construction to one router, and that router’s access manager decides who may pause it.
The router address cannot change, and the constructor rejects the zero address, so proof submission is never open to any caller. The pause roles are looked up in the router’s current access manager on every call, so granting or revoking them there takes effect at once, and no setup on the client is needed. A holder whose role carries an execution delay is rejected, because the client cannot consume a scheduled operation.
A paused client rejects
updateClient, verifyMembership, and verifyNonMembership, so packets on its connection stop until it is unpaused. The client freezes only on conflicting timestamps in updateClient, and its misbehaviour always reverts, so a paused client cannot be frozen.
Constructor and configuration
The constructor fixes what the client trusts: the attestor set, the quorum threshold, a first trusted height with its timestamp, and the router it is bound to.
The initial height becomes
latestHeight, and its timestamp is stored as the trusted timestamp for that height. A nonzero timestamp there lets a proof at the deployment height verify with no prior update. Deployment reverts on an empty attestor set, a zero threshold, a threshold larger than the set, a duplicate attestor address, or a zero router.
No function adds, removes, or replaces an attestor.
Signatures are not bound to a chain, router, or verifying client. The same signature can count toward the threshold on every client that trusts its key.
Deployment and replacement
The client is a plain deployment. It sits behind no proxy and has no upgrade function, because the constructor sets everything it needs. The Permissions and upgrades page carries the full proxy and upgrade-authority map. A deployed client becomes usable onceaddClient registers it on the router under a client ID. That same call sets its counterparty information.
Replacing a client is a router operation: the admin-gated migrateClient swaps a new client contract in behind an existing client ID.
That is also the only recovery from a freeze, since the contract itself cannot be unfrozen.
Migration overwrites the address and the supplied counterparty information. Preserve both the counterparty client ID and its Merkle prefix. The replacement has its own consensus state; migration copies no timestamps from the old contract.
Follow Rotate an attestor set to deploy a replacement client and migrate the existing client ID.