ADR-8: IBC Callbacks
If you send an IBC packet from a contract, you'll get an acknowledgement of receipt or timeout in the respective handler (see Packet lifecycle for more details). This is great, but what if you want to get notified on completion of IBC packets you did not send directly from the contract, but that were caused by a message you sent? One real-world use-case of this is ICS20 transfers. When you send an ICS20 transfer message, the transfer module on the chain sends an IBC packet, but you do not get any feedback on whether the transfer was successful or not and the destination does not get informed of its newfound wealth.
To solve this problem, the ADR-8 specification (opens in a new tab) was created. On the source chain, it provides callbacks when an IBC packet was acknowledged or timed out. On the destination chain, it triggers callbacks when a packet is received.
IBC Callbacks is a generalized successor of IBC Hooks (opens in a new tab) that works not just for ICS20 transfers, but any message that supports the required interface.
To receive callbacks, the chain needs to support IBC Callbacks for the message type.
Enabling IBC Callbacks for a message
You need to explicitly opt-in to IBC Callbacks for each message. In order to do this, you need to add some metadata to the message, including who should receive the callbacks.
The exact data format and how to add it to the message can vary, but for the IbcMsg::Transfer
message, this data is in JSON format and needs to be added to the memo
field.
To make this as easy as possible, we provide two ways to generate the correct JSON. One is a builder
type for the IbcMsg::Transfer
type which provides a type-safe way to generate the complete
IbcMsg::Transfer
, the other is a helper type IbcCallbackRequest
(opens in a new tab) that just generates the JSON
for the memo
field:
let msg = TransferMsgBuilder::new(
"channel-0".to_string(),
"cosmos1exampleaddress".to_string(),
Coin::new(10u32, "ucoin"),
Timestamp::from_seconds(12345),
)
.with_src_callback(IbcSrcCallback {
address: env.contract.address,
gas_limit: None,
})
.with_dst_callback(IbcDstCallback {
address: "cosmos1exampleaddress".to_string(),
gas_limit: None,
})
.build();
Ok(Response::new().add_message(msg))
As you can see, you can request callbacks for both the source and destination chain. However, you
can also request callbacks for only one of them. For this, you need to provide the address that
should receive the callback and you can optionally set a gas limit for the callback execution.
Please take a look at the IbcCallbackRequest
docs for more information.
The address
of the source callback always needs to be the contract address that sends the
message (env.contract.address
). Otherwise, the callback will error and the contract will not be
called.
Receiving IBC Callbacks
To receive callbacks, you need to implement two new entrypoints in your contract:
ibc_source_callback
, receiving anIbcSourceCallbackMsg
(opens in a new tab) enum which can be one of two types:IbcAckCallbackMsg
(opens in a new tab) if the packet was acknowledgedIbcTimeoutCallbackMsg
(opens in a new tab) if the packet timed out
ibc_destination_callback
, receiving anIbcDestinationCallbackMsg
(opens in a new tab)
Source Callback
The ibc_source_callback
entrypoint is called when the packet was either acknowledged or timed out.
You can use this to update your contract state, release locked funds or trigger other actions.
As mentioned above, the receiver of this callback is always the contract that sent the message. This means you don't need to assume that an attacker might be sending you fake callbacks, reducing the need for validation.
This is how you can implement the ibc_source_callback
entrypoint:
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn ibc_source_callback(
deps: DepsMut,
_env: Env,
msg: IbcSourceCallbackMsg,
) -> StdResult<IbcBasicResponse> {
match msg {
IbcSourceCallbackMsg::Acknowledgement(IbcAckCallbackMsg {
acknowledgement,
original_packet,
relayer,
..
}) => {
// handle the acknowledgement
}
IbcSourceCallbackMsg::Timeout(IbcTimeoutCallbackMsg {
packet, relayer, ..
}) => {
// handle the timeout
}
}
Ok(IbcBasicResponse::new().add_attribute("action", "ibc_source_callback"))
}
Acknowledgement
When the packet was acknowledged, you will receive the Acknowledgement(IbcAckCallbackMsg)
variant
of IbcSourceCallbackMsg
. This means that the packet was successfully received and processed by the
application on the destination chain. The message contains the original packet data, the
acknowledgement and the address of the relayer.
Timeout
When the packet timed out, you will receive the Timeout(IbcTimeoutCallbackMsg)
variant of
IbcSourceCallbackMsg
. This means that the packet was not delivered to the destination chain in
time (e.g. because no relayer picked it up or the chain is stopped). The message contains the
original packet data and the address of the relayer who told you about the timeout.
Destination Callback
The ibc_destination_callback
entrypoint is called when a packet was acknowledged on the
destination chain. The shape of an acknowledgement is protocol specific and usually contains both
success and error cases.
For the IbcMsg::Transfer
message, a success acknowledgement means that the tokens were
successfully transferred to the destination chain. It allows you to use the received tokens
immediately, update the contract state to reflect the new tokens or trigger other actions.
It is important to validate that the packet and acknowledgement are what you expect them to be. For example for a transfer message, the receiver of the funds is not necessarily the contract that receives the callbacks.
This is how you can implement the ibc_destination_callback
entrypoint:
This example uses the ibc
crate with the serde
feature, which provides a data type for the
transfer packet format to avoid defining that ourselves. You can add it to your Cargo.toml
by
running cargo add ibc --features serde
.
use ibc::apps::transfer::types::packet::PacketData as TransferPacketData;
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn ibc_destination_callback(
deps: DepsMut,
env: Env,
msg: IbcDestinationCallbackMsg,
) -> StdResult<IbcBasicResponse> {
ensure_eq!(
msg.packet.dest.port_id,
"transfer", // transfer module uses this port by default
StdError::generic_err("only want to handle transfer packets")
);
ensure_eq!(
msg.ack.data,
StdAck::success(b"\x01").to_binary(), // this is how a successful transfer ack looks
StdError::generic_err("only want to handle successful transfers")
);
// At this point we know that this is a callback for a successful transfer,
// but not to whom it is going, how much and what denom.
// Parse the packet data to get that information:
let packet_data: TransferPacketData = from_json(&msg.packet.data)?;
// The receiver should be a valid address on this chain.
// Remember, we are on the destination chain.
let receiver = deps.api.addr_validate(packet_data.receiver.as_ref())?;
ensure_eq!(
receiver,
env.contract.address,
StdError::generic_err("only want to handle transfers to this contract")
);
// We only care about this chain's native token in this example.
// The `packet_data.token.denom` is formatted as `{port id}/{channel id}/{denom}`,
// where the port id and channel id are the source chain's identifiers.
// Assuming we are running this on Neutron and only want to handle NTRN tokens,
// the denom should look like this:
let ntrn_denom = format!(
"{}/{}/untrn",
msg.packet.src.port_id, msg.packet.src.channel_id
);
ensure_eq!(
packet_data.token.denom.to_string(),
ntrn_denom,
StdError::generic_err("only want to handle NTRN tokens")
);
// Now, we can do something with our tokens.
// For example, we could send them to some address:
let msg = BankMsg::Send {
to_address: "neutron155exr8rqjrknusllpzxdfvezxr8ddpqehj9g9d".to_string(),
// this panics if the amount is too large
amount: coins(packet_data.token.amount.as_ref().as_u128(), "untrn"),
};
Ok(IbcBasicResponse::new()
.add_message(msg)
.add_attribute("action", "ibc_destination_callback"))
}
Please note that this example assumes an ICS20 v1 channel. At the time of writing, the specification and implementation have just been extended with a v2 which changes the packet format (opens in a new tab). If you want to use this in production code, you should make sure to support both formats, such that a channel upgrade does not break your contract.
As mentioned above, anyone can send you a destination callback for a packet. This means you need to make sure that the packet and acknowledgement are what you expect them to be. For example, for a transfer message, you need to make sure that the transfer was successful, that the receiver of the funds is your contract and the denomination is what you want to receive. This requires some knowledge about the packet format (opens in a new tab).
Error handling
Returning an error or panicking from a callback will not influence the IBC packet lifecycle. The packet will still be acknowledged or timed out. This means that you can safely return errors from your callbacks if you want to ignore the packet.
It will, however, undo any state changes that you made in the callback, just like most other entrypoints.