Fiber LogoFiber Docs

Basic Transfer Example

Learn how to set up and execute transfers between two nodes

Requirements
Updated 8/17/2026
latest

TL;DR

Set up two local nodes, open a channel between them, and send a CKB payment from one to the other. The whole process takes about 10 minutes.

Overview

This guide walks you through setting up and executing a basic token (CKB) transfer between two nodes on the Fiber Testnet.

Prerequisites

  • Git (if building from source)
  • Rust and Cargo (if building from source)
  • Basic understanding of command line operations
  • curl for making RPC calls
  • ckb-cli for generating keys

Setting Up Your Nodes

1. Prepare Fiber Binary

Download and extract the v0.9.0 portable archive as described in Run a Native Node. The extracted directory contains fnn, fnn-cli, and config/testnet/config.yml.

Alternatively, build the tagged source release:

git clone --branch v0.9.0 --depth 1 https://github.com/nervosnetwork/fiber.git
cd fiber
cargo build --release

macOS Security

If you're using macOS, remove the quarantine attribute:

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

HTTP Proxy Issues

If you encounter 503 errors when using fnn-cli, run:

export NO_PROXY=127.0.0.1,localhost

2. Create Data Directories

# For Node 1
mkdir -p node1
cp fnn fnn-cli node1/
cp config/testnet/config.yml node1/config.yml

# For Node 2
mkdir -p node2
cp fnn fnn-cli node2/
cp config/testnet/config.yml node2/config.yml

If you built from source, copy target/release/fnn and target/release/fnn-cli instead.

3. Configure Node Keys

Each node needs its own private key. Create two separate CKB accounts:

ckb-cli account new  # for Node 1
ckb-cli account new  # for Node 2

Export the keys:

# In node1 directory
mkdir -p ckb
ckb-cli account export --lock-arg <node1_lock_arg> --extended-privkey-path ./ckb/exported-key
sed '1s/^0x//' ./ckb/exported-key > ./ckb/key
chmod 600 ./ckb/key

# In node2 directory
mkdir -p ckb
ckb-cli account export --lock-arg <node2_lock_arg> --extended-privkey-path ./ckb/exported-key
sed '1s/^0x//' ./ckb/exported-key > ./ckb/key
chmod 600 ./ckb/key

Key File Format

The ckb/key file must contain only the 64-character hex private key (first line), without 0x prefix.

Get Testnet funds from https://faucet.nervos.org for both nodes.

4. Configure Ports

Edit config.yml for each node:

  • Node 1: RPC Port 8227, P2P Port 8228
  • Node 2: RPC Port 8237, P2P Port 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 expandable block below shows the essential Testnet settings for reference. Prefer the release config as your source so deployment scripts and dependencies stay current.

View complete config.yml example
fiber:
  listening_addr: "/ip4/0.0.0.0/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: 1000000000
services:
  - fiber
  - rpc
  - ckb

Step-by-Step Transfer Process

1. Start Both Nodes

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

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

2. Connect the Nodes

First get Node 2's pubkey, then connect from Node 1:

# Get Node 2's pubkey
cd node2 && ./fnn-cli --url http://127.0.0.1:8237 info | grep pubkey

# Connect from Node 1
cd node1 && ./fnn-cli peer connect_peer \
  --pubkey <node2_pubkey> \
  --address "/ip4/127.0.0.1/tcp/8238" \
  --save false
# Get Node 2's pubkey
curl -s -X POST -H "Content-Type: application/json" \
  -d '{"id":"42","jsonrpc":"2.0","method":"node_info"}' \
  http://localhost:8237 | grep pubkey

# Connect from Node 1
curl -s -X POST -H "Content-Type: application/json" \
  -d '{
    "id": "42", "jsonrpc": "2.0", "method": "connect_peer",
    "params": [{"pubkey": "<node2_pubkey>", "address": "/ip4/127.0.0.1/tcp/8238", "save": false}]
  }' http://localhost:8227
// Get Node 2's info
const node2 = new FiberSDK({ endpoint: "http://127.0.0.1:8237" });
const info = await node2.getNodeInfo();
console.log("Node 2 pubkey:", info.pubkey);

// Connect from Node 1
const sdk = new FiberSDK({ endpoint: "http://127.0.0.1:8227" });
await sdk.connectPeer({
  pubkey: "<node2_pubkey>",
  address: "/ip4/127.0.0.1/tcp/8238",
  save: false,
});

3. Open a Payment Channel

# funding-amount is in shannons (50000000000 = 500 CKB)
cd node1 && ./fnn-cli channel open_channel \
  --pubkey <node2_pubkey> \
  --funding-amount 50000000000 \
  --public true
# funding_amount in hex (0xba43b7400 = 500 CKB)
curl -s -X POST -H "Content-Type: application/json" \
  -d '{
    "id": "42", "jsonrpc": "2.0", "method": "open_channel",
    "params": [{"pubkey": "<node2_pubkey>", "funding_amount": "0xba43b7400", "public": true}]
  }' http://localhost:8227
// funding_amount: 0xba43b7400 = 500 CKB in shannons
const tempChannelId = await sdk.openChannel({
  pubkey: "<node2_pubkey>",
  fundingAmount: "0xba43b7400",
  public: true,
});
console.log("Temporary channel ID:", tempChannelId);

Check channel status — wait until state_name becomes ChannelReady:

./fnn-cli channel list_channels

# Optional: show only channel-opening attempts while waiting
./fnn-cli channel list_channels --only-pending true
curl -s -X POST -H "Content-Type: application/json" \
  -d '{"id":"42","jsonrpc":"2.0","method":"list_channels","params":[{}]}' \
  http://localhost:8227
const channels = await sdk.listChannels();
for (const ch of channels) {
  console.log(`${ch.channelId} — ${ch.state.stateName} — local: ${ch.localBalance}`);
}

4. Generate an Invoice

Create a payment invoice on Node 2 for 100 CKB:

# amount in shannons (10000000000 = 100 CKB)
cd node2 && ./fnn-cli --url http://127.0.0.1:8237 invoice new_invoice \
  --amount 10000000000 \
  --currency Fibt \
  --description "test invoice"
curl -s -X POST -H "Content-Type: application/json" \
  -d '{
    "id": "42", "jsonrpc": "2.0", "method": "new_invoice",
    "params": [{
      "amount": "0x2540be400",
      "currency": "Fibt",
      "description": "test invoice",
      "expiry": "0xe10"
    }]
  }' http://localhost:8237
// amount: 0x2540be400 = 100 CKB in shannons
const paymentPreimage = "0x" + Array.from(
  crypto.getRandomValues(new Uint8Array(32)),
  (byte) => byte.toString(16).padStart(2, "0"),
).join("");
const { invoiceAddress } = await node2.newInvoice({
  amount: "0x2540be400",
  currency: "Fibt",
  description: "test invoice",
  expiry: "0xe10",
  paymentPreimage,
});
console.log("Invoice:", invoiceAddress);

5. Make the Payment

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

# send_payment may initially return Created; poll with its payment_hash
./fnn-cli payment get_payment --payment-hash <payment_hash>
curl -s -X POST -H "Content-Type: application/json" \
  -d '{
    "id": "42", "jsonrpc": "2.0", "method": "send_payment",
    "params": [{"invoice": "fibt100000000001p..."}]
  }' http://localhost:8227

# Poll the returned payment_hash until status is Success or Failed
curl -s -X POST -H "Content-Type: application/json" \
  -d '{
    "id": "43", "jsonrpc": "2.0", "method": "get_payment",
    "params": [{"payment_hash": "<payment_hash>"}]
  }' http://localhost:8227
const result = await sdk.sendPayment({ invoice: invoiceAddress });
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);

Payments are asynchronous

send_payment can return Created or Inflight before the transfer finishes. Treat the payment as complete only after get_payment reports Success; inspect failed_error when it reports Failed.

6. Verify the Transfer

Check channel balances to confirm the transfer:

# Node 1
./fnn-cli channel list_channels

# Node 2
./fnn-cli --url http://127.0.0.1:8237 channel list_channels
curl -s -X POST -H "Content-Type: application/json" \
  -d '{"id":"42","jsonrpc":"2.0","method":"list_channels","params":[{}]}' \
  http://localhost:8227
// 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}`);
}

After sending 100 CKB from Node 1, Node 1's local_balance decreases by 100 CKB and remote_balance increases by 100 CKB.

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 -s -X POST -H "Content-Type: application/json" \
  -d '{
    "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
    }]
  }' http://localhost:8227
await sdk.shutdownChannel({
  channelId: "<channel_id>",
  closeScript: {
    codeHash: "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8",
    hashType: "type",
    args: "<your_lock_arg>",
  },
  feeRate: "0x3e8",
  force: false,
});
console.log("Cooperative close requested.");

Prefer a cooperative close

Get the complete close_script from fnn-cli info under default_funding_lock_script. A cooperative close (force: false) is fast and requires both peers to be online. Use force: true only when the peer is unavailable; a force close uses the channel's saved close parameters and locks funds until the commitment delay passes.

Next Steps