Send a Multi-Hop Invoice Payment
You control Sender A and Receiver C in this browser. Bottle B is the public node that forwards payments between them.
Behind the scenes
Follow the live source that prepares two nodes, creates an invoice, and confirms a payment through Bottle.
Why use multi-hop payments?
Imagine paying a shop for the first time or using a paid service you have never connected to before. Opening and funding a new channel with every recipient would tie up funds and require waiting for on-chain confirmation. Multi-hop payments let you reach a recipient through existing channels, provided the route has enough available liquidity.
In this tutorial, Sender A pays Receiver C through Bottle B. C creates an invoice requesting a specified amount of CKB, A submits it for payment, and Bottle forwards it. The payment redistributes liquidity across two channels without requiring a direct A–C channel or a new on-chain transaction for each payment. The sections below explain the code behind each operation.
Start a separate node for the sender and receiver
useFiberRoutingNode() creates a runtime for each node, and prepareNodes() starts both nodes. Each node has its own identity, keys, and storage; A and C do not share private node state. If saved profiles are detected, the tutorial reuses those identities.
A and C each open a separate public channel with Bottle. A public channel can be announced to the network for routing; it does not make the node’s private keys or secret payment data public. Connecting to Bottle and opening a channel are separate operations. connectAndOpen()connects the selected node to Bottle, and openChannel() reuses an existing channel from a saved node profile, if available, or opens a new public channel funded with the specified amount of CKB. The runtime also reconnects known peers when restoring channels.
Both channels must reach CHANNEL_READY before paying. A needs enough available liquidity toward Bottle, and Bottle needs enough available liquidity toward C. Funding C’s side does not automatically provide Bottle with the liquidity needed to forward a payment to C. For funding, opening a channel, and understanding its lifecycle, see Open a channel and send a payment.
Choose an amount the route can carry
invoiceAmountError() checks for a positive amount with at most eight decimal places. The invoice limit is the smaller of A’s available liquidity minus the 1 CKB routing-fee budget, and Bottle’s available liquidity toward C (the remote side of C’s channel). Pending outgoing transfers on A and incoming transfers on C are excluded. However, these liquidity checks do not guarantee route availability at payment time. Liquidity and node connectivity can change before the payment is sent.
If receiving liquidity runs low, Restore receiving liquidity lets C return funds through Bottle to A using the same channels. This increases Bottle’s liquidity toward C, allowing C to receive more payments.
The return amount defaults to 1 CKB for this campaign and can be changed.
Returning funds incurs routing fees and redistributes liquidity between the existing channels. It does not add new funds.
Create the invoice on Receiver C
createInvoice() uses the amount entered in the form and calls newInvoice() through Receiver C’s runtime. The response contains the encoded payment request in invoice_address. The component saves it in generatedInvoice and saves its payment hash for status checks.
invoice_address contains the long fibt... string shown in the tutorial. The prefix fibtidentifies a Fiber Testnet invoice. Note that invoice_address is not a CKB wallet address. It encodes payment details, including the amount, expiry, and payment hash, for the sender to use.
ckbToHex(amount) converts CKB to an amount in shannons, encoded as hexadecimal for the SDK (1 CKB = 100,000,000 shannons). currency: 'Fibt' selects Testnet, expiry: '0xe10' sets a one-hour expiry, and allow_trampoline_routing: true allows trampoline routing: the sender delegates onward route finding to another node. Here, that node is Bottle B. Creating an invoice requests payment; it does not transfer funds. The tutorial then opens the payment review step for A.
C keeps a secret called the preimage. Its hash is included in the invoice, while the secret itself is not. Conditional transfers along the route use this hash and timeouts to coordinate settlement or recovery if payment cannot complete. C reveals the preimage to settle the payment.
Pass the generated invoice to Sender A
The review screen displays generatedInvoice as read-only text alongside the payment amount and maximum routing fee. The tutorial passes C’s invoice directly to A; no paste step is needed.
createForwardPayment() creates the tutorial’s payment controller, which saves payment state and resumes status checks after a reload. When Send payment is clicked, payInvoice() asks this controller to submit the saved invoice.
The invoice carries the payment request between the nodes; it does not move funds by itself. Applications with separate sender and receiver interfaces can deliver the same encoded request through a QR code, chat, or another text transport.
components/tutorials/fiber-multi-hop-invoice.tsx · lines 488–498Use Bottle as the single public intermediary
A selects Bottle B as its trampoline node to route the payment to C across the two channels.
payInvoice() asks the payment controller to call sendPayment() on A with C’s saved invoice. trampoline_hops: [bottlePeer.pubkey] asks Bottle to handle onward routing through the A–Bottle and Bottle–C channels. max_fee_amount: '0x5f5e100' sets a routing-fee budget of 1 CKB in addition to the invoice amount. Trampoline routing can use the full budget. The RPC result’s fee field reports the actual charge shown beside the maximum fee after a successful payment.
In A–Bottle, A’s liquidity decreases and Bottle’s increases. In Bottle–C, Bottle’s liquidity decreases and C’s increases by the invoice amount. Bottle uses its own liquidity to forward the payment, and A covers any routing fee in addition to the amount C receives.
components/tutorials/multi-hop-payment.ts · lines 94–106Poll the node that created the invoice
Once an invoice exists, the payment controller polls Receiver C with getInvoice() every two seconds, using the payment hash to identify the invoice. The response updates invoiceStatusin the live demo and runtime panel. Polling begins before payment so the tutorial can also detect an expired invoice. A brief Received state may occur between checks.
| Status | Meaning |
|---|---|
Open | The invoice is awaiting payment and can be paid. |
Received | A payment has arrived but has not settled yet. |
Paid | The payment has settled and the invoice is paid. |
Cancelled | The invoice has been cancelled and can no longer be paid. |
Expired | The invoice’s expiry time has passed. Create a new invoice to request payment. |
Sender success and a paid invoice complete the flow
multiHopPaymentOutcome() reports success only when A’s payment status is Success and C’s invoice status is Paid. A sender status of Failed produces a failed outcome; otherwise the outcome stays pending. This keeps sender submission separate from confirmed receipt.
Before submitting the payment, the controller saves its invoice, amount, payment hash, and submitted state. After a reload, restore the same browser nodes to resume status checks. The controller never automatically submits the saved payment again. An uncertain submission remains pending until its status can be confirmed.
The payment hash identifies the payment for lookup; it is not standalone proof of receipt. getPayment() on A confirms the sender’s status, while getInvoice() on C confirms receipt. The Check payment status action runs these checks on demand.
If the route has insufficient liquidity, try a smaller amount; either channel can limit the payment. If the invoice has expired, create another one. If waiting times out, check the existing payment’s status before submitting another payment: a timeout alone does not establish that the payment failed.
Wire the two runtimes into React
FiberMultiHopInvoiceTutorial() owns both runtimes and the payment state. amount tracks the editable form value, while invoiceAmount preserves the amount used when the invoice was created. generatedInvoice holds the exact request paid by A. paymentStatus and invoiceStatus track each end independently.
multiHopStep() selects the visible stage from channel readiness, invoice creation, and payment submission. multiHopPaymentOutcome() determines the result. Reviewing an earlier stage changes reviewStep without resetting the payment. resetInvoice() clears the invoice and result only when no payment is pending, allowing another invoice to be created.
Run the complete project locally
Download the complete project. The archive contains this live tutorial’s React component, runtime, styles, article, and code panel in a standalone Next.js application, with the required browser headers. After extracting it, open the multi-hop-invoice directory and run:
npm install
npm run devThen open http://localhost:3000 in your browser.
1'use client';2 3import { useCallback, useEffect, useRef, useState } from 'react';4import Image from 'next/image';5import type { CkbInvoiceStatus } from '@fiber-pay/sdk/browser';6import {7 type RoutingCodeFile,8 type RoutingCodeFocus,9 RoutingTutorialFrame,10} from './routing-tutorial-frame';11import {12 bottlePeer,13 ckbToHex,14 findReusableChannel,15 hexToCkb,16 samePubkey,17 shorten,18 useFiberRoutingNode,19} from './fiber-routing-runtime';20import { channelOpeningProgress, channelStateLabel, isMultiHopChannelReady as isReadyState, multiHopStep, multiHopPaymentOutcome, paymentFailureMessage, paymentErrorDetail, invoiceAmountError, maximumInvoiceAmount, availableChannelBalance } from './multi-hop-progress';21import { participantStatusLabel } from '../../examples/tutorials/verified-result-payment/lib/job';22import { ExplainedTerm } from './explained-term';23import { createForwardPayment, type ForwardPayment } from './multi-hop-payment';24import { CopyValue } from './multi-hop-copy-value';25import { RestoreLiquidityPanel } from './multi-hop-restore-panel';26import styles from './fiber-wasm-quickstart.module.css';27import tutorialSource from './multi-hop-code.generated.json';28import { detectMultiHopProfiles, defaultSenderKey, receiverKey, senderChoiceKey, receiverChoiceKey, profileSetupLabel, type NodeProfileChoice } from './multi-hop-setup';29 30const channelAmount = '499';31const steps = ['Set up', 'Create invoice', 'Pay invoice', 'Outcome'];32 33type ChannelProgressStage =34 | 'idle'35 | 'connecting'36 | 'submitting'37 | 'confirming'38 | 'ready'39 | 'error';40 41// Source snapshot displayed by the article’s code panel.42const codeFiles: RoutingCodeFile[] = tutorialSource.codeFiles;43const sectionCode: Record<string, RoutingCodeFocus> = tutorialSource.sectionCode;44 45function sourceReference(section: string) {46 const focus = sectionCode[section];47 const file = codeFiles.find((file) => file.id === focus.file)!;48 return `${file.label} · lines ${focus.start}–${focus.end}`;49}50 51function progressFromChannelState(state: string): ChannelProgressStage {52 const normalized = state.replace(/[^a-z0-9]/gi, '').toLowerCase();53 if (normalized === 'channelready') return 'ready';54 if (normalized === 'awaitingchannelready') return 'confirming';55 return 'submitting';56}57 58function ChannelLiquidity({ left, right, leftAmount, rightAmount, leftDirection, rightDirection }: {59 left: string;60 right: string;61 leftAmount: bigint | undefined;62 rightAmount: bigint | undefined;63 leftDirection: string;64 rightDirection: string;65}) {66 const known = leftAmount !== undefined && rightAmount !== undefined;67 const total = (leftAmount ?? 0n) + (rightAmount ?? 0n);68 const share = known && total > 0n ? Number(leftAmount * 10_000n / total) / 100 : 0;69 return (70 <div className={styles.multiHopLiquidityChannel} role="group" aria-label={`${left}-${right} channel liquidity`}>71 <div className={styles.multiHopLiquidityTrack} aria-hidden="true">72 <b>{left}</b>73 <div className={styles.multiHopLiquidityBar} data-known={known}>74 <span style={{ width: `${share}%` }} />75 </div>76 <b>{right}</b>77 </div>78 <div className={styles.multiHopLiquidityLabels}>79 <div data-empty={leftAmount === 0n}><span>{leftDirection}</span><strong>{hexToCkb(leftAmount)} CKB</strong></div>80 <div><span>{rightDirection}</span><strong>{hexToCkb(rightAmount)} CKB</strong></div>81 </div>82 </div>83 );84}85 86 87function NodeSetup({88 number, title, profile, runtime, channelState, channelExists, progressStage,89 expanded, locked, onToggle, onConnectAndOpen,90}: {91 number: string;92 title: string;93 profile: NodeProfileChoice | null;94 runtime: ReturnType<typeof useFiberRoutingNode>;95 channelState: string;96 channelExists: boolean;97 progressStage: ChannelProgressStage;98 expanded: boolean;99 locked: boolean;100 onToggle: () => void;101 onConnectAndOpen: () => Promise<void>;102}) {103 const [openingRequested, setOpeningRequested] = useState(false);104 const hasFunding = (runtime.balance ?? 0n) >= BigInt(ckbToHex(channelAmount));105 const channelReady = isReadyState(channelState);106 const lifecycle = channelOpeningProgress(channelExists ? channelState : undefined);107 const pending = ['connecting', 'submitting', 'confirming'].includes(progressStage);108 const inAnotherTab = runtime.error.includes('another tutorial tab');109 const status = inAnotherTab ? 'In use in another tab' : channelExists ? channelStateLabel(channelState)110 : progressStage === 'error' ? 'Try again'111 : pending ? 'Waiting for channel state'112 : runtime.busy === 'start' ? 'Starting node…'113 : runtime.nodeInfo ? 'Node running' : 'Waiting for nodes';114 const tone = inAnotherTab ? 'error' : channelReady || (runtime.nodeInfo && !pending && progressStage !== 'error')115 ? 'success' : pending || runtime.busy === 'start' ? 'waiting' : 'idle';116 const channelRequirement = !runtime.nodeInfo ? 'Prepare the browser node first.'117 : !runtime.address ? 'Wait for the funding address.'118 : channelReady ? null119 : pending || channelExists ? 'Wait for CHANNEL_READY before paying.'120 : !hasFunding ? 'Not enough Testnet CKB to open this channel.'121 : runtime.busy ? 'Wait for the current action to finish.' : null;122 const channelAction = (123 <button124 className={`${styles.channelButton} ${styles.demoAction} ${hasFunding && !channelExists && !pending ? styles.demoPrimaryAction : ''}`}125 disabled={!runtime.nodeInfo || !runtime.address || channelExists || pending || !hasFunding || Boolean(runtime.busy)}126 onClick={() => {127 setOpeningRequested(true);128 void onConnectAndOpen();129 }}130 type="button"131 >132 {channelReady ? 'CHANNEL_READY' : 'Open channel'}133 </button>134 );135 136 return (137 <section className={styles.verifiedSetupDisclosure} data-expanded={expanded}>138 <button139 aria-controls={`multi-hop-node-${number}`}140 aria-expanded={expanded}141 className={styles.verifiedSetupDisclosureHeader}142 disabled={locked}143 onClick={onToggle}144 type="button"145 >146 <span className={styles.verifiedSetupDisclosureNumber}>{number}</span>147 <span className={styles.verifiedSetupDisclosureTitle}>148 <strong>{title}</strong>149 <small>{profile ? profileSetupLabel(profile, Boolean(runtime.nodeInfo)) : 'Checking saved profile…'}150 {runtime.nodeInfo ? channelReady ? ' · Channel is ready.' : channelExists ? ' · Channel opening; wait for confirmation.' : hasFunding ? ' · Funded; open a channel with Bottle.' : ' · Fund this node, then open a channel.' : profile?.saved ? ' · Start to check its funding and channel liquidity.' : ' · A separate identity will be created when you start.'}</small>151 </span>152 <span className={styles.verifiedSetupDisclosureStatus} data-tone={tone}>153 <i className={`${styles.statusDot} ${tone === 'success' ? styles.statusSuccess : tone === 'waiting' ? styles.statusWaiting : tone === 'error' ? styles.statusError : styles.statusIdle}`} />154 <b>{status}</b>155 </span>156 <i aria-hidden="true" className={styles.verifiedSetupChevron} />157 </button>158 {expanded && <div className={styles.verifiedSetupDisclosureBody} id={`multi-hop-node-${number}`}>159 <div className={styles.verifiedSetupTask}>160 <div className={styles.paymentFlowBody}>161 <strong>Fund the Testnet address</strong>162 {number === "3" && <span>C needs on-chain CKB to open its channel. Receiving payments also requires liquidity on Bottle’s side of that channel.</span>}163 <div className={styles.addressLine}>164 <code title={runtime.address}>{shorten(runtime.address)}</code>165 <CopyValue value={runtime.address} label="Copy address" iconOnly />166 </div>167 <span>On-chain balance: <b>{hexToCkb(runtime.balance)} CKB</b>. Allow more than 499 CKB for funding and fees.</span><span>Faucet CKB may take up to 30s to confirm on-chain.</span>168 </div>169 <div className={styles.fundingActions}>170 <a className={`${styles.faucetButton} ${styles.demoAction} ${!hasFunding ? styles.demoPrimaryAction : ''}`} href="https://faucet.nervos.org" rel="noreferrer" target="_blank">Get CKB ↗</a>171 <button className={`${styles.refreshButton} ${styles.demoAction}`} disabled={Boolean(runtime.busy)} onClick={() => void runtime.run('refresh', () => runtime.refresh())} type="button">Refresh</button>172 </div>173 </div>174 <div className={styles.verifiedSetupTask}>175 <div className={styles.paymentFlowBody}>176 <strong>Open a{' '}<ExplainedTerm explanation="The selected public peer requires at least 499 CKB to open a channel. Keep extra Testnet CKB available for the transaction fee." id={`multi-hop-channel-minimum-${number}`}>499 CKB</ExplainedTerm>{' '}channel</strong>177 <label>178 <input aria-label={`${title} channel funding amount in CKB`} disabled readOnly value={channelAmount} />179 <span>CKB</span>180 </label>181 </div>182 {channelRequirement ? <ExplainedTerm ariaLabel="Open channel" className={styles.disabledActionHint} explanation={channelRequirement} id={`multi-hop-channel-requirement-${number}`}>{channelAction}</ExplainedTerm> : channelAction}183 </div>184 {openingRequested && <div className={`${styles.channelTimeline} ${styles.multiHopLifecycle}`}>185 <span>Opening Channel <small>(This may take up to 1 min)</small></span>186 <div>187 {lifecycle.map(({ state, status }, index) => <span aria-current={status === 'current' ? 'step' : undefined} aria-label={`${state}: ${status}`} data-state={status} key={state}>{index > 0 && <i aria-hidden="true">→</i>}<b>{status === 'complete' && <i aria-hidden="true">✓ </i>}{state}</b></span>)}188 </div>189 </div>}190 </div>}191 {runtime.error && <div role="alert" className={styles.nodeChannelError}>{runtime.error}</div>}192 </section>193 );194}195 196export function FiberMultiHopInvoiceTutorial() {197 const [profiles, setProfiles] = useState<ReturnType<typeof detectMultiHopProfiles> | null>(null);198 const [freshStartRequested, setFreshStartRequested] = useState(false);199 const selectionEdited = useRef(false);200 const preparingRef = useRef(false);201 const sender = useFiberRoutingNode(profiles?.sender.key ?? defaultSenderKey);202 const receiver = useFiberRoutingNode(profiles?.receiver.key ?? receiverKey);203 const [amount, setAmount] = useState('1');204 const [restoreBlocked, setRestoreBlocked] = useState(true);205 const [generatedInvoice, setGeneratedInvoice] = useState('');206 const [paymentHash, setPaymentHash] = useState<`0x${string}` | ''>('');207 const [invoiceStatus, setInvoiceStatus] = useState<CkbInvoiceStatus | 'None'>('None');208 const [paymentFee, setPaymentFee] = useState<`0x${string}` | undefined>();209 const [paymentStatus, setPaymentStatus] = useState('Not sent');210 const [route, setRoute] = useState<string[]>([]);211 const [events, setEvents] = useState<string[]>([]);212 const [preparingNodes, setPreparingNodes] = useState(false);213 const [expandedRole, setExpandedRole] = useState<'sender' | 'receiver' | null>(null);214 const [reviewStep, setReviewStep] = useState<number | null>(null);215 const [invoiceAmount, setInvoiceAmount] = useState('1');216 const [paymentSubmitted, setPaymentSubmitted] = useState(false);217 const [paymentError, setPaymentError] = useState('');218 const [paymentErrorDetails, setPaymentErrorDetails] = useState('');219 const [mobileEventsExpanded, setMobileEventsExpanded] = useState(false);220 const [senderChannelProgress, setSenderChannelProgress] =221 useState<ChannelProgressStage>('idle');222 const [receiverChannelProgress, setReceiverChannelProgress] =223 useState<ChannelProgressStage>('idle');224 const forwardPayment = useRef<ReturnType<typeof createForwardPayment> | null>(null);225 const [paymentRecoveryReady, setPaymentRecoveryReady] = useState(false);226 const [paymentRecoveryError, setPaymentRecoveryError] = useState('');227 const [paymentRecovered, setPaymentRecovered] = useState(false);228 const paymentAttempting = useRef(false);229 const stageRef = useRef<HTMLElement | null>(null);230 const previousStep = useRef(0);231 232 const senderChannel = findReusableChannel(233 sender.channels,234 bottlePeer.pubkey,235 );236 const receiverChannel = findReusableChannel(237 receiver.channels,238 bottlePeer.pubkey,239 );240 const senderReady = Boolean(241 senderChannel && isReadyState(senderChannel.state.state_name),242 );243 const receiverReady = Boolean(244 receiverChannel && isReadyState(receiverChannel.state.state_name),245 );246 const nodesPrepared = Boolean(sender.nodeInfo && receiver.nodeInfo);247 const currentStep = multiHopStep({248 setupReady: senderReady && receiverReady,249 hasInvoice: Boolean(generatedInvoice),250 paymentStatus,251 invoiceStatus,252 paymentSubmitted,253 });254 const displayedStep = reviewStep !== null && reviewStep < currentStep ? reviewStep : currentStep;255 const reviewing = displayedStep < currentStep;256 const paymentOutcome = multiHopPaymentOutcome(paymentStatus, invoiceStatus);257 const paymentComplete = paymentOutcome === 'success';258 const rawPaymentError = paymentErrorDetails || (paymentSubmitted ? sender.error : '');259 const paymentPending = paymentSubmitted && paymentOutcome === 'pending';260 const invoiceExpired = invoiceStatus === 'Expired' || invoiceStatus === 'Cancelled';261 const invoiceStatusTone = invoiceStatus === 'Paid' ? styles.statusSuccess262 : invoiceExpired ? styles.statusError263 : invoiceStatus === 'Open' || invoiceStatus === 'Received' ? styles.statusWaiting264 : styles.statusIdle;265 const senderAvailable = availableChannelBalance(senderChannel?.local_balance, senderChannel?.offered_tlc_balance);266 const bottleSenderAvailable = availableChannelBalance(senderChannel?.remote_balance, senderChannel?.received_tlc_balance);267 // C's remote balance belongs to Bottle, which forwards the payment to C.268 const receiverAvailable = availableChannelBalance(receiverChannel?.remote_balance, receiverChannel?.received_tlc_balance);269 const receiverLocalAvailable = availableChannelBalance(receiverChannel?.local_balance, receiverChannel?.offered_tlc_balance);270 const maximumInvoice = maximumInvoiceAmount(senderAvailable, receiverAvailable);271 const amountError = invoiceAmountError(amount, senderAvailable, receiverAvailable);272 const amountValid = !amountError;273 274 useEffect(() => {275 if (previousStep.current === displayedStep) return;276 previousStep.current = displayedStep;277 stageRef.current?.focus({ preventScroll: true });278 if ((stageRef.current?.getBoundingClientRect().top ?? 0) < 80) {279 stageRef.current?.scrollIntoView({ block: 'start' });280 }281 }, [displayedStep]);282 283 useEffect(() => {284 if (!nodesPrepared) return;285 setExpandedRole(senderReady ? receiverReady ? null : 'receiver' : 'sender');286 }, [nodesPrepared, senderReady, receiverReady]);287 288 useEffect(() => {289 const inspect = () => {290 if (sender.nodeInfo || preparingRef.current || selectionEdited.current) return;291 const detected = detectMultiHopProfiles(window.localStorage);292 setProfiles(detected);293 };294 inspect();295 window.addEventListener('storage', inspect);296 return () => window.removeEventListener('storage', inspect);297 }, [sender.nodeInfo]);298 299 const startFreshNodes = () => {300 if (!profiles || preparingRef.current || sender.nodeInfo || receiver.nodeInfo) return;301 selectionEdited.current = true;302 sender.setError('');303 receiver.setError('');304 const id = crypto.randomUUID();305 setProfiles({306 sender: { key: `${defaultSenderKey}:fresh:${id}`, saved: false, source: 'new' },307 receiver: { key: `${receiverKey}:fresh:${id}`, saved: false, source: 'new' },308 });309 setFreshStartRequested(true);310 };311 312 const prepareNodes = async () => {313 if (!profiles || preparingRef.current) return;314 preparingRef.current = true;315 setPreparingNodes(true);316 try {317 window.localStorage.setItem(senderChoiceKey, profiles.sender.key);318 window.localStorage.setItem(receiverChoiceKey, profiles.receiver.key);319 await sender.start();320 if (!sender.nodeRef.current?.isRunning) return;321 await receiver.start();322 } catch (error) {323 sender.setError(error instanceof Error ? error.message : 'Unable to prepare the browser nodes.');324 } finally {325 preparingRef.current = false;326 setPreparingNodes(false);327 }328 };329 330 // Start after the new profile keys have reached both runtime hooks.331 useEffect(() => {332 if (!freshStartRequested) return;333 setFreshStartRequested(false);334 void prepareNodes();335 }, [freshStartRequested, prepareNodes]);336 337 useEffect(() => {338 if (senderChannel) {339 setSenderChannelProgress(340 progressFromChannelState(senderChannel.state.state_name),341 );342 }343 }, [senderChannel, senderReady]);344 345 useEffect(() => {346 if (receiverChannel) {347 setReceiverChannelProgress(348 progressFromChannelState(receiverChannel.state.state_name),349 );350 }351 }, [receiverChannel, receiverReady]);352 353 const addEvent = useCallback((message: string) => {354 setEvents((current) => [...current.slice(-8), message]);355 }, []);356 357 const openChannel = useCallback(358 async (359 runtime: ReturnType<typeof useFiberRoutingNode>,360 pubkey: `0x${string}`,361 label: string,362 ) => {363 const node = runtime.nodeRef.current;364 if (!node) return false;365 const latestChannels = (await node.listChannels()).channels;366 const existing = findReusableChannel(latestChannels, pubkey);367 if (existing) {368 addEvent(369 `${label} restored · ${channelStateLabel(existing.state.state_name)}`,370 );371 await runtime.refresh();372 return progressFromChannelState(existing.state.state_name);373 }374 375 const result = await runtime.run('open channel', (node) =>376 node.openChannel({377 pubkey,378 funding_amount: ckbToHex(channelAmount),379 public: true,380 }),381 );382 if (!result) return null;383 addEvent(`${label} submitted · ${shorten(result.temporary_channel_id)}`);384 await runtime.refresh();385 return 'confirming' as const;386 },387 [addEvent],388 );389 390 const connectAndOpen = useCallback(391 async (392 runtime: ReturnType<typeof useFiberRoutingNode>,393 label: string,394 setProgress: (stage: ChannelProgressStage) => void,395 ) => {396 runtime.setError('');397 setProgress('connecting');398 addEvent(`${label} · connecting to Bottle`);399 const alreadyConnected = runtime.peers.some((pubkey) =>400 samePubkey(pubkey, bottlePeer.pubkey),401 );402 const connected =403 alreadyConnected || (await runtime.connect(bottlePeer));404 if (!connected) {405 setProgress('error');406 addEvent(`${label} · connection failed`);407 return;408 }409 addEvent(`${label} · Bottle connected`);410 setProgress('submitting');411 let nextProgress: ChannelProgressStage | false | null;412 try {413 nextProgress = await openChannel(runtime, bottlePeer.pubkey, label);414 } catch (error) {415 runtime.setError(error instanceof Error ? error.message : 'Unable to open the channel.');416 setProgress('error');417 return;418 }419 if (!nextProgress) {420 setProgress('error');421 addEvent(`${label} · funding submission failed`);422 return;423 }424 setProgress(nextProgress);425 if (nextProgress === 'ready') {426 addEvent(`${label} · existing ready channel reused`);427 } else {428 addEvent(`${label} · existing or new channel is still progressing`);429 }430 },431 [addEvent, openChannel],432 );433 434 const createInvoice = useCallback(async () => {435 if (!paymentRecoveryReady || !amountValid || restoreBlocked || !senderReady || !receiverReady || receiver.busy) return;436 const result = await receiver.run('create invoice', (node) =>437 node.newInvoice({438 amount: ckbToHex(amount),439 currency: 'Fibt',440 description: 'Browser multi-hop tutorial',441 expiry: '0xe10',442 allow_trampoline_routing: true,443 }),444 );445 if (!result) return;446 try {447 forwardPayment.current?.setInvoice({ amount, invoice: result.invoice_address, hash: result.invoice.data.payment_hash });448 setPaymentRecovered(false);449 setReviewStep(null);450 addEvent(`Node C created ${amount} CKB invoice`);451 } catch (error) { setPaymentRecoveryError(paymentErrorDetail(error)); }452 }, [addEvent, amount, amountValid, restoreBlocked, paymentRecoveryReady, receiver, receiverReady, senderReady]);453 454 useEffect(() => {455 const payer = sender.nodeRef.current;456 const payee = receiver.nodeRef.current;457 if (!payer || !payee || !sender.nodeInfo || !receiver.nodeInfo) return;458 const apply = (record: ForwardPayment | null) => {459 setGeneratedInvoice(record?.invoice ?? '');460 setPaymentHash(record?.hash ?? '');461 setInvoiceAmount(record?.amount ?? '1');462 setPaymentFee(record?.fee);463 setPaymentStatus(record?.status ?? 'Not sent');464 setInvoiceStatus(record?.invoiceStatus ?? 'None');465 setPaymentSubmitted(record?.submitted ?? false);466 setRoute(record?.route ?? []);467 setPaymentErrorDetails(record?.detail ?? '');468 setPaymentError(record?.status === 'Failed' ? paymentFailureMessage(record.detail)469 : record?.detail ? 'Submission could not be confirmed. We are checking the saved payment; no second payment will be sent.' : '');470 };471 setPaymentRecoveryReady(false);472 setPaymentRecoveryError('');473 try {474 const flow = createForwardPayment({ payer, payee, bottle: bottlePeer.pubkey,475 key: `fiber-docs:forward-payment:${sender.nodeInfo.pubkey}:${receiver.nodeInfo.pubkey}`,476 storage: window.localStorage, onChange: apply });477 forwardPayment.current = flow;478 apply(flow.current());479 setPaymentRecovered(Boolean(flow.current()));480 setPaymentRecoveryReady(true);481 const check = () => void flow.check().catch(error => setPaymentRecoveryError(paymentErrorDetail(error)));482 check();483 const timer = window.setInterval(check, 2_000);484 return () => { flow.dispose(); forwardPayment.current = null; window.clearInterval(timer); };485 } catch (error) { setPaymentRecoveryError(paymentErrorDetail(error)); }486 }, [sender.nodeInfo?.pubkey, receiver.nodeInfo?.pubkey, sender.nodeRef, receiver.nodeRef]);487 488 const payInvoice = useCallback(async () => {489 if (!paymentRecoveryReady || !forwardPayment.current || paymentPending || paymentAttempting.current || sender.busy) return;490 paymentAttempting.current = true;491 setReviewStep(null);492 setPaymentRecoveryError('');493 try {494 await forwardPayment.current.send();495 addEvent('Checking the payment from Node A to Node C');496 } catch (error) { setPaymentRecoveryError(paymentErrorDetail(error)); }497 finally { paymentAttempting.current = false; }498 }, [addEvent, sender.busy, paymentPending, paymentRecoveryReady]);499 500 const checkPayment = async () => {501 try { await forwardPayment.current?.check(); }502 catch (error) { setPaymentRecoveryError(paymentErrorDetail(error)); }503 };504 505 const resetInvoice = () => {506 if (paymentPending || paymentAttempting.current || sender.busy || receiver.busy) return;507 try { forwardPayment.current?.clear(); }508 catch (error) { setPaymentRecoveryError(paymentErrorDetail(error)); return; }509 setPaymentRecovered(false);510 setAmount('1');511 setGeneratedInvoice('');512 setPaymentHash('');513 setInvoiceStatus('None');514 setPaymentFee(undefined);515 setPaymentStatus('Not sent');516 setPaymentSubmitted(false);517 setPaymentError('');518 setPaymentErrorDetails('');519 setReviewStep(null);520 setRoute([]);521 sender.setError('');522 receiver.setError('');523 };524 525 const article = (526 <>527 <section className={styles.section} data-tutorial-section="intro">528 <div className={styles.stepLabel}>How it works</div>529 <h2>Why use multi-hop payments?</h2>530 <p>531 Imagine paying a shop for the first time or using a paid service you have532 never connected to before. Opening and funding a new channel with every533 recipient would tie up funds and require waiting for on-chain confirmation.534 Multi-hop payments let you reach a recipient through existing channels,535 provided the route has enough available liquidity.536 </p>537 <p className={styles.followupParagraph}>538 In this tutorial, Sender A pays Receiver C through Bottle B. C creates an539 invoice requesting a specified amount of CKB, A submits it for payment,540 and Bottle forwards it. The payment redistributes liquidity across two channels541 without requiring a direct A–C channel or a new on-chain transaction for542 each payment. The sections below explain the code behind each operation.543 </p>544 </section>545 546 <section className={styles.section} data-tutorial-section="roles">547 <div className={styles.stepLabel}><span>1</span> Start two nodes</div>548 <h2>Start a separate node for the sender and receiver</h2>549 <p>550 <code>useFiberRoutingNode()</code> creates a runtime for each node, and551 <code> prepareNodes()</code> starts both nodes. Each node has its own552 identity, keys, and storage; A and C do not share private node state.553 If saved profiles are detected, the tutorial reuses those identities.554 </p>555 <p className={styles.followupParagraph}>556 A and C each open a separate public channel with Bottle. A public channel557 can be announced to the network for routing; it does not make the node’s558 private keys or secret payment data public. Connecting to Bottle and559 opening a channel are separate operations. <code>connectAndOpen()</code>560 connects the selected node to Bottle, and <code>openChannel()</code> reuses an existing channel from a saved node profile, if available,561 or opens a new public channel funded with the specified amount of CKB.562 The runtime also reconnects known peers when restoring channels.563 </p>564 <div className={styles.routeDiagram}>565 <b>Sender A</b><i>→</i><span>Bottle B · Public node</span><i>→</i><b>Receiver C</b>566 </div>567 <div className={styles.note}>568 <strong>Channel setup and liquidity</strong>569 <p>570 Both channels must reach <code>CHANNEL_READY</code> before paying.571 A needs enough available liquidity toward Bottle, and Bottle needs enough572 available liquidity toward C. Funding C’s side does not automatically573 provide Bottle with the liquidity needed to forward a payment to C.574 For funding, opening a channel, and understanding its lifecycle, see{' '}575 <a className={styles.inlineDownloadLink} href="/docs/build/open-channel-payment">576 Open a channel and send a payment577 </a>.578 </p>579 </div>580 <small className={styles.fileReference}>{sourceReference('roles')}</small>581 </section>582 583 <section className={styles.section} data-tutorial-section="liquidity">584 <div className={styles.stepLabel}><span>2</span> Manage liquidity</div>585 <h2>Choose an amount the route can carry</h2>586 <p className={styles.followupParagraph}>587 <code>invoiceAmountError()</code> checks for a positive amount with at588 most eight decimal places. The invoice limit is the smaller of A’s589 available liquidity minus the 1 CKB routing-fee budget, and Bottle’s590 available liquidity toward C (the remote side of C’s channel). Pending outgoing591 transfers on A and incoming transfers on C are excluded.592 However, these liquidity checks do not guarantee route availability at593 payment time. Liquidity and node connectivity can change before the594 payment is sent.595 </p>596 <p className={styles.followupParagraph}>597 If receiving liquidity runs low, <strong>Restore receiving liquidity</strong> lets C return funds through Bottle to A using the same channels.598 This increases Bottle’s liquidity toward C, allowing C to receive more payments.599 </p>600 <div className={`${styles.note} ${styles.noteInfo}`}>601 <strong>Restoring liquidity moves existing funds</strong>602 <p>The return amount defaults to 1 CKB for this campaign and can be changed.</p>603 <p>Returning funds incurs routing fees and redistributes liquidity between the existing channels. It does not add new funds.</p>604 </div>605 <small className={styles.fileReference}>{sourceReference('liquidity')}</small>606 </section>607 608 <section className={styles.section} data-tutorial-section="invoice">609 <div className={styles.stepLabel}><span>3</span> Create the invoice</div>610 <h2>Create the invoice on Receiver C</h2>611 <p>612 <code>createInvoice()</code> uses the amount entered in the form and613 calls <code>newInvoice()</code> through Receiver C’s runtime. The response614 contains the encoded payment request in <code>invoice_address</code>.615 The component saves it in <code>generatedInvoice</code> and saves its616 payment hash for status checks.617 </p>618 <p className={styles.followupParagraph}>619 <code>invoice_address</code> contains the long <code>fibt...</code> string620 shown in the tutorial. The prefix <code>fibt</code>621 identifies a Fiber Testnet invoice. Note that <code>invoice_address</code> is not a CKB wallet address.622 It encodes payment details, including the amount,623 expiry, and payment hash, for the sender to use.624 </p>625 <p className={styles.followupParagraph}>626 <code>ckbToHex(amount)</code> converts CKB to an amount in shannons,627 encoded as hexadecimal for the SDK (1 CKB = 100,000,000 shannons). <code>currency: 'Fibt'</code> selects Testnet,{' '}628 <code>expiry: '0xe10'</code> sets a one-hour expiry, and{' '}629 <code>allow_trampoline_routing: true</code> allows trampoline routing:630 the sender delegates onward route finding to another node. Here, that631 node is Bottle B. Creating an invoice requests payment; it does not632 transfer funds. The tutorial then opens the payment review step for A.633 </p>634 <div className={styles.note}>635 <strong>Payment hash and preimage</strong>636 <p>637 C keeps a secret called the preimage. Its hash is included in the638 invoice, while the secret itself is not. Conditional transfers along639 the route use this hash and timeouts to coordinate settlement or640 recovery if payment cannot complete. C reveals the preimage to settle641 the payment.642 </p>643 </div>644 <small className={styles.fileReference}>{sourceReference('invoice')}</small>645 </section>646 647 <section className={styles.section} data-tutorial-section="transfer">648 <div className={styles.stepLabel}><span>4</span> Transfer the invoice</div>649 <h2>Pass the generated invoice to Sender A</h2>650 <p>651 The review screen displays <code>generatedInvoice</code> as read-only652 text alongside the payment amount and maximum routing fee. The tutorial653 passes C’s invoice directly to A; no paste step is needed.654 </p>655 <p className={styles.followupParagraph}>656 <code>createForwardPayment()</code> creates the tutorial’s payment657 controller, which saves payment state and resumes status checks after a658 reload. When Send payment is clicked, <code>payInvoice()</code> asks this659 controller to submit the saved invoice.660 </p>661 <p className={styles.followupParagraph}>662 The invoice carries the payment request between the nodes; it does not663 move funds by itself. Applications with separate sender and receiver664 interfaces can deliver the same encoded request through a QR code, chat,665 or another text transport.666 </p>667 <small className={styles.fileReference}>{sourceReference('transfer')}</small>668 </section>669 670 <section className={styles.section} data-tutorial-section="route">671 <div className={styles.stepLabel}><span>5</span> Route the payment</div>672 <h2>Use Bottle as the single public intermediary</h2>673 <p>674 A selects Bottle B as its trampoline node to route the payment to C675 across the two channels.676 </p>677 <p className={styles.followupParagraph}>678 <code>payInvoice()</code> asks the payment controller to call <code>sendPayment()</code> on A with C’s saved invoice.{' '}679 <code>trampoline_hops: [bottlePeer.pubkey]</code> asks Bottle to680 handle onward routing through the A–Bottle and Bottle–C channels.{' '}681 <code>max_fee_amount: '0x5f5e100'</code> sets a routing-fee budget of682 1 CKB in addition to the invoice amount. Trampoline routing can use the683 full budget. The RPC result’s <code>fee</code> field reports the actual684 charge shown beside the maximum fee after a successful payment.685 </p>686 <p className={styles.followupParagraph}>687 In A–Bottle, A’s liquidity decreases and Bottle’s increases. In Bottle–C,688 Bottle’s liquidity decreases and C’s increases by the invoice amount.689 Bottle uses its own liquidity to forward the payment, and A covers any690 routing fee in addition to the amount C receives.691 </p>692 <small className={styles.fileReference}>{sourceReference('route')}</small>693 </section>694 695 <section className={styles.section} data-tutorial-section="status">696 <div className={styles.stepLabel}><span>6</span> Observe invoice state</div>697 <h2>Poll the node that created the invoice</h2>698 <p>699 Once an invoice exists, the payment controller polls Receiver C with700 <code> getInvoice()</code> every two seconds, using the payment hash to701 identify the invoice. The response updates <code>invoiceStatus</code>702 in the live demo and runtime panel. Polling begins before payment so703 the tutorial can also detect an expired invoice. A brief{' '}704 <code>Received</code> state may occur between checks.705 </p>706 <table className={styles.invoiceStatusTable} aria-label="Invoice statuses">707 <thead>708 <tr><th scope="col">Status</th><th scope="col">Meaning</th></tr>709 </thead>710 <tbody>711 <tr><th scope="row"><code>Open</code></th><td>The invoice is awaiting payment and can be paid.</td></tr>712 <tr><th scope="row"><code>Received</code></th><td>A payment has arrived but has not settled yet.</td></tr>713 <tr><th scope="row"><code>Paid</code></th><td>The payment has settled and the invoice is paid.</td></tr>714 <tr><th scope="row"><code>Cancelled</code></th><td>The invoice has been cancelled and can no longer be paid.</td></tr>715 <tr><th scope="row"><code>Expired</code></th><td>The invoice’s expiry time has passed. Create a new invoice to request payment.</td></tr>716 </tbody>717 </table>718 <small className={styles.fileReference}>{sourceReference('status')}</small>719 </section>720 721 <section className={styles.section} data-tutorial-section="result">722 <div className={styles.stepLabel}><span>7</span> Confirm both ends</div>723 <h2>Sender success and a paid invoice complete the flow</h2>724 <p>725 <code>multiHopPaymentOutcome()</code> reports success only when A’s726 payment status is <code>Success</code> and C’s invoice status is{' '}727 <code>Paid</code>. A sender status of <code>Failed</code> produces a failed728 outcome; otherwise the outcome stays pending. This keeps sender729 submission separate from confirmed receipt.730 </p>731 <p className={styles.followupParagraph}>732 Before submitting the payment, the controller saves its invoice,733 amount, payment hash, and submitted state. After a reload, restore the734 same browser nodes to resume status checks. The controller never735 automatically submits the saved payment again. An uncertain submission736 remains pending until its status can be confirmed.737 </p>738 <p className={styles.followupParagraph}>739 The payment hash identifies the payment for lookup; it is not standalone740 proof of receipt. <code>getPayment()</code> on A confirms the sender’s741 status, while <code>getInvoice()</code> on C confirms receipt. The742 Check payment status action runs these checks on demand.743 </p>744 <div className={styles.note}>745 <strong>If the payment does not complete</strong>746 <p>747 If the route has insufficient liquidity, try a smaller amount; either748 channel can limit the payment. If the invoice has expired, create749 another one. If waiting times out, check the existing payment’s status750 before submitting another payment: a timeout alone does not establish751 that the payment failed.752 </p>753 </div>754 <small className={styles.fileReference}>{sourceReference('result')}</small>755 </section>756 757 <section className={styles.section} data-tutorial-section="react">758 <div className={styles.stepLabel}><span>8</span> Wire React</div>759 <h2>Wire the two runtimes into React</h2>760 <p>761 <code>FiberMultiHopInvoiceTutorial()</code> owns both runtimes and the762 payment state. <code>amount</code> tracks the editable form value, while{' '}763 <code>invoiceAmount</code> preserves the amount used when the invoice was764 created. <code>generatedInvoice</code> holds the exact request paid by A.765 <code> paymentStatus</code> and <code>invoiceStatus</code> track each end766 independently.767 </p>768 <p className={styles.followupParagraph}>769 <code>multiHopStep()</code> selects the visible stage from channel770 readiness, invoice creation, and payment submission.{' '}771 <code>multiHopPaymentOutcome()</code> determines the result. Reviewing772 an earlier stage changes <code>reviewStep</code> without resetting the773 payment. <code>resetInvoice()</code> clears the invoice and result only774 when no payment is pending, allowing another invoice to be created.775 </p>776 <small className={styles.fileReference}>{sourceReference('react')}</small>777 </section>778 779 <section className={styles.section} data-tutorial-section="download">780 <div className={styles.stepLabel}>Optional local setup</div>781 <h2>Run the complete project locally</h2>782 <p>783 <a784 className={styles.inlineDownloadLink}785 download786 href="/downloads/fiber-multi-hop-invoice.zip"787 >788 Download the complete project789 </a>790 . The archive contains this live tutorial’s React component, runtime,791 styles, article, and code panel in a standalone Next.js application,792 with the required browser headers. After extracting it,793 open the <code>multi-hop-invoice</code> directory and run:794 </p>795 <div className={styles.setupCodeBlock}>796 <div className={styles.setupCodeHeader}><span>Terminal</span></div>797 <pre><code>{`npm install798npm run dev`}</code></pre>799 </div>800 <p className={styles.followupParagraph}>801 Then open <code>http://localhost:3000</code> in your browser.802 </p>803 </section>804 </>805 );806 807 const liveDemo = (808 <div className={`${styles.channelDemoSurface} ${styles.multiHopDemo}`}>809 <div className={styles.paymentPreviewStage}>810 <div className={styles.paymentCard}>811 <div className={`${styles.paymentStatusGrid} ${styles.verifiedStatusGrid}`} aria-live="polite">812 {[813 { label: 'Sender A', value: participantStatusLabel({ nodeRunning: Boolean(sender.nodeInfo), roleReady: senderReady }), tone: sender.nodeInfo ? styles.statusSuccess : styles.statusIdle },814 { label: 'Receiver C', value: participantStatusLabel({ nodeRunning: Boolean(receiver.nodeInfo), roleReady: receiverReady }), tone: receiver.nodeInfo ? styles.statusSuccess : styles.statusIdle },815 { label: 'Invoice', value: invoiceStatus, tone: invoiceStatus === 'Paid' ? styles.statusSuccess : invoiceStatus === 'Cancelled' ? styles.statusError : invoiceStatus === 'Received' ? styles.statusWaiting : styles.statusIdle },816 { label: 'Result', value: paymentComplete ? 'Paid' : paymentStatus === 'Failed' ? 'Failed' : paymentPending ? 'Submitted' : '—', tone: paymentComplete ? styles.statusSuccess : paymentStatus === 'Failed' ? styles.statusError : paymentPending ? styles.statusWaiting : styles.statusIdle },817 ].map(({ label, value, tone }) => <div key={label}>818 <span>{label}</span>819 <strong className={styles.verifiedStatusValue}><i className={`${styles.statusDot} ${tone}`} /><b className={styles.verifiedStatusText}>{value}</b></strong>820 </div>)}821 </div>822 823 <ol className={styles.verifiedResultProgress} aria-label="Tutorial progress">824 {steps.map((label, index) => <li data-state={index < currentStep ? 'done' : index === currentStep ? 'current' : 'upcoming'} data-viewing={index === displayedStep} key={label}>825 <button aria-current={index === displayedStep ? 'step' : undefined} disabled={index > currentStep || (displayedStep === 1 && restoreBlocked)} onClick={() => setReviewStep(index < currentStep ? index : null)} type="button">826 <i>{index < currentStep ? '✓' : index + 1}</i><span>{label}</span>827 </button>828 </li>)}829 </ol>830 831 {reviewing && <div className={styles.verifiedReviewBar}>832 <span>Reviewing: {steps[displayedStep]}</span>833 <button onClick={() => setReviewStep(null)} type="button">Return to current step</button>834 </div>}835 836 <section className={`${styles.verifiedResultStage} ${styles.multiHopStage} ${displayedStep === 0 && !reviewing ? styles.verifiedSetupStage : ''}`} aria-label={steps[displayedStep]} ref={stageRef} tabIndex={-1}>837 {displayedStep === 0 && (reviewing ? <div className={styles.verifiedReviewSummary}>838 <span>Set up complete</span>839 <div className={styles.verifiedSetupReviewGrid}>840 {[841 { label: 'Sender Node A', runtime: sender, channel: senderChannel },842 { label: 'Receiver Node C', runtime: receiver, channel: receiverChannel },843 ].map(({ label, runtime, channel }) => <section key={label}>844 <header><span>{label}</span><strong className={styles.verifiedStatusValue}><i className={`${styles.statusDot} ${channel && isReadyState(channel.state.state_name) ? styles.statusSuccess : styles.statusIdle}`} /><b className={styles.verifiedStatusText}>{channelStateLabel(channel?.state.state_name)}</b></strong></header>845 <dl><div><dt>On-chain balance</dt><dd>{hexToCkb(runtime.balance)} CKB</dd></div><div><dt>Local channel liquidity</dt><dd>{hexToCkb(channel?.local_balance)} CKB</dd></div><div><dt>Remote channel liquidity</dt><dd>{hexToCkb(channel?.remote_balance)} CKB</dd></div></dl>846 </section>)}847 </div>848 </div> : <div className={styles.verifiedSetupNode}>849 <div className={styles.paymentFlow}>850 <div className={styles.paymentFlowNumber}>1</div>851 <div className={styles.paymentFlowBody}>852 <strong>{!profiles ? 'Checking saved profiles…' : profiles.sender.saved || profiles.receiver.saved ? 'Continue with your existing browser nodes' : 'Set up your sender and receiver'}</strong>853 <span>{profiles?.sender.source === 'earlier'854 ? 'We found your browser node from an earlier tutorial. Reuse it as Sender A; Receiver C has a separate identity.'855 : profiles?.sender.saved || profiles?.receiver.saved856 ? 'Saved profiles found. Start the nodes to check their funding and channel liquidity.'857 : 'Sender A sends the payment. Receiver C creates the invoice and receives it. Start to create their separate identities.'}</span>858 </div>859 <div className={styles.multiHopSetupActions}>860 <button861 className={`${styles.startButton} ${styles.demoAction} ${!nodesPrepared ? styles.demoPrimaryAction : ''}`}862 disabled={!profiles || preparingNodes || freshStartRequested || nodesPrepared}863 onClick={sender.isolationReady === false || receiver.isolationReady === false ? () => window.location.reload() : () => void prepareNodes()}864 type="button"865 >{preparingNodes ? 'Starting nodes…' : sender.isolationReady === false || receiver.isolationReady === false ? 'Reload to enable WASM' : nodesPrepared ? 'Nodes running' : sender.error || receiver.error ? 'Retry setup' : profiles?.sender.saved || profiles?.receiver.saved ? 'Restore nodes' : 'Prepare nodes'}</button>866 {(profiles?.sender.saved || profiles?.receiver.saved) && !sender.nodeInfo && !receiver.nodeInfo && <button867 className={styles.demoAction}868 disabled={preparingNodes || freshStartRequested}869 onClick={startFreshNodes}870 type="button"871 >Start fresh nodes</button>}872 </div>873 </div>874 <div className={styles.verifiedSetupDisclosures}>875 <NodeSetup876 channelExists={Boolean(senderChannel)} channelState={senderChannel?.state.state_name ?? 'Not opened'}877 expanded={expandedRole === 'sender'} locked={!nodesPrepared} number="2"878 onToggle={() => setExpandedRole((role) => role === 'sender' ? null : 'sender')}879 onConnectAndOpen={() => connectAndOpen(sender, 'Node A channel', setSenderChannelProgress)}880 profile={profiles?.sender ?? null} progressStage={senderChannelProgress} runtime={sender} title="Sender A"881 />882 <NodeSetup883 channelExists={Boolean(receiverChannel)} channelState={receiverChannel?.state.state_name ?? 'Not opened'}884 expanded={expandedRole === 'receiver'} locked={!nodesPrepared} number="3"885 onToggle={() => setExpandedRole((role) => role === 'receiver' ? null : 'receiver')}886 onConnectAndOpen={() => connectAndOpen(receiver, 'Node C channel', setReceiverChannelProgress)}887 profile={profiles?.receiver ?? null} progressStage={receiverChannelProgress} runtime={receiver} title="Receiver C"888 />889 </div>890 </div>)}891 892 {displayedStep === 1 && <div className={styles.multiHopForm}>893 {reviewing ? <>894 <div className={styles.verifiedReviewState}><span>Invoice amount</span><strong>{invoiceAmount} CKB</strong></div>895 <div className={styles.multiHopField}>896 <span>Invoice created by Node C</span>897 <div className={styles.multiHopInvoiceText}>898 <code title={generatedInvoice}>{generatedInvoice}</code>899 <CopyValue value={generatedInvoice} label="Copy invoice" iconOnly />900 </div>901 </div>902 <button className={styles.verifiedPrimaryAction} disabled={paymentPending || Boolean(sender.busy) || Boolean(receiver.busy)} onClick={resetInvoice} type="button">Create another invoice</button>903 {paymentPending && <p className={styles.multiHopHelp}>Wait for this payment to finish before creating another invoice.</p>}904 </> : <>905 <label className={styles.multiHopField}>906 <span>Enter an invoice amount for Receiver C <span className={styles.multiHopExpiryNote}>(Expires after one hour)</span></span>907 <span className={styles.multiHopAmountInput}>908 <input aria-invalid={!amountValid} aria-describedby={`invoice-amount-help${amountError ? ' invoice-amount-error' : ''}`} inputMode="decimal" onChange={(event) => setAmount(event.target.value)} value={amount} disabled={!paymentRecoveryReady || Boolean(paymentRecoveryError) || restoreBlocked || Boolean(receiver.busy)} />909 <span>CKB</span>910 </span>911 </label>912 {amountError && <p role="alert" className={styles.multiHopError} id="invoice-amount-error">{amountError}</p>}913 <div className={styles.multiHopLiquidity}>914 <ChannelLiquidity left="A" right="B" leftAmount={senderAvailable} rightAmount={bottleSenderAvailable} leftDirection="Outbound from A" rightDirection="Inbound to A" />915 <ChannelLiquidity left="B" right="C" leftAmount={receiverAvailable} rightAmount={receiverLocalAvailable} leftDirection="Inbound to C" rightDirection="Outbound from C" />916 </div>917 <p className={styles.multiHopHelp} id="invoice-amount-help">918 Maximum invoice: <strong>{hexToCkb(maximumInvoice)} CKB</strong>919 {maximumInvoice !== undefined && maximumInvoice > 0n && ' · 1 CKB reserved for routing fees'}920 </p>921 <details className={styles.multiHopLiquidityExplanation}>922 <summary>How is available liquidity calculated?</summary>923 <p>A new channel funded with 499 CKB normally reserves 99 CKB for on-chain settlement, leaving 400 CKB for payments before transfers and fees. Another 1 CKB is reserved for the maximum routing fee.</p>924 </details>925 <button className={`${styles.verifiedPrimaryAction} ${styles.multiHopInvoiceAction}`} disabled={!paymentRecoveryReady || Boolean(paymentRecoveryError) || restoreBlocked || !senderReady || !receiverReady || !amountValid || Boolean(receiver.busy)} onClick={() => void createInvoice()} type="button">{receiver.busy === 'create invoice' ? 'Creating invoice…' : 'Create invoice'}</button>926 {paymentRecoveryReady && !paymentRecoveryError && <RestoreLiquidityPanel sender={sender} receiver={receiver} onBlocked={setRestoreBlocked} onRestored={() => { setAmount('1'); addEvent('Receiving liquidity restored · C → Bottle B → A'); }} />}927 </>}928 </div>}929 930 {displayedStep === 2 && <div className={styles.multiHopForm}>931 <dl className={styles.multiHopPaymentDetails}>932 <div className={styles.multiHopPaymentAmount}>933 <dt>Payment amount</dt>934 <dd>{invoiceAmount} <span>CKB</span></dd>935 </div>936 <div>937 <dt>Max. routing fee</dt>938 <dd>1 CKB</dd>939 </div>940 {paymentComplete && <div><dt>Actual routing fee</dt><dd>{paymentFee !== undefined ? `${hexToCkb(paymentFee)} CKB` : 'Unavailable'}</dd></div>}941 <div>942 <dt>Invoice generated by Receiver C</dt>943 <dd className={styles.multiHopInvoiceText}><code title={generatedInvoice}>{generatedInvoice}</code></dd>944 </div>945 </dl>946 {!reviewing && <>947 <div className={styles.multiHopActions}>948 {!invoiceExpired && <button className={styles.verifiedPrimaryAction} disabled={!paymentRecoveryReady || Boolean(paymentRecoveryError) || !senderReady || !receiverReady || !generatedInvoice || paymentPending || Boolean(sender.busy)} onClick={() => void payInvoice()} type="button">Send payment</button>}949 </div>950 {(invoiceExpired || paymentStatus === 'Failed') && !paymentPending && <div className={styles.verifiedReviewState}><strong>{invoiceExpired ? `This invoice is ${invoiceStatus.toLowerCase()}.` : 'Need to change the amount?'}</strong><p>Create a new request to continue with a different amount.</p><button disabled={Boolean(sender.busy) || Boolean(receiver.busy)} onClick={resetInvoice} type="button">Create a new invoice</button></div>}951 </>}952 {reviewing && <p className={styles.multiHopHelp}>View the payment status in Outcome.</p>}953 </div>}954 955 {displayedStep === 3 && <div className={styles.multiHopOutcome} aria-live="polite" data-outcome={paymentComplete ? 'paid' : paymentOutcome}>956 <header className={styles.multiHopOutcomeHeader}>957 <i className={styles.multiHopOutcomeMark} aria-hidden="true">958 {paymentComplete ? '✓' : paymentOutcome === 'failed' ? '!' : <span className={styles.multiHopPendingDots}><i /><i /><i /></span>}959 </i>960 <h2>{paymentComplete ? 'Payment completed' : paymentOutcome === 'failed' ? 'Payment failed' : 'Payment pending'}</h2>961 </header>962 {!paymentComplete && <p className={styles.multiHopHelp}>{paymentError || (paymentStatus === 'Success' ? 'Sender A has sent the payment. Waiting for Receiver C to confirm receipt.' : 'Your payment is being routed to Receiver C. Keep this page open while we check its status.')}</p>}963 {!paymentComplete && rawPaymentError && <p className={styles.multiHopError}><strong>Fiber error: </strong>{rawPaymentError}</p>}964 <dl className={styles.multiHopOutcomeFacts}>965 <div className={styles.multiHopOutcomeAmount}><dt>{paymentComplete ? 'Amount received' : 'Payment amount'}</dt><dd>{invoiceAmount} <span>CKB</span></dd></div>966 <div><dt>Routing fee</dt><dd>Actual: {paymentComplete ? paymentFee !== undefined ? `${hexToCkb(paymentFee)} CKB` : 'Unavailable' : paymentOutcome === 'failed' ? 'Unavailable' : 'Pending'} · Maximum: 1 CKB</dd></div>967 <div><dt>Payment flow</dt><dd className={styles.multiHopOutcomeFlow}><span>Sender A</span><i aria-hidden="true">→</i><span>Bottle B</span><i aria-hidden="true">→</i><span>Receiver C</span></dd></div>968 <div><dt>Invoice status</dt><dd className={styles.multiHopOutcomeInvoice}><i aria-hidden="true" className={`${styles.statusDot} ${invoiceStatusTone}`} />{invoiceStatus}</dd></div>969 </dl>970 {paymentComplete && <div className={styles.multiHopField}>971 <span id="payment-proof-label">Payment hash</span>972 <div className={styles.multiHopProofValue}>973 <code aria-labelledby="payment-proof-label" className={styles.multiHopHash}>{paymentHash}</code>974 <CopyValue value={paymentHash} label="Copy payment hash" iconOnly />975 </div>976 </div>}977 {paymentPending && <button disabled={Boolean(sender.busy)} onClick={() => void checkPayment()} type="button">Check payment status</button>}978 {paymentOutcome === 'failed' && <p className={styles.multiHopHelp}>Create another invoice to try again with a new payment hash.</p>}979 {!paymentPending && <button disabled={Boolean(sender.busy) || Boolean(receiver.busy)} onClick={resetInvoice} type="button">Create another invoice</button>}980 </div>}981 {paymentRecovered && generatedInvoice && <p className={styles.multiHopHelp}>Restored your saved {paymentSubmitted ? 'payment' : 'invoice'}. {paymentPending ? 'Checking its status; no new payment has been sent.' : 'Its current status is shown above.'}</p>}982 {paymentRecoveryError && <p role="alert" className={styles.multiHopError}>{paymentRecoveryError}</p>}983 {displayedStep !== 0 && (receiver.error || (sender.error && !paymentSubmitted)) && <div className={styles.multiHopError} role="alert">{receiver.error || sender.error}</div>}984 </section>985 </div>986 987 <aside className={`${styles.eventPanel} ${styles.multiHopEvents}`} aria-label="Runtime events and results">988 <div className={`${styles.eventPanelHeader} ${styles.multiHopDesktopEventHeader}`}><span>Runtime events and results</span><i className={styles.liveDot} /></div>989 <button aria-controls="multi-hop-event-body" aria-expanded={mobileEventsExpanded} className={`${styles.eventPanelHeader} ${styles.mobileEventToggle}`} onClick={() => setMobileEventsExpanded((expanded) => !expanded)} type="button">990 <span>Runtime events and results</span><span aria-hidden="true">{mobileEventsExpanded ? '−' : '+'}</span>991 </button>992 <div className={styles.multiHopEventBody} data-expanded={mobileEventsExpanded} id="multi-hop-event-body">993 <div className={styles.multiHopRoute} aria-label="Channels connecting Node A to Bottle and Bottle to Node C"><b>Node A</b><span className={styles.multiHopChannelConnector} aria-hidden="true" /><b>Bottle</b><span className={styles.multiHopChannelConnector} aria-hidden="true" /><b>Node C</b></div>994 <div className={styles.eventList} aria-live="polite">995 <div className={sender.nodeInfo ? styles.eventConnected : undefined}><time>A</time><code>sender</code><span>{sender.nodeInfo ? sender.nodeState : 'Node not started'}</span></div>996 <div className={receiver.nodeInfo ? styles.eventConnected : undefined}><time>C</time><code>receiver</code><span>{receiver.nodeInfo ? receiver.nodeState : 'Node not started'}</span></div>997 <div><time>A</time><code>A-B channel</code><span>{channelStateLabel(senderChannel?.state.state_name)}</span></div>998 <div><time>C</time><code>C-B channel</code><span>{channelStateLabel(receiverChannel?.state.state_name)}</span></div>999 <div><time>INV</time><code>invoice</code><span>{invoiceStatus}</span></div>1000 <div className={paymentStatus === 'Success' ? styles.eventConnected : paymentStatus === 'Failed' ? styles.eventError : undefined}><time>PAY</time><code>payment</code><span>{paymentStatus}</span></div>1001 {route.map((pubkey, index) => <div key={`${pubkey}-${index}`}><time>H{index + 1}</time><code>route_hop</code><span>{shorten(pubkey)}</span></div>)}1002 {events.map((event, index) => <div key={`${event}-${index}`}><time>{String(index + 1).padStart(2, '0')}</time><code>event</code><span>{event}</span></div>)}1003 {(sender.error || receiver.error || paymentError) && <div className={styles.eventError}><time>!</time><code>error</code><span>{paymentError || receiver.error || (paymentSubmitted ? paymentFailureMessage(sender.error) : sender.error)}</span></div>}1004 {!paymentComplete && rawPaymentError && <div className={styles.eventError}><time>!</time><code>fiber_error</code><span>{rawPaymentError}</span></div>}1005 </div>1006 </div>1007 </aside>1008 </div>1009 </div>1010 );1011 1012 return (1013 <RoutingTutorialFrame1014 article={article}1015 codeFiles={codeFiles}1016 currentTutorialIndex={2}1017 defaultFile="demo"1018 demoDescription="You control Sender A and Receiver C in this browser. Bottle B is the public node that forwards payments between them."1019 demoTitle="Send a Multi-Hop Invoice Payment"1020 demoFirst1021 walkthroughDescription="Follow the live source that prepares two nodes, creates an invoice, and confirms a payment through Bottle."1022 downloadHref="/downloads/fiber-multi-hop-invoice.zip"1023 liveDemo={liveDemo}1024 nextHref="/docs/build/unidirectional-channel"1025 previousHref="/docs/build/open-channel-payment"1026 sectionCode={sectionCode}1027 />1028 );1029}1030