Everything You Need to Know About IBC Channels (Part 1)
Channel ordering, handshakes, timeouts, and more
Channels are an integral part of the core Inter Blockchain Communication (IBC) protocol. They serve as a conduit to transfer packets between a module /application on one chain and a module on another chain. For application developers, channels are the most important layer of IBC abstraction to be familiar with.
This blog post aims to explain channels and address common questions/misconceptions developers have about IBC channels. Key topics covered include channel ordering, handshakes, closing, reopening, security properties of channels, and packet timeouts.
There is a lot to cover, so this is the first of a two-part series. Part 1 focuses on the fundamentals of IBC channels, while part 2 addresses some of the channel FAQs from application developers.
Types of channels
A channel is a conduit to transfer packets between two different modules/applications on two different chains. They ensure that packets are executed only once, and delivered only to the corresponding module owning the other end of the channel.
Each channel is associated with a particular connection, and a connection may have any number of associated channels.
Currently, there are two main types of channels:
- Ordered: where packets are delivered (to the destination) exactly in the order in which they were sent (from the source).
- Unordered: where packets can be delivered in any order, regardless of the order in which they were sent.
Channel ordering is enforced with the use of packet sequences.
Every packet has a packet sequence that corresponds to the order of sending and receiving packets. A packet with a sequence number 1 will be received and processed at the destination before a packet with a sequence number 2 or higher.
A channel end maintains three different sequences — nextSequenceSend
, nextSequenceRecv
, and a nextSequenceAck
. Every time a channel sends, receives, or acknowledges a packet, the nextSequenceSend
, nextSequenceRecv
, and the nextSequenceAck
counters are incremented, respectively.
On ordered channels, a packet is received and processed only if the packet sequence corresponds to the nextSequenceRecv
that the channel end expects. If the packet sequence is less than the nextSequenceRecv
, meaning that the packet was already received, then core IBC no-ops, as shown here.
If the packet sequence is greater than the nextSequenceRecv
, meaning that there exists a packet with an earlier sequence that needs to be received before the current one, the packet is rejected and a relayer eventually submits the correct packet.
When a packet is received on an unordered channel, core IBC simply ensures that it has not already been received (since ordering does not matter) by looking for a packet receipt — a single bit indicating that a packet was received. If the receipt already exists, then the channel no-ops. If it doesn’t exist, then the receipt is set and the packet is processed.
Channel opening/closing handshake
Channels are established through a 4-way handshake. At every step of the handshake process, core IBC performs basic verification and logic, while making callbacks to the application where custom handshake logic can be performed.
Version negotiation between the two modules takes place during the handshake. This allows applications to agree upon the structure of the packet data that will be sent over that channel, the logic with respect to using middleware, etc.
Channel handshake in the case of ICS-20¹ is as follows:
- A relayer calls the
chanOpenInit
function on chain A. Subsequently, theonChanOpenInit
callback allows the application on chain A to perform custom logic. This sets the channel end state (on A) to INIT. A relayer passes this version onto chain B in step 2. as thecounterpartyVersion
string. chanOpenTry
is called on chain B where the application performs custom handshake logiconChanOpenTry
, setting the channel end state to TRY. The application on B ensures that its version is the same as that proposed by the counterparty application, as shown here.chanOpenAck
is called on A. Application version negotiation is finalized during this step and the channel end state on A is set to OPEN.chanOpenConfirm
is called on B, where the channel end is also set to OPEN.
¹chanOpenInit can also be called by an application module (eg. ICA)
It is possible for the callbacks to return an error at any stage of the handshake process. In this case, the channel handshake fails and a new one needs to be negotiated.
Once a channel has been established between two modules, upgrading said modules or using middleware to wrap around the modules is not possible without either opening a new channel or coordinating a network-wide upgrade. Our current work on channel upgradability addresses this issue so that modules can upgrade to leverage new features or middleware can be added on both sides while maintaining the same channels.
A channel closes only under exceptional circumstances. We talk about all the reasons why a channel would close in part 2 of this series. Closing a channel is done through a 2-step channel closing handshake. A relayer submits ChannelCloseInit
which allows the application on that chain to execute custom logic onChanCloseInit
. ChannelCloseConfirm
is submitted on the counterparty where the onChanCloseConfirm
callback is called.
Packet timeouts
One of the key guarantees provided by core IBC is that a packet will not be received on the destination chain if the timeout has passed. Timeouts are specified within the packet interface by a timeoutHeight
and timoutTimestamp
of the counterparty (receiving) chain.
func NewPacket(
data []byte,
sequence uint64, sourcePort, sourceChannel,
destinationPort, destinationChannel string,
timeoutHeight clienttypes.Height, timeoutTimestamp uint64,
) Packet {
return Packet{
Data: data,
Sequence: sequence,
SourcePort: sourcePort,
SourceChannel: sourceChannel,
DestinationPort: destinationPort,
DestinationChannel: destinationChannel,
TimeoutHeight: timeoutHeight,
TimeoutTimestamp: timeoutTimestamp,
}
}
Note: a timeout always needs to be proven on the sending chain, based on the timeout height or timeout timestamp of the receiving chain.
Specifying the timeout based on the recipient’s local clock ensures that there will never be a situation where the sender believes a packet has timed out and takes an action, such as releasing escrowed tokens in a token transfer, while the packet was actually received on time by the counterparty, who then proceeds to mint tokens — resulting in a double spend.
There are two scenarios where a packet can timeout: 1) the standard case where the timeout timestamp or timeout height has passed on the receiving chain, or 2) a channel closes and therefore all packets that weren’t received will be timed out.
We’ll dive into both of these cases separately. First, let’s look at the most common scenario.
Standard packet timeouts
- When a packet is sent to the destination chain where the timeout timestamp/height has passed, the
RecvPacket
function returns an error. - A relayer scans for such errors (
ErrPacketTimeout
) and sends aTimeoutPacket
to the source chain. - The
TimeoutPacket
comprises of aproof
and aProofHeight
. Theproof
is used to prove to the source chain that at this particular height, the packet was not received. - If you’re familiar with how light clients work, then you’ll know that each
ProofHeight
is associated with a particularConsensusState
. And eachConsensusState
has a particular timestamp. Therefore, we grab the timestamp associated with a particularProofHeight
using theGetTimestampAtHeight
function. - Hence, the
ProofHeight
proves that the counterparty timestamp (associated with theProofHeight
) is greater than the timeout timestamp that was specified in the packet. - If a timeout height is specified instead of a timestamp, then we prove that the
ProofHeight
is greater than or equal to the timeout height. - Once we’ve proven steps 5. or 6., we go on to prove that at this height, the packet was not received. This step differs based on the channel ordering:
7.a. In the simple case of unordered channels, we prove that at this ProofHeight
, a packet receipt does not exist. In other words, this is a proof that a certain key does not exist in the state tree.²
7.b. For ordered channels, we verify that the nextSequenceReceive
is less than or equal to the packet sequence. This ensures that the destination chain has not received said packet. For example, if the nextSequenceReceive
is 3 and the packet sequence is 4, then the channel expects to receive the packet with sequence 3 before it receives 4. Unlike 7.a., this is a proof of membership of the nextSequenceReceive.
³
Once we’ve proved 5. (or 6.) and 7., the sending module calls OnTimeoutPacket
to revert any state changes or perform custom logic. In the case of ICS-20 token transfers, the escrowed tokens are unescrowed and refunded to the user.
²Also known as a proof of non-membership/non-existence. The IBC module maintains its state in an IAVL tree (similar to every other Cosmos SDK module). In an IAVL tree, the child keys are arranged alphabetically. Therefore, to prove that the key ‘b’ does not exist (a non-existence proof), one needs to prove that both ‘a’ and ‘c’ exist and that there is no other key in between them.
³Except for timeouts, all proofs in IBC are proofs of membership i.e. that a certain key does exist in the state tree.
Timeouts when a channel closes
The TimeoutOnClose
function is called by the sending module in order to prove that the channel on which a packet was sent has been closed.
Performing timeout logic when a channel closes is straightforward. Unlike the timeouts mentioned above where core IBC proves that the time on the counterparty has passed the packet timeout, in this case, we only need to prove that the channel state of the counterparty is closed (as shown here).
Token fungibility
In IBC, the same token sent over two different channels is not fungible. This is not a bug but a fundamental aspect of IBC’s security model.
Even in the scenario where there are two different channels 1 and 2, between the same two chains A and B, the same token $FOO sent over these channels will not be fungible. This is because each channel has its own security properties. For example, two different channels may have different versions. Or the channels may not be associated with the same connection, but with two different connections (which could be connecting different light clients). But importantly, modules have no way of knowing which chain the counterparty’s channel end belongs to. In other words, modules only have knowledge of the channel they are sending data on, and not the chain they are communicating with.
As a result, it is imperative that each channel is isolated in terms of its security boundaries, and that no naive assumptions are made with respect to channels and their associated light clients.
Conclusion
As outlined above, channels play an important role in the transport and ordering of packets within IBC. Given that modules and channels are tightly coupled, it is useful for application developers to be familiar with the ins and outs of IBC channels.
In part 2 of this series, we’ll clarify some of the FAQs from application developers regarding channels. In the meantime, feel free to check out our developer portal to learn more about IBC channels.
About the Author:
Adi Ravi Raj works at Interchain GmbH and is the Protocol Analyst for the IBC team.
Thank you to Susannah Evans, and Thomas Dekeyser for reviewing drafts of this post.