Skip to main content

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

  1. A funded account on safrochain-1 (mainnet) or safro-testnet-1
  2. A destination chain address (counterparty)
  3. A channel ID on Safrochain that connects to the destination chain

Live mainnet channels:

DestinationSafrochain channelCounterparty channel
Noble (noble-1)channel-0channel-581
Osmosis (osmosis-1)channel-1channel-110497

Full registry: IBC channels.

Key parameters

FieldMeaning
sourcePortalways transfer for ICS-20
sourceChannele.g. channel-0 (must exist on Safrochain)
token.denomusaf for native SAF, or ibc/<hash> for an IBC denom
receiverdestination chain bech32 address
timeoutHeightoptional safety cutoff at a specific block height
timeoutTimestampoptional 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:

  1. Maintain a config mapping, e.g. { "noble-1": "channel-0", "osmosis-1": "channel-1" }.
  2. Update it when new channels open (see Channels).
  3. Display the channel in your UI so users can verify it.

Common failure modes

ErrorLikely causeFix
channel not foundwrong sourceChanneluse the published channel registry
insufficient fundssender has no usafuse the faucet and retry
transfer succeeds but funds “stuck”relayer downtimewait for relayer, or retry via different channel
timeouttimeout too shortincrease timeoutTimestamp to 10–30 minutes

Next