ics | title | stage | category | kind | requires | required-by | author | created | modified |
---|---|---|---|---|---|---|---|---|---|
2 |
Client Semantics |
draft |
IBC/TAO |
interface |
23, 24 |
3 |
Juwoon Yun <joon@tendermint.com>, Christopher Goes <cwgoes@tendermint.com> |
2019-02-25 |
2019-08-25 |
This standard specifies the properties that consensus algorithms of machines implementing the interblockchain communication protocol are required to satisfy. These properties are necessary for efficient and safe verification in the higher-level protocol abstractions. The algorithm utilised in IBC to verify the consensus transcript & state sub-components of another machine is referred to as a "validity predicate", and pairing it with a state that the verifier assumes to be correct forms a "light client" (often shortened to "client").
This standard also specifies how light clients will be stored, registered, and updated in the canonical IBC handler. The stored client instances will be introspectable by a third party actor, such as a user inspecting the state of the chain and deciding whether or not to send an IBC packet.
In the IBC protocol, an actor, which may be an end user, an off-chain process, or a machine, needs to be able to verify updates to the state of another machine which the other machine's consensus algorithm has agreed upon, and reject any possible updates which the other machine's consensus algorithm has not agreed upon. A light client is the algorithm with which a machine can do so. This standard formalises the light client model and requirements, so that the IBC protocol can easily integrate with new machines which are running new consensus algorithms as long as associated light client algorithms fulfilling the listed requirements are provided.
Beyond the properties described in this specification, IBC does not impose any requirements on the internal operation of machines and their consensus algorithms. A machine may consist of a single process signing operations with a private key, a quorum of processes signing in unison, many processes operating a Byzantine fault-tolerant consensus algorithm, or other configurations yet to be invented — from the perspective of IBC, a machine is defined entirely by its light client validation & equivocation detection logic. Clients will generally not include validation of the state transition logic in general (as that would be equivalent to simply executing the other state machine), but may elect to validate parts of state transitions in particular cases.
Clients could also act as thresholding views of other clients. In the case where modules utilising the IBC protocol to interact with probabilistic-finality consensus algorithms which might require different finality thresholds for different applications, one write-only client could be created to track headers and many read-only clients with different finality thresholds (confirmation depths after which state roots are considered final) could use that same state.
The client protocol should also support third-party introduction. Alice, a module on a machine, wants to introduce Bob, a second module on a second machine who Alice knows (and who knows Alice), to Carol, a third module on a third machine, who Alice knows but Bob does not. Alice must utilise an existing channel to Bob to communicate the canonically-serialisable validity predicate for Carol, with which Bob can then open a connection and channel so that Bob and Carol can talk directly. If necessary, Alice may also communicate to Carol the validity predicate for Bob, prior to Bob's connection attempt, so that Carol knows to accept the incoming request.
Client interfaces should also be constructed so that custom validation logic can be provided safely to define a custom client at runtime, as long as the underlying state machine can provide an appropriate gas metering mechanism to charge for compute and storage. On a host state machine which supports WASM execution, for example, the validity predicate and equivocation predicate could be provided as executable WASM functions when the client instance is created.
-
get
,set
,Path
, andIdentifier
are as defined in ICS 24. -
CommitmentRoot
is as defined in ICS 23. It must provide an inexpensive way for downstream logic to verify whether key/value pairs are present in state at a particular height. -
ConsensusState
is an opaque type representing the state of a validity predicate.ConsensusState
must be able to verify state updates agreed upon by the associated consensus algorithm. It must also be serialisable in a canonical fashion so that third parties, such as counterparty machines, can check that a particular machine has stored a particularConsensusState
. It must finally be introspectable by the state machine which it is for, such that the state machine can look up its ownConsensusState
at a past height. -
ClientState
is an opaque type representing the state of a client. AClientState
must expose query functions to verify membership or non-membership of key/value pairs in state at particular heights and to retrieve the currentConsensusState
.
Light clients must provide a secure algorithm to verify other chains' canonical headers,
using the existing ConsensusState
. The higher level abstractions will then be able to verify
sub-components of the state with the CommitmentRoot
s stored in the ConsensusState
, which are
guaranteed to have been committed by the other chain's consensus algorithm.
Validity predicates are expected to reflect the behaviour of the full nodes which are running the
corresponding consensus algorithm. Given a ConsensusState
and a list of messages, if a full node
accepts the new Header
generated with Commit
, then the light client MUST also accept it,
and if a full node rejects it, then the light client MUST also reject it.
Light clients are not replaying the whole message transcript, so it is possible under cases of consensus misbehaviour that the light clients' behaviour differs from the full nodes'. In this case, a misbehaviour proof which proves the divergence between the validity predicate and the full node can be generated and submitted to the chain so that the chain can safely deactivate the light client, invalidate past state roots, and await higher-level intervention.
This specification outlines what each client type must define. A client type is a set of definitions of the data structures, initialisation logic, validity predicate, and misbehaviour predicate required to operate a light client. State machines implementing the IBC protocol can support any number of client types, and each client type can be instantiated with different initial consensus states in order to track different consensus instances. In order to establish a connection between two machines (see ICS 3), the machines must each support the client type corresponding to the other machine's consensus algorithm.
Specific client types shall be defined in later versions of this specification and a canonical list shall exist in this repository. Machines implementing the IBC protocol are expected to respect these client types, although they may elect to support only a subset.
ConsensusState
is an opaque data structure defined by a client type, used by the validity predicate to
verify new commits & state roots. Likely the structure will contain the last commit produced by
the consensus process, including signatures and validator set metadata.
ConsensusState
MUST be generated from an instance of Consensus
, which assigns unique heights
for each ConsensusState
(such that each height has exactly one associated consensus state).
Two ConsensusState
s on the same chain SHOULD NOT have the same height if they do not have
equal commitment roots. Such an event is called an "equivocation" and MUST be classified
as misbehaviour. Should one occur, a proof should be generated and submitted so that the client can be frozen
and previous state roots invalidated as necessary.
The ConsensusState
of a chain MUST have a canonical serialisation, so that other chains can check
that a stored consensus state is equal to another (see ICS 24 for the keyspace table).
type ConsensusState = bytes
The ConsensusState
MUST be stored under a particular key, defined below, so that other chains can verify that a particular consensus state has been stored.
A Header
is an opaque data structure defined by a client type which provides information to update a ConsensusState
.
Headers can be submitted to an associated client to update the stored ConsensusState
. They likely contain a height, a proof,
a commitment root, and possibly updates to the validity predicate.
type Header = bytes
Consensus
is a Header
generating function which takes the previous
ConsensusState
with the messages and returns the result.
type Consensus = (ConsensusState, [Message]) => Header
A blockchain is a consensus algorithm which generates valid Header
s.
It generates a unique list of headers starting from a genesis ConsensusState
with arbitrary
messages.
Blockchain
is defined as
interface Blockchain {
genesis: ConsensusState
consensus: Consensus
}
where
Genesis
is the genesisConsensusState
Consensus
is the header generating function
The headers generated from a Blockchain
are expected to satisfy the following:
- Each
Header
MUST NOT have more than one direct child
- Satisfied if: finality & safety
- Possible violation scenario: validator double signing, chain reorganisation (Nakamoto consensus)
- Each
Header
MUST eventually have at least one direct child
- Satisfied if: liveness, light-client verifier continuity
- Possible violation scenario: synchronised halt, incompatible hard fork
- Each
Header
s MUST be generated byConsensus
, which ensures valid state transitions
- Satisfied if: correct block generation & state machine
- Possible violation scenario: invariant break, super-majority validator cartel
Unless the blockchain satisfies all of the above the IBC protocol may not work as intended: the chain can receive multiple conflicting packets, the chain cannot recover from the timeout event, the chain can steal the user's asset, etc.
The validity of the validity predicate is dependent on the security model of the
Consensus
. For example, the Consensus
can be a proof of authority with
a trusted operator, or a proof of stake but with
insufficient value of stake. In such cases, it is possible that the
security assumptions break, the correspondence between Consensus
and
the validity predicate no longer exists, and the behaviour of the validity predicate becomes
undefined. Also, the Blockchain
may not longer satisfy
the requirements above, which will cause the chain to be incompatible with the IBC
protocol. In cases of attributable faults, a misbehaviour proof can be generated and submitted to the
chain storing the client to safely freeze the light client and
prevent further IBC packet relay.
A validity predicate is an opaque function defined by a client type to verify Header
s depending on the current ConsensusState
.
Using the validity predicate SHOULD be far more computationally efficient than replaying the full consensus algorithm
for the given parent Header
and the list of network messages.
The validity predicate & client state update logic are combined into a single checkValidityAndUpdateState
type, which is defined as
type checkValidityAndUpdateState = (Header) => Void
checkValidityAndUpdateState
MUST throw an exception if the provided header was not valid.
If the provided header was valid, the client MUST also mutate internal state to store now-finalised consensus roots and update any necessary signature authority tracking (e.g. changes to the validator set) for future calls to the validity predicate.
A misbehaviour predicate is an opaque function defined by a client type, used to check if data constitutes a violation of the consensus protocol. This might be two signed headers with different state roots but the same height, a signed header containing invalid state transitions, or other evidence of malfeasance as defined by the consensus algorithm.
The misbehaviour predicate & client state update logic are combined into a single checkMisbehaviourAndUpdateState
type, which is defined as
type checkMisbehaviourAndUpdateState = (bytes) => Void
checkMisbehaviourAndUpdateState
MUST throw an exception if the provided evidence was not valid.
If misbehaviour was valid, the client MUST also mutate internal state to mark appropriate heights which were previously considered valid as invalid, according to the nature of the misbehaviour.
ClientState
is an opaque data structure defined by a client type.
It may keep arbitrary internal state to track verified roots and past misbehaviours.
Light clients are representation-opaque — different consensus algorithms can define different light client update algorithms — but they must expose this common set of query functions to the IBC handler.
type ClientState = bytes
Client types must also define a method to initialize a client state with a provided consensus state:
type initialize = (state: ConsensusState) => ClientState
CommitmentProof
is an opaque data structure defined by a client type in accordance with ICS 23.
It is utilised to verify presence or absence of a particular key/value pair in state
at a particular finalised height (necessarily associated with a particular commitment root).
Client types must define functions to authenticate internal state of the state machine which the client tracks. Internal implementation details may differ (for example, a loopback client could simply read directly from the state and require no proofs).
verifyClientConsensusState
verifies a proof of the consensus state of the specified client stored on the target machine.
type verifyClientConsensusState = (
clientState: ClientState,
height: uint64,
proof: CommitmentProof,
clientIdentifier: Identifier,
consensusState: ConsensusState)
=> boolean
verifyConnectionState
verifies a proof of the connection state of the specified connection end stored on the target machine.
type verifyConnectionState = (
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
connectionIdentifier: Identifier,
connectionEnd: ConnectionEnd)
=> boolean
verifyChannelState
verifies a proof of the channel state of the specified channel end, under the specified port, stored on the target machine.
type verifyChannelState = (
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
channelEnd: ChannelEnd)
=> boolean
verifyPacketCommitment
verifies a proof of an outgoing packet commitment at the specified port, specified channel, and specified sequence.
type verifyPacketCommitment = (
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
sequence: uint64,
commitment: bytes)
=> boolean
verifyPacketAcknowledgement
verifies a proof of an incoming packet acknowledgement at the specified port, specified channel, and specified sequence.
type verifyPacketAcknowledgement = (
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
sequence: uint64,
acknowledgement: bytes)
=> boolean
verifyPacketAcknowledgementAbsence
verifies a proof of the absence of an incoming packet acknowledgement at the specified port, specified channel, and specified sequence.
type verifyPacketAcknowledgementAbsence = (
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
sequence: uint64)
=> boolean
verifyNextSequenceRecv
verifies a proof of the next sequence number to be received of the specified channel at the specified port.
type verifyNextSequenceRecv = (
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
nextSequenceRecv: uint64)
=> boolean
A loopback client of a local machine merely reads from the local state, to which it must have access.
A client of a solo machine with a known public key checks signatures on messages sent by that local machine,
which are provided as the Proof
parameter. The height
parameter can be used as a replay protection nonce.
Multi-signature or threshold signature schemes can also be used in such a fashion.
Proxy clients verify another (proxy) machine's verification of the target machine, by including in the proof first a proof of the client state on the proxy machine, and then a secondary proof of the sub-state of the target machine with respect to the client state on the proxy machine. This allows the proxy client to avoid storing and tracking the consensus state of the target machine itself, at the cost of adding security assumptions of proxy machine correctness.
For clients of state machines with Merklized state trees, these functions can be implemented by calling verifyMembership
or verifyNonMembership
, using a verified Merkle
root stored in the ClientState
, to verify presence or absence of particular key/value pairs in state at particular heights in accordance with ICS 23.
type verifyMembership = (ClientState, uint64, CommitmentProof, Path, Value) => boolean
type verifyNonMembership = (ClientState, uint64, CommitmentProof, Path) => boolean
IBC handlers MUST implement the functions defined below.
Clients are stored under a unique Identifier
prefix.
This ICS does not require that client identifiers be generated in a particular manner, only that they be unique.
However, it is possible to restrict the space of Identifier
s if required.
The validation function validateClientIdentifier
MAY be provided.
type validateClientIdentifier = (id: Identifier) => boolean
If not provided, the default validateClientIdentifier
will always return true
.
clientStatePath
takes an Identifier
and returns a Path
under which to store a particular client state.
function clientStatePath(id: Identifier): Path {
return "clients/{id}/state"
}
clientTypePath
takes an Identifier
and returns Path
under which to store the type of a particular client.
function clientTypePath(id: Identifier): Path {
return "clients/{id}/type"
}
Consensus states MUST be stored separately so that they can be independently verified.
consensusStatePath
takes an Identifier
and returns a Path
under which to store the consensus state of a client.
function consensusStatePath(id: Identifier): Path {
return "clients/{id}/consensusState"
}
To avoid race conditions between client updates (which change the state root) and proof-carrying transactions in handshakes or packet receipt, many IBC handler functions allow the caller to specify a particular past root to reference, which is looked up by height. IBC handler functions which do this must ensure that they also perform any requisite checks on the height passed in by the caller to ensure logical correctness.
Calling createClient
with the specified identifier & initial consensus state creates a new client.
function createClient(
id: Identifier,
clientType: ClientType,
consensusState: ConsensusState) {
abortTransactionUnless(validateClientIdentifier(id))
abortTransactionUnless(privateStore.get(clientStatePath(id)) === null)
abortSystemUnless(provableStore.get(clientTypePath(id)) === null)
clientState = clientType.initialize(consensusState)
privateStore.set(clientStatePath(id), clientState)
provableStore.set(clientTypePath(id), clientType)
}
Client consensus state and client internal state can be queried by identifier. The returned client state must fulfil an interface allowing membership / non-membership verification.
function queryClientConsensusState(id: Identifier): ConsensusState {
return provableStore.get(consensusStatePath(id))
}
function queryClient(id: Identifier): ClientState {
return privateStore.get(clientStatePath(id))
}
Updating a client is done by submitting a new Header
. The Identifier
is used to point to the
stored ClientState
that the logic will update. When a new Header
is verified with
the stored ClientState
's validity predicate and ConsensusState
, the client MUST
update its internal state accordingly, possibly finalising commitment roots and
updating the signature authority logic in the stored consensus state.
function updateClient(
id: Identifier,
header: Header) {
clientType = provableStore.get(clientTypePath(id))
abortTransactionUnless(clientType !== null)
clientState = privateStore.get(clientStatePath(id))
abortTransactionUnless(clientState !== null)
clientType.checkValidityAndUpdateState(clientState, header)
}
If the client detects evidence of misbehaviour, the client can be alerted, possibly invalidating previously valid state roots & preventing future updates.
function submitMisbehaviourToClient(
id: Identifier,
evidence: bytes) {
clientType = provableStore.get(clientTypePath(id))
abortTransactionUnless(clientType !== null)
clientState = privateStore.get(clientStatePath(id))
abortTransactionUnless(clientState !== null)
clientType.checkMisbehaviourAndUpdateState(clientState, evidence)
}
An example validity predicate is constructed for a chain running a single-operator consensus algorithm, where the valid blocks are signed by the operator. The operator signing Key can be changed while the chain is running.
The client-specific types are then defined as follows:
ConsensusState
stores the latest height and latest public keyHeader
s contain a height, a new commitment root, a signature by the operator, and possibly a new public keycheckValidityAndUpdateState
checks that the submitted height is monotonically increasing and that the signature is correct, then mutates the internal statecheckMisbehaviourAndUpdateState
checks for two headers with the same height & different commitment roots, then mutates the internal state
interface ClientState {
frozen: boolean
pastPublicKeys: Set<PublicKey>
verifiedRoots: Map<uint64, CommitmentRoot>
}
interface ConsensusState {
sequence: uint64
publicKey: PublicKey
}
interface Header {
sequence: uint64
commitmentRoot: CommitmentRoot
signature: Signature
newPublicKey: Maybe<PublicKey>
}
interface Evidence {
h1: Header
h2: Header
}
// algorithm run by operator to commit a new block
function commit(
commitmentRoot: CommitmentRoot,
sequence: uint64,
newPublicKey: Maybe<PublicKey>): Header {
signature = privateKey.sign(commitmentRoot, sequence, newPublicKey)
header = {sequence, commitmentRoot, signature, newPublicKey}
return header
}
// initialisation function defined by the client type
function initialize(consensusState: ConsensusState): ClientState {
return {
frozen: false,
pastPublicKeys: Set.singleton(consensusState.publicKey),
verifiedRoots: Map.empty()
}
}
// validity predicate function defined by the client type
function checkValidityAndUpdateState(
clientState: ClientState,
header: Header) {
abortTransactionUnless(consensusState.sequence + 1 === header.sequence)
abortTransactionUnless(consensusState.publicKey.verify(header.signature))
if (header.newPublicKey !== null) {
consensusState.publicKey = header.newPublicKey
clientState.pastPublicKeys.add(header.newPublicKey)
}
consensusState.sequence = header.sequence
clientState.verifiedRoots[sequence] = header.commitmentRoot
}
function verifyClientConsensusState(
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
clientIdentifier: Identifier,
consensusState: ConsensusState) {
path = applyPrefix(prefix, "clients/{clientIdentifier}/consensusState")
abortTransactionUnless(!clientState.frozen)
return clientState.verifiedRoots[sequence].verifyMembership(path, consensusState, proof)
}
function verifyConnectionState(
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
connectionIdentifier: Identifier,
connectionEnd: ConnectionEnd) {
path = applyPrefix(prefix, "connection/{connectionIdentifier}")
abortTransactionUnless(!clientState.frozen)
return clientState.verifiedRoots[sequence].verifyMembership(path, connectionEnd, proof)
}
function verifyChannelState(
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
channelEnd: ChannelEnd) {
path = applyPrefix(prefix, "ports/{portIdentifier}/channels/{channelIdentifier}")
abortTransactionUnless(!clientState.frozen)
return clientState.verifiedRoots[sequence].verifyMembership(path, channelEnd, proof)
}
function verifyPacketCommitment(
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
sequence: uint64,
commitment: bytes) {
path = applyPrefix(prefix, "ports/{portIdentifier}/channels/{channelIdentifier}/packets/{sequence}")
abortTransactionUnless(!clientState.frozen)
return clientState.verifiedRoots[sequence].verifyMembership(path, commitment, proof)
}
function verifyPacketAcknowledgement(
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
sequence: uint64,
acknowledgement: bytes) {
path = applyPrefix(prefix, "ports/{portIdentifier}/channels/{channelIdentifier}/acknowledgements/{sequence}")
abortTransactionUnless(!clientState.frozen)
return clientState.verifiedRoots[sequence].verifyMembership(path, acknowledgement, proof)
}
function verifyPacketAcknowledgementAbsence(
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
sequence: uint64) {
path = applyPrefix(prefix, "ports/{portIdentifier}/channels/{channelIdentifier}/acknowledgements/{sequence}")
abortTransactionUnless(!clientState.frozen)
return clientState.verifiedRoots[sequence].verifyNonMembership(path, proof)
}
function verifyNextSequenceRecv(
clientState: ClientState,
height: uint64,
prefix: CommitmentPrefix,
proof: CommitmentProof,
portIdentifier: Identifier,
channelIdentifier: Identifier,
nextSequenceRecv: uint64) {
path = applyPrefix(prefix, "ports/{portIdentifier}/channels/{channelIdentifier}/nextSequenceRecv")
abortTransactionUnless(!clientState.frozen)
return clientState.verifiedRoots[sequence].verifyMembership(path, nextSequenceRecv, proof)
}
// misbehaviour verification function defined by the client type
// any duplicate signature by a past or current key freezes the client
function checkMisbehaviourAndUpdateState(
clientState: ClientState,
evidence: Evidence) {
h1 = evidence.h1
h2 = evidence.h2
abortTransactionUnless(clientState.pastPublicKeys.contains(h1.publicKey))
abortTransactionUnless(h1.sequence === h2.sequence)
abortTransactionUnless(h1.commitmentRoot !== h2.commitmentRoot || h1.publicKey !== h2.publicKey)
abortTransactionUnless(h1.publicKey.verify(h1.signature))
abortTransactionUnless(h2.publicKey.verify(h2.signature))
clientState.frozen = true
}
- Client identifiers are immutable & first-come-first-serve. Clients cannot be deleted (allowing deletion would potentially allow future replay of past packets if identifiers were re-used).
Not applicable.
New client types can be added by IBC implementations at-will as long as they conform to this interface.
Coming soon.
Coming soon.
Mar 5, 2019 - Initial draft finished and submitted as a PR
May 29, 2019 - Various revisions, notably multiple commitment-roots
Aug 15, 2019 - Major rework for clarity around client interface
All content herein is licensed under Apache 2.0.