Callbacks

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:

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.