Skip to main content

Build With IBC Callbacks Module

This tutorial shows how to trigger a CosmWasm contract on ZIGChain from an ICS-20 transfer sent on Osmosis testnet using a dest_callback memo. By the end, you will compile and deploy the sample counter contract, send a callback-enabled transfer, relay the packet, and verify the counter update on ZIGChain.

The source example is available in the public ZIGChain examples repository: Callbacks Module example (examples/integrations/callbacks-module).

Disclaimer

This tutorial demonstrates an example flow and sample contract behavior. Validate chain settings, gas values, permissions, and contract logic for your own environment before any production use.

Prerequisites

Before you begin, install:

  • zigchaind v3.0.0 or above (for ZIGChain queries and transactions)
  • osmosisd (for Osmosis testnet transfer transactions)
  • hermes v1.10+ (for channel setup and packet relaying)
  • Rust + wasm32-unknown-unknown target
  • Docker (for deterministic CosmWasm optimizer builds)
  • jq (for parsing JSON responses)

Install the wasm target once:

rustup target add wasm32-unknown-unknown

Step 1: Configure Environment

Set up environment variables for both chains and placeholders you will populate during deployment:

# ZIGChain testnet
export ZIG_CHAIN_ID="zig-test-2"
export ZIG_NODE="https://testnet-rpc.zigchain.com:443"
export ZIG_LCD="https://testnet-api.zigchain.com"
export ZIG_DENOM="uzig"
export ZIG_GAS_PRICES="0.0025${ZIG_DENOM}"
export ZIG_KEY="my-key"

# Osmosis testnet
export OSMO_CHAIN_ID="osmo-test-5"
export OSMO_NODE="https://rpc.testnet.osmosis.zone:443"
export OSMO_DENOM="uosmo"
export OSMO_GAS_PRICES="0.1${OSMO_DENOM}"
export OSMO_KEY="my-key"

# Filled later
export CODE_ID=""
export CONTRACT=""
export OSMO_CHANNEL=""
export ZIG_CHANNEL=""
export ZIG_RECEIVER=""

Create keys if needed:

zigchaind keys add "$ZIG_KEY"
osmosisd keys add "$OSMO_KEY"

Before deploying the contract on ZIGChain testnet, ensure your zig1... wallet is whitelisted for CosmWasm uploads. Follow CosmWasm Whitelisting first, then continue.

Fund both testnet wallets before continuing:

Verify balances:

zigchaind q bank balances "$(zigchaind keys show "$ZIG_KEY" -a)" --node "$ZIG_NODE"
osmosisd q bank balances "$(osmosisd keys show "$OSMO_KEY" -a)" --node "$OSMO_NODE"

Step 2: Download the Example and Build the Contract

2.1 Clone the repository

Option A — Full clone (recommended for first-time setup):

git clone https://github.com/ZIGChain/zigchain-examples.git
cd zigchain-examples/examples/integrations/callbacks-module

Option B — Sparse checkout (download only callbacks module):

git clone --depth 1 --filter=blob:none --sparse https://github.com/ZIGChain/zigchain-examples.git zigchain-examples
cd zigchain-examples
git sparse-checkout set examples/integrations/callbacks-module
cd examples/integrations/callbacks-module

2.2 Build optimized wasm with Docker

From zigchain-examples/examples/integrations/callbacks-module, compile the optimized wasm:

docker run --rm -v "$(pwd)/contract":/code \
--mount type=volume,source="$(basename "$(pwd)")_cache",target=/target \
--mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \
cosmwasm/optimizer:0.16.1

Expected artifacts:

  • contract/artifacts/counter.wasm
  • contract/artifacts/checksums.txt

This sample contract exposes ibc_destination_callback and updates a counter only when the packet acknowledgement confirms transfer success.


Step 3: Store and Instantiate on ZIGChain

3.1 Store wasm

TX=$(zigchaind tx wasm store contract/artifacts/counter.wasm \
--from "$ZIG_KEY" \
--chain-id "$ZIG_CHAIN_ID" --node "$ZIG_NODE" \
--gas auto --gas-adjustment 1.4 --gas-prices "$ZIG_GAS_PRICES" \
--output json --yes | jq -r '.txhash')

Extract CODE_ID:

export CODE_ID=$(zigchaind q tx "$TX" --node "$ZIG_NODE" --output json \
| jq -r '.events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "code_id=$CODE_ID"

3.2 Instantiate

ADMIN=$(zigchaind keys show "$ZIG_KEY" -a)

zigchaind tx wasm instantiate "$CODE_ID" '{}' \
--label "counter-callback-demo" \
--admin "$ADMIN" \
--from "$ZIG_KEY" \
--chain-id "$ZIG_CHAIN_ID" --node "$ZIG_NODE" \
--gas auto --gas-adjustment 1.4 --gas-prices "$ZIG_GAS_PRICES" \
--output json --yes

Get contract address:

export CONTRACT=$(zigchaind q wasm list-contract-by-code "$CODE_ID" \
--node "$ZIG_NODE" --output json | jq -r '.contracts[-1]')
echo "contract=$CONTRACT"

Quick query check:

zigchaind q wasm contract-state smart "$CONTRACT" '{"count":{}}' --node "$ZIG_NODE"

Step 4: Create or Reuse an Osmosis to ZIGChain Transfer Channel

If you do not already have an open transfer channel between osmo-test-5 and zig-test-2, create one with Hermes:

hermes create channel \
--a-chain osmo-test-5 --b-chain zig-test-2 \
--a-port transfer --b-port transfer \
--new-client-connection --yes

Save channel IDs from output:

  • Osmosis side -> OSMO_CHANNEL
  • ZIGChain side -> ZIG_CHANNEL

Set receiver on ZIGChain:

export ZIG_RECEIVER="$(zigchaind keys show "$ZIG_KEY" -a)"

If you are reusing an existing channel, enumerate open transfer channels on Osmosis and pick the one whose client-state chain id is zig-test-2.


Step 5: Send ICS-20 Transfer With dest_callback

5.1 Minimal callback (+1 counter increment)

MEMO=$(jq -nc --arg addr "$CONTRACT" \
'{dest_callback: {address: $addr, gas_limit: "5000000"}}')

osmosisd tx ibc-transfer transfer \
transfer "$OSMO_CHANNEL" "$ZIG_RECEIVER" \
"1000${OSMO_DENOM}" \
--memo "$MEMO" \
--from "$OSMO_KEY" \
--chain-id "$OSMO_CHAIN_ID" --node "$OSMO_NODE" \
--gas auto --gas-adjustment 1.4 --gas-prices "$OSMO_GAS_PRICES" \
--packet-timeout-timestamp $(( $(date +%s%N) + 600000000000 )) \
--yes

Memo format:

{
"dest_callback": {
"address": "<contract-address-on-zigchain>",
"gas_limit": "500000"
}
}

5.2 With custom payload (counter.bump_by)

MEMO=$(jq -nc \
--arg addr "$CONTRACT" \
--argjson bump 5 \
'{dest_callback: {address: $addr, gas_limit: "500000"},
counter: {bump_by: $bump}}')

Then submit the same osmosisd tx ibc-transfer transfer ... command with this memo. The middleware reads dest_callback, while the contract reads counter.bump_by from packet memo data.


Step 6: Relay the Packet

Relay pending packets on the channel:

hermes clear packets \
--chain osmo-test-5 \
--port transfer \
--channel "$OSMO_CHANNEL"

Look for successful RecvPacket handling in output.


Step 7: Verify Callback Execution

Check count:

zigchaind q wasm contract-state smart "$CONTRACT" '{"count":{}}' --node "$ZIG_NODE"

Check transfer metadata captured by the contract:

zigchaind q wasm contract-state smart "$CONTRACT" '{"last_transfer":{}}' \
--node "$ZIG_NODE" --output json | jq

Optional event-level verification:

zigchaind q txs --query "recv_packet.packet_dst_channel='${ZIG_CHANNEL}'" \
--node "$ZIG_NODE" --output json --limit 1 \
| jq '.txs[0].events[] | select(.type=="wasm") | .attributes'

Troubleshooting

Counter stays at 0 after the transfer confirms on Osmosis.

  1. Packet not relayed yet — re-run hermes clear packets (Step 6) and watch its output for Success: RecvPacket tied to your OSMO_CHANNEL.
  2. Callbacks middleware is not wired into ZIGChain's ICS-20 stack. On v3.0.0 or above testnets/binaries it is, but if you're targeting a custom build, confirm with the ZIGChain team.
  3. gas_limit too low — bump it in the memo and retry.
  4. Memo JSON malformed — the middleware falls back to a plain transfer (tokens arrive, callback does not). Re-emit with jq -nc as shown above rather than hand-crafting.

Missing export ibc_destination_callback in the RecvPacket events.

The contract was compiled without a top-level #[entry_point] fn ibc_destination_callback(...). On cosmwasm-std 2.x this is a dedicated wasm export, not a sudo variant. Rebuild with the entry point and re-store; the old code id cannot process callbacks.

Wasm contract requires unavailable capabilities: {"cosmwasm_3_0"}.

ZIGChain v3.0.0 or above runs wasmvm v2.2.x. Pin cosmwasm-std = "2.2" with features ["cosmwasm_2_2", "stargate"] and parse the ICS-20 packet from cb.packet.data directly (the .transfer convenience field only exists in 3.0).

insufficient fees.

Bump --gas-adjustment to 1.5 and/or set a higher --gas-prices.

Receiver address rejected.

Make sure ZIG_RECEIVER is a ZIGChain bech32 address (zig1...), not an Osmosis address.


Next Steps

  • Extend the contract to enforce auth on reset paths.
  • Add richer memo payload schemas for app-specific callback logic.
  • Integrate the same callback pattern into your cross-chain settlement workflows.

References