Multi-hop Transfers
Route Testnet payments through Fiber's public relay nodes
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 receiverFiber 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, andconfig/testnet/config.ymlfiles 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
| Relay | Pubkey | Native TCP address |
|---|---|---|
| bottle | 02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71 | /dns4/bottle.fiber.channel/tcp/8119/p2p/QmXen3eUHhywmutEzydCsW4hXBoeVmdET2FJvMX69XJ1Eo |
| bracer | 0291a6576bd5a94bd74b27080a48340875338fff9f6d6361fe6b8db8d0d1912fcc | /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.ymlExport 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/keyStart 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 nodeBIf 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_channelsA 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 3600curl -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 02b6d4e3ab86a2ca2fad6fae0ecb2e1e559e0b911939872a90abdda6d20302be71The 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 falseWait 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
- Network Resources — current relay addresses, faucets, explorers, and dashboards
- Operate a Node — backups, monitoring, and production configuration