Transfer Stablecoins
Learn how to transfer stablecoins between nodes
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
- Completed Basic Transfer
- curl for RPC calls
- ckb-cli for key management
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 --versionThe 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-cli2. Configure Two Nodes
for node in node1 node2; do
mkdir $node
cp fnn $node/
cp fnn-cli $node/
cp config/testnet/config.yml $node/
doneIf 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/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/faucet (claim through a wallet such as JoyID Testnet, then transfer it to the node address shown by
ckb-cli util key-info --privkey-path ./ckb/key)
3. Configure Ports
- Node 1: RPC
8227, P2P8228 - Node 2: RPC
8237, P2P8238
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.ymlThe 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 falsecurl --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_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}`);
}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_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 1000 \
--force falsecurl --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, while2000000000="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 decimal20would mean only 20 base units (0.00000020 RUSD). Channels below the configured raw 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"])
Next Steps
- Multi-hop Transfers — route a payment through public relay nodes
- Network Resources — find public nodes, faucets, and explorers