From Hand-Rolled Channels to a Single Fiber SDK Call: Rebuilding "Chat-and-Pay" with Fiber Network

From Hand-Rolled Channels to a Single SDK Call: Rebuilding “Chat-and-Pay” with Fiber Network

fiber

If you read my previous “Chat-and-Pay” article, you might remember that I hand-rolled a Spillman unidirectional payment channel from scratch on CKB L1 — writing my own multisig contract, assembling transaction structures, handling signature verification — all to make a micropayment happen automatically every time the AI spit out a chunk.

That article got quite a bit of feedback. Some thought it was hardcore, others thought it was way too much hassle. Honestly, I agreed with the latter. Not because the Spillman protocol itself is that complex, but because when you implement an entire channel protocol from scratch, the server ends up doing way too much — so much that I started questioning whether it even qualified as “decentralized” anymore.

So this time, I rebuilt the same scenario using Fiber Network. The result? The server went from “managing everything” to “barely touching the money.” The browser now runs a full Fiber WASM node, and channel creation, payments, and settlement are all handled internally by the SDK.

This article covers what changed, what code disappeared, and why I think Fiber’s unidirectional channel is the right primitive for “streaming payment” scenarios.

Live demo: https://dapp.54-180-28-237.sslip.io


Recap: How Much Work the First Version Required

Let me quickly recap the original architecture, because understanding how “heavy” the first version was is the prerequisite for appreciating how “light” Fiber makes things.

In the Spillman version, the payment channel was built entirely from raw CKB L1 transactions. Here’s what the server had to do:

Channel creation phase: The frontend constructed a funding transaction (without broadcasting it), then sent the transaction structure to the server. The server verified the amounts, timelock, and multisig script, then pre-signed a refund transaction using the seller’s private key and returned the signature to the client. This pre-signature was the buyer’s “insurance” — if the seller disappeared, the buyer could wait for the timelock to expire and use this signature to recover all funds.

Off-chain payment phase: Every time the AI returned a chunk, the frontend signed a new “payment commitment” — essentially a transaction representing the latest fund allocation (“the multisig Cell should be split X to the buyer, Y to the seller”). This commitment was sent to the server, which verified the signature, checked that the amounts added up, and stored it. The server was effectively maintaining complete payment state for every active channel.

Settlement phase: A cron job ran every minute, scanning all channels. If a channel was about to expire (less than 15 minutes remaining), the server would take the latest payment commitment, add the seller’s signature, and broadcast it on-chain. Another cron job checked every 10 minutes for expired-but-unused channels and marked them as refundable.

Refund phase: When a user wanted to recover their deposit, the server had to retrieve the pre-signed refund transaction, combine it with the buyer’s signature, construct witness data (a carefully structured 132-byte buffer containing both signatures and pubkey indices), and broadcast the transaction.

On top of all this, I had to write a 2-of-2 multisig smart contract. I used CKB’s JavaScript VM to simulate multi-signature verification — calling secp256k1_blake160_sighash_all twice in JS to verify the buyer’s and seller’s signatures separately. The timelock was handled by CKB’s native since field, which I didn’t have to write myself, but the overall code surface area was still enormous.

The createPaymentChannel function alone was over 50 lines of transaction construction code. executeRefund was another 100+ lines of signature concatenation, witness assembly, and error handling. And every single line was something I had to understand, debug, and maintain.

Was it over-engineered? Maybe. Was the server doing too much? Most likely — in hindsight, having the server manage channel state, store pre-signed transactions, and run settlement cron jobs created an unreasonable single point of failure for a supposedly “decentralized” payment system. But at the time, I didn’t know a better way.


Migrating to Fiber: Putting the Server on a Diet

Then I discovered Fiber Network.

Fiber is CKB’s official off-chain payment channel SDK, providing a complete channel lifecycle — opening, payments, closing, settlement — through a well-designed API. Under the hood, it handles everything I was previously wrestling with manually: multisig scripts, commitment transactions, refund paths, witness construction, and on-chain settlement.

The migration was surprisingly smooth. Here’s the new architecture:

