Transfer Stablecoins
Learn how to transfer stablecoins between nodes
TL;DR
Set up three nodes, open UDT (stablecoin) channels, and send a stablecoin payment through a multi-hop path. Builds on the Basic Transfer example.
Overview
This guide demonstrates UDT transfers using stablecoins (RUSD) across 3 nodes via multi-hop routing.
Prerequisites
- Completed Basic Transfer
- curl for RPC calls
- ckb-cli for key management
Setting Up Your Environment
1. Prepare Fiber Binary
Download from GitHub Releases or build from source:
git clone https://github.com/nervosnetwork/fiber.git
cd fiber
cargo build --releasemacOS Security
xattr -d com.apple.quarantine fnn fnn-cli2. Configure Three Nodes
for node in node1 node2 node3; do
mkdir $node
cp target/release/fnn $node/
cp target/release/fnn-cli $node/
cp config/testnet/config.yml $node/
doneCreate a CKB account for each node and export the keys:
ckb-cli account new # repeat 3 times, 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
head -n 1 ./ckb/exported-key > ./ckb/keyKey File Format
ckb/key must contain only the 64-character hex private key, no 0x prefix.
Get Testnet funds:
- CKB: https://faucet.nervos.org
- RUSD: https://testnet0815.stablepp.xyz/stablecoin (claim via JoyID wallet at testnet.joyid.dev, then transfer to node address)
3. Configure Ports
- Node 1: RPC
8227, P2P8228 - Node 2: RPC
8237, P2P8238 - Node 3: RPC
8247, P2P8248
View complete config.yml example
fiber:
listening_addr: "/ip4/127.0.0.1/tcp/8228"
bootnode_addrs:
- "/ip4/54.179.226.154/tcp/8228/p2p/Qmes1EBD4yNo9Ywkfe6eRw9tG1nVNGLDmMud1xJMsoYFKy"
- "/ip4/16.163.7.105/tcp/8228/p2p/QmdyQWjPtbK4NWWsvy8s69NGJaQULwgeQDT5ZpNDrTNaeV"
announce_listening_addr: true
chain: testnet
scripts:
- name: FundingLock
script:
code_hash: 0x6c67887fe201ee0c7853f1682c0b77c0e6214044c156c7558269390a8afa6d7c
hash_type: type
args: 0x
cell_deps:
- type_id:
code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944
hash_type: type
args: 0x3cb7c0304fe53f75bb5727e2484d0beae4bd99d979813c6fc97c3cca569f10f6
- cell_dep:
out_point:
tx_hash: 0x12c569a258dd9c5bd99f632bb8314b1263b90921ba31496467580d6b79dd14a7
index: 0x0
dep_type: code
- name: CommitmentLock
script:
code_hash: 0x740dee83f87c6f309824d8fd3fbdd3c8380ee6fc9acc90b1a748438afcdf81d8
hash_type: type
args: 0x
cell_deps:
- type_id:
code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944
hash_type: type
args: 0xf7e458887495cf70dd30d1543cad47dc1dfe9d874177bf19291e4db478d5751b
- cell_dep:
out_point:
tx_hash: 0x12c569a258dd9c5bd99f632bb8314b1263b90921ba31496467580d6b79dd14a7
index: 0x0
dep_type: code
rpc:
listening_addr: "127.0.0.1:8227"
ckb:
rpc_url: "https://testnet.ckbapp.dev/"
udt_whitelist:
- name: RUSD
script:
code_hash: 0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a
hash_type: type
args: 0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b
cell_deps:
- type_id:
code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944
hash_type: type
args: 0x97d30b723c0b2c66e9cb8d4d0df4ab5d7222cbb00d4a9a2055ce2e5d7f0d8b0f
auto_accept_amount: 10
services:
- fiber
- rpc
- ckb4. Start All 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 .
# Terminal 3
cd node3 && FIBER_SECRET_KEY_PASSWORD='password3' 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/p2p/<node2_peer_id>"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"}]
}'await sdk.connectPeer({
address: "/ip4/127.0.0.1/tcp/8238/p2p/<node2_peer_id>",
});Get Node 2's peer ID and pubkey: cd node2 && ./fnn-cli --url http://127.0.0.1:8237 info
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 10 \
--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": "0xa",
"public": true,
"funding_udt_type_script": {
"code_hash": "0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a",
"hash_type": "type",
"args": "0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b"
}
}]
}'// funding_amount: 0xa = 10 RUSD
const tempChannelId = await sdk.openChannel({
pubkey: "<node2_pubkey>",
fundingAmount: "0xa",
public: true,
fundingUdtTypeScript: {
codeHash: "0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a",
hashType: "type",
args: "0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b",
},
});
console.log("Temporary channel ID:", tempChannelId);CKB Requirement
Opening a UDT channel also requires ~99 CKB in the node's wallet to cover on-chain cell capacity and shutdown fees. Make sure each node has sufficient CKB balance from the faucet.
3. Monitor Channel Status
Wait until state.state_name becomes "ChannelReady":
./fnn-cli channel list_channelscurl --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}`);
}Repeat steps 1–3 to connect Node 2 and Node 3 (using ports 8247/8248).
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 10 \
--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": "0xa",
"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 { invoiceAddress } = await node2.newInvoice({
amount: "0xa",
currency: "Fibt",
description: "test stablecoin invoice",
expiry: "0xe10",
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..."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..."}]
}'const result = await sdk.sendPayment({ invoice: "fibt10000000001p..." });
console.log("Payment hash:", result.paymentHash);
console.log("Status:", result.status);3. Check Channel Balance
# Node 1
./fnn-cli channel list_channels
# Node 2
./fnn-cli --url http://127.0.0.1:8237 channel list_channelscurl --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 1000curl --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"
}]
}'await sdk.shutdownChannel({
channelId: "<channel_id>",
closeScript: {
codeHash: "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8",
hashType: "type",
args: "<your_lock_arg>",
},
feeRate: "0x3e8",
});
console.log("Channel closed.");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: CLI uses base units (e.g.
10= 10 RUSD); RPC uses hex strings (e.g."0xa"= 10) - Invoice currency: Use
"Fibt"for testnet, not"RUSD" - auto_accept_amount: Channels with funding below this value require manual acceptance
- Channel state format: The
list_channelsresponse returns state as a nested object:state.state_name(e.g."ChannelReady") andstate.state_flags(e.g.["PublicChannel"]) - Gossip sync time: After starting nodes, wait for the gossip protocol to fully sync the network graph before attempting multi-hop payments. This may take several minutes depending on network conditions.