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

I use Binance P2P a lot. I have also watched people get locked out of those accounts, with their coins sitting frozen until the exchange decides to give them back.

That is a bad way to buy and sell CKB with local money. Right now, onboarding into CKB via fiat has four major problems:

  1. Custodial risk and account freezes. To sell on centralized P2P desks, you must deposit your coins into the exchange hot wallet first. If the platform freezes your account, your funds are trapped.
  2. The KYC barrier. Centralized fiat ramps require heavy identity checks and frequently reject users from emerging markets where P2P trading is needed most.
  3. DEX limitations. On-chain DEXs (like UTXOSwap) excel at crypto-to-crypto swaps, but they cannot process off-chain bank transfers, payment apps, or local cash.
  4. Underutilized Fiber capabilities. The Nervos Fiber Network has cryptographic primitive (HTLC hold invoices), but no KYC-free, non-custodial fiat desk exists on CKB to use it.

The solution: Twine

Twine is a non-custodial peer-to-peer desk built to solve this. You post an offer in your local currency. Someone accepts it. Your CKB locks on Fiber in a conditional hold invoice until they pay you. You check the receipt, verify the money in your bank app, and click release. If they never pay, the coins return to you automatically when the lock expires.

There is no Twine account. Twine coordinates the order and releases the lock only when the seller confirms the money arrived. Your CKB stays locked on Fiber the entire time. Twine never takes custody of your funds.

Who is involved

Each phone talks to Twine to manage the order and chat, and communicates directly with its own Fiber node to move funds. Twine never holds your private keys or Fiber channels.

flowchart TB
  SellerApp[Seller phone]
  BuyerApp[Buyer phone]
  Twine[Twine coordinator]
  SellerNode[Seller Fiber node]
  BuyerNode[Buyer Fiber node]
  TwineNode[Twine Fiber node]
  Fiber[Fiber Network]

  SellerApp -->|offer, chat, release| Twine
  BuyerApp -->|take, receipt, chat| Twine
  SellerApp --> SellerNode
  BuyerApp --> BuyerNode
  Twine --> TwineNode
  SellerNode --- Fiber
  BuyerNode --- Fiber
  TwineNode --- Fiber

Fiat stays completely off-chain. Dollars, Naira, or Pesos move directly from buyer to seller via bank transfer, payment apps, or cash.

The coordinator is whoever runs the Twine daemon. They can view the active order, the trade chat, and the uploaded payment receipt. If a trade goes into dispute, the coordinator acts as the arbiter who reviews the evidence and decides the outcome.

The code is 100% open source. The mobile app lets you choose which coordinator daemon to connect to. If you do not trust the default coordinator, you can run the daemon yourself or use one hosted by your local community. Both traders use that same coordinator for the trade. The orders, encrypted messages, receipts, and release instructions live on the coordinator you chose.

flowchart LR
  people[Both traders]
  people --> choice{Coordinator you both trust}
  choice --> twine[Default Twine Node]
  choice --> own[A node you or your community run]
  twine --> job[Coordinates order, chat, receipt, and release]
  own --> job

How a trade works

Twine initiates a hold invoice. The seller locks CKB into it by accepting the trade. The buyer pays outside the app and uploads a payment receipt. Clicking Release instructs the coordinator to reveal the preimage, settling the payment to the buyer on Fiber first, and closing the hold. If that payout cannot be routed, the hold remains unsettled and the seller’s coins are not given away.

sequenceDiagram
  actor Seller
  actor Buyer
  participant Twine
  participant Fiber

  Seller->>Twine: Post an offer
  Buyer->>Twine: Take the offer
  Twine->>Fiber: Create hold invoice
  Seller->>Fiber: Accept and lock CKB into HTLC
  Buyer->>Buyer: Pay fiat outside the app
  Buyer->>Twine: Upload receipt and mark paid
  Seller->>Twine: Release
  Twine->>Fiber: Settle HTLC to buyer
  Twine->>Fiber: Close hold

Here is that same trade running on testnet: 1 CKB for 1 USD across two phones.

  1. The buyer takes the offer. Nothing is locked yet, and either party can still cancel.

  1. The seller sees the incoming trade on their posted offer.

  2. Accepting locks the CKB on Fiber.

  1. The buyer pays off-chain, attaches a receipt, and taps I have paid. On the seller’s phone, the coins remain securely locked while this happens.


  1. The seller verifies the funds in their bank or payment app, checks the receipt, and taps Release. The buyer receives the CKB, and the hold closes.

What happens during a dispute?

Once CKB is locked, either party can open a dispute and state a reason. The trade pauses immediately. The coins remain locked in the HTLC while the coordinator reviews the encrypted chat logs and the payment receipt uploaded by the buyer.

  • If the buyer paid: The coordinator reveals the preimage to settle the HTLC on Fiber. The buyer receives the CKB, and the hold closes.
  • If the buyer did not pay: The coordinator withholds the preimage and does not close the hold. Fiber automatically returns the locked CKB to the seller when the HTLC timelock expires. On this testnet lab, the lock duration is 16 hours (the minimum hold supported by the node). The coordinator cannot pull a locked payment back early.
sequenceDiagram
  actor Seller
  actor Buyer
  participant Coordinator
  participant Fiber

  Note over Seller,Buyer: CKB is locked in Fiber HTLC
  Seller->>Coordinator: Open dispute and submit reason
  Note over Buyer,Coordinator: Buyer can also trigger dispute
  Coordinator->>Coordinator: Review chat logs and receipt
  alt Buyer paid
    Coordinator->>Fiber: Reveal preimage and settle to buyer
    Fiber-->>Buyer: CKB arrives in buyer channel
  else Buyer did not pay
    Coordinator->>Fiber: Withhold preimage and let HTLC expire
    Fiber-->>Seller: Lock expires, CKB returns to seller
  end

Edge cases and failure handling

A standard release pays the buyer. If the Fiber path payment fails to route to the buyer, the coins stay locked in the hold until the buyer retries or the HTLC timelock expires, returning the CKB to the seller.

flowchart TD
  locked[CKB locked on Fiber HTLC]
  locked --> release[Seller releases]
  locked --> stuck[Fiber payout to buyer fails]
  locked --> dispute[Dispute opened]
  locked --> expire[HTLC timelock expires]
  release --> buyer[Buyer receives CKB]
  stuck --> retry[Buyer retries route]
  retry --> buyer
  stuck --> expire
  dispute --> paid{Coordinator reviews chat and receipt}
  paid -->|Buyer paid| buyer
  paid -->|Buyer did not pay| expire
  expire --> seller[CKB returns to seller]

This is currently a testnet proof-of-concept. The mobile app is built in Flutter, supported by a lightweight Rust coordinator daemon. Each phone interacts directly with its own Fiber node.

I want your honest, blunt feedback on anything.
Whether it’s the UX, potential edge cases I missed, protocol architecture, or general thoughts on P2P trading, I’d love to hear it.
If you’re not sure where to start, here are a few things I’m especially curious about:

  1. Would you use a non-custodial desk like this over centralized P2P desks?
  2. Which local payment methods matter most in your region?
  3. What would you need to see before you fully trust the Release flow?
9 Likes

Update: Dispute resolution is now built into the app (PoC)

In my previous post, I outlined the concept behind handling disputes: the coordinator reviews the chat logs and uploaded receipt, then either reveals the preimage to settle CKB to the buyer or lets the HTLC expire so CKB returns to the seller.

I’ve now integrated that Dispute Desk directly into the Flutter app as a tab for faster testing.

Important PoC Note: In this local testnet setup, there is no admin authentication required—anyone connected to the daemon can take a case and resolve it for lab testing. In a production build, this desk would be cryptographically restricted to authorized coordinator/solver keys (or accessible via twine-cli).

Here is a walkthrough of the dispute flow using a 1 CKB / 1 NGN test trade after the buyer marked the order as paid:

1. File a dispute from the order room

Either party can type a reason and tap File dispute. The order status freezes, and the normal Release button is disabled in the trade room.

2. Review pending dispute cases

The Disputes tab lists open appeals. In this example, the seller opened a dispute because they hadn’t seen the fiat transfer land in their bank account.

3. Review evidence and resolve

Tapping Take opens the full case file: trade amounts, both Fiber public keys, the stated dispute reason, the complete chat history, and the uploaded receipt image. The coordinator can post in the chat thread as an admin, then execute one of two actions:

  • Pay Taker (Buyer): Twine pays the buyer’s invoice and triggers settle_invoice. The buyer receives the CKB on Fiber.

  • Refund Lister (Seller): Twine withholds the preimage. The hold remains locked until Fiber’s HTLC timelock expires (16 hours on this fnn lab setup), automatically returning the CKB to the seller.

As a reminder, the coordinator cannot pull locked funds back early. If the buyer actually paid, you reveal the preimage to settle. If they did not pay, you let the HTLC timelock expire.

The repository has been updated with this new flow:

:link: github.com/chukwuma619/twine-fiber-poc

I’d love your blunt feedback on this UI and process:

  1. Is this review view (chat + receipt + pubkeys) enough information for an arbiter to make a fair decision?

  2. For production, do you prefer having the Dispute Desk inside the app behind an admin key login, or should solver tools remain strictly in a separate CLI tool (twine-cli)?

3 Likes

I think this is a good idea, and the PoC is well done. The UI already makes the basic trading and dispute resolution flows clear :+1:

For the next step, I suggest adding more on defining clear rules: how evidence is verified, who can take which actions, and who is responsible when something goes wrong. Tests showing that these rules hold up when payments fail, time out, or are disputed would go a long way toward building trust.

Thanks Quake.

I took that approach: the rules are written down, and the daemon tests fail if a payment, a timeout, or a dispute breaks them.

Fiat stays off the protocol. A receipt is a JPEG or PNG the buyer uploaded. The solver reads that picture, the chat, and the order. Nothing checks a bank. What the tests lock down is the daemon’s order state machine: which Fiber RPCs it calls, and which calls it refuses, against a mock Fiber node. They do not run CKB or a real fnn. The hold is a Fiber invoice. This code never force-closes a channel.

Who can act

  • Seller: accept the hold, release (including during an open dispute, if the buyer already sent a payout invoice), file a dispute, or agree to walk away.
  • Buyer: mark paid and upload the receipt, file a dispute, agree to walk away, or retry after a failed payout.
  • Solver: take the case with TWINE_SOLVER_TOKEN, then pay the buyer or refund the seller. Take and both award routes reject any other token. An award before take is rejected. The order room cannot award.
  • Daemon: if the trade is FiatSent and the invoice is still Received, it opens a dispute two hours before hold_by, with dispute_from = twine, and leaves S on the watchtower. That gives a solver time to call settle_invoice before Fiber expires. It does not stop the timelock. If nobody settles, the hold still expires. A hold whose pay window closed, and where fiat was never marked sent, is left for Fiber to expire.

Filing a dispute does not freeze Fiber’s clock, and it does not freeze the two parties. Until the solver awards, the seller can still release, or both sides can agree to walk away. It takes both. After an award or a walk-away, those actions stop.

When something goes wrong

  • If the payout to the buyer cannot route, settle_invoice is not called. The hold stays Received and the state is Leg2Failed. The buyer retries with a new invoice.
  • A Received hold is refunded by Fiber’s timelock. cancel_invoice is not called on it. An Open hold can still be cancelled inside the 15-minute accept window.
  • S is stored in the daemon and registered on the Twine node with create_preimage. GET /trades never returns it. The watchtower copy stays through FiatSent, Leg2Failed, and a dispute opened from those states, so a force-close can still be claimed while the trade can still settle. remove_preimage drops that copy on cancel, when the pay window closes, when the solver refunds the seller, on a mutual walk-away, after a successful settle, and when the timelock expires. The daemon’s own copy stays in market.json.

Tests (daemon/src/order/poc_test.rs)