Browser (Fiber WASM Node)  ←→  WebSocket Proxy  ←→  Merchant fnn Node  ←→  CKB Testnet
                                      ↕
                              Next.js Server
                        (AI streaming + Invoice creation)

The browser now runs a FiberBrowserNode from @fiber-pay/sdk/browser. This is a complete Fiber node compiled to WebAssembly, managing channel state locally, signing payments locally, and communicating with the merchant’s fnn (Fiber Network Node) through a WebSocket proxy. The server’s role has been reduced to two things:

  1. Proxying AI responses: The /api/chat route streams text from Ollama, injecting an invoice event every 20 tokens.
  2. Creating invoices: The server calls the merchant node’s new_invoice RPC to generate payment requests. That’s it — a single JSON-RPC call.

Everything else — channel state management, payment signing, settlement, refunds — is handled by the Fiber SDK on the client side and the fnn binary on the merchant side. The server doesn’t store channel state, doesn’t pre-sign anything, and doesn’t need to run settlement cron jobs (well, I kept one for compatibility, but it’s no longer a load-bearing component).

Let me show you what this looks like in code.

Opening a channel: before vs. after

Before (Spillman): Construct funding transaction → compute tx hash → construct refund transaction with timelock → send to server → server verifies and pre-signs → client stores signature → user broadcasts funding transaction → server confirms.

After (Fiber):

await node.openChannel({
    pubkey: merchantPubkey,
    funding_amount: fundingAmount,
    public: false,
    one_way: true,
});

That’s it. One function call. The Fiber SDK handles peer negotiation, commitment transaction exchange, funding transaction construction, and on-chain confirmation — all inside openChannel.

Making a payment: before vs. after

Before (Spillman): Construct a transaction representing the latest fund allocation → sign with buyer’s private key → send to server → server verifies signature and stores → server maintains cumulative state.

After (Fiber):

const result = await node.sendPayment({ invoice });

The merchant creates an invoice (a Lightning-style payment request with amount, expiry, and payment hash), and the browser node pays it. The payment is atomic within the channel. No server-side state management needed.

Closing a channel: before vs. after

Before (Spillman): Retrieve pre-signed refund transaction → add buyer signature → construct 132-byte witness buffer → broadcast transaction → wait for confirmation. Plus a cron job running every minute to auto-settle expiring channels.

After (Fiber):

await node.shutdownChannel({ channel_id: channelId });

Another single function call. The SDK handles the cooperative close protocol, constructs the closing transaction, and broadcasts it.


What Does the Server Actually Do Now?

Let me be honest about what the server still does in the Fiber version, because it’s an interesting design question.

In a “pure” Fiber architecture, the server might not need to exist at all — the browser WASM node talks directly to the merchant’s fnn node, and payments happen entirely within the channel. But in our AI chat scenario, the server still plays two roles:

AI streaming proxy. The Ollama model runs on the server, and the chat stream is a server-side operation. The server streams text tokens to the browser, and every 20 tokens calls the merchant node’s new_invoice RPC to create a payment request. This invoice is injected into the stream as a custom data-invoice-request event:

// Create an invoice every 20 tokens
if (tokenCount % TOKENS_PER_CHUNK === 0) {
    const invoiceData = await createInvoice(chunkIndex);
    writer.write({
        type: 'data-invoice-request',
        data: { invoice, payment_hash, amount, chunkIndex },
    });
}

The browser listens for these events and auto-pays them through the WASM node.

Invoice creation. The server calls the merchant node’s RPC to create invoices. This is a server-side operation because the merchant node’s RPC endpoint shouldn’t be directly exposed to the browser (it sits behind the server, not publicly accessible).

Could you eliminate the server entirely? In theory, yes — if the merchant node exposed a public API for invoice creation and the AI model could be called from the client. In practice, keeping a thin server layer for these two responsibilities is reasonable and doesn’t reintroduce the trust issues of the old architecture.

The key difference is: the server no longer touches money. It doesn’t hold channel state, doesn’t pre-sign transactions, doesn’t manage settlement timing. If the server crashes mid-conversation, the user’s funds are still safe in the channel, and the channel can still be closed cooperatively or unilaterally through the Fiber protocol.

Compare this to the Spillman version: if the server crashed there, you’d lose the pre-signed refund transaction, the stored payment commitments, and the settlement cron job — recovering your funds would require manual intervention. That’s a fundamental architectural improvement.


Unidirectional vs. Bidirectional Channels: Why One-Way

One of the more interesting design decisions in this rebuild was using Fiber’s unidirectional (one-way) payment channel instead of a bidirectional one.

What’s the difference?

A bidirectional channel is what you typically see in Lightning Network or most payment channel implementations. Both parties deposit funds into the channel, and payments can flow in either direction. The channel has a “local balance” and a “remote balance,” and each payment shifts the split. This works well for scenarios where both parties might need to pay each other — like two people playing a betting game, or a marketplace where both buyers and sellers need to send and receive.

A unidirectional channel is simpler: only one party (the funder) deposits money, and payments can only flow in one direction — from funder to receiver. The receiver doesn’t need to contribute funds, and the channel state is simpler because there’s only one “payer” and one “payee.”

In code, the difference is a single flag:

await node.openChannel({
    pubkey: merchantPubkey,
    funding_amount: fundingAmount,
    public: false,
    one_way: true,  // ← This is the unidirectional flag
});

Why unidirectional is the right choice here

In the AI chat scenario, money only flows in one direction: from user to merchant. The user pays for AI responses. The merchant never pays the user. There’s no scenario where the merchant needs to send money back through the same channel.

Using a unidirectional channel gives us several advantages:

Simpler setup. The merchant doesn’t need to lock up any funds. In a bidirectional channel, both sides typically need to contribute — meaning the merchant needs capital available for every channel they open. For a service provider with thousands of users, that’s a significant capital requirement. With unidirectional channels, the merchant’s capital requirement is zero.

Simpler state machine. Bidirectional channels need to handle commitments from both sides, HTLCs (Hash Time-Locked Contracts) for routing, and more complex dispute resolution. Unidirectional channels have a simpler commitment structure because only one side can initiate payments. This means fewer edge cases and less room for protocol-level bugs.

No routing complexity. Bidirectional channels are designed for multi-hop routing through a network of interconnected channels. That’s powerful (as I showed in the EV charging article), but it’s overkill for a direct user-to-merchant scenario. A unidirectional channel is a point-to-point connection — easier to reason about, easier to debug, and lighter on resources.

Lower channel reserve. Fiber’s bidirectional channels require a minimum reserve from both sides (to cover potential on-chain settlement fees). Unidirectional channels also have a protocol reserve, but the economics are simpler since only one party is funding.

When would you want bidirectional?

Bidirectional channels make sense when:

  • Payments need to flow in both directions (e.g., a betting game, a marketplace with refunds)
  • You need multi-hop routing through a network (as in the charging scenario, where users pay through a Router to reach merchants they don’t have a direct channel with)
  • Both parties need to be able to initiate payments independently

For our AI chat demo, none of these apply. The user pays, the merchant receives. End of story. Unidirectional is the right primitive.


Passkey Login: Goodbye, Manual Private Key Entry

One more thing I changed in this rebuild: authentication.

In the Spillman version, users had to manually type their 64-character hex private key into a login form. I warned everyone at the time that this was a bad idea for production (private keys in localStorage are one XSS attack away from being stolen), but it was the fastest way to get the demo running.

With Fiber, the browser WASM node needs a key pair to operate. Instead of asking users to provide one, I switched to Passkey (WebAuthn) — the same standard your phone uses for fingerprint and face recognition logins.

const credentialProvider = new PasskeyCredentialProvider('dapp-2-fiber-ai');
if (!credentialProvider.isConfigured()) {
    await credentialProvider.register(passkeyDisplayName);
}
const node = new FiberBrowserNode({
    network: sdkNetwork,
    credential: credentialProvider,
    nodeConfig: { bootnodes: [] },
});
await node.start();

The user clicks “Connect,” the browser prompts for fingerprint or Face ID, the Passkey generates a key pair, the WASM node starts, and the user is connected. No private key to manage, no localStorage security vulnerability, no “please type your 64-character hex string” UX nightmare.

