IBC transfers for apps (ICS-20)
This page is for app developers who want to send IBC transfers from Safrochain (ICS-20). It is not a relayer setup guide. If you are operating a relayer, see IBC: Hermes setup.
What you need
- A funded account on
safrochain-1(mainnet) orsafro-testnet-1 - A destination chain address (counterparty)
- A channel ID on Safrochain that connects to the destination chain
Live mainnet channels:
| Destination | Safrochain channel | Counterparty channel |
|---|---|---|
Noble (noble-1) | channel-0 | channel-581 |
Osmosis (osmosis-1) | channel-1 | channel-110497 |
Full registry: IBC channels.
Key parameters
| Field | Meaning |
|---|---|
sourcePort | always transfer for ICS-20 |
sourceChannel | e.g. channel-0 (must exist on Safrochain) |
token.denom | usaf for native SAF, or ibc/<hash> for an IBC denom |
receiver | destination chain bech32 address |
timeoutHeight | optional safety cutoff at a specific block height |
timeoutTimestamp | optional safety cutoff in nanoseconds since epoch |
Most apps use timeoutTimestamp (recommended) because it is chain-agnostic.
CosmJS: MsgTransfer (timeoutTimestamp)
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { SigningStargateClient, assertIsDeliverTxSuccess } from "@cosmjs/stargate";
const RPC = "https://rpc1.safrochain.network:443";
const CHAIN_ID = "safrochain-1";
const mnemonic = process.env.MNEMONIC!;
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, {
prefix: "addr_safro",
});
const [{ address: sender }] = await wallet.getAccounts();
const client = await SigningStargateClient.connectWithSigner(RPC, wallet, {
gasPrice: { denom: "usaf", amount: "0.05" },
});
// Noble: channel-0 · Osmosis: channel-1
const sourcePort = "transfer";
const sourceChannel = "channel-1";
const receiver = "osmo1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
const token = { denom: "usaf", amount: "1000000" }; // 1 SAF
const memo = "ibc test";
// 10 minutes from now, in nanoseconds
const timeoutTimestampNs = BigInt(Date.now() + 10 * 60 * 1000) * 1_000_000n;
const msg = {
typeUrl: "/ibc.applications.transfer.v1.MsgTransfer",
value: {
sourcePort,
sourceChannel,
token,
sender,
receiver,
timeoutTimestamp: timeoutTimestampNs,
memo,
},
};
const res = await client.signAndBroadcast(sender, [msg], "auto");
assertIsDeliverTxSuccess(res);
console.log({ txHash: res.transactionHash, height: res.height });
Choosing a channel ID
There is no universal channel-0 rule. Your app must pick the right channel
for the destination chain.
Recommended approach:
- Maintain a config mapping, e.g.
{ "noble-1": "channel-0", "osmosis-1": "channel-1" }. - Update it when new channels open (see Channels).
- Display the channel in your UI so users can verify it.
Common failure modes
| Error | Likely cause | Fix |
|---|---|---|
channel not found | wrong sourceChannel | use the published channel registry |
insufficient funds | sender has no usaf | use the faucet and retry |
| transfer succeeds but funds “stuck” | relayer downtime | wait for relayer, or retry via different channel |
| timeout | timeout too short | increase timeoutTimestamp to 10–30 minutes |
Next
- Relayer operations: Hermes setup
- IBC background: IBC overview