cargo test in daemon/ runs these against the mock Fiber node:

  • failed_payment_does_not_settle_until_retry_pays
  • received_hold_timeout_refunds_seller_and_settle_fails
  • dispute_freezes_release_and_award_follows_the_hold
  • parties_can_release_or_abandon_an_open_dispute
  • fiat_sent_near_hold_expiry_opens_a_dispute_without_dropping_the_preimage
  • built_in_watchtower_holds_the_preimage_until_the_trade_ends

The same rules are in the README under “Four endings” and “Disputes”. The open gap is the one you pointed at: the solver is still trusting a picture. Nothing in this PoC proves the fiat moved.

2 Likes

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

Nice write-up. I cloned the repo and read it end to end; the state machine is tidy and the tests are honest. One line of yours has been sitting with me:

The operator can still settle or drop the preimage while the trade is open. That is the trust left in the coordinator.

I’d fix that before deploying anything, because it turns out to be fixable with the RPCs you are already calling.

Today it sits a little past ‘trust’: the daemon mints S at take, keeps it in SQLite and registers it with the watchtower before the seller has even paid. ‘Release pays the buyer first’ is a property of your process, not of the construction, and the biscuit carries write("invoices") — the operator can settle the hold straight off the node and never pay anyone.

The trick: make the hold’s hash the buyer’s, not yours.

  1. The buyer’s node generates S_b and issues an invoice with payment_hash = H(S_b). new_invoice already takes a caller-supplied hash — you use exactly that to create holds — so Fiber needs nothing new.
  2. take carries that invoice; the daemon builds the seller’s hold under the same hash and holds no preimage at all.
  3. send_payment succeeds, and only then does your node learn S_b; settle follows.
sequenceDiagram
    autonumber
    participant B as Buyer node
    participant D as Daemon
    participant O as Operator node
    participant S as Seller
    B->>B: mints S_b, invoice under H(S_b)
    S->>D: new-order
    B->>D: take with payout invoice
    D->>O: hold under H(S_b), no preimage anywhere
    S->>O: pays hold, becomes Received
    Note over O: without S_b: no settle, no force-close claim
    B->>D: fiat-sent
    S->>D: release
    D->>O: send_payment
    O->>B: pays H(S_b), learns S_b
    O->>O: create_preimage then settle_invoice

After that, settling without paying stops being a broken promise and becomes impossible: no S_b means no live settle and no on-chain claim, and the timelock just refunds the seller. A pleasant side effect — every settle is now a receipt that the buyer was paid.

Mind the edges. Buy posts need an invoice-request round trip, since the maker is not in the take message; never reuse a hash across takes, because the first settle hands S_b to your node; re-submitted invoices during awaiting-invoice must keep the same hash; and persist the payout intent before send_payment, so a crash recovers via get_payment rather than paying twice. The trust page stays honest in places: a buyer can still hand S_b over — the same betrayal the fiat leg already relies on — and TWINE_SOLVER remains yours to appoint.

One bug while I was in there: the payout poll breaks on the first error, so a wobble after a successful payout strands the trade until the timelock refunds the seller while the buyer already holds the coins. That step wants retrying, not abandoning.

2 Likes

thank you, i will look into it

1 Like

Thank you very much for your feedback.

The hold hash is now the buyer’s. The daemon stores no preimage at take. It learns payment_preimage only after paying the buyer’s invoice, then registers that with the watchtower and settles. Until that payment succeeds, the timelock refunds the seller.

On a sell post the invoice is on take. On a buy post both sides get need-invoice, and the buyer sends payout-invoice before any hold exists. A hash already used by another trade is rejected, and a replacement invoice, including one in a dispute or a solver award for the buyer, has to keep the same hash. The payout hash is written before send_payment, and the payout poll retries on error instead of stopping.

A buyer can still hand the preimage to someone else, and TWINE_SOLVER is still a key the operator configures. This needs a Fiber node that returns payment_preimage. Current develop does.

