Fiber LogoFiber Docs
Fiber tutorials

Choose a chapter

Interactive tutorial · About 30 minutes

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.

Sender AReady to start
Receiver CReady to start
InvoiceNone
Result—
1
Checking saved profiles…Sender A sends the payment. Receiver C creates the invoice and receives it. Start to create their separate identities.
Implementation walkthrough

Behind the scenes

Follow the live source that prepares two nodes, creates an invoice, and confirms a payment through Bottle.

How it works

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.

1 Start two nodes

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.

Sender A→Bottle B · Public node→Receiver C
Channel setup and liquidity

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.

components/tutorials/fiber-routing-runtime.ts · lines 200–265
2 Manage liquidity

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.

Restoring liquidity moves existing funds

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.

components/tutorials/multi-hop-progress.ts · lines 61–100
3 Create the invoice

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.

Payment hash and preimage

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.

components/tutorials/fiber-multi-hop-invoice.tsx · lines 434–452
4 Transfer the invoice

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–498
5 Route the payment

Use 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–106
6 Observe invoice state

Poll 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.

StatusMeaning
OpenThe invoice is awaiting payment and can be paid.
ReceivedA payment has arrived but has not settled yet.
PaidThe payment has settled and the invoice is paid.
CancelledThe invoice has been cancelled and can no longer be paid.
ExpiredThe invoice’s expiry time has passed. Create a new invoice to request payment.
components/tutorials/fiber-multi-hop-invoice.tsx · lines 454–486
7 Confirm both ends

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 payment does not complete

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.

components/tutorials/multi-hop-progress.ts · lines 49–70
8 Wire React

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.

components/tutorials/fiber-multi-hop-invoice.tsx · lines 196–272
Optional local setup

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:

Terminal
npm install
npm run dev

Then 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 
components/tutorials/fiber-multi-hop-invoice.tsx tsx