Skip to content

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

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions