IBC: Sending & Receiving Data Packages

Published on: Dec 19, 2022

Dec 19, 2022

With the inception of blockchains in 2019, more and more came around with different innovations, some targeting the shortcomings, some targeting to create something new, while others just came and went away. As many as 126 Blockchains are live now, like Ethereum, Bitcoin, Cosmos, and Polkadot, to name a few. This did bring new ideas to the paradigm, but DApps built on top of these blockchains face liquidity fragmentation and composability issues, to name a few problems.

This may be solved by enabling interoperability between these blockchains.

While many bridges are in the picture targeting to provide interoperability, the security of those isn't very ideal as of today. This brings us to IBC, a protocol that allows blockchains to communicate but unlike most bridges, it doesnt have committees. In this article, we will have a look at how IBC is used to transfer data packages, enabling blockchains to communicate, providing interoperability. 

IBC was launched as part of the Cosmos ecosystem; although it is not just for Cosmos, any blockchain can make use of it. Without further ado, let's dive in.

Read about the Cosmos Architecture.

Inter-Blockchain Communication 

Inter-blockchain communication, or IBC, allows blockchains to talk with each other. It provides a standard for data structures, semantics, protocol, and framework that enable every blockchain implementing IBC to authenticate and transport data among them in a permissionless manner, providing interoperability.

Chains built with Cosmos SDK can implement IBC quite easily as it is part of the SDK. But that doesn’t mean chains with consensus other than Tendermint can’t adopt IBC. They can, although the implementation will need modifications.

To implement IBC, we need to implement a minimal set of functions as per ICS standards.

IBC comprises of a transport layer, an application layer, light clients, and relayers.

Figure 1

Let’s go through the basic functionality of each of these.

  • The transport layer underpins the message authentication and provides secure connections between the chains. It also takes care of the ordering of message packages.
  • The application layer takes care of how these messages should be packaged and interpreted.
  • A light client of the remote state machine is the algorithm that enables the actor to verify state updates of that state machine without having to execute the state machine, hence the world light.
  • Finally, the relayer, it opens paths, creates clients, connections, and channels between chains, and relays messages along those paths.

Transport Layer

As we already know transport layer is responsible for transferring, authenticating, and ordering of messages. To do that, we first need a connection between the concerned blockchains. 

Build Connections

The connection is built via a 4-way handshake. Let’s explore how that is accomplished while observing a reference implementation.  For the sake of simplicity, we will consider chain A and chain B, which are trying to build a connection.

  1. ConnOpenInit

The first thing to establish a connection is to initialize the connection. 


func (k Keeper) ConnOpenInit(
	ctx sdk.Context,
	clientID string,
	counterparty types.Counterparty, // counterpartyPrefix, counterpartyClientIdentifier
	version *types.Version,
	delayPeriod uint64,
) (string, error) {
	versions := types.GetCompatibleVersions()
	if version != nil {
		if !types.IsSupportedVersion(types.GetCompatibleVersions(), version) {
			return "", sdkerrors.Wrap(types.ErrInvalidVersion, "version is not supported")
		}

		versions = []exported.Version{version}
	}

	connectionID := k.GenerateConnectionIdentifier(ctx)
	if err := k.addConnectionToClient(ctx, clientID, connectionID); err != nil {
		return "", err
	}

	// connection defines chain A's ConnectionEnd
	connection := types.NewConnectionEnd(types.INIT, clientID, counterparty, types.ExportedVersionsToProto(versions), delayPeriod)
	k.SetConnection(ctx, connectionID, connection)

	k.Logger(ctx).Info("connection state updated", "connection-id", connectionID, "previous-state", "NONE", "new-state", "INIT")

	defer func() {
		telemetry.IncrCounter(1, "ibc", "connection", "open-init")
	}()

	EmitConnectionOpenInitEvent(ctx, connectionID, clientID, counterparty)

	return connectionID, nil
}

The function first checks for version compatibility. Then we set the values of the function NewConnectionEnd, which include 

  1. State, i.e., whether the connection is 
  • INIT: A connection end has just started the opening handshake.
  • TRY OPEN:  A connection end has acknowledged the handshake step on the counterparty chain.
  • OPEN: A connection end has completed the handshake.
  • UNINITIALIZED: Default State. 
  1. ClientID, i.e., the ID of the chain associated with the connection.
  2. Counterparty, i.e., the ID of the counterparty chain associated with the connection.
  3. Version, i.e., IBC versions, to determine protocols and encoding being used.
  4. DelayPeriod, i.e., the time to pass before the consensus client checks for packet verification. However, the implementation of this may not be required by every client.

The function creates a unique connection ID and adds it to the list of connections associated with the chain. In this function, clientID is chain A, and the counterparty is chain B.

  1. ConnOpenTry

This function tells chain B that chain A is trying to open a connection between them.


func (k Keeper) ConnOpenTry(
	ctx sdk.Context,
	counterparty types.Counterparty, // counterpartyConnectionIdentifier, counterpartyPrefix and counterpartyClientIdentifier
	delayPeriod uint64,
	clientID string, // clientID of chainA
	clientState exported.ClientState, // clientState that chainA has for chainB
	counterpartyVersions []exported.Version, // supported versions of chain A
	proofInit []byte, // proof that chainA stored connectionEnd in state (on ConnOpenInit)
	proofClient []byte, // proof that chainA stored a light client of chainB
	proofConsensus []byte, // proof that chainA stored chainB's consensus state at consensus height
	proofHeight exported.Height, // height at which relayer constructs proof of A storing connectionEnd in state
	consensusHeight exported.Height, // latest height of chain B which chain A has stored in its chain B client
) (string, error) {
	// generate a new connection
	connectionID := k.GenerateConnectionIdentifier(ctx)

	selfHeight := clienttypes.GetSelfHeight(ctx)
	if consensusHeight.GTE(selfHeight) {
		return "", sdkerrors.Wrapf(
			sdkerrors.ErrInvalidHeight,
			"consensus height is greater than or equal to the current block height (%s >= %s)", consensusHeight, selfHeight,
		)
	}

	// validate client parameters of a chainB client stored on chainA
	if err := k.clientKeeper.ValidateSelfClient(ctx, clientState); err != nil {
		return "", err
	}

	expectedConsensusState, err := k.clientKeeper.GetSelfConsensusState(ctx, consensusHeight)
	if err != nil {
		return "", sdkerrors.Wrapf(err, "self consensus state not found for height %s", consensusHeight.String())
	}

	// expectedConnection defines Chain A's ConnectionEnd
	// NOTE: chain A's counterparty is chain B (i.e where this code is executed)
	// NOTE: chainA and chainB must have the same delay period
	prefix := k.GetCommitmentPrefix()
	expectedCounterparty := types.NewCounterparty(clientID, "", commitmenttypes.NewMerklePrefix(prefix.Bytes()))
	expectedConnection := types.NewConnectionEnd(types.INIT, counterparty.ClientId, expectedCounterparty, types.ExportedVersionsToProto(counterpartyVersions), delayPeriod)

	// chain B picks a version from Chain A's available versions that is compatible
	// with Chain B's supported IBC versions. PickVersion will select the intersection
	// of the supported versions and the counterparty versions.
	version, err := types.PickVersion(types.GetCompatibleVersions(), counterpartyVersions)
	if err != nil {
		return "", err
	}

	// connection defines chain B's ConnectionEnd
	connection := types.NewConnectionEnd(types.TRYOPEN, clientID, counterparty, []*types.Version{version}, delayPeriod)

	// Check that ChainA committed expectedConnectionEnd to its state
	if err := k.VerifyConnectionState(
		ctx, connection, proofHeight, proofInit, counterparty.ConnectionId,
		expectedConnection,
	); err != nil {
		return "", err
	}

	// Check that ChainA stored the clientState provided in the msg
	if err := k.VerifyClientState(ctx, connection, proofHeight, proofClient, clientState); err != nil {
		return "", err
	}

	// Check that ChainA stored the correct ConsensusState of chainB at the given consensusHeight
	if err := k.VerifyClientConsensusState(
		ctx, connection, proofHeight, consensusHeight, proofConsensus, expectedConsensusState,
	); err != nil {
		return "", err
	}

	// store connection in chainB state
	if err := k.addConnectionToClient(ctx, clientID, connectionID); err != nil {
		return "", sdkerrors.Wrapf(err, "failed to add connection with ID %s to client with ID %s", connectionID, clientID)
	}

	k.SetConnection(ctx, connectionID, connection)
	k.Logger(ctx).Info("connection state updated", "connection-id", connectionID, "previous-state", "NONE", "new-state", "TRYOPEN")

	defer func() {
		telemetry.IncrCounter(1, "ibc", "connection", "open-try")
	}()

	EmitConnectionOpenTryEvent(ctx, connectionID, clientID, counterparty)

	return connectionID, nil
}

