Fiber LogoFiber Docs

Multi-hop Transfers

Route Testnet payments through Fiber's public relay nodes

Requirements
Updated 8/17/2026
latest

TL;DR

Run two local v0.9.1 nodes, connect each one to a different public Testnet relay, open two public channels, and pay an invoice from Node A to Node B. Neither local node needs a public IP.

┌────────┐       ┌────────┐       ┌────────┐       ┌────────┐
│ Node A │ ────▶ │ bottle │ ────▶ │ bracer │ ────▶ │ Node B │
│ :8227  │       │ public │       │ public │       │ :8237  │
└────────┘       └────────┘       └────────┘       └────────┘
  sender           relay 1          relay 2          receiver

Fiber supports two routing modes:

  • Gossip routing: Node A learns the network graph and builds the route.
  • Trampoline routing: Node A delegates route selection to a relay, useful for clients without a complete graph.

Prerequisites

  • Complete Basic Transfer first.
  • Use the v0.9.1 fnn, fnn-cli, and config/testnet/config.yml files from the same release package.
  • Fund each local node with at least 561 Testnet CKB: 499 CKB for the channel, about 61 CKB for a change cell, and a small transaction-fee margin.

Testnet CKB has no real-world value, but opening a channel still creates an on-chain transaction and temporarily locks the funds. Use throwaway Testnet accounts and close both channels when the test is complete.

Public Testnet Relays

RelayPubkeyNative TCP address
bottle02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71/dns4/bottle.fiber.channel/tcp/8119/p2p/QmXen3eUHhywmutEzydCsW4hXBoeVmdET2FJvMX69XJ1Eo
bracer0291a6576bd5a94bd74b27080a48340875338fff9f6d6361fe6b8db8d0d1912fcc/dns4/bracer.fiber.channel/tcp/8119/p2p/QmbKyzq9qUmymW2Gi8Zq7kKVpPiNA1XUJ6uMvsUC4F3p89

Browser/WASM clients must use the corresponding /tcp/443/wss/ addresses listed in Network Resources. Native fnn uses the TCP addresses above.

1. Prepare Two Local Nodes

Create Node A with the default ports. Create Node B from a second copy of the release config and change its ports:

mkdir -p nodeA/ckb nodeB/ckb
cp config/testnet/config.yml nodeA/config.yml
cp config/testnet/config.yml nodeB/config.yml

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

Export a different CKB private key into nodeA/ckb/key and nodeB/ckb/key. Each file must contain exactly 64 hex characters without a 0x prefix and should be readable only by its owner:

chmod 600 nodeA/ckb/key nodeB/ckb/key

Start the nodes in separate terminals:

# Terminal 1
FIBER_SECRET_KEY_PASSWORD='choose-a-strong-password-a' \
  RUST_LOG=info ./fnn -c nodeA/config.yml -d nodeA

# Terminal 2
FIBER_SECRET_KEY_PASSWORD='choose-a-strong-password-b' \
  RUST_LOG=info ./fnn -c nodeB/config.yml -d nodeB

If a system-wide proxy intercepts local RPC traffic, set NO_PROXY=127.0.0.1,localhost.

2. Connect Each Node to a Relay

Connect Node A to bottle and Node B to bracer.

The CCC examples use one client for each local RPC endpoint:

import { FiberSDK } from "@ckb-ccc/fiber";

const nodeA = new FiberSDK({ endpoint: "http://127.0.0.1:8227" });
const nodeB = new FiberSDK({ endpoint: "http://127.0.0.1:8237" });
# Node A → bottle
./fnn-cli --url http://127.0.0.1:8227 peer connect_peer \
  --address '/dns4/bottle.fiber.channel/tcp/8119/p2p/QmXen3eUHhywmutEzydCsW4hXBoeVmdET2FJvMX69XJ1Eo' \
  --pubkey 02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71 \
  --save true

# Node B → bracer
./fnn-cli --url http://127.0.0.1:8237 peer connect_peer \
  --address '/dns4/bracer.fiber.channel/tcp/8119/p2p/QmbKyzq9qUmymW2Gi8Zq7kKVpPiNA1XUJ6uMvsUC4F3p89' \
  --pubkey 0291a6576bd5a94bd74b27080a48340875338fff9f6d6361fe6b8db8d0d1912fcc \
  --save true
# Node A → bottle
curl -s http://127.0.0.1:8227 \
  -H 'Content-Type: application/json' \
  -d '{
    "id": 1, "jsonrpc": "2.0", "method": "connect_peer",
    "params": [{
      "address": "/dns4/bottle.fiber.channel/tcp/8119/p2p/QmXen3eUHhywmutEzydCsW4hXBoeVmdET2FJvMX69XJ1Eo",
      "pubkey": "02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71",
      "save": true
    }]
  }'

# Node B → bracer
curl -s http://127.0.0.1:8237 \
  -H 'Content-Type: application/json' \
  -d '{
    "id": 1, "jsonrpc": "2.0", "method": "connect_peer",
    "params": [{
      "address": "/dns4/bracer.fiber.channel/tcp/8119/p2p/QmbKyzq9qUmymW2Gi8Zq7kKVpPiNA1XUJ6uMvsUC4F3p89",
      "pubkey": "0291a6576bd5a94bd74b27080a48340875338fff9f6d6361fe6b8db8d0d1912fcc",
      "save": true
    }]
  }'
await nodeA.connectPeer({
  address: "/dns4/bottle.fiber.channel/tcp/8119/p2p/QmXen3eUHhywmutEzydCsW4hXBoeVmdET2FJvMX69XJ1Eo",
  pubkey: "02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71",
  save: true,
});

