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 safro-testnet-1
  2. A destination chain address (counterparty)
  3. A channel ID on Safrochain that connects to the destination chain

Channel IDs are published in the chain’s IBC registry once the channel is live:

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://rpc.testnet.safrochain.com:443";
const CHAIN_ID = "safro-testnet-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" },
});

// Replace with a real channel from the IBC registry once live
const sourcePort = "transfer";
const sourceChannel = "channel-0";

const receiver = "cosmos1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
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 small config mapping like { chainId -> sourceChannel }.
  2. Update it when the foundation opens new channels (or when channels are rotated).
  3. For safety, display the channel you intend to use in your UI so operators 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