Twine: Buy and Sell CKB Without Depositing on an Exchange (Fiber P2P PoC)

The last reply described the proof of concept: a Flutter app and a Rust daemon on one machine, an HTTP API, a mock Fiber node in the tests, and a solver who was trusting a picture. I stopped extending that lab. The desk is now two programs.

The PoC repository is still the lab that reply was about. These two replace it.

What was wrong with the PoC

The trade loop was real. The way it was hosted was not something I would point a stranger at.

  1. The user was a string. A Fiber pubkey typed into an HTTP body was the identity. Anyone who could reach the daemon could post, take, release, or dispute as that string. Nothing was signed.

  2. The coordinator could read the trade. Chat was plaintext on the daemon. The receipt was a JPEG on disk. The payment handle (Opay · 0803…) was on the public ad. The dispute desk was a tab in the same app, gated by a shared TWINE_SOLVER_TOKEN. A shared secret is not a key.

  3. The phone talked to the user’s Fiber node. Accepting a trade called send_payment from the app against that phone’s fnn. That worked in the lab because both nodes were on the same machine. A real install would have meant giving the app RPC access to the wallet that holds the coins.

  4. The preimage lived in the wrong places. S stayed in market.json after the trade was over. The watchtower copy was removed after a successful settle_invoice. That is backwards. settle_invoice pays the live channel. A force-close of an older commitment that still contains the HTLC still needs S on the watchtower. A file that keeps S forever is a second copy you did not need.

  5. The book was sell-only, and the clocks fought the timelock. Buy bids were out of scope. The hold was Fiber’s 16-hour minimum. If fiat had been marked sent, the daemon opened a dispute two hours before hold_by and left S registered, but it would still start a payout with no margin for a route that was in flight when the timelock hit. Filing a dispute did not stop Fiber’s clock. The two-party “walk away” button did not stop it either.

  6. The tests never talked to a node. They locked the state machine against a mock. That was the right test for the lab. It was not a desk.

What replaced it

The CKB path is the same idea. The seller’s coins sit in a Fiber hold invoice on the seller’s own channel. The daemon creates that invoice on the operator’s node. The seller pays it from their own Fiber wallet. The buyer pays fiat outside the app and names a Fiber invoice for the CKB. Release pays that invoice first. Only a successful payout calls settle_invoice. A failed payout does not settle. The buyer submits another invoice. A Received hold is never canceled. Fiber’s timelock is the refund. The daemon does not force-close a channel.

What changed is who is allowed to see what, and who is allowed to call Fiber.

Identity is a Nostr key. The app creates or imports an nsec. That pubkey is the account. Actions are signed events. The daemon treats the event author as the sender. The poster, the seller, the buyer, and the solver are four checks against that author, not a field in a JSON body.

The private half is NIP-44. These kinds are application constants, not NIPs.

Kind What it is
4242 An action to the daemon, or a reply from it. Encrypted to the recipient.
31420 The public order. Addressable. The d tag is the order id.
31421 The operator’s Fiber pubkey, so a wallet can open a channel to that node.
31422 The payment catalog.
4243 Chat between the two traders. The daemon does not subscribe to it.

There is no HTTP API for trades. One relay accepting an event is enough. An event id the daemon has already handled is ignored. On startup the daemon republishes every stored order. The app asks for my-trades again when a relay socket is replaced, so a reconnect does not lose the book.

The coordinator no longer holds the evidence. Account numbers are not on the order. After the hold locks, the seller sends the account name and number on the encrypted thread. The buyer’s receipt is a picture on that same thread, not a file on the daemon. The daemon never reads kind 4243.

A dispute is the one moment a third person can read that thread. Either party can file during waiting-fiat, fiat-sent, or awaiting-invoice, and only when TWINE_SOLVER is set to a Nostr pubkey. The app sends the NIP-44 conversation key with the dispute. The parties are told who the solver is. The solver receives the key. A key that is not 32 bytes of hex is dropped, and the dispute still opens. The daemon forwards that key. It does not use it. There is no dispute tab in the app. resolve is a signed action from that solver key, winner of buyer or seller.

The operator’s Fiber RPC is a biscuit, not an open port. TWINE_RPC_TOKEN is sent as Authorization: Bearer. The token the daemon needs is:

read("node");
read("invoices"); write("invoices");
read("payments"); write("payments");
node("<this fnn pubkey>");
write("watchtower");

create_preimage refuses a token with no node fact. The daemon cannot do more on that node than invoices, payments, and the watchtower. How to mint the key is in Fiber’s biscuit auth. The first successful RPC pins that node’s pubkey into SQLite. Restarting against a different node while a hold is open fails on purpose. The database file is created mode 0600.

The watchtower copy and the database copy are split. On take, the daemon registers S with Fiber’s built-in watchtower. On startup it puts every still-claimable hold back, and drops any trade already in refunding. After a successful settle it deletes S from SQLite and leaves the watchtower copy, because a force-close of a commitment that still holds the HTLC has to be claimable. On cancel, and when the decision is to refund the seller, it calls remove_preimage and deletes the database copy, so that hold can no longer pay the operator.

Both sides of the book exist. A post is sell (offering CKB) or buy (bidding for CKB). The seller is whoever locks the hold. On a sell post that is the poster. On a buy post that is the taker. The public order shows the method id, kind, label, and currency. This build ships two methods, GTBank for NGN and Zelle for USD. Account numbers stay in the thread.

The clocks leave a margin. All of these are fixed.

Clock Value If it passes
Unpaid hold 1 hour The invoice expires. The slice returns to the post.
Payment window 15 minutes after the hold is Received refunding. The preimage is dropped.
Safety margin 30 minutes before the hold ends A payout or a buyer-wins resolution that would start inside this window refunds the seller instead.
Hold 36 hours Fiber accepts 16 to 48. A Received invoice cannot be canceled, so this is also how long the coins can sit.

The two-hour auto-dispute from the lab is gone. So is the two-party walk-away. If nobody releases, the payment window or the safety margin drops the preimage and the timelock returns the coins. I would rather refund early than call settle_invoice in a race with Fiber.

The rules that did not change: the seller can still release during an open dispute when a payout invoice is already stored. After the solver awards, that stops. A buyer award pays the buyer’s invoice, then settles. A seller award drops the preimage and waits for the timelock. The payout invoice must be this node’s currency (Fibt on testnet, Fibb on mainnet) and the same number of shannons as the trade.

What this means right now

A person can post or take without depositing CKB on an exchange. The coins stay in the seller’s channel, locked to an invoice on the operator’s node, until release or until the 36-hour hold expires.

What they need:

  • The app, and a daemon npub plus the relays that daemon uses. A new install offers one. Settings replaces it. Both traders on a trade use the same daemon.
  • Their own Fiber wallet. The app shows the operator’s pubkey (kind 31421) so they can open a channel to it, and it shows the hold invoice so they can pay it there. The app does not pay it.
  • A solver, if they want disputes. The operator sets TWINE_SOLVER to one Nostr pubkey. Until that is set, dispute is refused. There is no solver screen in the app yet.

What I am not claiming:

  • Twine charges no fee, keeps no ratings, and takes no bond. There is no UDT.
  • One open trade per post. The next take waits until that trade is settled, canceled, or expired.
  • cargo test still drives the state machine without a node. A few tests talk to a live fnn and are ignored unless TWINE_RPC and TWINE_RPC_TOKEN are set. They check that take creates a hold and registers the preimage, that pay-invoice does not contain S, that release pays the buyer before it settles, and that cancel is refused once the hold is Received.
  • Fiat is still off the protocol. The receipt is still a picture the buyer uploaded. Nothing checks a bank.

Why this matters for Fiber

Fiber already had the primitive this needed: a hold invoice, a timelock that refunds a Received payment the coordinator cannot cancel, and a built-in watchtower that can claim that HTLC on force-close if it has the preimage. The lab called those RPCs from a mock. This daemon calls them on an fnn, with a biscuit scoped to that job, and it never closes a channel to do it.

The split is the part I think other Fiber apps can copy. The user’s node is the wallet. The operator’s node is the counterparty the hold is locked to. The coordinator process is not that node. It holds one secret, the preimage, and a token that can create invoices, pay invoices, and register or remove that preimage. It cannot spend the rest of the node. The phone is not a wallet either. It shows an invoice and gets out of the way.

The limit is Fiber’s, and it should stay visible. A received hold cannot be pulled back early. The shortest hold this network accepts is 16 hours. This desk uses 36, and it refuses to start a payout in the last 30 minutes. A fiat trade on Fiber is a long lock with a person at the end of it, not an instant swap. That is the shape of the tool. Designing around it is the product.

The part Fiber cannot close is the same gap as the last reply. The seller looks at their bank. If they will not release, a solver looks at an encrypted thread they could not read until someone filed. The operator can still settle or drop the preimage while the trade is open. That is the trust left in the coordinator. It is no longer the user’s Fiber key, the user’s RPC, the chat, or the account number.

The trade

I ran one on testnet. 2 CKB for 2 NGN, GTBank. It settled.

Posting 1000 CKB at 1 NGN. The Fiber pubkey is the wallet. The Nostr key is the account.

The same post on the book. Buy is other people’s offers. Sell is yours.

After the take, nothing is locked. Either side can still cancel. This take was 2 CKB, so 2 NGN.

The seller pays the hold invoice in their Fiber wallet. The app only shows it. After this, cancel is gone.

The seller sends the account on the encrypted thread. The relay can see that the two keys are talking. It cannot read this.

The buyer pays 2 NGN outside the app, puts the trade reference on the transfer, and marks it paid with a Fiber invoice for the CKB. A receipt that is too big for one relay event has to be cropped. A smaller one went through.

The seller checks the account, then releases.

Settled. The naira never touched the app. The buyer had the CKB.

Next is deploying the daemon, then an APK, then the coordinator CLI so a solver can actually take a dispute.

cc: @quake

2 Likes