Fiber LogoFiber Docs

Transfer Stablecoins

Learn how to transfer stablecoins between nodes

Requirements
Updated 8/17/2026
latest

TL;DR

Set up two nodes, open a UDT (stablecoin) channel, and send RUSD directly between them. Builds on the Basic Transfer example.

Overview

This guide demonstrates a direct UDT transfer using Testnet RUSD. For routing through relay nodes, continue with Multi-hop Transfers after completing this guide.

Prerequisites

Setting Up Your Environment

1. Prepare Fiber Binary

Download the v0.9.0 package for your platform from GitHub Releases. For example, on an Apple Silicon Mac:

curl -LO https://github.com/nervosnetwork/fiber/releases/download/v0.9.0/fnn_v0.9.0-aarch64-darwin-portable.tar.gz
tar -xzf fnn_v0.9.0-aarch64-darwin-portable.tar.gz
./fnn --version

The final command should report Fiber v0.9.0. To build instead, check out the matching tag with git clone --branch v0.9.0 --depth 1 https://github.com/nervosnetwork/fiber.git before running cargo build --release.

macOS Security

xattr -d com.apple.quarantine fnn fnn-cli

2. Configure Two Nodes

for node in node1 node2; do
  mkdir $node
  cp fnn $node/
  cp fnn-cli $node/
  cp config/testnet/config.yml $node/
done

If you built from source, copy target/release/fnn and target/release/fnn-cli instead of the release-package paths shown above.

Create a CKB account for each node and export the keys:

ckb-cli account new  # repeat twice, save each lock_arg

# For each node directory:
mkdir ckb
ckb-cli account export --lock-arg <node_lock_arg> --extended-privkey-path ./ckb/exported-key
sed -n '1{s/^0x//;p;}' ./ckb/exported-key > ./ckb/key
chmod 600 ./ckb/key

Key File Format

ckb/key must contain only the 64-character hex private key, no 0x prefix.

Get Testnet funds:

3. Configure Ports

  • Node 1: RPC 8227, P2P 8228
  • Node 2: RPC 8237, P2P 8238

Keep Node 1's release config unchanged. Change only Node 2's listening ports:

sed -i.bak 's|/ip4/0.0.0.0/tcp/8228|/ip4/127.0.0.1/tcp/8238|' node2/config.yml
sed -i.bak 's|127.0.0.1:8227|127.0.0.1:8237|' node2/config.yml

The v0.9.0 Testnet config already contains the current RUSD type script and cell dependency. Its local auto_accept_amount is 10 RUSD; this guide funds 20 RUSD to match the minimum accepted by the official Testnet public nodes.

4. Start Both Nodes

# Terminal 1
cd node1 && FIBER_SECRET_KEY_PASSWORD='password1' RUST_LOG=info ./fnn -c config.yml -d .

# Terminal 2
cd node2 && FIBER_SECRET_KEY_PASSWORD='password2' RUST_LOG=info ./fnn -c config.yml -d .

Creating Stablecoin Payment Channels

1. Connect Node 1 and Node 2

cd node1 && ./fnn-cli peer connect_peer \
  --address "/ip4/127.0.0.1/tcp/8238" \
  --pubkey <node2_pubkey> \
  --save false
curl --location 'http://127.0.0.1:8227' \
  --header 'Content-Type: application/json' \
  --data '{
    "id": 42, "jsonrpc": "2.0", "method": "connect_peer",
    "params": [{"pubkey": "<node2_pubkey>", "address": "/ip4/127.0.0.1/tcp/8238", "save": false}]
  }'
await sdk.connectPeer({
  address: "/ip4/127.0.0.1/tcp/8238",
  pubkey: "<node2_pubkey>",
  save: false,
});

Get Node 2's pubkey: cd node2 && ./fnn-cli --url http://127.0.0.1:8237 info. save: false avoids persisting a local address that includes no routable /p2p/... suffix.

2. Open a Stablecoin Channel (Node 1 → Node 2)

The funding_udt_type_script identifies the RUSD token:

cd node1 && ./fnn-cli channel open_channel \
  --pubkey <node2_pubkey> \
  --funding-amount 2000000000 \
  --public true \
  --funding-udt-type-script '{"code_hash":"0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a","hash_type":"type","args":"0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b"}'
curl --location 'http://127.0.0.1:8227' \
  --header 'Content-Type: application/json' \
  --data '{
    "id": 42, "jsonrpc": "2.0", "method": "open_channel",
    "params": [{
      "pubkey": "<node2_pubkey>",
      "funding_amount": "0x77359400",
      "public": true,
      "funding_udt_type_script": {
        "code_hash": "0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a",
        "hash_type": "type",
        "args": "0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b"
      }
    }]
  }'
// fundingAmount: 0x77359400 = 2,000,000,000 base units = 20 RUSD
const tempChannelId = await sdk.openChannel({
  pubkey: "<node2_pubkey>",
  fundingAmount: "0x77359400",
  public: true,
  fundingUdtTypeScript: {
    codeHash: "0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a",
    hashType: "type",
    args: "0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b",
  },
});
console.log("Temporary channel ID:", tempChannelId);

CKB Requirement

Opening a UDT channel reserves about 99 CKB from each participant for on-chain cell capacity and shutdown fees. Fund both node accounts with CKB, and fund Node 1 with at least 20 RUSD. The 20 RUSD amount also meets the official Testnet public nodes' current auto-accept threshold.

Why funding-amount 10 also runs

Fiber passes UDT amounts through as raw unsigned integers and does not apply the token's display decimals. Therefore, --funding-amount 10 is valid, but for this 8-decimal Testnet RUSD deployment it funds 10 base units (0.00000010 RUSD), not 10 RUSD.

3. Monitor Channel Status

Wait until state.state_name becomes "ChannelReady":

./fnn-cli channel list_channels
curl --location 'http://127.0.0.1:8227' \
  --header 'Content-Type: application/json' \
  --data '{"id": 42, "jsonrpc": "2.0", "method": "list_channels", "params": [{}]}'
const channels = await sdk.listChannels();
for (const ch of channels) {
  console.log(`${ch.channelId} — ${ch.state.stateName}`);
}

Generating Invoices and Making Payments

1. Generate a Stablecoin Invoice on Node 2

cd node2 && ./fnn-cli --url http://127.0.0.1:8237 invoice new_invoice \
  --amount 100000000 \
  --currency Fibt \
  --description "test stablecoin invoice" \
  --expiry 3600 \
  --udt-type-script '{"code_hash":"0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a","hash_type":"type","args":"0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b"}'
curl --location 'http://127.0.0.1:8237' \
  --header 'Content-Type: application/json' \
  --data '{
    "id": 42, "jsonrpc": "2.0", "method": "new_invoice",
    "params": [{
      "amount": "0x5f5e100",
      "currency": "Fibt",
      "description": "test stablecoin invoice",
      "expiry": "0xe10",
      "udt_type_script": {
        "code_hash": "0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a",
        "hash_type": "type",
        "args": "0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b"
      }
    }]
  }'
// Create invoice on Node 2
const node2 = new FiberSDK({ endpoint: "http://127.0.0.1:8237" });
const paymentPreimage = "0x" + Array.from(
  crypto.getRandomValues(new Uint8Array(32)),
  (byte) => byte.toString(16).padStart(2, "0"),
).join("");
const { invoiceAddress } = await node2.newInvoice({
  amount: "0x5f5e100",
  currency: "Fibt",
  description: "test stablecoin invoice",
  expiry: "0xe10",
  paymentPreimage,
  udtTypeScript: {
    codeHash: "0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a",
    hashType: "type",
    args: "0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b",
  },
});
console.log("Invoice:", invoiceAddress);

The payment_preimage is auto-generated by both CLI and RPC. You can optionally provide your own with "payment_preimage": "<your_preimage>" in the RPC params if needed.

2. Send the Stablecoin Payment from Node 1

cd node1 && ./fnn-cli payment send_payment --invoice "fibt10000000001p..."

# Poll with the payment_hash returned above
./fnn-cli payment get_payment --payment-hash <payment_hash>
curl --location 'http://127.0.0.1:8227' \
  --header 'Content-Type: application/json' \
  --data '{
    "id": 42, "jsonrpc": "2.0", "method": "send_payment",
    "params": [{"invoice": "fibt10000000001p..."}]
  }'

# Poll with the payment_hash returned above
curl --location 'http://127.0.0.1:8227' \
  --header 'Content-Type: application/json' \
  --data '{
    "id": 43, "jsonrpc": "2.0", "method": "get_payment",
    "params": [{"payment_hash": "<payment_hash>"}]
  }'
const result = await sdk.sendPayment({ invoice: "fibt10000000001p..." });
console.log("Payment hash:", result.paymentHash);
console.log("Status:", result.status);

const final = await sdk.getPayment({ paymentHash: result.paymentHash });
console.log("Final status:", final.status);
// Repeat getPayment while the status is Created or Inflight.

After a successful 1 RUSD payment, Node 1's local RUSD balance decreases by 100000000 base units and Node 2's local RUSD balance increases by the same amount.

Payments are asynchronous

send_payment can initially return Created or Inflight. Poll get_payment with the returned payment_hash until the status is Success or Failed before treating the transfer as complete.

3. Check Channel Balance

# Node 1
./fnn-cli channel list_channels

# Node 2
./fnn-cli --url http://127.0.0.1:8237 channel list_channels
curl --location 'http://127.0.0.1:8227' \
  --header 'Content-Type: application/json' \
  --data '{"id": 42, "jsonrpc": "2.0", "method": "list_channels", "params": [{}]}'
// Node 1
const node1Channels = await sdk.listChannels();
for (const ch of node1Channels) {
  console.log(`local: ${ch.localBalance}, remote: ${ch.remoteBalance}`);
}

// Node 2
const node2Channels = await node2.listChannels();
for (const ch of node2Channels) {
  console.log(`local: ${ch.localBalance}, remote: ${ch.remoteBalance}`);
}

Closing the Channel

cd node1 && ./fnn-cli channel shutdown_channel \
  --channel-id <channel_id> \
  --close-script '{"code_hash":"0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8","hash_type":"type","args":"<your_lock_arg>"}' \
  --fee-rate 1000 \
  --force false
curl --location 'http://127.0.0.1:8227' \
  --header 'Content-Type: application/json' \
  --data '{
    "id": 42, "jsonrpc": "2.0", "method": "shutdown_channel",
    "params": [{
      "channel_id": "<channel_id>",
      "close_script": {
        "code_hash": "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8",
        "hash_type": "type",
        "args": "<your_lock_arg>"
      },
      "fee_rate": "0x3e8",
      "force": false
    }]
  }'
await sdk.shutdownChannel({
  channelId: "<channel_id>",
  closeScript: {
    codeHash: "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8",
    hashType: "type",
    args: "<your_lock_arg>",
  },
  feeRate: "0x3e8",
  force: false,
});
console.log("Cooperative close requested.");

close_script

Get args from fnn-cli info under default_funding_lock_script.args. The fee_rate (shannons/KW) is optional and defaults to 1000. You can also use --force (CLI) or "force": true (RPC) to force-close a channel unilaterally, in which case close_script and fee_rate are ignored.

Important Notes

  • RUSD amounts: This Testnet RUSD has 8 display decimals. CLI uses decimal base units and RPC/SDK examples use hex quantities: 100000000 = "0x5f5e100" = 1 RUSD, while 2000000000 = "0x77359400" = 20 RUSD.
  • CKB and shannons: CKB independently uses 8 decimal places (1 CKB = 100000000 shannons). The numeric multipliers happen to match, but RUSD base units are not shannons.
  • Invoice currency: Use "Fibt" for testnet, not "RUSD"
  • auto_accept_amount: This field is also expressed in raw UDT base units. The official Testnet public-node value is 2000000000 ("0x77359400" over RPC), which represents 20 RUSD. Setting it to decimal 20 would mean only 20 base units (0.00000020 RUSD). Channels below the configured raw value require manual acceptance.
  • Channel state format: The list_channels response returns state as a nested object: state.state_name (e.g. "ChannelReady") and state.state_flags (e.g. ["PublicChannel"])

Next Steps