sequenceDiagram
    autonumber
    participant BN as Buyer node
    participant B as Buyer
    participant D as Daemon
    participant O as Operator node
    participant S as Seller

    alt Sell post
        S->>D: new-order
        BN->>BN: mint S_b, invoice under H(S_b)
        B->>D: take with that invoice
    else Buy post
        B->>D: new-order
        S->>D: take, no invoice
        D-->>B: need-invoice
        D-->>S: need-invoice
        BN->>BN: mint S_b, invoice under H(S_b)
        B->>D: payout-invoice
    end

    D->>O: hold under H(S_b), no preimage
    D-->>S: pay-invoice
    D-->>B: pay-invoice
    Note over O: no S_b, so no settle and no force-close claim
    S->>O: pays the hold
    O-->>D: Received
    D-->>B: waiting-fiat
    D-->>S: waiting-fiat
    B->>S: fiat, outside Twine
    B->>D: fiat-sent
    S->>D: release
    D->>D: record the hash before paying
    D->>O: send_payment
    O->>BN: pays H(S_b)
    O-->>D: payment_preimage is S_b
    D->>O: create_preimage, then settle
    D-->>B: settled
    D-->>S: settled
stateDiagram-v2
    [*] --> waitingInvoice: buy post taken
    [*] --> waitingHold: sell post taken
    waitingInvoice --> waitingHold: payout-invoice
    waitingInvoice --> canceled: cancel, or 1 hour with no invoice
    waitingHold --> waitingFiat: seller pays the hold
    waitingHold --> canceled: cancel while the invoice is Open
    waitingHold --> expired: unpaid hold expires
    waitingFiat --> fiatSent: fiat-sent
    waitingFiat --> refunding: 15 minutes with no fiat-sent
    waitingFiat --> disputed: dispute
    fiatSent --> releasing: seller releases
    fiatSent --> refunding: safety deadline
    fiatSent --> disputed: dispute
    releasing --> settled: buyer paid, then hold settled
    releasing --> awaitingInvoice: payout fails
    awaitingInvoice --> releasing: new invoice, same payment hash
    awaitingInvoice --> refunding: safety deadline
    awaitingInvoice --> disputed: dispute
    disputed --> releasing: seller releases, or solver awards the buyer
    disputed --> refunding: solver awards the seller, or safety deadline
    refunding --> expired: hold expires, slice returns to the post

One correction in the sequence: the operator node learns S_b by paying. The buyer node already had it.

2 Likes

Update: sell-only book, and JIT inbound at release

1. The book is sell-only

A post offers CKB. The poster is the seller. The taker is the buyer. There is no buy post.

The last build had both sides: sell (offering CKB) and buy (bidding for CKB). A buy post made the taker the seller. take had no invoice. Both sides got need-invoice, and the buyer sent payout-invoice before any hold existed.

That path is gone.

  • new-order with side other than sell is refused.
  • Taking a leftover buy post is refused.
  • take always carries the buyer’s payout invoice. The hold is created there. No need-invoice round trip on take.
  • The app no longer has Buy / Sell tabs. Posts is one list of sell offers. A buy post on the relay is hidden.
  • New post has no side picker. It always sells. Confirm is Sell CKB?

You buy CKB by taking someone else’s sell. You sell by posting.

sequenceDiagram
    autonumber
    participant BN as Buyer node
    participant B as Buyer
    participant D as Daemon
    participant O as Operator node
    participant S as Seller

    S->>D: new-order (sell)
    BN->>BN: mint S_b, invoice under H(S_b)
    B->>D: take with that invoice
    D->>O: hold under H(S_b), no preimage
    D-->>S: pay-invoice
    D-->>B: pay-invoice
    Note over O: no S_b, so no settle and no force-close claim
    S->>O: pays the hold
    O-->>D: Received
    D-->>B: waiting-fiat
    D-->>S: waiting-fiat
    B->>S: fiat, outside Twine
    B->>D: fiat-sent
    S->>D: release
    D->>D: record the hash before paying
    D->>O: send_payment
    O->>BN: pays H(S_b)
    O-->>D: payment_preimage is S_b
    D->>O: create_preimage, then settle
    D-->>B: settled
    D-->>S: settled

2. JIT inbound at release

A Fiber payment needs inbound on the receiver. Capacity is not inbound. A channel the buyer funds alone cannot receive. Asking every buyer to dual-fund a channel before they take a small trade is a bad first step, especially on a laptop with no public IP.

