# Transport, Authentication, and Ordering Layer - Clients

In this section, you will learn:

  • How a client is created.
  • How client state and consensus can be verified.
  • How packets are verified.

As previously shown, IBC is structured as several layers of abstraction. At the top, applications such as interchain standard (ICS) 20 token transfers (opens new window) implement the ICS-26 IBC standard (opens new window), which describe the routing and callback functionality used to connect the application layer to the transport layer. Underneath the application are channels, which are unique for each application (for example, a channel that allows a transfer application on chain A to speak to a transfer application on chain B). Connections, which may have many channels, are used to connect two clients (for example, to allow the entire IBC stack of chain A to connect to the IBC stack of chain B). These clients, which may have many connections, comprise the foundational layer of IBC.

IBC application developers will primarily interact with IBC channels. This layer is comprised of the handshakes and packet callbacks.

In the IBC setup, each chain will have a client of the other chain in its own IBC stack. IBC clients track the consensus states of other blockchains, and the proof specs of those blockchains that are required to properly verify proofs against the client's consensus state. The packets, acknowledgements, and timeouts that off-chain relayers send back and forth can be verified by proving that the packet commitments exist inside of these clients on each chain.

Although relayers do not perform any verification of the packets, and therefore do not need to be trusted, relayers have a particularly important role in IBC setup in addition to IBC network liveness through submission of packets. They are responsible for submitting the initial messages to create a new client, as well as keeping the client states updated on each chain, so that proof verification on a submitted packet is successful. Relayers are also responsible for sending the connection and channel handshakes to establish connections and channels between chains. Furthermore, relayers can submit evidence of misbehaviour if a chain on the other end of a connection tries to fork or attempts other types of malicious behaviour.

# Creating a client

Start with core IBC's msg_server.go (opens new window), which is where the messages come in. This is the first appearance of the CreateClient function, which will be submitted by a relayer through the relaying software to create an IBC client on the chain that the message is submitted to:

Copy // CreateClient defines a rpc handler method for MsgCreateClient. func (k Keeper) CreateClient(goCtx context.Context, msg *clienttypes.MsgCreateClient) (*clienttypes.MsgCreateClientResponse, error) { ctx := sdk.UnwrapSDKContext(goCtx) clientState, err := clienttypes.UnpackClientState(msg.ClientState) ... consensusState, err := clienttypes.UnpackConsensusState(msg.ConsensusState) ... ... = k.ClientKeeper.CreateClient(ctx, clientState, consensusState); ... } modules core keeper msg_server.go View source

It creates a client by calling ClientKeeper.CreateClient (opens new window):

Copy // CreateClient creates a new client state and populates it with a given consensus // state as defined in func (k Keeper) CreateClient( ctx sdk.Context, clientState exported.ClientState, consensusState exported.ConsensusState, ) (string, error) { ... clientID := k.GenerateClientIdentifier(ctx, clientState.ClientType()) ... k.SetClientState(ctx, clientID, clientState) ... // verifies initial consensus state against client state and initializes client store with any client-specific metadata // e.g. set ProcessedTime in CometBFT clients ... := clientState.Initialize(ctx, k.cdc, k.ClientStore(ctx, clientID), consensusState); ... EmitCreateClientEvent(ctx, clientID, clientState) return clientID, nil } modules core ... keeper client.go View source

A local, unique identifier clientID is generated for each client on the chain. This is not related to the chainID, as IBC does not actually use the chainID as an identifier.

The IBC security model is based on clients and not specific chains. This means that the IBC protocol does not need to know who the chains are on either side of a connection, provided that the IBC clients are kept in sync with valid updates, and these updates or other types of messages (i.e. ICS-20 token transfers) can be verified as a Merkle proof against an initial consensus state (root of trust). This is analogous to IP addresses and DNS, where IP addresses would be the corollary to IBC clientIDs, and DNS the chainIDs.

Because of this separation of concerns, IBC clients can be created for any number of machine types, from fully-fledged blockchains to keypair-based solo machines, and upgrades to chains which increment the chainID do not break the underlying IBC client and connections.

In addition, you can see that the function expects a ClientState. This ClientState will look different depending on which type of client is to be created for IBC. In the case of Cosmos-SDK chains and the corresponding implementation of ibc-go, the CometBFT (Tendermint) client (opens new window) is offered out of the box:

Copy interface ClientState { chainID: string trustLevel: Rational trustingPeriod: uint64 unbondingPeriod: uint64 latestHeight: Height frozenHeight: Maybe<uint64> upgradePath: []string maxClockDrift: uint64 proofSpecs: []ProofSpec }