The CKB address derived from the Fiber node’s funding lock script becomes the user’s identity. The server uses this address to look up user records — no passwords, no JWTs, no session tokens. It’s the cleanest authentication flow I’ve ever built.


The Code That Disappeared

Let me take a moment to appreciate all the code that simply disappeared after migrating to Fiber. This is the most compelling comparison.

Gone: the custom multisig contract. That 2-of-2 JavaScript smart contract verifying two secp256k1 signatures — replaced by Fiber’s built-in FundingLock and CommitmentLock scripts. These are deployed on CKB mainnet and testnet, battle-tested, and don’t need to be written or deployed by the application developer.

Gone: funding transaction construction. That 50-line createPaymentChannel function — building the funding transaction, computing the hash, constructing the refund transaction with timelock, assembling witness data — replaced by node.openChannel().

Gone: refund transaction handling. That 100-line executeRefund function — parsing the pre-signed transaction, generating the buyer’s signature with since binding, assembling the 132-byte witness buffer, broadcasting the transaction — replaced by node.shutdownChannel().

Gone: server-side channel state management. Database tables tracking channel state, API endpoints for creating/confirming/settling channels, server-side signature verification — all gone. Channel state now lives in the browser’s WASM node and the merchant’s fnn node. The comment in the code reads: // NOTE: Channel sync to server has been removed. Channel state is now managed entirely via the WASM Fiber node.

Gone: settlement cron job urgency. That every-minute cron scanning for expiring channels and auto-settling them — no longer critical. Fiber’s protocol handles channel lifecycle internally, including cooperative and unilateral close. The cron job still exists in the codebase for compatibility, but it’s no longer a load-bearing component.

Gone: signature arithmetic. That generateCkbSecp256k1SignatureWithSince function — computing a combined hash of the transaction hash and the since value, then trying all four recovery IDs to find the correct one — gone. Fiber SDK handles all signature operations internally.

What’s left? About 500 lines of React hooks and payment processing logic orchestrating the Fiber SDK, plus the chat streaming route that injects invoice events. That’s the entire “payment” part of the application. The rest is just a chat UI.


The Invoice Payment Model: Charging Like the Lightning Network

One of the best things Fiber borrows from the Lightning Network is the invoice-based payment model. Here’s how it works in our scenario:

  1. The server calls the merchant node’s new_invoice RPC, specifying the amount (0.5 CKB per chunk), currency, description, and expiry.
  2. The merchant node returns an invoice — a string encoding the payment hash, amount, and destination.
  3. The server injects this invoice into the AI stream as a data-invoice-request event.
  4. The browser’s WASM node calls sendPayment({ invoice }) to pay it.
  5. If the payment returns Success immediately, we’re done. If it returns Created or Inflight (which can happen in multi-hop scenarios), the browser polls waitForPayment until it reaches a terminal state.

This is fundamentally different from the Spillman approach. In Spillman, each “payment” was a transaction representing the latest allocation of the multisig Cell. In Fiber’s invoice model, each payment is an atomic operation within the channel — more like a “push payment” than a “state update.”

This also means the payment flow is decoupled from the channel lifecycle. You can create and pay invoices at any time while the channel is open, without constructing or exchanging transaction structures. This dramatically simplifies the code and makes the mental model much easier to reason about.


An Interesting Comparison: How the Server’s Role Changed

Let me put the server-side responsibilities side by side for a more intuitive comparison.

Spillman version — server responsibilities:

  • Receive funding transaction structure from client
  • Verify amounts, timelock, multisig script
  • Pre-sign refund transaction and return signature
  • Store and manage channel state in database
  • Receive and verify payment commitments from client
  • Maintain cumulative payment state per channel
  • Run cron job every minute to auto-settle expiring channels
  • Run cron job every 10 minutes to detect unused expired channels
  • Retrieve and serve pre-signed transactions for refunds
  • Construct witness data and broadcast settlement transactions

Fiber version — server responsibilities:

  • Proxy AI responses from Ollama to the browser
  • Create invoices by calling the merchant node’s RPC

That’s the difference. The server went from being a critical piece of payment infrastructure (without which nothing works) to a thin application layer that happens to host the AI model. The payment system runs entirely between the browser and the merchant node.