Again, NewConnectionEnd is updated.

This function creates a new counterparty instance and establishes a new connection on this end. Chain B chooses the available IBC-compatible version with chain A.

In a nutshell, chain B verifies the ID of chain A, tries to open the connection by verifying that the chains are indeed compatible, and counter-checks whether chain A has the correct information. To make sure that the state update was successful, the relayer relays this update to the light clients.

  1. ConnOpenAck

Through this function, chain B acknowledges the connection request initiated by chain A by retrieving the connection, checking the state of the connections, checking the client’s states, and then updating the connection state to OPEN.


func (k Keeper) ConnOpenAck(
	ctx sdk.Context,
	connectionID string,
	clientState exported.ClientState, // client state for chainA on chainB
	version *types.Version, // version that ChainB chose in ConnOpenTry
	counterpartyConnectionID string,
	proofTry []byte, // proof that connectionEnd was added to ChainB state in ConnOpenTry
	proofClient []byte, // proof of client state on chainB for chainA
	proofConsensus []byte, // proof that chainB has stored ConsensusState of chainA on its client
	proofHeight exported.Height, // height that relayer constructed proofTry
	consensusHeight exported.Height, // latest height of chainA that chainB has stored on its chainA client
) error {
	// Check that chainB client hasn't stored invalid height
	selfHeight := clienttypes.GetSelfHeight(ctx)
	if consensusHeight.GTE(selfHeight) {
		return sdkerrors.Wrapf(
			sdkerrors.ErrInvalidHeight,
			"consensus height is greater than or equal to the current block height (%s >= %s)", consensusHeight, selfHeight,
		)
	}

	// Retrieve connection
	connection, found := k.GetConnection(ctx, connectionID)
	if !found {
		return sdkerrors.Wrap(types.ErrConnectionNotFound, connectionID)
	}

	// verify the previously set connection state
	if connection.State != types.INIT {
		return sdkerrors.Wrapf(
			types.ErrInvalidConnectionState,
			"connection state is not INIT (got %s)", connection.State.String(),
		)
	}

	// ensure selected version is supported
	if !types.IsSupportedVersion(types.ProtoVersionsToExported(connection.Versions), version) {
		return sdkerrors.Wrapf(
			types.ErrInvalidConnectionState,
			"the counterparty selected version %s is not supported by versions selected on INIT", version,
		)
	}

	// validate client parameters of a chainA client stored on chainB
	if err := k.clientKeeper.ValidateSelfClient(ctx, clientState); err != nil {
		return err
	}

	// Retrieve chainA's consensus state at consensusheight
	expectedConsensusState, err := k.clientKeeper.GetSelfConsensusState(ctx, consensusHeight)
	if err != nil {
		return sdkerrors.Wrapf(err, "self consensus state not found for height %s", consensusHeight.String())
	}

	prefix := k.GetCommitmentPrefix()
	expectedCounterparty := types.NewCounterparty(connection.ClientId, connectionID, commitmenttypes.NewMerklePrefix(prefix.Bytes()))
	expectedConnection := types.NewConnectionEnd(types.TRYOPEN, connection.Counterparty.ClientId, expectedCounterparty, []*types.Version{version}, connection.DelayPeriod)

	// Ensure that ChainB stored expected connectionEnd in its state during ConnOpenTry
	if err := k.VerifyConnectionState(
		ctx, connection, proofHeight, proofTry, counterpartyConnectionID,
		expectedConnection,
	); err != nil {
		return err
	}

	// Check that ChainB stored the clientState provided in the msg
	if err := k.VerifyClientState(ctx, connection, proofHeight, proofClient, clientState); err != nil {
		return err
	}

	// Ensure that ChainB has stored the correct ConsensusState for chainA at the consensusHeight
	if err := k.VerifyClientConsensusState(
		ctx, connection, proofHeight, consensusHeight, proofConsensus, expectedConsensusState,
	); err != nil {
		return err
	}

	k.Logger(ctx).Info("connection state updated", "connection-id", connectionID, "previous-state", "INIT", "new-state", "OPEN")

	defer func() {
		telemetry.IncrCounter(1, "ibc", "connection", "open-ack")
	}()

	// Update connection state to Open
	connection.State = types.OPEN
	connection.Versions = []*types.Version{version}
	connection.Counterparty.ConnectionId = counterpartyConnectionID
	k.SetConnection(ctx, connectionID, connection)

	EmitConnectionOpenAckEvent(ctx, connectionID, connection)

	return nil
}
  1. ConnOpenConfirm

This function confirms that the connection is established between chain A and chain B. It first retrieves the connections and checks that the state is TRYOPEN on chain B and OPEN on chain a. Then it sets the state of connection on chain B to OPEN, hence successfully opening the connection.


func (k Keeper) ConnOpenConfirm(
	ctx sdk.Context,
	connectionID string,
	proofAck []byte, // proof that connection opened on ChainA during ConnOpenAck
	proofHeight exported.Height, // height that relayer constructed proofAck
) error {
	// Retrieve connection
	connection, found := k.GetConnection(ctx, connectionID)
	if !found {
		return sdkerrors.Wrap(types.ErrConnectionNotFound, connectionID)
	}

	// Check that connection state on ChainB is on state: TRYOPEN
	if connection.State != types.TRYOPEN {
		return sdkerrors.Wrapf(
			types.ErrInvalidConnectionState,
			"connection state is not TRYOPEN (got %s)", connection.State.String(),
		)
	}

	prefix := k.GetCommitmentPrefix()
	expectedCounterparty := types.NewCounterparty(connection.ClientId, connectionID, commitmenttypes.NewMerklePrefix(prefix.Bytes()))
	expectedConnection := types.NewConnectionEnd(types.OPEN, connection.Counterparty.ClientId, expectedCounterparty, connection.Versions, connection.DelayPeriod)

	// Check that connection on ChainA is open
	if err := k.VerifyConnectionState(
		ctx, connection, proofHeight, proofAck, connection.Counterparty.ConnectionId,
		expectedConnection,
	); err != nil {
		return err
	}

	// Update ChainB's connection to Open
	connection.State = types.OPEN
	k.SetConnection(ctx, connectionID, connection)
	k.Logger(ctx).Info("connection state updated", "connection-id", connectionID, "previous-state", "TRYOPEN", "new-state", "OPEN")

	defer func() {
		telemetry.IncrCounter(1, "ibc", "connection", "open-confirm")
	}()

	EmitConnectionOpenConfirmEvent(ctx, connectionID, connection)

	return nil
}

Needless to say, in case of being unable to fulfill any check, the connection doesn’t go through.

Build Channels

Once the connection is built, we need channels to transfer the message packages along the channels. Do note that, connections can have multiple channels. To initiate a channel, again, a 4-way communication is initiated. 

  1. ChanOpenInit

This function is called by modules to initiate a channel on the source chain.

The counterparty's current channelID sequence is validated to be empty. Chain A opens channel with it hence setting the channelID. It gets the connection information, and does necessary checks like whether the version is agreed upon and supported. The caller of the function owns the portID to initiate a channel, then finally generates the channel identifier.

Channel type for chain A is set to INIT. An application can only do that if it has the command of the channel and port, this function runs the checks to ensure that as well.

  1. ChanOpenTry

The counterparty accepts the open channel request through this function.

Generates the channel identifier for chain B, and does the same necessary checks which ChanOpenInit function did, only in that function, it was for the source chain, and in this function it is for the counterparty chain. Then, sets the expected ID for counterparty i.e., chain B, and the expected channel. These are later used by chain A for acknowledgment.

Channel type for chain B is set to INIT.

  1. ChanOpenAck

Source chain runs this function to acknowledge the acceptance of counterparty chain.

This makes the channel live by setting the channel type to OPENTRY.

  1. ChanOpenConfirm

Counterparty confirms the channel opening with the source chain by calling this function.

It sets the channel type to OPEN, which is the confirmation indication. This successfully opens the channel between the two chains.

Again, we didn't talk about error conditions and failure redirection, but in that case, the algorithm wouldn’t enable the channel.

With the completion of these 2 four-way-handshakes, we shall have live connections and channels. Why did we create these? To transfer packages along the path, so our next step is exactly that.

Send Message Packages

Three functions make sure the sending of message packages. Let’s have a look at them in this section. The transferring is done from the application layer, and the relayer is responsible to do that.

  1. SendPacket

This function is called to send a message package to the channel. It first calls the GetChannel function, which returns the identifier bound with the port. Then checks that the state of the channel is not closed and the caller chain, Chain A, has the capability of the port. Then, it creates the package instance, which takes in the parameters Data i.e., custom data for a package, Sequence i.e., the sequence number of packets in the channel, SourcePort, SourceChannel, DestinationPort, DestinationChannel, TimeoutHeight, TimeoutTimestamp

The basic validation for channel fields is also done in this function. Then, it checks that the package is not timed out and finally commits the package.

  1. RecvPacket

The counterparty calls this function to receive and process the IBC message package. It runs the same checks to verify that the counterparty has the command of the channel and the port it is trying to authenticate receiving the packet from. Checks that the connection is OPEN, and that the package is not timed out. Depending upon whether the channel ordering parameter is ordered or unordered, the function checks / arranges the sequence.

  1. AcknowledgePacket

The source chain receives the acknowledgment of the counter-party chain. This function first does the necessary checks, which are the same as the previous two functions, then verifies the package acknowledgment. Then since the package commitment is of no use anymore, it deletes it, finally logs the acknowledgment, and with that, we are done.

Figure 2

Figure 2 visually represents the functions mentioned in the section “Sending Message packages.”

Clients:

Blockchain bridges depend on committees for security. What does IBC make use of for security reasons? IBC Clients. So, what are IBC clients? They are onchain light clients that track the consensus state of other blockchains. They also track proof specifications to verify the proof against the client consensus state.

IBC light clients also track and verify the state of other blockchains; it does so for commitment proof of sending and receiving messages on the source and destination blockchains.

The benefit of that is you don’t trust relayers or any third party. You trust these proofs provided by Light clients. In the case where all the relayers are malicious and send the wrong packages, the packages will get declined because the proof generated won’t be valid.

IBC already has client implementations such as Solo Machine Light Client, Tendermint light client, and Localhost (loopback) client.

Relayer

We previously established that messages and proof are sent to the destination chain; to do that, we open channels and create connections. The transferring of these packages is done by relayer. Relayers ensure that the packages are relayed for that they have access to full nodes.

Our package sending and receiving functions emit events at the completion. Relayers listen for these events. You can create a relayer software or use an existing one. A relayer software must have information about the chains between which it is relaying the messaging.

We have Go-Relayer and Hermez-Relayer clients available. 

IBC opens up the connection to send and receive packages, whether the chain is malicious or not; that is something IBC has no concern with. We’ve already looked at how light clients are responsible for wrong message packets.

Application Layer

The application layer in IBC works similarly to the transport layer, except it has additional information i.e, encoding, decoding, and interpretation of the data to trigger the custom application logic.

Inter-Blockchain Communication (IBC) is a protocol that allows blockchains to communicate with each other and exchange data. It provides a standard for data structures, semantics, protocol, and framework that enables every blockchain implementing IBC to authenticate and transport data among them in a permissionless manner, providing interoperability. IBC consists of a transport layer, an application layer, light clients, and relayers. The transport layer is responsible for transferring, authenticating, and ordering messages between the blockchains. The application layer takes care of how these messages should be packaged and interpreted. The light client of the remote state machine is an algorithm that enables actors to verify state updates of that state machine without having to execute the state machine. Finally, the relayer helps establish connections, create clients, channels, and paths between chains, and relays messages along those paths. IBC can be implemented by any blockchain, although the implementation may require modifications for blockchains with consensus mechanisms other than Tendermint.

Written by

Researcher. Blockchain Enthusiast. ZK Maximalist. Interested in scalability and privacy-preserving.

Similar Articles

January 9, 2021
Author: Zainab Hasan
January 28, 2021
Author: Zainab Hasan
March 15, 2021
Author: Zainab Hasan
1 2 3 16

Get notified on our latest Web3 researches and catch Xord at a glance.

    By checking this box , I agree to receive email communication from Xord.

    We research blockchain core and infrastructure while developing products for the Web3 ecosystem in addition to providing blockchain development services, audit & tokenomics, along with design in the Web3 space.

    © 2023 | All Rights Reserved
    linkedin facebook pinterest youtube rss twitter instagram facebook-blank rss-blank linkedin-blank pinterest youtube twitter instagram