The CometBFT ClientState contains all the information needed to verify a header. This includes properties which are applicable for all CometBFT clients, such as the corresponding chainID, the unbonding period of the chain, the latest height of the client, etc.

TrustingPeriod determines the duration of the period since the latest timestamp during which the submitted headers are valid for upgrade. If a client is not updated within the TrustingPeriod, the client will expire. This does not mean the client is irrecoverable. However, recovery of an expired CometBFT client will require a governance proposal (opens new window) for each client which has expired. If both clients on either side of a connection have expired, then a governance proposal will be required on each chain in order to revive each client.

TrustLevel determines the portion of the validator set you want to have signing a header for it to be considered as valid. CometBFT defines this as 2/3, and the IBC CometBFT client inherits this property from CometBFT.

Properties such as TrustLevel and TrustingPeriod can be customised, such that different clients on the same chain can have different security guarantees with different tradeoffs for efficiency of processing updates.

As stated before, TrustLevel is inherited from CometBFT and will be 2/3 for all CometBFT clients. However, this could change for other client types.

It is recommended that TrustingPeriod should be set as 2/3 of the UnbondingPeriod.

It is also recommended that MaxClockDrift should be set to at least 5sec and up to 15sec, depending on expected block size differences between the chains in the connection. The Hermes (Rust) relayer will compute this value for you if you do not manually set it.

It is important to highlight that certain parameters of an IBC client cannot be updated after the client has been created, in order to preserve the security guarantees of each client and prevent a situation where a relayer unilaterally updates those security guarantees. These parameters are: MaxClockDrift and TrustLevel.

CreateClient additionally expects a ConsensusState (opens new window). In the case of a CometBFT client, the initial root of trust (or consensus state) looks like this:

Copy interface ConsensusState { timestamp: uint64 nextValidatorsHash: []byte commitmentRoot: []byte }

The CometBFT client ConsensusState tracks the timestamp of the block being created, the hash of the validator set for the next block of the counterparty blockchain, and the root of the counterparty blockchain. The initial ConsensusState does not need to start with the genesis block of a counterparty chain.

The next validator set is used for verifying subsequent submitted headers or updates to the counterparty ConsensusState. See the following part on Updating clients for more information about what happens when a validator set changes between blocks.

The root is the AppHash, or the hash of the application state of the counterparty blockchain that this client is representing. This root hash is particularly important because it is the root hash used on a receiving chain when verifying Merkle (opens new window) proofs associated with a packet coming over IBC, to determine whether or not the relevant transaction has been actually been executed on the sending chain. If the Merkle proof associated with a packet commitment delivered by a relayer successfully hashes up to this ConsensusState root hash, it is certain that the transaction was actually executed on the sending chain and included in the state of the sending blockchain.

The following is an example of how the CometBFT client handles this Merkle proof verification (opens new window). The ICS-23 spec (opens new window) addresses how to construct membership proofs, and the ICS-23 implementation (opens new window) currently supports CometBFT IAVL and simple Merkle proofs out of the box.

Non-CometBFT client types may choose to handle proof verification differently.

Copy // VerifyMembership verifies the membership of a merkle proof against the given root, path, and value. func (proof MerkleProof) VerifyMembership(specs []*ics23.ProofSpec, root exported.Root, path exported.Path, value []byte) error { if err := proof.validateVerificationArgs(specs, root); err != nil { return err } // VerifyMembership specific argument validation mpath, ok := path.(MerklePath) if !ok { return sdkerrors.Wrapf(ErrInvalidProof, "path %v is not of type MerklePath", path) } if len(mpath.KeyPath) != len(specs) { return sdkerrors.Wrapf(ErrInvalidProof, "path length %d not same as proof %d", len(mpath.KeyPath), len(specs)) } if len(value) == 0 { return sdkerrors.Wrap(ErrInvalidProof, "empty value in membership proof") } // Since every proof in chain is a membership proof we can use verifyChainedMembershipProof from index 0 // to validate entire proof if err := verifyChainedMembershipProof(root.GetHash(), specs, proof.Proofs, mpath, value, 0); err != nil { return err } return nil }

IBC on-chain clients can also be referred to as light clients. In contrast to the full nodes, which track the entire state of blockchain and contain every single tx/block, these on-chain IBC "light clients" track only the few pieces of information about counterparty chains previously mentioned (timestamp, root hash, next validator set hash). This saves space and increases the efficiency of processing consensus state updates.

The objective is to avoid a situation where it is necessary to have a copy of chain B on chain A in order to create a trustless IBC connection. However, full nodes which track the entire state of a blockchain are useful for IBC relayer operators as an endpoint to query for the proofs needed to verify IBC packet commitments. This entire process maintains the trustless, permissionless, and highly secure design of IBC. As proof verification still happens in the IBC client itself, no trust in the relayer operator is needed and anyone can permissionlessly spin up a relaying operation, provided that they have access to a full node endpoint.