On release, if Twine cannot already pay the buyer, the daemon opens a private one-way channel to them, funded from the Twine Fiber node.

That is JIT inbound. It happens at payout, not at take.

sequenceDiagram
    autonumber
    participant S as Seller
    participant D as Daemon
    participant O as Twine Fiber
    participant B as Buyer node

    S->>D: release
    D->>O: list_channels toward buyer
    alt outbound already covers the trade
        D->>O: send_payment
    else no inbound
        D->>O: connect_peer if needed
        D->>O: open_channel one_way, private, trade + reserve, min 100 CKB
        O-->>B: channel confirms
        D->>O: send_payment
    end
    O-->>D: payment_preimage is S_b
    D->>O: create_preimage, then settle

If the buyer already has inbound, Twine pays and does not open. If they are already peered, it does not dial again. If the node is offline, it does not hang on the hold. It asks for a new invoice. The buyer reconnects, stays online, and retries. A pending open is not paid twice.

The hub now publishes its listen addresses on kind 31421. Take can include a public Fiber listen address. Loopback and LAN addresses are refused. A laptop connects outbound to the hub. It does not need a public IP.

What you do

Role Before the trade At payout
Seller Post the offer. Open and fund a channel to the Twine hub so you can lock the hold. Release, same as before.
Buyer Take that sell. Connect outbound to the hub. Leave auto-accept on. Keep about 99 CKB on-chain. Stay online. Twine opens inbound to you if you cannot receive yet.

The app says this on Post, Take, and Trade. Post and Take show a channel checklist. Seller has to confirm outbound before they can post or take. Buyer is told to stay online so Twine can open inbound at payout.

What this spends

The Twine node now spends on-chain CKB to fund those channels. Keep spare CKB on it. The RPC biscuit grew: read / write on channels and peers, on top of invoices, payments, node, and watchtower.

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

A one-way channel only lets the opener send. Twine is the opener. A buyer-funded one-way still cannot receive.

3. Deploy

There is a VPS install path: DEPLOY.md. Fiber 0.9.1, port 8228 public, RPC on localhost, systemd for the daemon. The README covers the CKB key, the biscuit, and announced_addrs for wallets that are not on the same machine.

What this does not change

  • Fiat is still off the protocol.
  • The daemon still stores no preimage at take.
  • A failed payout still does not settle. The buyer can still send another invoice with the same payment hash.
  • The 36-hour hold, the 15-minute payment window, and the 30-minute safety margin are the same.
  • Disputes are the same.

Tests (cargo test in the daemon, mock Fiber)

  • a_buy_post_cannot_be_created_or_taken
  • a_post_must_be_sell
  • payout_skips_jit_when_the_buyer_already_has_inbound
  • payout_opens_a_one_way_channel_when_the_buyer_has_no_inbound
  • payout_skips_connect_when_the_buyer_already_peered
  • payout_asks_for_a_new_invoice_when_the_buyer_node_is_offline
  • a_buyer_funded_one_way_channel_cannot_receive
  • operator_funded_one_way_can_pay

The app tests that Buy / Sell tabs are gone, that a buy post is hidden, and that the buyer checklist is JIT inbound at payout.

Code

Ask for review

@ArthurZhang @quake — please look at this JIT path. The hold hash is still the buyer’s. The new piece is that the operator node now opens a private one-way channel at release when there is no route.

I want blunt notes on:

  1. Opening at release, not at take. The buyer has to stay online through an on-chain channel open while the hold is already Received.
  2. Funding with trade + 99 CKB reserve, floored at 100 CKB so a default Fiber node auto-accepts. one_way: true, public: false.
  3. The biscuit now includes write("channels") and write("peers").
  4. If the open is slow, the 30-minute safety margin can still refund the seller after fiat was marked sent.
  5. Anything I am missing on Fiber auto-accept, one-way channels, or a laptop that only connected outbound.
1 Like

I can see some very good design decisions here, but also a few potential pitfalls worth looking into. I’ll go through it more carefully and give you a detailed response on Friday evening.

2 Likes