Fiber LogoFiber Docs

Multi-hop Transfers

Guide to sending multi-hop payments through the Fiber Network

Requirements
Updated 6/17/2026
latest

TL;DR

Connect your local node to Fiber's public relay nodes, open a channel, and send multi-hop payments through the Testnet. Fiber supports two routing modes: gossip routing (source routing, the sender builds the full path) and trampoline routing (the sender delegates path-finding to a relay node). Your local node does not need a public IP.

┌───────┐        ┌───────┐        ┌───────┐
│ nodeA │ ─────▶ │ node1 │ ─────▶ │ node2 │
│ local │        │public │        │public │
└───────┘        └───────┘        └───────┘
  sender          relay            receiver

Public Node Addresses

Mainnet

NodePubkey
node103a8d7da8d0934363dbc17f52c872e8d833016415266eabb3527439c5dd17adc6b
node2033a69e5be369dab43aefa96fa729d83c571ccb066f312136c6ab2d354fcc028f9

Testnet

NodePubkey
node102b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71
node20291a6576bd5a94bd74b27080a48340875338fff9f6d6361fe6b8db8d0d1912fcc

Local Node Setup

  1. Download fnn from the releases page:

    mkdir tmp && cd tmp
    tar xzvf fnn-latest.tar.gz

    macOS Security

    xattr -d com.apple.quarantine fnn fnn-cli
  2. Create account and export private key:

    mkdir -p testnet-fnn/nodeA/ckb
    ./ckb-cli account new           # save the lock_arg
    ./ckb-cli account export --lock-arg <YOUR_LOCK_ARG> --extended-privkey-path ./exported-key
    head -n 1 ./exported-key > testnet-fnn/nodeA/ckb/key
    chmod 600 testnet-fnn/nodeA/ckb/key
  3. Copy config and tools:

    cp config/testnet/config.yml testnet-fnn/nodeA
    cp fnn-cli testnet-fnn/nodeA

    HTTP Proxy Issues

    export NO_PROXY=127.0.0.1,localhost
  4. Fund nodeA's address with 10,000 CKB:

  5. Start nodeA:

    FIBER_SECRET_KEY_PASSWORD='123' RUST_LOG=info ./fnn -c testnet-fnn/nodeA/config.yml -d testnet-fnn/nodeA > testnet-fnn/nodeA/a.log 2>&1 &

CKB Multi-Hop Payment (nodeA → node1 → node2)

CLI amounts are in shannons (1 CKB = 100,000,000). RPC amounts are hex strings (e.g. "0x5f5e100" = 100,000,000).

1. Connect to node1

First get node1's pubkey:

curl -s 'http://18.162.235.225:8227' \
  -H 'Content-Type: application/json' \
  -d '{"id":1,"jsonrpc":"2.0","method":"node_info"}' | grep -o '"pubkey":"[^"]*"'

Then connect:

cd testnet-fnn/nodeA && ./fnn-cli peer connect_peer \
  --pubkey 02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71
curl -s 'http://127.0.0.1:8227' \
  -H 'Content-Type: application/json' \
  -d '{
    "id": 1, "jsonrpc": "2.0", "method": "connect_peer",
    "params": [{"pubkey": "<node1_pubkey>"}]
  }'
await sdk.connectPeer({
  address: "/ip4/18.162.235.225/tcp/8119/p2p/QmXen3eUHhywmutEzydCsW4hXBoeVmdET2FJvMX69XJ1Eo",
});

2. Open a CKB Channel

node1's auto_accept_min_ckb_funding_amount is set to 400 CKB. Here we fund 500 CKB.

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

3. Wait for ChannelReady

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

After ChannelReady, wait a few minutes before sending payments — the gossip protocol needs time to sync channel announcements and routing info across the network.

4. Create an Invoice on node2

Generate a preimage: payment_preimage="0x$(openssl rand -hex 32)"

./fnn-cli --url http://18.163.221.211:8227 invoice new_invoice \
  --amount 100000000 \
  --currency Fibt \
  --description "test invoice" \
  --expiry 3600 \
  --payment-preimage $payment_preimage
curl -s 'http://18.163.221.211:8227' \
  -H 'Content-Type: application/json' \
  -d "{
    \"id\": 4, \"jsonrpc\": \"2.0\", \"method\": \"new_invoice\",
    \"params\": [{
      \"amount\": \"0x5f5e100\",
      \"currency\": \"Fibt\",
      \"description\": \"test invoice\",
      \"expiry\": \"0xe10\",
      \"payment_preimage\": \"$payment_preimage\",
      \"hash_algorithm\": \"sha256\"
    }]
  }"
// Create invoice on node2 (endpoint: http://18.163.221.211:8227)
const node2 = new FiberSDK({ endpoint: "http://18.163.221.211:8227" });
const paymentPreimage = "0x" + "01".repeat(32);
const { invoiceAddress } = await node2.newInvoice({
  amount: "0x5f5e100",
  currency: "Fibt",
  description: "test invoice",
  expiry: "0xe10",
  paymentPreimage,
  hashAlgorithm: "sha256",
});
console.log("Invoice:", invoiceAddress);

5. Send Payment (nodeA → node1 → node2)

Fiber supports two routing modes for multi-hop payments:

Option A: Gossip Routing (Source Routing)

The sender discovers routes via the gossip protocol and constructs the full payment path locally. No additional parameters are needed — the node automatically finds the best route to the destination.

After ChannelReady, wait a few minutes for gossip to propagate channel announcements across the network before attempting gossip-routed payments.

./fnn-cli payment send_payment --invoice "<invoice_address>"
curl -s 'http://127.0.0.1:8227' \
  -H 'Content-Type: application/json' \
  -d '{"id": 6, "jsonrpc": "2.0", "method": "send_payment", "params": [{"invoice": "<invoice_address>"}]}'
const result = await sdk.sendPayment({ invoice: "<invoice_address>" });
console.log("Payment hash:", result.paymentHash);
console.log("Status:", result.status);

Option B: Trampoline Routing

The sender delegates route-finding to a relay node. This is useful when the sender doesn't have full network topology (e.g. a mobile wallet). Specify trampoline_hops with the relay node's pubkey.

./fnn-cli payment send_payment --invoice "<invoice_address>" \
  --trampoline-hops <node1_pubkey>
curl -s 'http://127.0.0.1:8227' \
  -H 'Content-Type: application/json' \
  -d '{"id": 6, "jsonrpc": "2.0", "method": "send_payment", "params": [{"invoice": "<invoice_address>", "trampoline_hops": ["<node1_pubkey>"]}]}'
const result = await sdk.sendPayment({
  invoice: "<invoice_address>",
  trampolineHops: ["<node1_pubkey>"],
});
console.log("Payment hash:", result.paymentHash);
console.log("Status:", result.status);

6. Close the Channel

./fnn-cli channel shutdown_channel \
  --channel-id <channel_id> \
  --close-script '{"code_hash":"0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8","hash_type":"type","args":"<your_lock_arg>"}'
curl -s 'http://127.0.0.1:8227' \
  -H 'Content-Type: application/json' \
  -d '{
    "id": 9, "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": "0x3FC"
    }]
  }'
await sdk.shutdownChannel({
  channelId: "<channel_id>",
  closeScript: {
    codeHash: "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8",
    hashType: "type",
    args: "<your_lock_arg>",
  },
  feeRate: "0x3FC",
});
console.log("Channel closed.");