# Updating a client

Assume that the initial ConsensusState was created at block 50, but you want to submit a proof of a transaction which happened in block 100. In this case, you need to first update the ConsensusState to reflect all the changes that have happened between block 50 and block 100.

To update the ConsensusState of the counterparty on the client, a MsgUpdateClient containing a Header of the chain to be updated must be submitted by a relayer. For all IBC client types, CometBFT or otherwise, this Header contains the information necessary to update the ConsensusState. However, IBC does not dictate what the Header must contain beyond the basic methods for returning ClientType and GetClientID. The specifics of what each client expects as important information to perform a ConsensusState update will be found in each client implementation.

For example, the CometBFT client Header looks like this (opens new window):

Copy interface Header extends TendermintSignedHeader { identifier: string validatorSet: List<Pair<Address, uint64>> trustedHeight: Height trustedValidatorSet: List<Pair<Address, uint64>> }

The CometBFT SignedHeader is a header and commit that the counterparty chain has created. In the MsgUpdateClient example, this would be the header of block 100 which will contain the timestamp of the block, the hash of the next validator set, and the root hash needed to update the ConensusState on record for the counterparty chain. The commit will be a signature of at least 2/3 of the validator set over that header, which is guaranteed as part of CometBFT's consensus model.

ValidatorSet will be the actual validator set, as opposed to the hash of the next validator set stored on the ConsensusState. This is important for the CometBFT UpdateClient method because, in order to preserve the CometBFT security model, it is necessary to be able to prove that at least 2/3 of the validators who signed the initial header at block 50 have signed the header to update the ConsensusState to block 100. This ValidatorSet will be submitted by the relayer as part of the MsgUpdateClient, as the relayer has access to full nodes from which this information can be extracted.

TrustedValidators are the validators associated with that height. Note that TrustedValidators must hash to the ConsensusState NextValidatorsHash since that is the last trusted validator set at the TrustedHeight.

The TrustedHeight is the height of a stored ConsensusState on the client that will be used to verify the new untrusted header. You can see the code that takes the ConsensusState at the TrustedHeight and uses it to verify the new header here (opens new window). This code proves that the submitted header is valid and creates a verified ConsensusState for the submitted header, as well as updating the client state to reflect the new latest height of the submitted header. This verified ConsensusState will be added to the client as part of the set of ClientConsensusStates, and can subsequently be used as a trusted state at its corresponding height.

If you want to see where ConsensusState is stored, see the interchain standard (ICS) 24 (opens new window), which also describes the paths for other keys to be stored and used by IBC.

# Verifying packet commitments

As shown in the deep dive on channels, a relayer will first submit a MsgUpdateClient to update the sending chain client on the destination chain, before relaying packets containing other message types, such as ICS-20 token transfers. The destination chain can be sure that the packet will be contained in its ConsensusState root hash, and successfully verify this packet and packet commitment proof against the state contained in its (updated) IBC light client.

The pseudo-code snippet below from 03-connection (opens new window), which illustrates how a client verifies an incoming packet, is as follows:

Copy function verifyPacketCommitment( connection: ConnectionEnd, height: Height, proof: CommitmentProof, portIdentifier: Identifier, channelIdentifier: Identifier, sequence: uint64, commitmentBytes: bytes ) { clientState = queryClientState(connection.clientIdentifier) path = applyPrefix( connection.counterpartyPrefix, packetCommitmentPath(portIdentifier, channelIdentifier, sequence)) return verifyMembership( clientState, height, connection.delayPeriodTime, connection.delayPeriodBlocks, proof, path, commitmentBytes) }

To summarize, this section has explored:

  • How each chain communicating through IBC will have a client of the other chain in its own IBC stack, which tracks the consensus state and proof specs of the other chain.
  • How these on-chain "light clients" avoid the need for one chain to hold a complete copy of another in order to create a trustless IBC connection, saving space and increasing efficiency.
  • How the packets, acknowledgements, and timeouts that off-chain relayers send back and forth can be verified by proving that the packet commitments exist inside of these clients on each chain.
  • How relayers perform vital functions such as submitting the initial messages to create a new client, keeping client states updated on each chain, sending the handshakes that establish connections and channels between chains, and submitting evidence of attempts to fork or other malicious behavior.
  • How the IBC protocol does not need to know who the chains are provided that their IBC clients are kept in sync with valid and verifiable updates and messages, meaning clients can be created for any number of machine types.