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).
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:
zigchaindv3.0.0 or above (for ZIGChain queries and transactions)osmosisd(for Osmosis testnet transfer transactions)hermesv1.10+ (for channel setup and packet relaying)- Rust +
wasm32-unknown-unknowntarget - 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:
- ZIGChain testnet faucet: ZIGChain testnet faucet
- Osmosis testnet faucet: Osmosis testnet faucet
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.wasmcontract/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.
- Packet not relayed yet — re-run
hermes clear packets(Step 6) and watch its output forSuccess: RecvPackettied to yourOSMO_CHANNEL. - 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.
gas_limittoo low — bump it in the memo and retry.- Memo JSON malformed — the middleware falls back to a plain transfer (tokens arrive, callback does not). Re-emit with
jq -ncas 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.