This has profound implications for the trust model. In the Spillman version, you had to trust the server to: correctly pre-sign refund transactions, securely store your payment commitments, run settlement on time, and not run off with the funds (technically impossible since funds were in a multisig Cell, but the server controlled settlement timing and held the seller’s signature). In the Fiber version, there’s nothing to trust — the protocol handles everything, and the server literally cannot interfere with your funds.


From Clone to Online: Making the Demo Actually Runnable

Beyond the technical details, let me address a more practical question: how to actually get this demo running.

When the first “Chat-and-Pay” version was published, to try it out you had to clone the repo, install a bunch of dependencies, run a local CKB node (offckb), deploy contracts, register for OpenRouter to get an API key for the LLM, and then pray that all the environment configuration didn’t break… just the setup was enough to scare off most people. Looking back, I realize it wasn’t very friendly — you write an article inviting people to try it out, and they spend half an hour just trying to get the environment set up.

After this rebuild, I did a few things to lower the barrier:

Deployed it online. Now you can just open a browser and use it — no cloning repos, no running anything locally. Visit https://dapp.54-180-28-237.sslip.io directly, connect to the Fiber node, and start experiencing it.

Swapped the LLM for a local small model. The first version used OpenRouter to connect to commercial models like GPT-4 / Claude. The response quality was great, but you needed to register for OpenRouter, top up credits, and configure API keys. This time I switched to qwen2.5:0.5b running on Ollama — a very small local model. Honestly, it’s pretty dumb, and its answers are often hilariously off. But the core of this demo is showcasing streaming payments, not competing on model intelligence. A dumb but free local model actually brings the experience barrier down to zero.

Fiber node runs in the browser. Users don’t need to install any wallet plugins or clients. Open the page, click Connect, the browser generates a key pair via Passkey (fingerprint/face), the WASM node starts in the background, and connects directly to the merchant node. The whole process is no different from logging into a regular website.

The gap between “clone + configure environment + register API” and “open browser and use it” isn’t about technical difficulty — it’s about product awareness. A demo that only the author can run will always be just a demo.


Web2 vs Web3: What’s Different This Time

In the first article, I said the Spillman demo was a hybrid — Web2 engineering (chat UI, cron jobs, session management) plus Web3 settlement (channels, signatures, timelocks). The hybrid was honest and practical, but it also exposed a tension: the more the server had to do, the less “decentralized” the system felt.

With Fiber, that tension is largely resolved. The server still exists (it has to — the AI model runs somewhere), but it’s no longer a critical part of the payment system. If someone else wanted to build a different frontend using the same AI model and the same merchant node, they absolutely could — regardless of what UI sits on top, the payment channel works the same way.

In the first article, I said: “Use Web2 experience to carry Web3 value.” I still believe in that principle, but now I’d add: let the Web3 protocol layer handle what it should, and let the Web2 service layer do only what it’s good at.

AI streaming proxy is what Web2 does well — HTTP streaming, model serving, session management. Payment channels are what Web3 does well — trustless fund management, atomic payments, on-chain settlement. With Fiber, the boundary between these two responsibilities is clean and clear.


Final Thoughts

This rebuild taught me something unexpected: the hardest part of building with raw blockchain primitives isn’t the cryptography or the transaction formats — it’s state management. Tracking channel lifecycles, managing pre-signed transactions, running settlement cron jobs, handling edge cases when the server crashes — these are all state management problems, and they should all be solved for you by a well-designed protocol layer.

Fiber solves them. Not perfectly (the SDK is still evolving, and browser WASM support still has some rough edges), but well enough that the application code went from “I need to understand every detail of UTXO transactions” to “I just need to call openChannel and sendPayment.”

For anyone looking to build streaming payment scenarios on CKB — AI chat, live video, IoT data feeds, API metering — Fiber’s unidirectional channel is the primitive you want. It’s simpler than hand-rolling a Spillman channel, safer than trusting a server to manage channel state, and more practical than setting up a full bidirectional channel network for a use case that only needs one-way payments.

Open source: GitHub - HappySonnyDev/UnidirectionalChannelByFiber · GitHub

See you in the next article.

3 Likes