await nodeB.connectPeer({
  address: "/dns4/bracer.fiber.channel/tcp/8119/p2p/QmbKyzq9qUmymW2Gi8Zq7kKVpPiNA1XUJ6uMvsUC4F3p89",
  pubkey: "0291a6576bd5a94bd74b27080a48340875338fff9f6d6361fe6b8db8d0d1912fcc",
  save: true,
});

Run peer list_peers on both local RPC endpoints and confirm that the expected relay pubkey appears.

3. Open Both Public Channels

The documented public relays auto-accept a channel funded with at least 499 CKB. In shannons, 499 CKB is 49900000000; its RPC hex quantity is 0xb9e459300.

# Node A ↔ bottle
./fnn-cli --url http://127.0.0.1:8227 channel open_channel \
  --pubkey 02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71 \
  --funding-amount 49900000000 \
  --public true

# Node B ↔ bracer
./fnn-cli --url http://127.0.0.1:8237 channel open_channel \
  --pubkey 0291a6576bd5a94bd74b27080a48340875338fff9f6d6361fe6b8db8d0d1912fcc \
  --funding-amount 49900000000 \
  --public true
# Node A ↔ bottle
curl -s http://127.0.0.1:8227 \
  -H 'Content-Type: application/json' \
  -d '{
    "id": 2, "jsonrpc": "2.0", "method": "open_channel",
    "params": [{
      "pubkey": "02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71",
      "funding_amount": "0xb9e459300",
      "public": true
    }]
  }'

# Node B ↔ bracer
curl -s http://127.0.0.1:8237 \
  -H 'Content-Type: application/json' \
  -d '{
    "id": 2, "jsonrpc": "2.0", "method": "open_channel",
    "params": [{
      "pubkey": "0291a6576bd5a94bd74b27080a48340875338fff9f6d6361fe6b8db8d0d1912fcc",
      "funding_amount": "0xb9e459300",
      "public": true
    }]
  }'
await nodeA.openChannel({
  pubkey: "02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71",
  fundingAmount: "0xb9e459300",
  public: true,
});

await nodeB.openChannel({
  pubkey: "0291a6576bd5a94bd74b27080a48340875338fff9f6d6361fe6b8db8d0d1912fcc",
  fundingAmount: "0xb9e459300",
  public: true,
});

Poll both nodes until their channels report state.state_name: "ChannelReady":

./fnn-cli --url http://127.0.0.1:8227 channel list_channels
./fnn-cli --url http://127.0.0.1:8237 channel list_channels

A channel can become ready before its announcement has propagated through the network. If the first payment reports Failed to build route, wait a few minutes and try again.

4. Create an Invoice on Node B

Create a 1 CKB invoice on the receiver's local RPC endpoint. 100000000 shannons equals 1 CKB.

./fnn-cli --url http://127.0.0.1:8237 invoice new_invoice \
  --amount 100000000 \
  --currency Fibt \
  --description "multi-hop test" \
  --expiry 3600
curl -s http://127.0.0.1:8237 \
  -H 'Content-Type: application/json' \
  -d '{
    "id": 4, "jsonrpc": "2.0", "method": "new_invoice",
    "params": [{
      "amount": "0x5f5e100",
      "currency": "Fibt",
      "description": "multi-hop test",
      "expiry": "0xe10"
    }]
  }'
const { invoiceAddress } = await nodeB.newInvoice({
  amount: "0x5f5e100",
  currency: "Fibt",
  description: "multi-hop test",
  expiry: "0xe10",
  paymentPreimage: "0x" + Array.from(
    crypto.getRandomValues(new Uint8Array(32)),
    (byte) => byte.toString(16).padStart(2, "0"),
  ).join(""),
});
console.log("Invoice:", invoiceAddress);

The CLI and RPC generate a random payment preimage when neither payment_preimage nor payment_hash is supplied.

5. Send and Verify the Payment

Gossip routing

No routing parameter is required:

./fnn-cli --url http://127.0.0.1:8227 payment send_payment \
  --invoice '<invoice_address>'

Trampoline routing

To delegate route finding to bottle, pass its pubkey as the trampoline hop:

./fnn-cli --url http://127.0.0.1:8227 payment send_payment \
  --invoice '<invoice_address>' \
  --trampoline-hops 02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71

The equivalent RPC field is "trampoline_hops": ["02b6...be71"]; the CCC field is trampolineHops.

Payments are asynchronous

send_payment may return Created or Inflight. Copy its payment_hash and poll until the status is Success or Failed.

./fnn-cli --url http://127.0.0.1:8227 payment get_payment \
  --payment-hash <payment_hash>

After Success, compare channel list_channels on Node A and Node B. Node A's local balance decreases by the invoice amount plus relay fees; Node B's local balance increases by the invoice amount.

6. Close Both Channels

Get each channel_id from list_channels and each complete default_funding_lock_script from info. Request a cooperative close while both relay connections are online:

# Close Node A's channel on port 8227.
./fnn-cli --url http://127.0.0.1:8227 channel shutdown_channel \
  --channel-id <node_a_channel_id> \
  --close-script '<node_a_default_funding_lock_script_json>' \
  --fee-rate 1000 \
  --force false

# Close Node B's channel on port 8237.
./fnn-cli --url http://127.0.0.1:8237 channel shutdown_channel \
  --channel-id <node_b_channel_id> \
  --close-script '<node_b_default_funding_lock_script_json>' \
  --fee-rate 1000 \
  --force false

Wait for both closing transactions to confirm before deleting either data directory. Use force: true only if the peer is unavailable; a force close locks funds until the commitment delay passes.

Next Steps