Skip to main content
AttestationLightClient is an IBC light client that trusts a fixed set of attestor addresses, and it accepts a claim once a quorum of that set has signed it. Attestors claim two things about the counterparty chain: the timestamp of a block at a given height, and the values held at commitment paths at that height. One instance is deployed per client and registered on the ICS26Router under a client ID. The router calls it to advance the client and to check the proof behind every packet operation. Verification is self-contained. The client checks signatures against its own configuration and stores what it accepts, reaching outside itself for nothing. Source: AttestationLightClient.sol Interacts with: ICS26Router, which drives its updates and proof checks, and the attestors that produce its signatures. The trust model it implements is detailed on the attestation light client page.

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.
The 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 is sha256(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:
AttestationLightClient implements all five and adds two getters for its trust configuration. It also inherits the router binding and the pause switch every IBC-solidity light client shares, described in Permissions and pausing.

Client updates

Of the five interface functions, only updateClient 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 sets isFrozen 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.
Use separate keys for independent clients and chains. A signature from a retained key also verifies on earlier deployments during a set replacement.

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 once addClient 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.