Skip to content

Implement Cashu 2-of-3 multisig escrow (client-side) #175

@grunch

Description

@grunch

Implement Cashu 2-of-3 multisig escrow (client-side)

This issue tracks the work needed in mostro-cli to support the new Cashu ecash escrow mode, the non-custodial alternative to Lightning hold invoices. It is the client counterpart of the daemon effort described in mostro/docs/CASHU_ESCROW_ARCHITECTURE.md.

The protocol types already landed in mostro-core 0.12.0 (feat(cashu): protocol types for 2-of-3 multisig escrow (F1)), and the daemon foundation is in review:

  • mostro#758 — F2 config, escrow mode, conditional boot
  • mostro#760 — F3 EscrowBackend trait + Lightning impl + Cashu stub
  • mostro#759 — F4 CashuClient wrapper around cdk
  • mostro#761 — F5 DB helpers (escrow lock CAS + active-locked query)

⚠️ This is a guide to scope the work, not a finished design. Confirm exact cdk APIs against the version the daemon pins (see mostro#759) before implementing.


Core idea

Lightning hold invoices are replaced by Cashu ecash locked to a NUT-11 P2PK 2-of-3 spending condition over three keys:

  • P_Bbuyer's per-order trade pubkey
  • P_Sseller's per-order trade pubkey
  • P_M — Mostro's arbitrator pubkey (provided by the daemon)

Mostro holds 1 of 3 keys and never takes custody: it only validates the lock and coordinates. All wallet operations (token swap, signing, mint swap submission) happen in the client — i.e. in mostro-cli. Two of {P_B, P_M} must sign to spend (primary key P_S).

🔑 Critical constraint: P_B and P_S MUST be the per-order trade keys, never the identity (master) keys — for unlinkability at the mint and for consistency with every other signature in an order. mostro-cli already derives a trade key per order (Order.trade_keys, User::get_next_trade_keys); reuse those exact keys for the Cashu condition and signatures.


What mostro-core 0.12.0 already gives us

Bump mostro-core = "0.11.3""0.12.0" in Cargo.toml. New surface to wire up:

Actions (Action):

  • AddCashuEscrow — seller → Mostro: submit the locked token (replaces seller paying a hold invoice)
  • CashuEscrowLocked — Mostro → buyer: token validated & unspent, "send fiat"
  • CashuPmSignature — Mostro → dispute winner: hands over Mostro's P_M signatures

Payloads (Payload):

Payload::CashuLockProof(CashuLockProof)        // on AddCashuEscrow
Payload::CashuSignatures(Vec<CashuProofSignature>) // on CashuPmSignature
pub struct CashuLockProof {
    pub token: String,         // serialized locked Cashu token
    pub mint_url: String,      // mint hosting the proofs (must match node config)
    pub buyer_pubkey: String,  // P_B (hex) — buyer trade pubkey
    pub seller_pubkey: String, // P_S (hex) — seller trade pubkey
    pub mostro_pubkey: String, // P_M (hex) — Mostro arbitrator pubkey
}

pub struct CashuProofSignature {
    pub secret: String,    // NUT-11 secret of the proof this sig applies to
    pub signature: String, // P_M signature (hex) to insert into that proof's witness
}

Under NUT-11 SIG_INPUTS, each input proof carries its own witness signed over that proof's own secret. A token split across denominations has multiple proofs → one signature per proof, matched by secret. Any signature-exchange handling must be a Vec, not a single signature.

Order fields (Order in mostro-core; mirror in our src/db.rs Order):

  • cashu_mint_url: Option<String>
  • cashu_escrow_token: Option<String>
  • cashu_escrow_locked_at: Option<i64>

Errors (CantDoReason): InvalidCashuToken, CashuMintUnavailable, CashuEscrowNotLocked, CashuSignatureMissing — surface these to the user in print_dm_events / response handling.


New dependency: a Cashu wallet

The client must embed a real ecash wallet. Add cdk (Cashu Dev Kit) — the same crate the daemon's CashuClient (mostro#759) wraps. We need wallet-side capabilities the daemon doesn't:

  • mint connection + keyset fetch
  • holding unencumbered proofs (a local balance)
  • swapping unencumbered → P2PK-locked proofs (SIG_INPUTS)
  • producing NUT-11 witness signatures with a trade key
  • submitting the final swap to the mint to redeem

Build the 2-of-3 condition with cdk::nuts::nut10 (from the architecture doc):

use cdk::nuts::nut10::{Conditions, SpendingConditions, SigFlag};
use cdk::nuts::PublicKey;

let conditions = Conditions::new(
    None,
    Some(vec![p_b, p_m]),       // secondary keys (2 of 3)
    None,
    Some(2),                    // require exactly 2 signatures
    None,
    Some(SigFlag::SigInputs),   // baseline choice for first rollout
).unwrap();

let secret = SpendingConditions::new_p2pk(p_s, Some(conditions)); // primary = seller

Configuration

Add a Cashu config block + CLI flag, mirroring the daemon ([cashu] enabled, mint_url):

[cashu]
enabled = true
mint_url = "https://mint.example.com"
  • New --mint-url flag / MINT_URL env, alongside the existing MOSTRO_PUBKEY/RELAYS/POW pattern in get_env_var (src/cli.rs).
  • One mint per node for now — no per-order mint negotiation. Prompt the user to confirm the mint at trade start.
  • Cashu and LN bonds are mutually exclusive (design decision Add release command #5); the daemon enforces this — the CLI should not offer bond flows in Cashu mode.

Local wallet storage (DB migration)

src/db.rs is SQLite via sqlx. We need new tables/columns for:

  • the wallet's unencumbered proofs + mint keysets (or whatever cdk's store layer needs)
  • escrow state per order: add cashu_mint_url, cashu_escrow_token, cashu_escrow_locked_at to the Order struct + schema, plus locally-held release/cancel signatures received via DM.

Follow the existing migration approach used for the orders/users tables.


Message flow & client work

Seller — lock escrow (replaces AddInvoice for the seller)

Mirror src/cli/add_invoice.rs / take_order.rs. After the order is matched, Mostro provides P_B and P_M. The seller wallet must:

  1. Take unencumbered proofs from the local balance.
  2. Build the 2-of-3 condition (P_S primary, {P_B, P_M} secondary, n_sigs = 2, SIG_INPUTS).
  3. Swap unencumbered → locked proofs at the mint.
  4. Serialize the locked token and send Action::AddCashuEscrow with Payload::CashuLockProof { token, mint_url, P_B, P_S, P_M } (signed with the order's trade key via the existing send_dm path).
  5. Wait for CashuEscrowLocked from Mostro (wait_for_dm / print_dm_events).

Buyer — release (happy path)

Signature exchange bypasses Mostro — it goes party-to-party over NIP-59 DM using trade keys. The CLI already has this infra (send_dm gift-wrap, wait_for_dm, get_dm_user).

  1. Seller, on Release, signs each escrowed proof and DMs the signatures to the buyer (Vec of per-proof signatures keyed by secret), then notifies Mostro (informational).
  2. Buyer receives the seller's signatures via NIP-59 DM, adds their own P_B signature per proof.
  3. Buyer builds a SwapRequest with both witnesses and submits to the mint (/v1/swap, NUT-11).
  4. Mint issues unencumbered ecash to the buyer; store it in the local wallet.
  5. Buyer notifies Mostro: trade complete.

Cooperative cancel (buyer → seller P2P)

  1. Buyer generates their P_B signature(s), DMs them to the seller via NIP-59.
  2. Seller combines with their own P_S signature → P_S + P_B, submits to the mint to reclaim the escrow.

Dispute resolution

Mostro supplies its P_M signatures via Action::CashuPmSignature / Payload::CashuSignatures(Vec<CashuProofSignature>):

  • admin_settle → winner is the buyer: combine P_M (matched per proof by secret) with P_B, swap, redeem.
  • admin_cancel → winner is the seller: combine P_M with P_S, reclaim.

Suggested new CLI subcommands (src/cli.rs, src/cli/)

(Names indicative — align with daemon semantics.)

  • add-cashu-escrow --order-id <id> — seller lock flow.
  • release / cancel — extend existing commands to, in Cashu mode, emit the P2P signature DM + mint swap instead of LN settle/cancel.
  • wallet helpers: cashu-balance, cashu-mint <token> (deposit), cashu-send/cashu-receive for funding the local wallet.
  • Cashu paths inside dispute admin commands (adm-settle/adm-cancel) to deliver P_M signatures (daemon side) — on the client, consume CashuPmSignature.

Implementation checklist

  • Bump mostro-core to 0.12.0; wire up the new Action/Payload/error variants in response handling (src/parser, src/util/messaging.rs).
  • Add cdk dependency; build a src/cashu/ module (wallet wrapper: connect, balance, swap-to-locked, sign proof, swap-to-redeem, verify_2of3_condition).
  • Config: [cashu] block, --mint-url/MINT_URL, mode selection, mutual-exclusion with bonds.
  • DB: wallet proof/keyset storage + cashu_* order fields + received-signature storage (mirror mostro#761 helpers where useful).
  • Track A — seller AddCashuEscrow lock flow + handle CashuEscrowLocked.
  • Track B — buyer release: receive seller sigs over NIP-59, add P_B sigs, mint swap, store unencumbered ecash.
  • Track C — cooperative cancel: buyer→seller P2P signatures, seller reclaim.
  • Track D — dispute: consume CashuPmSignature, combine with own key, redeem/reclaim.
  • Tests against a containerized mint (mirror daemon's F6 harness).
  • Docs / README usage for Cashu mode.

Notes & open questions

  • SIG_INPUTS vs SIG_ALL: baseline is SIG_INPUTS (simplest UX; assumes a non-adversarial agreed mint). SIG_ALL (buyer pre-builds outputs, seller signs the bundle) protects against a malicious front-running mint but needs tighter coordination — future work.
  • Fees: deferred in the initial design. Today's Mostro fee is taken from LN amounts; fee collection in Cashu mode needs its own design. No fee mechanism in the first rollout.
  • Offline resilience: signatures move async over Nostr — parties don't need to be online simultaneously; no CLTV timeout pressure.
  • Reuse the existing trade-key derivation and NIP-59 DM infra — no new key machinery is needed, just point the Cashu condition/signatures at the order's trade key.

Depends on: mostro#758, mostro#759, mostro#760, mostro#761 landing and a tagged daemon release exposing the mint_url config.

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions