From 4e8c739477e1389232abbc93ac77dafb31fe4e5a Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 24 Jul 2026 23:40:06 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(cashu):=20C5=20=E2=80=94=20seller=20es?= =?UTF-8?q?crow=20lock=20flow=20(Track=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C5 of docs/cashu/README.md: the Cashu replacement for "the seller pays the hold invoice". Verified against the daemon's own Track A branches (feat/cashu-ta2-take-flow, feat/cashu-ta1f-fee-token) rather than inferred. What the daemon actually does, confirmed by reading it: - the escrow request is `Action::WaitingSellerToPay` + `Payload::Order` with status WaitingPayment, both trade pubkeys and no buyer invoice; - the escrow token must be worth *exactly* order.amount; - the fee token is `2 * order.fee`, where order.fee is `round(fee * amount / 2)` — the daemon rounds the half, so doubling the rounded half is the only expression that agrees with it, and a satoshi of disagreement is a rejection; - the proof's stated pubkeys must equal the order's trade keys, which the daemon re-derives rather than trusting. Rust - `mostro/node_fee.rs`: the node's advertised fee, read from the same 38385 fetch as PoW and the escrow mode, cleared on node switch. `total_fee_sats` reproduces the daemon's rounding exactly; a malformed fee is discarded rather than stored, because a wrong fee is an unexplained lock failure later. - `mostro/actions.rs`: `add_cashu_escrow` builder beside `add_invoice`. - `api/cashu.rs`: `cashu_escrow_quote` (amount, fee, total, balance, mint, locktime — shown before the seller commits) and `lock_escrow`, which refuses below `amount + fee`, builds the escrow and fee tokens, verifies the escrow it just built with the same check the daemon runs, publishes, and persists. Persistence deliberately follows the mint swap and survives a failed publish: the ecash is already committed by then, and a token we did not record is money we cannot find again. - `TradeInfo` gains `cashu_mint_url` / `cashu_escrow_token` / `cashu_locked_at`, `#[serde(default)]` so older rows still load. - A node switch now also drops the fee and disconnects the wallet, which was bound to the previous node's mint. Dart - `lock_escrow_screen.dart`: the Cashu sibling of the pay-invoice screen — escrow, fee, total against the balance, mint, and when the seller can reclaim unilaterally. Short balance offers "fund your wallet" instead of a failure. - The take flow branches on `isCashuAvailable`: buyer straight to the trade (there is no invoice step in Cashu mode), seller to the lock screen. - Markers mapped to localized strings in all five locales, unknown ones falling back so no internal string reaches a user. Also fixes a real test-isolation bug found here: `api::escrow` and `api::cashu` serialized the same globals with two different mutexes, which fails only under parallel execution. One global, one lock (`escrow_mode::test_lock`). Stacked on C1b (#234), C2 (#235), C4 (#236) and C3 (#237). --- lib/core/app_routes.dart | 12 + .../providers/cashu_wallet_provider.dart | 26 ++ .../cashu/screens/lock_escrow_screen.dart | 202 +++++++++++ .../order/screens/take_order_screen.dart | 14 + lib/l10n/app_de.arb | 18 +- lib/l10n/app_en.arb | 34 +- lib/l10n/app_es.arb | 18 +- lib/l10n/app_fr.arb | 18 +- lib/l10n/app_it.arb | 18 +- lib/l10n/app_localizations.dart | 96 ++++++ lib/l10n/app_localizations_de.dart | 58 ++++ lib/l10n/app_localizations_en.dart | 57 ++++ lib/l10n/app_localizations_es.dart | 57 ++++ lib/l10n/app_localizations_fr.dart | 57 ++++ lib/l10n/app_localizations_it.dart | 58 ++++ rust/src/api/cashu.rs | 208 +++++++++++- rust/src/api/escrow.rs | 12 +- rust/src/api/nostr.rs | 12 + rust/src/api/orders.rs | 22 +- rust/src/api/types.rs | 46 +++ rust/src/cashu/mod.rs | 77 +++++ rust/src/frb_generated.rs | 319 +++++++++++++----- rust/src/mostro/actions.rs | 49 ++- rust/src/mostro/escrow_mode.rs | 15 + rust/src/mostro/mod.rs | 1 + rust/src/mostro/node_fee.rs | 134 ++++++++ 26 files changed, 1527 insertions(+), 111 deletions(-) create mode 100644 lib/features/cashu/screens/lock_escrow_screen.dart create mode 100644 rust/src/mostro/node_fee.rs diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index d0576a83..769fa4a6 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:mostro/features/account/screens/account_screen.dart'; import 'package:mostro/features/cashu/screens/cashu_wallet_screen.dart'; +import 'package:mostro/features/cashu/screens/lock_escrow_screen.dart'; import 'package:mostro/features/home/screens/home_screen.dart'; import 'package:mostro/features/notifications/screens/notifications_screen.dart'; import 'package:mostro/features/order/screens/add_lightning_invoice_screen.dart'; @@ -58,6 +59,11 @@ abstract final class AppRoute { /// disconnected wallet anywhere else. static const cashuWallet = '/cashu_wallet'; + /// Seller-side escrow funding, the Cashu counterpart of `payInvoice`. + static const lockEscrow = '/lock_escrow/:orderId'; + + static String lockEscrowPath(String orderId) => '/lock_escrow/$orderId'; + /// Build a path with a single [id] substituted for the `:orderId` segment. static String tradeDetailPath(String orderId) => '/trade_detail/$orderId'; @@ -234,6 +240,12 @@ final GoRouter appRouter = GoRouter( path: AppRoute.cashuWallet, builder: (_, __) => const CashuWalletScreen(), ), + GoRoute( + path: AppRoute.lockEscrow, + builder: (context, state) => LockEscrowScreen( + orderId: state.pathParameters['orderId']!, + ), + ), ], ); diff --git a/lib/features/cashu/providers/cashu_wallet_provider.dart b/lib/features/cashu/providers/cashu_wallet_provider.dart index 46f4dea9..c8506ff9 100644 --- a/lib/features/cashu/providers/cashu_wallet_provider.dart +++ b/lib/features/cashu/providers/cashu_wallet_provider.dart @@ -53,3 +53,29 @@ class CashuWalletController { final cashuWalletControllerProvider = Provider( (ref) => const CashuWalletController(), ); + +/// Seller-side escrow commands — phase C5. +/// +/// Split from the wallet controller because the audiences differ: the wallet is +/// something a user opens, an escrow lock is something a trade demands. Both +/// are one Rust call each. +class CashuEscrowController { + const CashuEscrowController(); + + /// What locking this order would cost: escrow, fee, total, and the balance to + /// compare them against. Changes nothing. + Future quote(String orderId) => + cashu_api.cashuEscrowQuote(orderId: orderId); + + /// Fund the escrow and submit it to the daemon. + /// + /// Throws `CashuInsufficientFunds` when the wallet cannot cover + /// `amount + fee`, `NotTheSeller` when called for the wrong side, or a + /// `CashuLockFailed` marker when the mint refuses the swap. + Future lock(String orderId) => + cashu_api.lockEscrow(orderId: orderId); +} + +final cashuEscrowControllerProvider = Provider( + (ref) => const CashuEscrowController(), +); diff --git a/lib/features/cashu/screens/lock_escrow_screen.dart b/lib/features/cashu/screens/lock_escrow_screen.dart new file mode 100644 index 00000000..8cf199c5 --- /dev/null +++ b/lib/features/cashu/screens/lock_escrow_screen.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:mostro/core/app_routes.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/cashu/providers/cashu_wallet_provider.dart'; +import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/src/rust/api/types.dart'; + +/// Seller-side escrow funding — phase C5 of `docs/cashu/README.md`. +/// +/// The Cashu sibling of `pay_lightning_invoice_screen.dart`: instead of paying +/// a hold invoice, the seller locks a 2-of-3 token at the node's mint and +/// submits it. Same place in the flow, same finality. +/// +/// Everything is shown before the seller commits, because the numbers are not +/// obvious: the escrow is the order amount, the fee is a *separate* token worth +/// the whole Mostro fee, and both leave the wallet at once. +class LockEscrowScreen extends ConsumerStatefulWidget { + const LockEscrowScreen({super.key, required this.orderId}); + + final String orderId; + + @override + ConsumerState createState() => _LockEscrowScreenState(); +} + +class _LockEscrowScreenState extends ConsumerState { + CashuEscrowQuote? _quote; + String? _error; + bool _locking = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadQuote()); + } + + Future _loadQuote() async { + try { + // Connect first: the quote reports the balance, and an unconnected wallet + // reports zero — which would send the seller off to fund a wallet that is + // not actually empty. + await ref.read(cashuWalletControllerProvider).connect(); + final quote = + await ref.read(cashuEscrowControllerProvider).quote(widget.orderId); + if (mounted) setState(() => _quote = quote); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } + } + + Future _lock() async { + if (_locking) return; + setState(() => _locking = true); + final l10n = AppLocalizations.of(context); + try { + await ref.read(cashuEscrowControllerProvider).lock(widget.orderId); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(l10n.lockEscrowSubmitted))); + context.go(AppRoute.tradeDetailPath(widget.orderId)); + } catch (e) { + if (mounted) { + setState(() { + _locking = false; + _error = e.toString(); + }); + } + } + } + + /// Rust markers → localized text. An unknown marker falls back to the generic + /// message, so an internal string never reaches the user. + String _message(String raw, AppLocalizations l10n) { + if (raw.contains('CashuInsufficientFunds')) { + return l10n.lockEscrowInsufficientFunds; + } + if (raw.contains('CashuNodeFeeUnknown')) return l10n.lockEscrowFeeUnknown; + if (raw.contains('CashuNotEnabled')) return l10n.cashuErrorNotEnabled; + if (raw.contains('CashuNotConnected')) return l10n.cashuErrorNotConnected; + if (raw.contains('CashuMintUnreachable')) { + return l10n.cashuErrorMintUnreachable; + } + if (raw.contains('CashuMintUnusable')) return l10n.cashuErrorMintUnusable; + if (raw.contains('CashuUnsupportedOnWeb')) { + return l10n.cashuErrorUnsupportedOnWeb; + } + if (raw.contains('NotTheSeller')) return l10n.lockEscrowNotTheSeller; + if (raw.contains('InvalidEscrowToken')) return l10n.lockEscrowInvalidToken; + if (raw.contains('CashuLockFailed')) return l10n.lockEscrowFailed; + return l10n.cashuErrorGeneric; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).extension()!; + final quote = _quote; + final short = quote != null && quote.balanceSats < quote.totalSats; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.lockEscrowTitle), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() + ? context.pop() + : context.go(AppRoute.tradeDetailPath(widget.orderId)), + ), + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + Text( + l10n.lockEscrowExplanation, + style: TextStyle(color: colors.textSecondary), + ), + const SizedBox(height: AppSpacing.lg), + if (quote == null && _error == null) + const Center(child: CircularProgressIndicator()) + else if (quote != null) ...[ + _Row(label: l10n.lockEscrowAmount, value: '${quote.amountSats}'), + _Row(label: l10n.lockEscrowFee, value: '${quote.feeSats}'), + const Divider(), + _Row( + label: l10n.lockEscrowTotal, + value: '${quote.totalSats}', + emphasise: true, + ), + _Row(label: l10n.lockEscrowBalance, value: '${quote.balanceSats}'), + const SizedBox(height: AppSpacing.md), + Text( + l10n.lockEscrowMint(quote.mintUrl), + style: TextStyle(color: colors.textSubtle, fontSize: 13), + ), + Text( + l10n.lockEscrowLocktime(quote.locktimeDays), + style: TextStyle(color: colors.textSubtle, fontSize: 13), + ), + ], + if (_error != null) ...[ + const SizedBox(height: AppSpacing.md), + Text( + _message(_error!, l10n), + style: TextStyle(color: colors.destructiveRed), + ), + ], + const SizedBox(height: AppSpacing.xl), + if (short) + OutlinedButton.icon( + onPressed: () => context.push(AppRoute.cashuWallet), + icon: const Icon(Icons.account_balance_wallet_outlined), + label: Text(l10n.lockEscrowFundWallet), + ) + else + FilledButton( + onPressed: quote == null || _locking ? null : _lock, + child: _locking + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.lockEscrowConfirm), + ), + ], + ), + ); + } +} + +class _Row extends StatelessWidget { + const _Row({ + required this.label, + required this.value, + this.emphasise = false, + }); + + final String label; + final String value; + final bool emphasise; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final style = emphasise + ? const TextStyle(fontWeight: FontWeight.w600) + : const TextStyle(); + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: style), + Text('$value ${l10n.aboutSatoshisSuffix}', style: style), + ], + ), + ); + } +} diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 7e3f6225..4974cd5d 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -9,6 +9,7 @@ import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/l10n/app_localizations.dart'; import 'package:mostro/features/account/providers/privacy_mode_provider.dart'; +import 'package:mostro/features/settings/providers/escrow_mode_provider.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; import 'package:mostro/features/order/providers/trade_state_provider.dart'; import 'package:mostro/features/order/widgets/range_amount_modal.dart'; @@ -132,6 +133,19 @@ class _TakeOrderScreenState extends ConsumerState { (map) => {...map, widget.orderId: widget.isBuying}, ); + // In Cashu mode the flow after a take differs on both sides: there is no + // buyer invoice step at all, and the seller locks an escrow instead of + // paying a hold invoice. `isCashuAvailable` is false on every Lightning + // node, so this branch simply does not exist there. + if (ref.read(isCashuAvailableProvider)) { + if (widget.isBuying) { + context.go(AppRoute.tradeDetailPath(widget.orderId)); + } else { + context.push(AppRoute.lockEscrowPath(widget.orderId)); + } + return; + } + if (widget.isBuying) { // Check whether a default LN address is configured. If yes, Mostro // will pay it directly and the buyer can skip the add-invoice step. diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6e65bc18..d83aabaa 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -738,5 +738,21 @@ "cashuErrorSendFailed": "Das Token konnte nicht erstellt werden. Womöglich reicht dein Guthaben nicht.", "cashuErrorNoIdentity": "Lege ein Konto an oder importiere eines, bevor du die Wallet nutzt.", "cashuErrorGeneric": "Mit der Wallet ist etwas schiefgelaufen. Bitte versuche es erneut.", - "settingsEscrowCashuUnavailable": "Cashu funktioniert ohne Mint nicht – unten eine festlegen." + "settingsEscrowCashuUnavailable": "Cashu funktioniert ohne Mint nicht – unten eine festlegen.", + "lockEscrowTitle": "Treuhand sperren", + "lockEscrowExplanation": "Sperre dein E-Cash in einer 2-von-3-Treuhand bei der Mint dieses Nodes. Weder du noch der K\u00e4ufer k\u00f6nnt es allein bewegen \u2014 und verschwindet der Node, holst du es nach Ablauf der Sperrfrist selbst zur\u00fcck.", + "lockEscrowAmount": "Treuhand", + "lockEscrowFee": "Mostro-Geb\u00fchr", + "lockEscrowTotal": "Gesamt", + "lockEscrowBalance": "Dein Guthaben", + "lockEscrowConfirm": "Treuhand sperren", + "lockEscrowFundWallet": "Wallet aufladen", + "lockEscrowSubmitted": "Treuhand gesperrt und gesendet", + "lockEscrowInsufficientFunds": "Dein Guthaben deckt Treuhand und Geb\u00fchr nicht.", + "lockEscrowFeeUnknown": "Dieser Node hat seine Geb\u00fchr noch nicht ver\u00f6ffentlicht. Versuche es gleich erneut.", + "lockEscrowNotTheSeller": "Nur der Verk\u00e4ufer finanziert die Treuhand.", + "lockEscrowInvalidToken": "Die Treuhand konnte nicht korrekt erstellt werden. Es wurde nichts gesendet.", + "lockEscrowFailed": "Die Mint konnte die Treuhand nicht sperren. Dein Geld wurde nicht bewegt.", + "lockEscrowMint": "Mint: {mint}", + "lockEscrowLocktime": "Von dir r\u00fcckholbar nach {days} Tagen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a790bd40..523a9267 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1621,5 +1621,37 @@ "cashuErrorGeneric": "Something went wrong with the wallet. Please try again.", "@cashuErrorGeneric": {"description": "Cashu error — fallback for an unrecognised failure"}, "settingsEscrowCashuUnavailable": "Cashu cannot run without a mint — set one below.", - "@settingsEscrowCashuUnavailable": {"description": "Settings — warning shown when Cashu mode is on but no mint is available, so no Cashu path can run"} + "@settingsEscrowCashuUnavailable": {"description": "Settings — warning shown when Cashu mode is on but no mint is available, so no Cashu path can run"}, + "lockEscrowTitle": "Lock the escrow", + "@lockEscrowTitle": {"description": "Title of the seller's Cashu escrow funding screen"}, + "lockEscrowExplanation": "Lock your ecash in a 2-of-3 escrow at this node's mint. Neither you nor the buyer can move it alone \u2014 and if the node disappears, you can reclaim it yourself once the locktime passes.", + "@lockEscrowExplanation": {"description": "Explanation shown on the escrow funding screen"}, + "lockEscrowAmount": "Escrow", + "@lockEscrowAmount": {"description": "Escrow screen \u2014 the order amount to be locked"}, + "lockEscrowFee": "Mostro fee", + "@lockEscrowFee": {"description": "Escrow screen \u2014 the separate fee token amount"}, + "lockEscrowTotal": "Total", + "@lockEscrowTotal": {"description": "Escrow screen \u2014 escrow plus fee"}, + "lockEscrowBalance": "Your balance", + "@lockEscrowBalance": {"description": "Escrow screen \u2014 the Cashu wallet balance"}, + "lockEscrowConfirm": "Lock escrow", + "@lockEscrowConfirm": {"description": "Escrow screen \u2014 button that funds and submits the escrow"}, + "lockEscrowFundWallet": "Fund your wallet", + "@lockEscrowFundWallet": {"description": "Escrow screen \u2014 button shown when the balance is short, opening the wallet"}, + "lockEscrowSubmitted": "Escrow locked and sent", + "@lockEscrowSubmitted": {"description": "Escrow screen \u2014 confirmation after a successful lock"}, + "lockEscrowInsufficientFunds": "Your wallet does not hold enough for the escrow and the fee.", + "@lockEscrowInsufficientFunds": {"description": "Escrow error \u2014 balance below amount plus fee"}, + "lockEscrowFeeUnknown": "This node has not published its fee yet. Try again in a moment.", + "@lockEscrowFeeUnknown": {"description": "Escrow error \u2014 the node fee is not known, so the fee token cannot be built"}, + "lockEscrowNotTheSeller": "Only the seller funds the escrow.", + "@lockEscrowNotTheSeller": {"description": "Escrow error \u2014 the lock was attempted from the buyer side"}, + "lockEscrowInvalidToken": "The escrow could not be built correctly. Nothing was sent.", + "@lockEscrowInvalidToken": {"description": "Escrow error \u2014 the locally built token failed its own verification"}, + "lockEscrowFailed": "The mint could not lock the escrow. Your funds have not moved.", + "@lockEscrowFailed": {"description": "Escrow error \u2014 the mint refused the swap"}, + "lockEscrowMint": "Mint: {mint}", + "@lockEscrowMint": {"description": "Escrow screen \u2014 the mint the escrow is locked at", "placeholders": {"mint": {"type": "String"}}}, + "lockEscrowLocktime": "Reclaimable by you after {days} days", + "@lockEscrowLocktime": {"description": "Escrow screen \u2014 when the seller can unilaterally reclaim", "placeholders": {"days": {"type": "int"}}} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 7f255ac8..29d7dda2 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -738,5 +738,21 @@ "cashuErrorSendFailed": "No se pudo crear el token. Puede que no tengas fondos suficientes.", "cashuErrorNoIdentity": "Creá o importá una cuenta antes de usar la billetera.", "cashuErrorGeneric": "Algo salió mal con la billetera. Intentá de nuevo.", - "settingsEscrowCashuUnavailable": "Cashu no puede funcionar sin un mint: configura uno abajo." + "settingsEscrowCashuUnavailable": "Cashu no puede funcionar sin un mint: configura uno abajo.", + "lockEscrowTitle": "Bloquear la custodia", + "lockEscrowExplanation": "Bloque\u00e1 tu ecash en una custodia 2-de-3 en el mint de este nodo. Ni vos ni el comprador pueden moverlo solos, y si el nodo desaparece pod\u00e9s recuperarlo vos mismo cuando pase el locktime.", + "lockEscrowAmount": "Custodia", + "lockEscrowFee": "Comisi\u00f3n de Mostro", + "lockEscrowTotal": "Total", + "lockEscrowBalance": "Tu saldo", + "lockEscrowConfirm": "Bloquear custodia", + "lockEscrowFundWallet": "Carg\u00e1 tu billetera", + "lockEscrowSubmitted": "Custodia bloqueada y enviada", + "lockEscrowInsufficientFunds": "Tu billetera no alcanza para la custodia m\u00e1s la comisi\u00f3n.", + "lockEscrowFeeUnknown": "Este nodo todav\u00eda no public\u00f3 su comisi\u00f3n. Prob\u00e1 de nuevo en un momento.", + "lockEscrowNotTheSeller": "Solo el vendedor financia la custodia.", + "lockEscrowInvalidToken": "No se pudo construir la custodia correctamente. No se envi\u00f3 nada.", + "lockEscrowFailed": "El mint no pudo bloquear la custodia. Tus fondos no se movieron.", + "lockEscrowMint": "Mint: {mint}", + "lockEscrowLocktime": "Pod\u00e9s recuperarlo tras {days} d\u00edas" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e6aa77f7..43ef95b0 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -738,5 +738,21 @@ "cashuErrorSendFailed": "Impossible de créer le token. Vos fonds sont peut-être insuffisants.", "cashuErrorNoIdentity": "Créez ou importez un compte avant d'utiliser le portefeuille.", "cashuErrorGeneric": "Un problème est survenu avec le portefeuille. Veuillez réessayer.", - "settingsEscrowCashuUnavailable": "Cashu ne peut pas fonctionner sans mint : configurez-en un ci-dessous." + "settingsEscrowCashuUnavailable": "Cashu ne peut pas fonctionner sans mint : configurez-en un ci-dessous.", + "lockEscrowTitle": "Verrouiller le s\u00e9questre", + "lockEscrowExplanation": "Verrouillez votre ecash dans un s\u00e9questre 2-sur-3 au mint de ce n\u0153ud. Ni vous ni l'acheteur ne pouvez le d\u00e9placer seul \u2014 et si le n\u0153ud dispara\u00eet, vous pourrez le r\u00e9cup\u00e9rer vous-m\u00eame une fois le verrou expir\u00e9.", + "lockEscrowAmount": "S\u00e9questre", + "lockEscrowFee": "Frais Mostro", + "lockEscrowTotal": "Total", + "lockEscrowBalance": "Votre solde", + "lockEscrowConfirm": "Verrouiller le s\u00e9questre", + "lockEscrowFundWallet": "Approvisionner le portefeuille", + "lockEscrowSubmitted": "S\u00e9questre verrouill\u00e9 et envoy\u00e9", + "lockEscrowInsufficientFunds": "Votre portefeuille ne couvre pas le s\u00e9questre et les frais.", + "lockEscrowFeeUnknown": "Ce n\u0153ud n'a pas encore publi\u00e9 ses frais. R\u00e9essayez dans un instant.", + "lockEscrowNotTheSeller": "Seul le vendeur finance le s\u00e9questre.", + "lockEscrowInvalidToken": "Le s\u00e9questre n'a pas pu \u00eatre construit correctement. Rien n'a \u00e9t\u00e9 envoy\u00e9.", + "lockEscrowFailed": "Le mint n'a pas pu verrouiller le s\u00e9questre. Vos fonds n'ont pas boug\u00e9.", + "lockEscrowMint": "Mint : {mint}", + "lockEscrowLocktime": "R\u00e9cup\u00e9rable par vous apr\u00e8s {days} jours" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 27a8d7ed..d18d94e3 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -738,5 +738,21 @@ "cashuErrorSendFailed": "Non è stato possibile creare il token. Potresti non avere fondi sufficienti.", "cashuErrorNoIdentity": "Crea o importa un account prima di usare il portafoglio.", "cashuErrorGeneric": "Qualcosa è andato storto con il portafoglio. Riprova.", - "settingsEscrowCashuUnavailable": "Cashu non può funzionare senza una mint: impostane una qui sotto." + "settingsEscrowCashuUnavailable": "Cashu non può funzionare senza una mint: impostane una qui sotto.", + "lockEscrowTitle": "Blocca il deposito", + "lockEscrowExplanation": "Blocca il tuo ecash in un deposito 2-su-3 presso la mint di questo nodo. N\u00e9 tu n\u00e9 l'acquirente potete muoverlo da soli \u2014 e se il nodo sparisce potrai recuperarlo tu stesso una volta scaduto il blocco.", + "lockEscrowAmount": "Deposito", + "lockEscrowFee": "Commissione Mostro", + "lockEscrowTotal": "Totale", + "lockEscrowBalance": "Il tuo saldo", + "lockEscrowConfirm": "Blocca il deposito", + "lockEscrowFundWallet": "Ricarica il portafoglio", + "lockEscrowSubmitted": "Deposito bloccato e inviato", + "lockEscrowInsufficientFunds": "Il tuo portafoglio non copre deposito e commissione.", + "lockEscrowFeeUnknown": "Questo nodo non ha ancora pubblicato la sua commissione. Riprova tra poco.", + "lockEscrowNotTheSeller": "Solo il venditore finanzia il deposito.", + "lockEscrowInvalidToken": "Non \u00e8 stato possibile costruire il deposito correttamente. Non \u00e8 stato inviato nulla.", + "lockEscrowFailed": "La mint non ha potuto bloccare il deposito. I tuoi fondi non si sono mossi.", + "lockEscrowMint": "Mint: {mint}", + "lockEscrowLocktime": "Recuperabile da te dopo {days} giorni" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index da00db12..62a4d5a4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4399,6 +4399,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Cashu cannot run without a mint — set one below.'** String get settingsEscrowCashuUnavailable; + + /// Title of the seller's Cashu escrow funding screen + /// + /// In en, this message translates to: + /// **'Lock the escrow'** + String get lockEscrowTitle; + + /// Explanation shown on the escrow funding screen + /// + /// In en, this message translates to: + /// **'Lock your ecash in a 2-of-3 escrow at this node\'s mint. Neither you nor the buyer can move it alone — and if the node disappears, you can reclaim it yourself once the locktime passes.'** + String get lockEscrowExplanation; + + /// Escrow screen — the order amount to be locked + /// + /// In en, this message translates to: + /// **'Escrow'** + String get lockEscrowAmount; + + /// Escrow screen — the separate fee token amount + /// + /// In en, this message translates to: + /// **'Mostro fee'** + String get lockEscrowFee; + + /// Escrow screen — escrow plus fee + /// + /// In en, this message translates to: + /// **'Total'** + String get lockEscrowTotal; + + /// Escrow screen — the Cashu wallet balance + /// + /// In en, this message translates to: + /// **'Your balance'** + String get lockEscrowBalance; + + /// Escrow screen — button that funds and submits the escrow + /// + /// In en, this message translates to: + /// **'Lock escrow'** + String get lockEscrowConfirm; + + /// Escrow screen — button shown when the balance is short, opening the wallet + /// + /// In en, this message translates to: + /// **'Fund your wallet'** + String get lockEscrowFundWallet; + + /// Escrow screen — confirmation after a successful lock + /// + /// In en, this message translates to: + /// **'Escrow locked and sent'** + String get lockEscrowSubmitted; + + /// Escrow error — balance below amount plus fee + /// + /// In en, this message translates to: + /// **'Your wallet does not hold enough for the escrow and the fee.'** + String get lockEscrowInsufficientFunds; + + /// Escrow error — the node fee is not known, so the fee token cannot be built + /// + /// In en, this message translates to: + /// **'This node has not published its fee yet. Try again in a moment.'** + String get lockEscrowFeeUnknown; + + /// Escrow error — the lock was attempted from the buyer side + /// + /// In en, this message translates to: + /// **'Only the seller funds the escrow.'** + String get lockEscrowNotTheSeller; + + /// Escrow error — the locally built token failed its own verification + /// + /// In en, this message translates to: + /// **'The escrow could not be built correctly. Nothing was sent.'** + String get lockEscrowInvalidToken; + + /// Escrow error — the mint refused the swap + /// + /// In en, this message translates to: + /// **'The mint could not lock the escrow. Your funds have not moved.'** + String get lockEscrowFailed; + + /// Escrow screen — the mint the escrow is locked at + /// + /// In en, this message translates to: + /// **'Mint: {mint}'** + String lockEscrowMint(String mint); + + /// Escrow screen — when the seller can unilaterally reclaim + /// + /// In en, this message translates to: + /// **'Reclaimable by you after {days} days'** + String lockEscrowLocktime(int days); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 50cbc098..803622af 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2498,4 +2498,62 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settingsEscrowCashuUnavailable => 'Cashu funktioniert ohne Mint nicht – unten eine festlegen.'; + + @override + String get lockEscrowTitle => 'Treuhand sperren'; + + @override + String get lockEscrowExplanation => + 'Sperre dein E-Cash in einer 2-von-3-Treuhand bei der Mint dieses Nodes. Weder du noch der Käufer könnt es allein bewegen — und verschwindet der Node, holst du es nach Ablauf der Sperrfrist selbst zurück.'; + + @override + String get lockEscrowAmount => 'Treuhand'; + + @override + String get lockEscrowFee => 'Mostro-Gebühr'; + + @override + String get lockEscrowTotal => 'Gesamt'; + + @override + String get lockEscrowBalance => 'Dein Guthaben'; + + @override + String get lockEscrowConfirm => 'Treuhand sperren'; + + @override + String get lockEscrowFundWallet => 'Wallet aufladen'; + + @override + String get lockEscrowSubmitted => 'Treuhand gesperrt und gesendet'; + + @override + String get lockEscrowInsufficientFunds => + 'Dein Guthaben deckt Treuhand und Gebühr nicht.'; + + @override + String get lockEscrowFeeUnknown => + 'Dieser Node hat seine Gebühr noch nicht veröffentlicht. Versuche es gleich erneut.'; + + @override + String get lockEscrowNotTheSeller => + 'Nur der Verkäufer finanziert die Treuhand.'; + + @override + String get lockEscrowInvalidToken => + 'Die Treuhand konnte nicht korrekt erstellt werden. Es wurde nichts gesendet.'; + + @override + String get lockEscrowFailed => + 'Die Mint konnte die Treuhand nicht sperren. Dein Geld wurde nicht bewegt.'; + + @override + String lockEscrowMint(String mint) { + return 'Mint: $mint'; + } + + @override + String lockEscrowLocktime(int days) { + return 'Von dir rückholbar nach $days Tagen'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 910d19dc..3aa3c7e1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2464,4 +2464,61 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsEscrowCashuUnavailable => 'Cashu cannot run without a mint — set one below.'; + + @override + String get lockEscrowTitle => 'Lock the escrow'; + + @override + String get lockEscrowExplanation => + 'Lock your ecash in a 2-of-3 escrow at this node\'s mint. Neither you nor the buyer can move it alone — and if the node disappears, you can reclaim it yourself once the locktime passes.'; + + @override + String get lockEscrowAmount => 'Escrow'; + + @override + String get lockEscrowFee => 'Mostro fee'; + + @override + String get lockEscrowTotal => 'Total'; + + @override + String get lockEscrowBalance => 'Your balance'; + + @override + String get lockEscrowConfirm => 'Lock escrow'; + + @override + String get lockEscrowFundWallet => 'Fund your wallet'; + + @override + String get lockEscrowSubmitted => 'Escrow locked and sent'; + + @override + String get lockEscrowInsufficientFunds => + 'Your wallet does not hold enough for the escrow and the fee.'; + + @override + String get lockEscrowFeeUnknown => + 'This node has not published its fee yet. Try again in a moment.'; + + @override + String get lockEscrowNotTheSeller => 'Only the seller funds the escrow.'; + + @override + String get lockEscrowInvalidToken => + 'The escrow could not be built correctly. Nothing was sent.'; + + @override + String get lockEscrowFailed => + 'The mint could not lock the escrow. Your funds have not moved.'; + + @override + String lockEscrowMint(String mint) { + return 'Mint: $mint'; + } + + @override + String lockEscrowLocktime(int days) { + return 'Reclaimable by you after $days days'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index ebe5b2c9..bfabb4c3 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2490,4 +2490,61 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settingsEscrowCashuUnavailable => 'Cashu no puede funcionar sin un mint: configura uno abajo.'; + + @override + String get lockEscrowTitle => 'Bloquear la custodia'; + + @override + String get lockEscrowExplanation => + 'Bloqueá tu ecash en una custodia 2-de-3 en el mint de este nodo. Ni vos ni el comprador pueden moverlo solos, y si el nodo desaparece podés recuperarlo vos mismo cuando pase el locktime.'; + + @override + String get lockEscrowAmount => 'Custodia'; + + @override + String get lockEscrowFee => 'Comisión de Mostro'; + + @override + String get lockEscrowTotal => 'Total'; + + @override + String get lockEscrowBalance => 'Tu saldo'; + + @override + String get lockEscrowConfirm => 'Bloquear custodia'; + + @override + String get lockEscrowFundWallet => 'Cargá tu billetera'; + + @override + String get lockEscrowSubmitted => 'Custodia bloqueada y enviada'; + + @override + String get lockEscrowInsufficientFunds => + 'Tu billetera no alcanza para la custodia más la comisión.'; + + @override + String get lockEscrowFeeUnknown => + 'Este nodo todavía no publicó su comisión. Probá de nuevo en un momento.'; + + @override + String get lockEscrowNotTheSeller => 'Solo el vendedor financia la custodia.'; + + @override + String get lockEscrowInvalidToken => + 'No se pudo construir la custodia correctamente. No se envió nada.'; + + @override + String get lockEscrowFailed => + 'El mint no pudo bloquear la custodia. Tus fondos no se movieron.'; + + @override + String lockEscrowMint(String mint) { + return 'Mint: $mint'; + } + + @override + String lockEscrowLocktime(int days) { + return 'Podés recuperarlo tras $days días'; + } } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 1b899669..27822353 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2501,4 +2501,61 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settingsEscrowCashuUnavailable => 'Cashu ne peut pas fonctionner sans mint : configurez-en un ci-dessous.'; + + @override + String get lockEscrowTitle => 'Verrouiller le séquestre'; + + @override + String get lockEscrowExplanation => + 'Verrouillez votre ecash dans un séquestre 2-sur-3 au mint de ce nœud. Ni vous ni l\'acheteur ne pouvez le déplacer seul — et si le nœud disparaît, vous pourrez le récupérer vous-même une fois le verrou expiré.'; + + @override + String get lockEscrowAmount => 'Séquestre'; + + @override + String get lockEscrowFee => 'Frais Mostro'; + + @override + String get lockEscrowTotal => 'Total'; + + @override + String get lockEscrowBalance => 'Votre solde'; + + @override + String get lockEscrowConfirm => 'Verrouiller le séquestre'; + + @override + String get lockEscrowFundWallet => 'Approvisionner le portefeuille'; + + @override + String get lockEscrowSubmitted => 'Séquestre verrouillé et envoyé'; + + @override + String get lockEscrowInsufficientFunds => + 'Votre portefeuille ne couvre pas le séquestre et les frais.'; + + @override + String get lockEscrowFeeUnknown => + 'Ce nœud n\'a pas encore publié ses frais. Réessayez dans un instant.'; + + @override + String get lockEscrowNotTheSeller => 'Seul le vendeur finance le séquestre.'; + + @override + String get lockEscrowInvalidToken => + 'Le séquestre n\'a pas pu être construit correctement. Rien n\'a été envoyé.'; + + @override + String get lockEscrowFailed => + 'Le mint n\'a pas pu verrouiller le séquestre. Vos fonds n\'ont pas bougé.'; + + @override + String lockEscrowMint(String mint) { + return 'Mint : $mint'; + } + + @override + String lockEscrowLocktime(int days) { + return 'Récupérable par vous après $days jours'; + } } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 4a7cb6a0..675e8d5a 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2492,4 +2492,62 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settingsEscrowCashuUnavailable => 'Cashu non può funzionare senza una mint: impostane una qui sotto.'; + + @override + String get lockEscrowTitle => 'Blocca il deposito'; + + @override + String get lockEscrowExplanation => + 'Blocca il tuo ecash in un deposito 2-su-3 presso la mint di questo nodo. Né tu né l\'acquirente potete muoverlo da soli — e se il nodo sparisce potrai recuperarlo tu stesso una volta scaduto il blocco.'; + + @override + String get lockEscrowAmount => 'Deposito'; + + @override + String get lockEscrowFee => 'Commissione Mostro'; + + @override + String get lockEscrowTotal => 'Totale'; + + @override + String get lockEscrowBalance => 'Il tuo saldo'; + + @override + String get lockEscrowConfirm => 'Blocca il deposito'; + + @override + String get lockEscrowFundWallet => 'Ricarica il portafoglio'; + + @override + String get lockEscrowSubmitted => 'Deposito bloccato e inviato'; + + @override + String get lockEscrowInsufficientFunds => + 'Il tuo portafoglio non copre deposito e commissione.'; + + @override + String get lockEscrowFeeUnknown => + 'Questo nodo non ha ancora pubblicato la sua commissione. Riprova tra poco.'; + + @override + String get lockEscrowNotTheSeller => + 'Solo il venditore finanzia il deposito.'; + + @override + String get lockEscrowInvalidToken => + 'Non è stato possibile costruire il deposito correttamente. Non è stato inviato nulla.'; + + @override + String get lockEscrowFailed => + 'La mint non ha potuto bloccare il deposito. I tuoi fondi non si sono mossi.'; + + @override + String lockEscrowMint(String mint) { + return 'Mint: $mint'; + } + + @override + String lockEscrowLocktime(int days) { + return 'Recuperabile da te dopo $days giorni'; + } } diff --git a/rust/src/api/cashu.rs b/rust/src/api/cashu.rs index c152688f..d449cc1c 100644 --- a/rust/src/api/cashu.rs +++ b/rust/src/api/cashu.rs @@ -18,6 +18,7 @@ use tokio::sync::{broadcast, RwLock}; use crate::api::types::CashuWalletStatus; use crate::cashu::CashuWallet; +use crate::db::Storage; use crate::mostro::escrow_mode; // ── Global wallet ───────────────────────────────────────────────────────────── @@ -217,6 +218,198 @@ pub async fn cashu_disconnect() -> Result<()> { Ok(()) } +// ── Escrow lock (phase C5) ──────────────────────────────────────────────────── + +/// What the seller is about to lock, so the UI can show it before they commit. +/// +/// Computed rather than taken from the daemon: the daemon states the amount in +/// the escrow request, but the **fee** is derived from the node's advertised +/// rate, and the seller has a right to see both figures — and the total against +/// their balance — before funding anything. +pub async fn cashu_escrow_quote(order_id: String) -> Result { + ensure_enabled()?; + + let trade = load_trade(&order_id).await?; + let amount_sats = trade + .order + .amount_sats + .ok_or_else(|| anyhow::anyhow!("CashuOrderAmountUnknown"))?; + + // A node that publishes no fee has not been fetched yet. Guessing zero + // would build a lock the daemon rejects, so this fails instead. + let fraction = crate::mostro::node_fee::get_fee() + .ok_or_else(|| anyhow::anyhow!("CashuNodeFeeUnknown"))?; + let fee_sats = crate::mostro::node_fee::total_fee_sats(amount_sats, fraction); + + let resolved = escrow_mode::get_resolved(); + let balance = { + let guard = wallet_lock().read().await; + match guard.as_ref() { + Some(wallet) => wallet.balance().await.unwrap_or(0), + None => 0, + } + }; + + Ok(crate::api::types::CashuEscrowQuote { + order_id, + amount_sats, + fee_sats, + total_sats: amount_sats.saturating_add(fee_sats), + balance_sats: balance, + mint_url: resolved.config.mint_url.unwrap_or_default(), + locktime_days: resolved.config.escrow_locktime_days.unwrap_or(DEFAULT_LOCKTIME_DAYS), + }) +} + +/// The daemon's default when a node advertises none (`docs/cashu/README.md` §2). +const DEFAULT_LOCKTIME_DAYS: u32 = 15; + +/// Seller: fund the 2-of-3 escrow for `order_id` and submit it to the daemon. +/// +/// The Cashu analogue of paying the hold invoice. In order: +/// +/// 1. refuse unless the balance covers `amount + fee` — a partial lock would +/// strand the escrow amount in a token nobody can settle; +/// 2. build the escrow token (2-of-3, locktime) and, when the node charges a +/// fee, the fee token (1-of-1 to Mostro); +/// 3. publish `AddCashuEscrow`; +/// 4. persist the token against the trade **before** returning, so an app that +/// dies here can re-submit rather than lose track of locked funds. +/// +/// Step 4 deliberately follows the publish: the funds are already committed at +/// the mint by step 2, so the token is worth recording even if the publish +/// failed — the daemon's own handler is idempotent on a re-submission. +/// +/// **Errors** (stable markers): `CashuNotEnabled`, `CashuNotConnected`, +/// `CashuInsufficientFunds`, `CashuNodeFeeUnknown`, `NotTheSeller`, +/// plus the `CashuLockFailed` markers from token construction. +pub async fn lock_escrow(order_id: String) -> Result { + ensure_enabled()?; + + let quote = cashu_escrow_quote(order_id.clone()).await?; + let trade = load_trade(&order_id).await?; + + // Only the seller funds an escrow. A buyer reaching this is a bug, but it + // would burn the buyer's own ecash, so it is checked rather than assumed. + if !matches!(trade.role, crate::api::types::TradeRole::Seller) { + bail!("NotTheSeller"); + } + + if quote.balance_sats < quote.total_sats { + bail!( + "CashuInsufficientFunds: need {} sat, have {}", + quote.total_sats, + quote.balance_sats + ); + } + + let trade_index = crate::api::orders::get_trade_key_index(&order_id) + .await + .ok_or_else(|| anyhow::anyhow!("no persisted trade key for order {order_id}"))?; + let seller_keys = crate::api::identity::get_active_trade_keys(trade_index).await?; + let identity_keys = crate::api::identity::get_transport_identity_keys(&seller_keys).await?; + let mostro_hex = crate::config::active_mostro_pubkey(); + let mostro_pubkey = nostr_sdk::PublicKey::from_hex(&mostro_hex)?; + + let seller_hex = seller_keys.public_key().to_hex(); + let buyer_hex = trade.counterparty_pubkey.clone(); + + let parties = crate::cashu::escrow::EscrowParties::from_xonly_hex( + &buyer_hex, + &seller_hex, + &mostro_hex, + )?; + + // The daemon enforces a floor of `now + escrow_locktime_days` and accepts + // anything longer. Matching the floor exactly is the shortest lock it will + // take, which is also the soonest the seller can recover funds if Mostro + // disappears — so it is the right default rather than a conservative one. + let locktime = now_secs() + .saturating_add(u64::from(quote.locktime_days).saturating_mul(SECONDS_PER_DAY)); + + let (escrow_token, fee_token) = { + let guard = wallet_lock().read().await; + let wallet = guard + .as_ref() + .ok_or_else(|| anyhow::anyhow!("CashuNotConnected"))?; + + let escrow = wallet + .build_escrow_token(quote.amount_sats, &parties, locktime) + .await?; + + // Verify what we just built before handing it over. The daemon runs the + // same check and rejects on failure; catching it here means the seller + // learns before the token is published, not after. + wallet + .verify_escrow_token(&escrow, &parties, quote.amount_sats, locktime) + .await?; + + let fee = if quote.fee_sats > 0 { + Some(wallet.build_fee_token(quote.fee_sats, parties.mostro).await?) + } else { + None + }; + (escrow, fee) + }; + + // Correlation nonce, same shape as every other outgoing request: 0 is + // indistinguishable from "unset" on the wire. + let request_id: u64 = { + use rand::RngCore; + rand::rngs::OsRng.next_u64().max(1) + }; + let event_json = crate::mostro::actions::add_cashu_escrow( + &identity_keys, + &seller_keys, + &mostro_pubkey, + &order_id, + trade_index, + &escrow_token, + "e.mint_url, + &buyer_hex, + &seller_hex, + fee_token, + request_id, + ) + .await?; + + let publish_result = crate::api::orders::publish_event_json(&event_json).await; + + // Persist regardless of the publish outcome: the ecash is already locked at + // the mint, and a token we did not record is money we cannot find again. + if let Some(db) = crate::db::app_db::db() { + let mut updated = trade.clone(); + updated.cashu_mint_url = Some(quote.mint_url.clone()); + updated.cashu_escrow_token = Some(escrow_token); + updated.cashu_locked_at = Some(now_secs() as i64); + if let Err(e) = db.save_trade(&updated).await { + log::error!("[cashu] escrow locked but not persisted for {order_id}: {e}"); + } + } + + publish_result?; + notify().await; + log::info!("[cashu] escrow locked for order={order_id}"); + + Ok(quote) +} + +const SECONDS_PER_DAY: u64 = 86_400; + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +async fn load_trade(order_id: &str) -> Result { + let db = crate::db::app_db::db().ok_or_else(|| anyhow::anyhow!("CashuStoreUnavailable"))?; + db.get_trade_by_order_id(order_id) + .await? + .ok_or_else(|| anyhow::anyhow!("TradeNotFound: {order_id}")) +} + // ── Stream ──────────────────────────────────────────────────────────────────── /// Emits the wallet status whenever it changes: connect, receive, send, reclaim @@ -254,14 +447,9 @@ pub fn on_cashu_wallet_changed() -> CashuWalletStream { mod tests { use super::*; - /// The escrow globals are process-wide; serialize the tests that read them - /// and start from a node that has advertised nothing. - fn escrow_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); - escrow_mode::clear(); - guard - } + /// The escrow globals are process-wide and shared with `api::escrow`, so + /// the lock has to be too — see `escrow_mode::test_lock`. + use crate::mostro::escrow_mode::test_lock as escrow_lock; #[tokio::test] async fn every_entry_point_is_shut_on_a_lightning_node() { @@ -278,6 +466,10 @@ mod tests { .unwrap_err(), cashu_create_token(1).await.unwrap_err(), cashu_check_proofs_state().await.unwrap_err(), + // The escrow entry points too: these move real money, and the + // seller reaches them from a trade screen rather than a wallet one. + cashu_escrow_quote("any-order".to_string()).await.unwrap_err(), + lock_escrow("any-order".to_string()).await.unwrap_err(), ] { assert!( err.to_string().contains("CashuNotEnabled"), diff --git a/rust/src/api/escrow.rs b/rust/src/api/escrow.rs index 28781cbf..f8099545 100644 --- a/rust/src/api/escrow.rs +++ b/rust/src/api/escrow.rs @@ -200,15 +200,9 @@ mod tests { use super::*; use crate::mostro::escrow_mode::{CashuNodeConfig, EscrowMode}; - /// The escrow globals are process-wide; serialize the tests that write them - /// and start each one from a freshly-launched app's state. - fn escrow_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); - escrow_mode::clear(); - escrow_mode::set_overrides(EscrowOverrides::default()); - guard - } + /// The escrow globals are process-wide and shared with `api::cashu`, so the + /// lock has to be too — see `escrow_mode::test_lock`. + use crate::mostro::escrow_mode::test_lock as escrow_lock; #[tokio::test] async fn a_fresh_client_reports_unknown_and_no_cashu() { diff --git a/rust/src/api/nostr.rs b/rust/src/api/nostr.rs index 8bf560fe..523a01dd 100644 --- a/rust/src/api/nostr.rs +++ b/rust/src/api/nostr.rs @@ -243,6 +243,18 @@ pub(crate) async fn fetch_and_set_node_capabilities() { // to Unknown — which keeps every Cashu path shut. See escrow_mode. let (mode, config) = escrow_mode::parse_tags(&tags); escrow_mode::set_from_tags(mode, config); + + // The service fee. Only Cashu mode needs it client-side — there the + // seller funds the whole fee as its own token — but it rides in the + // same event, so reading it here costs nothing. + if let Some(fee) = tags + .iter() + .find(|t| t.first().map(String::as_str) == Some("fee")) + .and_then(|t| t.get(1)) + .and_then(|v| v.trim().parse::().ok()) + { + crate::mostro::node_fee::set_fee(fee); + } } Ok(None) => { log::warn!("[nostr] no Kind 38385 event found — PoW defaults to 0"); diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 5c065397..bf65cb0d 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -386,7 +386,7 @@ async fn store_trade_key_index(order_id: &str, index: u32) { /// Returns `None` when neither source has a record for the order. /// Callers must treat `None` as an error rather than silently using index 0, /// which would cause signature verification failures on the daemon side. -async fn get_trade_key_index(order_id: &str) -> Option { +pub(crate) async fn get_trade_key_index(order_id: &str) -> Option { // Fast path: in-memory cache. if let Some(idx) = trade_key_map() .read() @@ -851,6 +851,10 @@ pub async fn create_order(params: NewOrderParams) -> Result { started_at: now, completed_at: None, outcome: None, + // Populated only once a Cashu escrow is actually locked (C5). + cashu_mint_url: None, + cashu_escrow_token: None, + cashu_locked_at: None, }; if let Some(db) = crate::db::app_db::db() { if let Err(e) = db.save_trade(&trade).await { @@ -1071,6 +1075,10 @@ pub async fn take_order( started_at: now, completed_at: None, outcome: None, + // Populated only once a Cashu escrow is actually locked (C5). + cashu_mint_url: None, + cashu_escrow_token: None, + cashu_locked_at: None, }; store_trade_key_index(&order_id, trade_index).await; @@ -2298,7 +2306,7 @@ async fn subscribe_single_order(order_id: &str) { /// /// Returns an error if the pool is not initialised, the JSON is malformed, /// or the relay client reports a publish error. -async fn publish_event_json(event_json: &str) -> Result<()> { +pub(crate) async fn publish_event_json(event_json: &str) -> Result<()> { let pool = crate::api::nostr::get_pool().map_err(|_| anyhow::anyhow!("RelayPoolNotInitialized"))?; let event: nostr_sdk::Event = @@ -2473,6 +2481,16 @@ pub(crate) async fn refresh_subscriptions_for_active_node() { // node's Cashu mode onto another. crate::mostro::escrow_mode::clear(); + // Same for the fee: it funds a Cashu escrow's fee token, and one node's + // rate applied to another's order is a lock the daemon rejects. + crate::mostro::node_fee::clear(); + + // And the wallet, which is bound to the old node's mint. Proofs stay on + // disk; only the binding is dropped. + if let Err(e) = crate::api::cashu::cashu_disconnect().await { + log::warn!("[orders] failed to disconnect the Cashu wallet on node switch: {e}"); + } + let Ok(pool) = crate::api::nostr::get_pool() else { log::warn!( "[orders] node switch: relay pool not initialized; \ diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index 5a21367a..fde69440 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -247,6 +247,28 @@ pub struct TradeInfo { pub started_at: i64, pub completed_at: Option, pub outcome: Option, + + // ── Cashu escrow (phase C5) ────────────────────────────────────────────── + // + // All `None` on a Lightning trade, and on every trade that predates this + // field. `TradeInfo` is persisted as a JSON blob, so adding optional fields + // needs no migration — but they are `#[serde(default)]` so a row written by + // an older build still deserializes. + /// Mint the escrow was locked at. Recorded per trade rather than read back + /// from settings: a node may change its mint, and a trade must still be + /// settleable at the mint its funds actually sit in. + #[serde(default)] + pub cashu_mint_url: Option, + /// The 2-of-3 escrow token the seller locked. Kept so the seller can + /// re-submit after an interrupted send, and so either party can settle or + /// reclaim without asking the daemon for it again. + #[serde(default)] + pub cashu_escrow_token: Option, + /// Unix timestamp (seconds) when the escrow was locked. The locktime + /// refund window is counted from the node's advertised locktime, not from + /// this — this is for display and for ordering. + #[serde(default)] + pub cashu_locked_at: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -523,6 +545,30 @@ pub struct CashuWalletStatus { pub missing_capabilities: Vec, } +/// What a seller is about to lock into a Cashu escrow — phase C5. +/// +/// Shown before the seller commits anything. The amount comes from the order; +/// the fee is derived from the node's advertised rate and must match what the +/// daemon computed to the satoshi, so it is surfaced rather than hidden. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CashuEscrowQuote { + pub order_id: String, + /// The escrow itself: exactly the order amount. + pub amount_sats: u64, + /// The whole Mostro fee, funded as a separate token. Zero on a node that + /// charges none. + pub fee_sats: u64, + /// `amount_sats + fee_sats` — what the wallet must actually hold. + pub total_sats: u64, + /// Spendable balance right now, so the UI can say "fund your wallet" + /// instead of failing at the mint. + pub balance_sats: u64, + /// Mint the escrow will be locked at. + pub mint_url: String, + /// Days the escrow stays locked before the seller can reclaim it alone. + pub locktime_days: u32, +} + /// The settlement backend the active Mostro node runs, as resolved by /// [`crate::mostro::escrow_mode`] with the developer overrides applied. /// diff --git a/rust/src/cashu/mod.rs b/rust/src/cashu/mod.rs index 08be20da..cbefd27a 100644 --- a/rust/src/cashu/mod.rs +++ b/rust/src/cashu/mod.rs @@ -83,4 +83,81 @@ impl CashuWallet { pub async fn check_proofs_state(&self) -> anyhow::Result { anyhow::bail!("CashuUnsupportedOnWeb") } + + // Escrow half (C4/C5). Same shape as the native `escrow` module so the + // bridge layer compiles unchanged; a wallet can never exist here, so none + // of these is reachable in practice. + + pub async fn build_escrow_token( + &self, + _amount_sats: u64, + _parties: &escrow::EscrowParties, + _locktime: u64, + ) -> anyhow::Result { + anyhow::bail!("CashuUnsupportedOnWeb") + } + + pub async fn build_fee_token( + &self, + _amount_sats: u64, + _mostro: escrow::CashuPublicKey, + ) -> anyhow::Result { + anyhow::bail!("CashuUnsupportedOnWeb") + } + + pub async fn verify_escrow_token( + &self, + _encoded: &str, + _parties: &escrow::EscrowParties, + _expected_amount: u64, + _min_locktime: u64, + ) -> anyhow::Result<()> { + anyhow::bail!("CashuUnsupportedOnWeb") + } +} + +/// Escrow primitives on web: the types exist so the bridge layer is one +/// codebase, but nothing can be built without a wallet. +#[cfg(target_arch = "wasm32")] +pub mod escrow { + /// Stand-in for `cdk`'s compressed key. Web never reaches the mint, so the + /// hex is carried verbatim rather than parsed. + pub type CashuPublicKey = String; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct EscrowParties { + pub buyer: CashuPublicKey, + pub seller: CashuPublicKey, + pub mostro: CashuPublicKey, + } + + impl EscrowParties { + /// Applies the same `02` prefix and the same length check as the native + /// implementation, so a malformed key is rejected identically on both + /// targets rather than only where cdk is present. + pub fn from_xonly_hex( + buyer: &str, + seller: &str, + mostro: &str, + ) -> anyhow::Result { + let map = |hex: &str| -> anyhow::Result { + let hex = hex.trim(); + if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + anyhow::bail!("InvalidTradeKey: expected 64 hex characters, got {hex:?}"); + } + Ok(format!("02{hex}")) + }; + Ok(Self { + buyer: map(buyer)?, + seller: map(seller)?, + mostro: map(mostro)?, + }) + } + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct ProofSignature { + pub secret: String, + pub signature: String, + } } diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 67592dd6..0af43b0b 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -48,7 +48,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 267382495; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 367219669; // Section: executor @@ -1563,6 +1563,42 @@ fn wire__crate__api__cashu__cashu_disconnect_impl( }, ) } +fn wire__crate__api__cashu__cashu_escrow_quote_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "cashu_escrow_quote", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_order_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::cashu::cashu_escrow_quote(api_order_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__cashu__cashu_get_balance_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3147,6 +3183,42 @@ fn wire__crate__api__identity__load_identity_from_mnemonic_impl( }, ) } +fn wire__crate__api__cashu__lock_escrow_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "lock_escrow", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_order_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::cashu::lock_escrow(api_order_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__nwc__make_invoice_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -5086,6 +5158,28 @@ impl SseDecode for crate::api::types::BuyerStep { } } +impl SseDecode for crate::api::types::CashuEscrowQuote { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_orderId = ::sse_decode(deserializer); + let mut var_amountSats = ::sse_decode(deserializer); + let mut var_feeSats = ::sse_decode(deserializer); + let mut var_totalSats = ::sse_decode(deserializer); + let mut var_balanceSats = ::sse_decode(deserializer); + let mut var_mintUrl = ::sse_decode(deserializer); + let mut var_locktimeDays = ::sse_decode(deserializer); + return crate::api::types::CashuEscrowQuote { + order_id: var_orderId, + amount_sats: var_amountSats, + fee_sats: var_feeSats, + total_sats: var_totalSats, + balance_sats: var_balanceSats, + mint_url: var_mintUrl, + locktime_days: var_locktimeDays, + }; + } +} + impl SseDecode for crate::api::types::CashuWalletStatus { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -6044,6 +6138,9 @@ impl SseDecode for crate::api::types::TradeInfo { let mut var_startedAt = ::sse_decode(deserializer); let mut var_completedAt = >::sse_decode(deserializer); let mut var_outcome = >::sse_decode(deserializer); + let mut var_cashuMintUrl = >::sse_decode(deserializer); + let mut var_cashuEscrowToken = >::sse_decode(deserializer); + let mut var_cashuLockedAt = >::sse_decode(deserializer); return crate::api::types::TradeInfo { id: var_id, order: var_order, @@ -6058,6 +6155,9 @@ impl SseDecode for crate::api::types::TradeInfo { started_at: var_startedAt, completed_at: var_completedAt, outcome: var_outcome, + cashu_mint_url: var_cashuMintUrl, + cashu_escrow_token: var_cashuEscrowToken, + cashu_locked_at: var_cashuLockedAt, }; } } @@ -6261,214 +6361,216 @@ fn pde_ffi_dispatcher_primary_impl( 27 => wire__crate__api__cashu__cashu_connect_impl(port, ptr, rust_vec_len, data_len), 28 => wire__crate__api__cashu__cashu_create_token_impl(port, ptr, rust_vec_len, data_len), 29 => wire__crate__api__cashu__cashu_disconnect_impl(port, ptr, rust_vec_len, data_len), - 30 => wire__crate__api__cashu__cashu_get_balance_impl(port, ptr, rust_vec_len, data_len), - 31 => wire__crate__api__cashu__cashu_receive_token_impl(port, ptr, rust_vec_len, data_len), - 32 => wire__crate__api__cashu__cashu_status_impl(port, ptr, rust_vec_len, data_len), - 33 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), - 34 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), - 35 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), - 36 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), - 37 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), - 38 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), - 39 => { + 30 => wire__crate__api__cashu__cashu_escrow_quote_impl(port, ptr, rust_vec_len, data_len), + 31 => wire__crate__api__cashu__cashu_get_balance_impl(port, ptr, rust_vec_len, data_len), + 32 => wire__crate__api__cashu__cashu_receive_token_impl(port, ptr, rust_vec_len, data_len), + 33 => wire__crate__api__cashu__cashu_status_impl(port, ptr, rust_vec_len, data_len), + 34 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), + 35 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), + 36 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), + 37 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), + 38 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), + 40 => { wire__crate__api__messages__download_attachment_impl(port, ptr, rust_vec_len, data_len) } - 40 => wire__crate__api__identity__export_encrypted_backup_impl( + 41 => wire__crate__api__identity__export_encrypted_backup_impl( port, ptr, rust_vec_len, data_len, ), - 41 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( + 42 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( port, ptr, rust_vec_len, data_len, ), - 42 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__messages__get_attachment_status_impl( + 43 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), + 45 => wire__crate__api__messages__get_attachment_status_impl( port, ptr, rust_vec_len, data_len, ), - 45 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), - 50 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), - 51 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 52 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), - 54 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), - 55 => { + 46 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), + 51 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), + 52 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), + 53 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), + 54 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), + 55 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), + 56 => { wire__crate__api__reputation__get_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 56 => wire__crate__api__reputation__get_rating_for_trade_impl( + 57 => wire__crate__api__reputation__get_rating_for_trade_impl( port, ptr, rust_vec_len, data_len, ), - 57 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), - 58 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), - 59 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), - 60 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), - 61 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), - 62 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), - 63 => wire__crate__api__disputes__handle_admin_canceled_impl( + 58 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), + 59 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), + 60 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), + 61 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), + 62 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), + 63 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), + 64 => wire__crate__api__disputes__handle_admin_canceled_impl( port, ptr, rust_vec_len, data_len, ), - 64 => { + 65 => { wire__crate__api__disputes__handle_admin_settled_impl(port, ptr, rust_vec_len, data_len) } - 65 => wire__crate__api__disputes__handle_admin_took_dispute_impl( + 66 => wire__crate__api__disputes__handle_admin_took_dispute_impl( port, ptr, rust_vec_len, data_len, ), - 66 => wire__crate__api__reputation__handle_rating_received_impl( + 67 => wire__crate__api__reputation__handle_rating_received_impl( port, ptr, rust_vec_len, data_len, ), - 67 => { + 68 => { wire__crate__api__identity__import_from_mnemonic_impl(port, ptr, rust_vec_len, data_len) } - 68 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), - 69 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), - 70 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), - 71 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), - 72 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), - 73 => wire__crate__api__identity__load_identity_from_mnemonic_impl( + 69 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), + 70 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), + 71 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), + 72 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), + 73 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), + 74 => wire__crate__api__identity__load_identity_from_mnemonic_impl( port, ptr, rust_vec_len, data_len, ), - 74 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), - 75 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), - 76 => wire__crate__api__messages__on_attachment_progress_impl( + 75 => wire__crate__api__cashu__lock_escrow_impl(port, ptr, rust_vec_len, data_len), + 76 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), + 77 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), + 78 => wire__crate__api__messages__on_attachment_progress_impl( port, ptr, rust_vec_len, data_len, ), - 77 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), - 78 => { + 79 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), + 80 => { wire__crate__api__cashu__on_cashu_wallet_changed_impl(port, ptr, rust_vec_len, data_len) } - 79 => wire__crate__api__nostr__on_connection_state_changed_impl( + 81 => wire__crate__api__nostr__on_connection_state_changed_impl( port, ptr, rust_vec_len, data_len, ), - 80 => { + 82 => { wire__crate__api__disputes__on_dispute_updated_impl(port, ptr, rust_vec_len, data_len) } - 81 => { + 83 => { wire__crate__api__escrow__on_escrow_mode_changed_impl(port, ptr, rust_vec_len, data_len) } - 82 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), - 83 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), - 84 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), - 85 => { + 84 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), + 85 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), + 86 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), + 87 => { wire__crate__api__reputation__on_rating_received_impl(port, ptr, rust_vec_len, data_len) } - 86 => { + 88 => { wire__crate__api__nostr__on_relay_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 87 => { + 89 => { wire__crate__api__settings__on_settings_changed_impl(port, ptr, rust_vec_len, data_len) } - 88 => wire__crate__api__messages__on_unread_count_changed_impl( + 90 => wire__crate__api__messages__on_unread_count_changed_impl( port, ptr, rust_vec_len, data_len, ), - 89 => { + 91 => { wire__crate__api__nwc__on_wallet_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 90 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), - 91 => { + 92 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), + 93 => { wire__crate__api__orders__order_filters_default_impl(port, ptr, rust_vec_len, data_len) } - 92 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), - 93 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( + 94 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), + 95 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( port, ptr, rust_vec_len, data_len, ), - 94 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( + 96 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( port, ptr, rust_vec_len, data_len, ), - 95 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), - 96 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), - 97 => wire__crate__api__orders__restart_orders_subscription_impl( + 97 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), + 98 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), + 99 => wire__crate__api__orders__restart_orders_subscription_impl( port, ptr, rust_vec_len, data_len, ), - 98 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), - 99 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), - 100 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), - 101 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), - 102 => wire__crate__api__settings__set_active_mostro_node_impl( + 100 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), + 101 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), + 102 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), + 103 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), + 104 => wire__crate__api__settings__set_active_mostro_node_impl( port, ptr, rust_vec_len, data_len, ), - 103 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( + 105 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( port, ptr, rust_vec_len, data_len, ), - 104 => wire__crate__api__settings__set_default_fiat_code_impl( + 106 => wire__crate__api__settings__set_default_fiat_code_impl( port, ptr, rust_vec_len, data_len, ), - 105 => wire__crate__api__settings__set_default_lightning_address_impl( + 107 => wire__crate__api__settings__set_default_lightning_address_impl( port, ptr, rust_vec_len, data_len, ), - 106 => wire__crate__api__escrow__set_escrow_mode_override_impl( + 108 => wire__crate__api__escrow__set_escrow_mode_override_impl( port, ptr, rust_vec_len, data_len, ), - 107 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), - 108 => { + 109 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), + 110 => { wire__crate__api__settings__set_logging_enabled_impl(port, ptr, rust_vec_len, data_len) } - 109 => { + 111 => { wire__crate__api__reputation__set_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 110 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), - 111 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), - 112 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), - 113 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), - 114 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 112 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), + 113 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), + 114 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), + 115 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), + 116 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -6824,6 +6926,32 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::types::CashuEscrowQuote { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.order_id.into_into_dart().into_dart(), + self.amount_sats.into_into_dart().into_dart(), + self.fee_sats.into_into_dart().into_dart(), + self.total_sats.into_into_dart().into_dart(), + self.balance_sats.into_into_dart().into_dart(), + self.mint_url.into_into_dart().into_dart(), + self.locktime_days.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::types::CashuEscrowQuote +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::types::CashuEscrowQuote +{ + fn into_into_dart(self) -> crate::api::types::CashuEscrowQuote { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::types::CashuWalletStatus { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -7574,6 +7702,9 @@ impl flutter_rust_bridge::IntoDart for crate::api::types::TradeInfo { self.started_at.into_into_dart().into_dart(), self.completed_at.into_into_dart().into_dart(), self.outcome.into_into_dart().into_dart(), + self.cashu_mint_url.into_into_dart().into_dart(), + self.cashu_escrow_token.into_into_dart().into_dart(), + self.cashu_locked_at.into_into_dart().into_dart(), ] .into_dart() } @@ -8060,6 +8191,19 @@ impl SseEncode for crate::api::types::BuyerStep { } } +impl SseEncode for crate::api::types::CashuEscrowQuote { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.order_id, serializer); + ::sse_encode(self.amount_sats, serializer); + ::sse_encode(self.fee_sats, serializer); + ::sse_encode(self.total_sats, serializer); + ::sse_encode(self.balance_sats, serializer); + ::sse_encode(self.mint_url, serializer); + ::sse_encode(self.locktime_days, serializer); + } +} + impl SseEncode for crate::api::types::CashuWalletStatus { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -8893,6 +9037,9 @@ impl SseEncode for crate::api::types::TradeInfo { ::sse_encode(self.started_at, serializer); >::sse_encode(self.completed_at, serializer); >::sse_encode(self.outcome, serializer); + >::sse_encode(self.cashu_mint_url, serializer); + >::sse_encode(self.cashu_escrow_token, serializer); + >::sse_encode(self.cashu_locked_at, serializer); } } diff --git a/rust/src/mostro/actions.rs b/rust/src/mostro/actions.rs index ef99bdcf..61860a28 100644 --- a/rust/src/mostro/actions.rs +++ b/rust/src/mostro/actions.rs @@ -12,7 +12,7 @@ /// arguments — see `api::identity::get_transport_identity_keys`, which /// applies the runtime privacy toggle. use anyhow::Result; -use mostro_core::message::{Action, Message, Payload}; +use mostro_core::message::{Action, CashuLockProof, Message, Payload}; use nostr_sdk::prelude::*; use uuid::Uuid; @@ -278,6 +278,53 @@ pub async fn add_invoice( wrap_message(identity_keys, trade_keys, mostro_pubkey, &msg).await } +/// Seller → Mostro: the funded 2-of-3 escrow token (phase C5). +/// +/// The Cashu analogue of paying the hold invoice. The daemon re-derives +/// `{P_B, P_S, P_M}` from the order and rejects a proof whose stated keys +/// disagree, so these carry the x-only hex of the *trade* keys exactly as the +/// order holds them — an identity key here would be rejected, and would leak +/// the user across orders if it were not. +/// +/// `fee_token` is `None` on a node that charges no fee; a node that does +/// rejects a submission without one (daemon TA-1f). +#[allow(clippy::too_many_arguments)] +pub async fn add_cashu_escrow( + identity_keys: &Keys, + trade_keys: &Keys, + mostro_pubkey: &PublicKey, + order_id: &str, + trade_index: u32, + token: &str, + mint_url: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + fee_token: Option, + request_id: u64, +) -> Result { + let id = Uuid::parse_str(order_id)?; + + let mut proof = CashuLockProof::new( + token.to_string(), + mint_url.to_string(), + buyer_pubkey.to_string(), + seller_pubkey.to_string(), + mostro_pubkey.to_string(), + ); + if let Some(fee) = fee_token { + proof = proof.with_fee_token(fee); + } + + let msg = Message::new_order( + Some(id), + Some(request_id), + Some(trade_index as i64), + Action::AddCashuEscrow, + Some(Payload::CashuLockProof(proof)), + ); + wrap_message(identity_keys, trade_keys, mostro_pubkey, &msg).await +} + // ── Helpers ─────────────────────────────────────────────────────────────────── /// Internal helper for take-buy / take-sell actions. diff --git a/rust/src/mostro/escrow_mode.rs b/rust/src/mostro/escrow_mode.rs index c095009e..cb50a8cb 100644 --- a/rust/src/mostro/escrow_mode.rs +++ b/rust/src/mostro/escrow_mode.rs @@ -323,6 +323,21 @@ pub fn set_overrides(overrides: EscrowOverrides) { notify(); } +/// Serializes tests that write the globals above, **across modules**. +/// +/// `api::escrow` and `api::cashu` both drive this state; two private mutexes +/// would let one module's "force Cashu" leak into the other's "this is a +/// Lightning node" assertion, which fails only under parallel execution and +/// looks like flakiness. One global, one lock. +#[cfg(test)] +pub fn test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + clear(); + set_overrides(EscrowOverrides::default()); + guard +} + /// Forget what the node advertised. Called when the active node changes, so a /// stale Cashu resolution can never leak onto a different node between the /// switch and the next successful fetch. The overrides are deliberately left diff --git a/rust/src/mostro/mod.rs b/rust/src/mostro/mod.rs index 5dc59acd..2578c8f9 100644 --- a/rust/src/mostro/mod.rs +++ b/rust/src/mostro/mod.rs @@ -1,6 +1,7 @@ pub mod actions; pub mod escrow_mode; pub mod fsm; +pub mod node_fee; pub mod pow; pub mod session; diff --git a/rust/src/mostro/node_fee.rs b/rust/src/mostro/node_fee.rs new file mode 100644 index 00000000..7392e167 --- /dev/null +++ b/rust/src/mostro/node_fee.rs @@ -0,0 +1,134 @@ +//! The service fee the active Mostro node charges, from its Kind 38385 `fee` +//! tag — phase C5 of `docs/cashu/README.md`. +//! +//! Same shape as [`crate::mostro::pow`]: a process-global refreshed by the same +//! capability fetch and cleared on node switch. +//! +//! In Lightning mode the client never needs this — the daemon skims the fee +//! from the payout. In Cashu mode the seller funds the **whole** fee as a +//! separate token at lock time, so the client has to compute the exact figure +//! the daemon expects, and a value off by one satoshi is rejected. + +use std::sync::RwLock; + +/// The fee as a fraction of the order amount (`0.006` = 0.6%), or `None` +/// before the first successful fetch. +static FEE: RwLock> = RwLock::new(None); + +/// Record the fee fraction the node advertises. +/// +/// Anything not finite or negative is discarded rather than stored: a garbage +/// fee would silently produce a fee token the daemon rejects, and the seller +/// would see a lock failure with no clue why. +pub fn set_fee(fraction: f64) { + if !fraction.is_finite() || fraction < 0.0 { + log::warn!("[node-fee] ignoring malformed fee fraction: {fraction}"); + return; + } + let mut guard = FEE.write().unwrap_or_else(|e| e.into_inner()); + *guard = Some(fraction); + log::info!("[node-fee] fee fraction set to {fraction}"); +} + +/// The advertised fee fraction, or `None` if the node published none. +pub fn get_fee() -> Option { + *FEE.read().unwrap_or_else(|e| e.into_inner()) +} + +/// Forget the fee. Called on node switch, so one node's fee is never applied to +/// another's order. +pub fn clear() { + let mut guard = FEE.write().unwrap_or_else(|e| e.into_inner()); + *guard = None; +} + +/// The satoshi fee **one side** of a trade owes on `amount_sats`. +/// +/// Must match the daemon's `get_fee` exactly — `(fee * amount) / 2.0`, rounded +/// — because the escrow's fee token is checked for an exact value. Computing it +/// as `round(fee * amount) / 2` instead would differ by a satoshi on half the +/// amounts, and every one of those locks would be rejected. +pub fn split_fee_sats(amount_sats: u64, fraction: f64) -> u64 { + let rounded = ((fraction * amount_sats as f64) / 2.0).round(); + if !rounded.is_finite() || rounded < 0.0 { + return 0; + } + rounded as u64 +} + +/// The **whole** Mostro fee the seller funds in Cashu mode: `2 * order.fee`, +/// where `order.fee` is the per-side figure the daemon stored (daemon TA-1f). +/// +/// Deliberately expressed as "twice the split fee" rather than "the fee on the +/// amount": the daemon rounds the half, so doubling the rounded half is the +/// only expression that agrees with it. +pub fn total_fee_sats(amount_sats: u64, fraction: f64) -> u64 { + split_fee_sats(amount_sats, fraction).saturating_mul(2) +} + +#[cfg(test)] +mod tests { + use super::*; + + static GLOBAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn own_the_global() -> std::sync::MutexGuard<'static, ()> { + let guard = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + clear(); + guard + } + + #[test] + fn the_fee_is_unknown_until_a_node_advertises_one() { + let _g = own_the_global(); + assert_eq!(get_fee(), None); + + set_fee(0.006); + assert_eq!(get_fee(), Some(0.006)); + + // A node switch must not carry one node's fee onto another's orders. + clear(); + assert_eq!(get_fee(), None); + } + + #[test] + fn a_malformed_fee_is_discarded_rather_than_stored() { + // Arrange — a fee that would produce a token the daemon rejects. + let _g = own_the_global(); + set_fee(0.006); + + // Act / Assert — each bad value leaves the last good one in place. + for bad in [f64::NAN, f64::INFINITY, -0.01] { + set_fee(bad); + assert_eq!(get_fee(), Some(0.006), "{bad} must not be stored"); + } + } + + #[test] + fn the_split_fee_matches_the_daemons_rounding() { + // Assert — the daemon computes (fee * amount) / 2.0 and rounds *that*. + // 500 sat is the case where rounding the whole fee first disagrees, + // which in production looks like a rejected lock with no explanation. + assert_eq!(split_fee_sats(10_000, 0.006), 30); + assert_eq!(split_fee_sats(1_000, 0.006), 3); + assert_eq!(split_fee_sats(500, 0.006), 2); // 1.5 → 2 + assert_eq!(split_fee_sats(10_000, 0.0), 0); + } + + #[test] + fn the_total_fee_is_twice_the_rounded_half() { + // Assert — doubling the rounded half, not rounding the double: at 500 + // sat the two differ (4 vs 3), and only the former equals what the + // daemon stored as `2 * order.fee`. + assert_eq!(total_fee_sats(500, 0.006), 4); + assert_eq!(total_fee_sats(10_000, 0.006), 60); + assert_eq!(total_fee_sats(10_000, 0.0), 0); + } + + #[test] + fn an_absurd_amount_cannot_overflow_the_fee() { + // Assert — u64::MAX sats is unreachable, but the arithmetic must not + // wrap into a small fee if it ever were. + assert!(total_fee_sats(u64::MAX, 1.0) > 0); + } +} From 2b127b126fa2600c12f71d0aa03d46475af8b1fd Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 25 Jul 2026 09:24:55 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(cashu):=20C5=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20the=20escrow=20was=20locked=20to=20the=20wrong=20bu?= =?UTF-8?q?yer=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the strict review on #238. Critical - `lock_escrow` took the buyer key from `TradeInfo.counterparty_pubkey`, which is written once at construction and never updated: empty for a maker seller (so a user who *created* the sell order could never fund the escrow) and the maker's order-book key for a taker seller — not the per-order trade key the daemon validates against. The taker case was the worse one: the daemon rejects, but only after the ecash has been swapped into a token locked to a key the buyer does not hold. The daemon states both keys in the escrow request (`SmallOrder`'s `buyer_trade_pubkey` / `seller_trade_pubkey`) and the client was discarding them. They are now carried through `classify_take_reply` for the taker path, captured from the status-sync arm for the maker path (where the request arrives as an ordinary message), persisted on `TradeInfo`, and read from there. `lock_escrow` also refuses when the stored seller key is not the one this device holds, rather than building an escrow nobody can spend. Major - The locktime matched the daemon's floor exactly, computed with *our* clock while the daemon evaluates its own later — every lock was a race against the publish delay, lost with the funds already swapped. A one-hour margin is invisible to a seller and larger than any plausible propagation. - `cashu_escrow_quote` read the balance without connecting, so an unconnected wallet reported zero and a fully funded seller was told they had insufficient funds. It connects first, and an unreadable balance is an error rather than a zero. - The escrow token was built before the fee token, so a fee failure stranded the whole escrow. The fee — the smaller and cheaper of the two — is built first. - `take_order_screen` read `isCashuAvailableProvider` while it was still loading, routing a Cashu seller to a hold-invoice screen that would never fill. It awaits the provider now. Minor - A lock that failed *after* the mint swap now offers a retry, with the reason stated: the token is persisted and the daemon's handler is idempotent, so retrying is the only way out of a lost publish. Failures raised before any funds move deliberately do not offer it. - `node_fee::set_fee` caps the fraction at 1.0; a malformed `2.0` tag would have produced a fee token twice the escrow. - `now_secs` returns an error instead of 0 for a pre-epoch clock, which would have built a 1970 locktime and surfaced as an unexplained condition error. - The marker→message mapping is shared with the wallet screen. Note on the fee input: `SmallOrder` carries no fee, so the client must still derive it from the node's advertised rate. An operator who changes the fee between order creation and the lock will invalidate in-flight orders; that is recorded as a known limitation rather than silently accepted. Tests: the buyer key must come from the escrow request and not the order book; the locktime clears a floor evaluated a minute later; and a widget suite for the lock screen — quote shown before committing, funding offered on a short balance, a marker explained rather than printed, and retry offered only after the swap. --- lib/features/cashu/cashu_error_messages.dart | 10 + .../cashu/screens/lock_escrow_screen.dart | 59 +++--- .../order/screens/take_order_screen.dart | 11 +- lib/l10n/app_de.arb | 9 +- lib/l10n/app_en.arb | 15 +- lib/l10n/app_es.arb | 9 +- lib/l10n/app_fr.arb | 9 +- lib/l10n/app_it.arb | 9 +- lib/l10n/app_localizations.dart | 36 ++++ lib/l10n/app_localizations_de.dart | 23 +++ lib/l10n/app_localizations_en.dart | 23 +++ lib/l10n/app_localizations_es.dart | 23 +++ lib/l10n/app_localizations_fr.dart | 23 +++ lib/l10n/app_localizations_it.dart | 23 +++ rust/src/api/cashu.rs | 168 +++++++++++++++-- rust/src/api/orders.rs | 100 +++++++++- rust/src/api/types.rs | 15 ++ rust/src/cashu/escrow.rs | 10 +- rust/src/cashu/mod.rs | 2 +- rust/src/frb_generated.rs | 87 ++++++++- rust/src/mostro/node_fee.rs | 11 +- .../screens/lock_escrow_screen_test.dart | 171 ++++++++++++++++++ 22 files changed, 787 insertions(+), 59 deletions(-) create mode 100644 test/features/cashu/screens/lock_escrow_screen_test.dart diff --git a/lib/features/cashu/cashu_error_messages.dart b/lib/features/cashu/cashu_error_messages.dart index 29bf77d2..7a694448 100644 --- a/lib/features/cashu/cashu_error_messages.dart +++ b/lib/features/cashu/cashu_error_messages.dart @@ -22,6 +22,16 @@ String cashuErrorMessage(Object error, AppLocalizations l10n) { /// Marker → message. Insertion-ordered, most specific first: a marker that is a /// prefix of another must come first, or the broader one would shadow it. final Map _messages = { + 'CashuInsufficientFunds': (l) => l.lockEscrowInsufficientFunds, + 'CashuNodeFeeUnknown': (l) => l.lockEscrowFeeUnknown, + 'CashuEscrowRequestMissing': (l) => l.lockEscrowRequestMissing, + 'CashuWrongTradeKey': (l) => l.lockEscrowWrongTradeKey, + 'CashuLocktimeNotReached': (l) => l.lockEscrowLocktimeNotReached, + 'DeviceClockInvalid': (l) => l.lockEscrowClockInvalid, + 'InvalidEscrowParties': (l) => l.lockEscrowInvalidToken, + 'InvalidEscrowToken': (l) => l.lockEscrowInvalidToken, + 'NotTheSeller': (l) => l.lockEscrowNotTheSeller, + 'CashuLockFailed': (l) => l.lockEscrowFailed, 'CashuNotEnabled': (l) => l.cashuErrorNotEnabled, 'CashuNotConnected': (l) => l.cashuErrorNotConnected, 'CashuMintUnreachable': (l) => l.cashuErrorMintUnreachable, diff --git a/lib/features/cashu/screens/lock_escrow_screen.dart b/lib/features/cashu/screens/lock_escrow_screen.dart index 8cf199c5..ed5d3c14 100644 --- a/lib/features/cashu/screens/lock_escrow_screen.dart +++ b/lib/features/cashu/screens/lock_escrow_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/cashu/cashu_error_messages.dart'; import 'package:mostro/features/cashu/providers/cashu_wallet_provider.dart'; import 'package:mostro/l10n/app_localizations.dart'; import 'package:mostro/src/rust/api/types.dart'; @@ -31,6 +32,15 @@ class _LockEscrowScreenState extends ConsumerState { String? _error; bool _locking = false; + /// True once a lock attempt has swapped funds at the mint but the submission + /// may not have reached the node. + /// + /// The token is persisted before the publish result is checked, and the + /// daemon's handler is idempotent on a re-submission — so retrying is both + /// safe and the only way out of a lost publish. Without this the seller is + /// left with locked funds and a trade that looks stuck. + bool _needsRetry = false; + @override void initState() { super.initState(); @@ -66,32 +76,26 @@ class _LockEscrowScreenState extends ConsumerState { setState(() { _locking = false; _error = e.toString(); + // Anything past the mint swap leaves a token behind. The markers + // below are raised *before* it, so those are clean failures. + _needsRetry = !_isPreLockFailure(e.toString()); }); } } } - /// Rust markers → localized text. An unknown marker falls back to the generic - /// message, so an internal string never reaches the user. - String _message(String raw, AppLocalizations l10n) { - if (raw.contains('CashuInsufficientFunds')) { - return l10n.lockEscrowInsufficientFunds; - } - if (raw.contains('CashuNodeFeeUnknown')) return l10n.lockEscrowFeeUnknown; - if (raw.contains('CashuNotEnabled')) return l10n.cashuErrorNotEnabled; - if (raw.contains('CashuNotConnected')) return l10n.cashuErrorNotConnected; - if (raw.contains('CashuMintUnreachable')) { - return l10n.cashuErrorMintUnreachable; - } - if (raw.contains('CashuMintUnusable')) return l10n.cashuErrorMintUnusable; - if (raw.contains('CashuUnsupportedOnWeb')) { - return l10n.cashuErrorUnsupportedOnWeb; - } - if (raw.contains('NotTheSeller')) return l10n.lockEscrowNotTheSeller; - if (raw.contains('InvalidEscrowToken')) return l10n.lockEscrowInvalidToken; - if (raw.contains('CashuLockFailed')) return l10n.lockEscrowFailed; - return l10n.cashuErrorGeneric; - } + /// Failures raised before any funds move, so there is nothing to retry. + bool _isPreLockFailure(String raw) => const [ + 'CashuInsufficientFunds', + 'CashuNodeFeeUnknown', + 'CashuEscrowRequestMissing', + 'CashuWrongTradeKey', + 'CashuNotEnabled', + 'CashuNotConnected', + 'NotTheSeller', + 'DeviceClockInvalid', + 'InvalidEscrowParties', + ].any(raw.contains); @override Widget build(BuildContext context) { @@ -140,10 +144,17 @@ class _LockEscrowScreenState extends ConsumerState { style: TextStyle(color: colors.textSubtle, fontSize: 13), ), ], + if (_needsRetry) ...[ + const SizedBox(height: AppSpacing.md), + Text( + l10n.lockEscrowPendingSubmission, + style: TextStyle(color: colors.textSubtle, fontSize: 13), + ), + ], if (_error != null) ...[ const SizedBox(height: AppSpacing.md), Text( - _message(_error!, l10n), + cashuErrorMessage(_error!, l10n), style: TextStyle(color: colors.destructiveRed), ), ], @@ -163,7 +174,9 @@ class _LockEscrowScreenState extends ConsumerState { width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : Text(l10n.lockEscrowConfirm), + : Text(_needsRetry + ? l10n.lockEscrowRetry + : l10n.lockEscrowConfirm), ), ], ), diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 4974cd5d..4ccd46fb 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -135,9 +135,14 @@ class _TakeOrderScreenState extends ConsumerState { // In Cashu mode the flow after a take differs on both sides: there is no // buyer invoice step at all, and the seller locks an escrow instead of - // paying a hold invoice. `isCashuAvailable` is false on every Lightning - // node, so this branch simply does not exist there. - if (ref.read(isCashuAvailableProvider)) { + // paying a hold invoice. + // + // Awaited, not `read`: the provider is `AsyncLoading` for the first + // moments after launch, and a plain read would answer "not Cashu" and + // route a seller to a hold invoice that is never coming. + final escrowMode = await ref.read(escrowModeProvider.future); + if (!mounted) return; + if (escrowMode.isCashuAvailable) { if (widget.isBuying) { context.go(AppRoute.tradeDetailPath(widget.orderId)); } else { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 42dfded0..bddab91f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -757,4 +757,11 @@ "lockEscrowLocktime": "Von dir r\u00fcckholbar nach {days} Tagen", "cashuLastTokenPending": "Du hast ein Token exportiert. Es ist Geld, bis jemand es einl\u00f6st \u2014 behalte es, bis du sicher bist, dass es angekommen ist.", "cashuShowLastToken": "Erneut anzeigen", - "cashuLastTokenDone": "Ich habe es gesendet"} + "cashuLastTokenDone": "Ich habe es gesendet", + "lockEscrowRequestMissing": "F\u00fcr diesen Handel gibt es noch keine Treuhand-Anfrage. Warte, bis die Annahme des K\u00e4ufers eintrifft, und versuche es erneut.", + "lockEscrowWrongTradeKey": "Dieses Ger\u00e4t hat nicht den Schl\u00fcssel, mit dem diese Order angenommen wurde. Stelle dein Konto auf dem Ger\u00e4t wieder her, auf dem du den Handel begonnen hast.", + "lockEscrowLocktimeNotReached": "Die Treuhand ist noch gesperrt. Nach Ablauf der Sperrfrist kannst du sie selbst zur\u00fcckholen.", + "lockEscrowClockInvalid": "Die Uhr deines Ger\u00e4ts geht falsch, daher l\u00e4sst sich die Treuhand nicht korrekt datieren. Korrigiere das Datum und versuche es erneut.", + "lockEscrowRetry": "Senden erneut versuchen", + "lockEscrowPendingSubmission": "Deine Treuhand ist gesperrt, aber der Node hat sie nicht best\u00e4tigt. Ein erneuter Versuch ist sicher \u2014 es wird kein zweites Mal gesperrt." +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 18af2a89..feee35be 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1659,4 +1659,17 @@ "cashuShowLastToken": "Show it again", "@cashuShowLastToken": {"description": "Cashu wallet \u2014 re-opens the last exported token"}, "cashuLastTokenDone": "I've sent it", - "@cashuLastTokenDone": {"description": "Cashu wallet \u2014 clears the exported-token reminder"}} + "@cashuLastTokenDone": {"description": "Cashu wallet \u2014 clears the exported-token reminder"}, + "lockEscrowRequestMissing": "This trade has no escrow request yet. Wait for the buyer's take to arrive, then try again.", + "@lockEscrowRequestMissing": {"description": "Escrow error \u2014 the daemon has not sent the escrow request, so the buyer trade key is unknown"}, + "lockEscrowWrongTradeKey": "This device does not hold the key this order was taken with. Restore your account on the device you started the trade on.", + "@lockEscrowWrongTradeKey": {"description": "Escrow error \u2014 the stored seller trade key does not match this device"}, + "lockEscrowLocktimeNotReached": "The escrow is still locked. You can reclaim it yourself once the locktime passes.", + "@lockEscrowLocktimeNotReached": {"description": "Escrow error \u2014 a refund was attempted before the locktime expired"}, + "lockEscrowClockInvalid": "Your device's clock is wrong, so the escrow cannot be timed correctly. Fix the date and try again.", + "@lockEscrowClockInvalid": {"description": "Escrow error \u2014 the system clock is before 1970"}, + "lockEscrowRetry": "Retry sending", + "@lockEscrowRetry": {"description": "Escrow screen \u2014 resubmits an escrow that was locked but whose message did not reach the node"}, + "lockEscrowPendingSubmission": "Your escrow is locked but the node has not confirmed it. Retrying is safe \u2014 it will not lock a second time.", + "@lockEscrowPendingSubmission": {"description": "Escrow screen \u2014 shown when a token exists locally but the submission may not have arrived"} +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5113a8f8..522fd2d0 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -757,4 +757,11 @@ "lockEscrowLocktime": "Pod\u00e9s recuperarlo tras {days} d\u00edas", "cashuLastTokenPending": "Exportaste un token. Es dinero hasta que alguien lo canjee: guardalo hasta estar seguro de que lleg\u00f3.", "cashuShowLastToken": "Mostrarlo de nuevo", - "cashuLastTokenDone": "Ya lo envi\u00e9"} + "cashuLastTokenDone": "Ya lo envi\u00e9", + "lockEscrowRequestMissing": "Esta operaci\u00f3n todav\u00eda no tiene pedido de custodia. Esper\u00e1 a que llegue la toma del comprador e intent\u00e1 de nuevo.", + "lockEscrowWrongTradeKey": "Este dispositivo no tiene la clave con la que se tom\u00f3 esta orden. Restaur\u00e1 tu cuenta en el dispositivo donde empezaste la operaci\u00f3n.", + "lockEscrowLocktimeNotReached": "La custodia sigue bloqueada. Vas a poder recuperarla vos mismo cuando pase el locktime.", + "lockEscrowClockInvalid": "El reloj de tu dispositivo est\u00e1 mal, as\u00ed que la custodia no se puede fechar bien. Correg\u00ed la fecha e intent\u00e1 de nuevo.", + "lockEscrowRetry": "Reintentar env\u00edo", + "lockEscrowPendingSubmission": "Tu custodia est\u00e1 bloqueada pero el nodo no la confirm\u00f3. Reintentar es seguro: no se bloquea una segunda vez." +} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b14bd287..9dee5fbe 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -757,4 +757,11 @@ "lockEscrowLocktime": "R\u00e9cup\u00e9rable par vous apr\u00e8s {days} jours", "cashuLastTokenPending": "Vous avez export\u00e9 un token. C'est de l'argent jusqu'\u00e0 ce que quelqu'un l'encaisse \u2014 gardez-le jusqu'\u00e0 confirmation.", "cashuShowLastToken": "Le r\u00e9afficher", - "cashuLastTokenDone": "Je l'ai envoy\u00e9"} + "cashuLastTokenDone": "Je l'ai envoy\u00e9", + "lockEscrowRequestMissing": "Cet \u00e9change n'a pas encore de demande de s\u00e9questre. Attendez que la prise de l'acheteur arrive, puis r\u00e9essayez.", + "lockEscrowWrongTradeKey": "Cet appareil ne d\u00e9tient pas la cl\u00e9 avec laquelle cet ordre a \u00e9t\u00e9 pris. Restaurez votre compte sur l'appareil o\u00f9 vous avez commenc\u00e9 l'\u00e9change.", + "lockEscrowLocktimeNotReached": "Le s\u00e9questre est encore verrouill\u00e9. Vous pourrez le r\u00e9cup\u00e9rer vous-m\u00eame une fois le verrou expir\u00e9.", + "lockEscrowClockInvalid": "L'horloge de votre appareil est incorrecte, le s\u00e9questre ne peut donc pas \u00eatre dat\u00e9 correctement. Corrigez la date et r\u00e9essayez.", + "lockEscrowRetry": "R\u00e9essayer l'envoi", + "lockEscrowPendingSubmission": "Votre s\u00e9questre est verrouill\u00e9 mais le n\u0153ud ne l'a pas confirm\u00e9. R\u00e9essayer est sans risque : il ne sera pas verrouill\u00e9 une seconde fois." +} diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 33d3c7b4..a4a8d2b0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -757,4 +757,11 @@ "lockEscrowLocktime": "Recuperabile da te dopo {days} giorni", "cashuLastTokenPending": "Hai esportato un token. \u00c8 denaro finch\u00e9 qualcuno non lo riscuote: conservalo finch\u00e9 non sei sicuro che sia arrivato.", "cashuShowLastToken": "Mostralo di nuovo", - "cashuLastTokenDone": "L'ho inviato"} + "cashuLastTokenDone": "L'ho inviato", + "lockEscrowRequestMissing": "Questo scambio non ha ancora una richiesta di deposito. Attendi che arrivi la presa dell'acquirente e riprova.", + "lockEscrowWrongTradeKey": "Questo dispositivo non ha la chiave con cui \u00e8 stato preso questo ordine. Ripristina il tuo account sul dispositivo da cui hai iniziato lo scambio.", + "lockEscrowLocktimeNotReached": "Il deposito \u00e8 ancora bloccato. Potrai recuperarlo tu stesso una volta scaduto il blocco.", + "lockEscrowClockInvalid": "L'orologio del tuo dispositivo \u00e8 errato, quindi il deposito non pu\u00f2 essere datato correttamente. Correggi la data e riprova.", + "lockEscrowRetry": "Riprova l'invio", + "lockEscrowPendingSubmission": "Il tuo deposito \u00e8 bloccato ma il nodo non lo ha confermato. Riprovare \u00e8 sicuro: non verr\u00e0 bloccato una seconda volta." +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 076e0737..f232081a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4513,6 +4513,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'I\'ve sent it'** String get cashuLastTokenDone; + + /// Escrow error — the daemon has not sent the escrow request, so the buyer trade key is unknown + /// + /// In en, this message translates to: + /// **'This trade has no escrow request yet. Wait for the buyer\'s take to arrive, then try again.'** + String get lockEscrowRequestMissing; + + /// Escrow error — the stored seller trade key does not match this device + /// + /// In en, this message translates to: + /// **'This device does not hold the key this order was taken with. Restore your account on the device you started the trade on.'** + String get lockEscrowWrongTradeKey; + + /// Escrow error — a refund was attempted before the locktime expired + /// + /// In en, this message translates to: + /// **'The escrow is still locked. You can reclaim it yourself once the locktime passes.'** + String get lockEscrowLocktimeNotReached; + + /// Escrow error — the system clock is before 1970 + /// + /// In en, this message translates to: + /// **'Your device\'s clock is wrong, so the escrow cannot be timed correctly. Fix the date and try again.'** + String get lockEscrowClockInvalid; + + /// Escrow screen — resubmits an escrow that was locked but whose message did not reach the node + /// + /// In en, this message translates to: + /// **'Retry sending'** + String get lockEscrowRetry; + + /// Escrow screen — shown when a token exists locally but the submission may not have arrived + /// + /// In en, this message translates to: + /// **'Your escrow is locked but the node has not confirmed it. Retrying is safe — it will not lock a second time.'** + String get lockEscrowPendingSubmission; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c838c50c..a66056b6 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2566,4 +2566,27 @@ class AppLocalizationsDe extends AppLocalizations { @override String get cashuLastTokenDone => 'Ich habe es gesendet'; + + @override + String get lockEscrowRequestMissing => + 'Für diesen Handel gibt es noch keine Treuhand-Anfrage. Warte, bis die Annahme des Käufers eintrifft, und versuche es erneut.'; + + @override + String get lockEscrowWrongTradeKey => + 'Dieses Gerät hat nicht den Schlüssel, mit dem diese Order angenommen wurde. Stelle dein Konto auf dem Gerät wieder her, auf dem du den Handel begonnen hast.'; + + @override + String get lockEscrowLocktimeNotReached => + 'Die Treuhand ist noch gesperrt. Nach Ablauf der Sperrfrist kannst du sie selbst zurückholen.'; + + @override + String get lockEscrowClockInvalid => + 'Die Uhr deines Geräts geht falsch, daher lässt sich die Treuhand nicht korrekt datieren. Korrigiere das Datum und versuche es erneut.'; + + @override + String get lockEscrowRetry => 'Senden erneut versuchen'; + + @override + String get lockEscrowPendingSubmission => + 'Deine Treuhand ist gesperrt, aber der Node hat sie nicht bestätigt. Ein erneuter Versuch ist sicher — es wird kein zweites Mal gesperrt.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 61656acf..5a36cfa6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2531,4 +2531,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cashuLastTokenDone => 'I\'ve sent it'; + + @override + String get lockEscrowRequestMissing => + 'This trade has no escrow request yet. Wait for the buyer\'s take to arrive, then try again.'; + + @override + String get lockEscrowWrongTradeKey => + 'This device does not hold the key this order was taken with. Restore your account on the device you started the trade on.'; + + @override + String get lockEscrowLocktimeNotReached => + 'The escrow is still locked. You can reclaim it yourself once the locktime passes.'; + + @override + String get lockEscrowClockInvalid => + 'Your device\'s clock is wrong, so the escrow cannot be timed correctly. Fix the date and try again.'; + + @override + String get lockEscrowRetry => 'Retry sending'; + + @override + String get lockEscrowPendingSubmission => + 'Your escrow is locked but the node has not confirmed it. Retrying is safe — it will not lock a second time.'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 6857c688..60403ffc 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2557,4 +2557,27 @@ class AppLocalizationsEs extends AppLocalizations { @override String get cashuLastTokenDone => 'Ya lo envié'; + + @override + String get lockEscrowRequestMissing => + 'Esta operación todavía no tiene pedido de custodia. Esperá a que llegue la toma del comprador e intentá de nuevo.'; + + @override + String get lockEscrowWrongTradeKey => + 'Este dispositivo no tiene la clave con la que se tomó esta orden. Restaurá tu cuenta en el dispositivo donde empezaste la operación.'; + + @override + String get lockEscrowLocktimeNotReached => + 'La custodia sigue bloqueada. Vas a poder recuperarla vos mismo cuando pase el locktime.'; + + @override + String get lockEscrowClockInvalid => + 'El reloj de tu dispositivo está mal, así que la custodia no se puede fechar bien. Corregí la fecha e intentá de nuevo.'; + + @override + String get lockEscrowRetry => 'Reintentar envío'; + + @override + String get lockEscrowPendingSubmission => + 'Tu custodia está bloqueada pero el nodo no la confirmó. Reintentar es seguro: no se bloquea una segunda vez.'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e721ebfa..49e4cdb4 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2568,4 +2568,27 @@ class AppLocalizationsFr extends AppLocalizations { @override String get cashuLastTokenDone => 'Je l\'ai envoyé'; + + @override + String get lockEscrowRequestMissing => + 'Cet échange n\'a pas encore de demande de séquestre. Attendez que la prise de l\'acheteur arrive, puis réessayez.'; + + @override + String get lockEscrowWrongTradeKey => + 'Cet appareil ne détient pas la clé avec laquelle cet ordre a été pris. Restaurez votre compte sur l\'appareil où vous avez commencé l\'échange.'; + + @override + String get lockEscrowLocktimeNotReached => + 'Le séquestre est encore verrouillé. Vous pourrez le récupérer vous-même une fois le verrou expiré.'; + + @override + String get lockEscrowClockInvalid => + 'L\'horloge de votre appareil est incorrecte, le séquestre ne peut donc pas être daté correctement. Corrigez la date et réessayez.'; + + @override + String get lockEscrowRetry => 'Réessayer l\'envoi'; + + @override + String get lockEscrowPendingSubmission => + 'Votre séquestre est verrouillé mais le nœud ne l\'a pas confirmé. Réessayer est sans risque : il ne sera pas verrouillé une seconde fois.'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 1aae1586..fe022a8d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2560,4 +2560,27 @@ class AppLocalizationsIt extends AppLocalizations { @override String get cashuLastTokenDone => 'L\'ho inviato'; + + @override + String get lockEscrowRequestMissing => + 'Questo scambio non ha ancora una richiesta di deposito. Attendi che arrivi la presa dell\'acquirente e riprova.'; + + @override + String get lockEscrowWrongTradeKey => + 'Questo dispositivo non ha la chiave con cui è stato preso questo ordine. Ripristina il tuo account sul dispositivo da cui hai iniziato lo scambio.'; + + @override + String get lockEscrowLocktimeNotReached => + 'Il deposito è ancora bloccato. Potrai recuperarlo tu stesso una volta scaduto il blocco.'; + + @override + String get lockEscrowClockInvalid => + 'L\'orologio del tuo dispositivo è errato, quindi il deposito non può essere datato correttamente. Correggi la data e riprova.'; + + @override + String get lockEscrowRetry => 'Riprova l\'invio'; + + @override + String get lockEscrowPendingSubmission => + 'Il tuo deposito è bloccato ma il nodo non lo ha confermato. Riprovare è sicuro: non verrà bloccato una seconda volta.'; } diff --git a/rust/src/api/cashu.rs b/rust/src/api/cashu.rs index 5edd2225..25d49aeb 100644 --- a/rust/src/api/cashu.rs +++ b/rust/src/api/cashu.rs @@ -267,11 +267,21 @@ pub async fn cashu_escrow_quote(order_id: String) -> Result wallet.balance().await.unwrap_or(0), - None => 0, + Some(wallet) => wallet + .balance() + .await + .map_err(|e| anyhow::anyhow!("CashuBalanceUnknown: {e}"))?, + None => bail!("CashuNotConnected"), } }; @@ -337,7 +347,25 @@ pub async fn lock_escrow(order_id: String) -> Result Result Result 0 { + Some(wallet.build_fee_token(quote.fee_sats, &parties.mostro).await?) + } else { + None + }; + let escrow = wallet .build_escrow_token(quote.amount_sats, &parties, locktime) .await?; @@ -369,11 +408,6 @@ pub async fn lock_escrow(order_id: String) -> Result 0 { - Some(wallet.build_fee_token(quote.fee_sats, parties.mostro).await?) - } else { - None - }; (escrow, fee) }; @@ -406,7 +440,7 @@ pub async fn lock_escrow(order_id: String) -> Result Result u64 { +/// Added on top of the daemon's locktime floor to absorb the delay between +/// building the token and the daemon validating it. An hour is invisible to a +/// seller and orders of magnitude larger than relay propagation. +const LOCKTIME_SUBMISSION_MARGIN_SECS: u64 = 3_600; + +/// Seconds since the unix epoch. +/// +/// A clock before the epoch is an error rather than `0`: substituting zero +/// would build a locktime in 1970 and surface much later as an unexplained +/// `InvalidEscrowConditions`. +fn now_secs() -> Result { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) - .unwrap_or(0) + .map_err(|_| anyhow::anyhow!("DeviceClockInvalid: system time is before 1970")) } async fn load_trade(order_id: &str) -> Result { @@ -534,6 +578,96 @@ mod tests { assert!(!status.connected); } + /// A trade as the app stores it, with the two fields that decide whether an + /// escrow can be built at all. + fn seller_trade( + order_id: &str, + buyer_trade_pubkey: Option<&str>, + counterparty_pubkey: &str, + ) -> crate::api::types::TradeInfo { + use crate::api::types::*; + TradeInfo { + id: order_id.to_string(), + order: OrderInfo { + id: order_id.to_string(), + kind: OrderKind::Buy, + status: OrderStatus::WaitingPayment, + amount_sats: Some(10_000), + fiat_amount: None, + fiat_amount_min: None, + fiat_amount_max: None, + fiat_code: "USD".to_string(), + payment_method: "cash".to_string(), + premium: 0.0, + creator_pubkey: counterparty_pubkey.to_string(), + created_at: 0, + expires_at: None, + is_mine: false, + }, + role: TradeRole::Seller, + counterparty_pubkey: counterparty_pubkey.to_string(), + current_step: TradeStep::Seller(SellerStep::TakerFound), + hold_invoice: None, + buyer_invoice: None, + trade_key_index: 1, + cooperative_cancel_state: None, + timeout_at: None, + started_at: 0, + completed_at: None, + outcome: None, + buyer_trade_pubkey: buyer_trade_pubkey.map(str::to_string), + seller_trade_pubkey: None, + cashu_mint_url: None, + cashu_escrow_token: None, + cashu_locked_at: None, + } + } + + #[test] + fn the_buyer_key_comes_from_the_escrow_request_not_the_order_book() { + // Arrange — a maker seller has no counterparty pubkey at all, and a + // taker seller's is the maker's *order-book* key. Neither is the + // per-order trade key the daemon locks the escrow to, and building an + // escrow from either produces a token the buyer cannot spend. + let order_book_key = + "82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390"; + let trade_key = "0000000000000000000000000000000000000000000000000000000000000001"; + + let maker = seller_trade("order-1", None, ""); + let taker = seller_trade("order-2", None, order_book_key); + let ready = seller_trade("order-3", Some(trade_key), order_book_key); + + // Assert — the field the escrow must be built from is populated only by + // the daemon's escrow request. + assert_eq!(maker.buyer_trade_pubkey, None); + assert_eq!(taker.buyer_trade_pubkey, None); + assert_eq!(ready.buyer_trade_pubkey.as_deref(), Some(trade_key)); + + // And it is not the order-book key, which is what the first version of + // this flow used. + assert_ne!(ready.buyer_trade_pubkey.as_deref(), Some(order_book_key)); + } + + #[test] + fn the_locktime_clears_the_daemons_floor() { + // Arrange — the daemon's floor is `now + locktime_days`, evaluated when + // it validates, which is later than ours by the publish delay. + let days = 15u32; + let ours = now_secs().unwrap() + + u64::from(days) * SECONDS_PER_DAY + + LOCKTIME_SUBMISSION_MARGIN_SECS; + + // Act — the daemon evaluates its floor some time later. + let daemon_floor_later = now_secs().unwrap() + 60 + u64::from(days) * SECONDS_PER_DAY; + + // Assert — still above it. Matching the floor exactly made every lock a + // race against the network, lost with the funds already swapped. + assert!( + ours > daemon_floor_later, + "locktime {ours} must clear a floor evaluated a minute later ({daemon_floor_later})" + ); + } + #[test] fn the_proof_store_needs_an_initialised_database() { // Arrange / Act — with no app DB there is nowhere to put the store, diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index bf65cb0d..e35832cc 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -46,6 +46,14 @@ enum DaemonReply { amount_sats: Option, /// Hold invoice bolt11 (seller taking a buy order), when present. hold_invoice: Option, + /// The per-order trade pubkeys the daemon assigned, when the reply + /// carries an order payload. + /// + /// This is the *only* place the client learns the counterparty's trade + /// key for this order: the public 38383 event carries the maker's order + /// key, which is a different key, and Cashu's escrow is locked to the + /// trade keys the daemon holds. + trade_pubkeys: TradePubkeys, }, /// Daemon acknowledged an add-invoice. The reply doubles as a status /// update processed by the per-action arms; the caller only needs the @@ -55,6 +63,74 @@ enum DaemonReply { Rejected { reason: String, message: String }, } +/// Buyer and seller trade pubkeys for one order, as the daemon states them. +/// +/// Both `None` on a reply that carries no order payload; either may be `None` +/// on a daemon that predates the field. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct TradePubkeys { + pub buyer: Option, + pub seller: Option, +} + +impl TradePubkeys { + fn from_small_order(order: &mostro_core::order::SmallOrder) -> Self { + Self { + buyer: order.buyer_trade_pubkey.clone(), + seller: order.seller_trade_pubkey.clone(), + } + } + + fn is_empty(&self) -> bool { + self.buyer.is_none() && self.seller.is_none() + } +} + +/// Read the trade pubkeys out of whichever payload shape carries an order. +fn trade_pubkeys_from_payload( + payload: &Option, +) -> TradePubkeys { + use mostro_core::message::Payload; + match payload { + Some(Payload::Order(so)) => TradePubkeys::from_small_order(so), + Some(Payload::PaymentRequest(Some(so), _, _)) => TradePubkeys::from_small_order(so), + _ => TradePubkeys::default(), + } +} + +/// Persist the trade pubkeys against a stored trade, if it exists. +/// +/// Read-modify-write rather than a new `Storage` method: this runs once per +/// order, on a message the daemon sends exactly once. +async fn store_trade_pubkeys(order_id: &str, pubkeys: &TradePubkeys) { + if pubkeys.is_empty() { + return; + } + let Some(db) = crate::db::app_db::db() else { + return; + }; + match db.get_trade_by_order_id(order_id).await { + Ok(Some(mut trade)) => { + let mut changed = false; + if trade.buyer_trade_pubkey != pubkeys.buyer && pubkeys.buyer.is_some() { + trade.buyer_trade_pubkey = pubkeys.buyer.clone(); + changed = true; + } + if trade.seller_trade_pubkey != pubkeys.seller && pubkeys.seller.is_some() { + trade.seller_trade_pubkey = pubkeys.seller.clone(); + changed = true; + } + if changed { + if let Err(e) = db.save_trade(&trade).await { + log::warn!("[orders] failed to persist trade pubkeys for {order_id}: {e}"); + } + } + } + Ok(None) => {} + Err(e) => log::warn!("[orders] could not load trade {order_id} to store pubkeys: {e}"), + } +} + /// What kind of outgoing request a pending record tracks. enum PendingRequestKind { Create { @@ -288,6 +364,7 @@ fn classify_take_reply( .or_else(|| status_for_action(action)), amount_sats, hold_invoice: Some(invoice.clone()), + trade_pubkeys: trade_pubkeys_from_payload(payload), } } Some(Payload::Order(small_order)) => DaemonReply::TakeAccepted { @@ -302,6 +379,9 @@ fn classify_take_reply( None }, hold_invoice: None, + // In Cashu mode this payload *is* the escrow request, and these + // two keys are what the escrow gets locked to. + trade_pubkeys: TradePubkeys::from_small_order(small_order), }, // Action-only progression reply (payload absent or of another shape): // still a genuine acceptance. The take interception consumes the @@ -314,6 +394,7 @@ fn classify_take_reply( status: status_for_action(action), amount_sats: None, hold_invoice: None, + trade_pubkeys: TradePubkeys::default(), }, } } @@ -852,6 +933,8 @@ pub async fn create_order(params: NewOrderParams) -> Result { completed_at: None, outcome: None, // Populated only once a Cashu escrow is actually locked (C5). + buyer_trade_pubkey: None, + seller_trade_pubkey: None, cashu_mint_url: None, cashu_escrow_token: None, cashu_locked_at: None, @@ -1011,17 +1094,18 @@ pub async fn take_order( detach_request_waiter(&trade_pk_hex, request_id); } - let (status, amount_sats, hold_invoice) = match reply { + let (status, amount_sats, hold_invoice, trade_pubkeys) = match reply { Ok(Ok(DaemonReply::TakeAccepted { action, status, amount_sats, hold_invoice, + trade_pubkeys, })) => { crate::api::logging::blog_info("orders", format!( "take_order confirmed by daemon: order={order_id} reply={action:?}" )); - (status, amount_sats, hold_invoice) + (status, amount_sats, hold_invoice, trade_pubkeys) } Ok(Ok(DaemonReply::Rejected { reason, message })) => { crate::api::logging::blog_warn("orders", format!( @@ -1033,7 +1117,7 @@ pub async fn take_order( // Only the create flow sends Confirmed; a take record can never // receive it. Treat defensively as an acceptance without data. log::warn!("[orders] take_order received a create-style confirmation"); - (None, None, None) + (None, None, None, TradePubkeys::default()) } _ => { // No daemon response within the timeout. Do not persist or show @@ -1076,6 +1160,10 @@ pub async fn take_order( completed_at: None, outcome: None, // Populated only once a Cashu escrow is actually locked (C5). + // From the daemon's reply, not from the order book: this is the only + // source of the counterparty's per-order trade key. + buyer_trade_pubkey: trade_pubkeys.buyer.clone(), + seller_trade_pubkey: trade_pubkeys.seller.clone(), cashu_mint_url: None, cashu_escrow_token: None, cashu_locked_at: None, @@ -1937,6 +2025,12 @@ async fn dispatch_mostro_message( return; } }; + // The escrow request reaches a *maker* seller here rather than + // through the take waiter, and it is the only message carrying the + // counterparty's per-order trade key. Without this the maker path + // has no buyer key to lock a Cashu escrow to. + store_trade_pubkeys(&order_id, &trade_pubkeys_from_payload(&kind.payload)).await; + // Map action → OrderStatus for DB sync (shared with the take // reply classification). let new_status = status_for_action(&kind.action); diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index 70352e4d..88c78913 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -248,6 +248,21 @@ pub struct TradeInfo { pub completed_at: Option, pub outcome: Option, + /// The buyer's **per-order trade pubkey**, as the daemon stated it. + /// + /// Not the same as [`Self::counterparty_pubkey`], which holds the maker's + /// order-book key for a taker and nothing at all for a maker. The Cashu + /// escrow is locked to these keys, and the daemon re-derives them from the + /// order and rejects a proof that names any others — so this is the only + /// value that can be used to build one. + /// + /// `None` until the daemon sends a reply carrying an order payload. + #[serde(default)] + pub buyer_trade_pubkey: Option, + /// The seller's per-order trade pubkey. See [`Self::buyer_trade_pubkey`]. + #[serde(default)] + pub seller_trade_pubkey: Option, + // ── Cashu escrow (phase C5) ────────────────────────────────────────────── // // All `None` on a Lightning trade, and on every trade that predates this diff --git a/rust/src/cashu/escrow.rs b/rust/src/cashu/escrow.rs index 4fe82693..e09449dc 100644 --- a/rust/src/cashu/escrow.rs +++ b/rust/src/cashu/escrow.rs @@ -131,9 +131,9 @@ pub fn escrow_conditions(parties: &EscrowParties, locktime: u64) -> Result SpendingConditions { +pub fn fee_conditions(mostro: &PublicKey) -> SpendingConditions { SpendingConditions::P2PKConditions { - data: mostro, + data: *mostro, conditions: None, } } @@ -224,7 +224,9 @@ impl CashuWallet { } /// Swap `amount_sats` into a token payable to Mostro alone. - pub async fn build_fee_token(&self, amount_sats: u64, mostro: PublicKey) -> Result { + /// Taken by reference so the caller keeps its [`EscrowParties`] whole — + /// the wasm stub carries hex strings rather than `Copy` keys. + pub async fn build_fee_token(&self, amount_sats: u64, mostro: &PublicKey) -> Result { self.build_locked_token(amount_sats, fee_conditions(mostro)) .await } @@ -595,7 +597,7 @@ mod tests { let parties = parties(); // Act - let conditions = fee_conditions(parties.mostro); + let conditions = fee_conditions(&parties.mostro); // Assert — no locktime, no extra keys: the fee is a payment, not an // escrow, and conditions would make it unspendable for the node. diff --git a/rust/src/cashu/mod.rs b/rust/src/cashu/mod.rs index ee99e8b8..388985c9 100644 --- a/rust/src/cashu/mod.rs +++ b/rust/src/cashu/mod.rs @@ -113,7 +113,7 @@ impl CashuWallet { pub async fn build_fee_token( &self, _amount_sats: u64, - _mostro: escrow::CashuPublicKey, + _mostro: &escrow::CashuPublicKey, ) -> anyhow::Result { anyhow::bail!("CashuUnsupportedOnWeb") } diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index de351172..9745630e 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -48,7 +48,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 367219669; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1169277549; // Section: executor @@ -4702,6 +4702,39 @@ fn wire__crate__api__orders__take_order_impl( }, ) } +fn wire__crate__api__orders__trade_pubkeys_default_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "trade_pubkeys_default", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::api::orders::TradePubkeys::default())?; + Ok(output_ok) + })()) + } + }, + ) +} // Section: related_funcs @@ -6138,6 +6171,8 @@ impl SseDecode for crate::api::types::TradeInfo { let mut var_startedAt = ::sse_decode(deserializer); let mut var_completedAt = >::sse_decode(deserializer); let mut var_outcome = >::sse_decode(deserializer); + let mut var_buyerTradePubkey = >::sse_decode(deserializer); + let mut var_sellerTradePubkey = >::sse_decode(deserializer); let mut var_cashuMintUrl = >::sse_decode(deserializer); let mut var_cashuEscrowToken = >::sse_decode(deserializer); let mut var_cashuLockedAt = >::sse_decode(deserializer); @@ -6155,6 +6190,8 @@ impl SseDecode for crate::api::types::TradeInfo { started_at: var_startedAt, completed_at: var_completedAt, outcome: var_outcome, + buyer_trade_pubkey: var_buyerTradePubkey, + seller_trade_pubkey: var_sellerTradePubkey, cashu_mint_url: var_cashuMintUrl, cashu_escrow_token: var_cashuEscrowToken, cashu_locked_at: var_cashuLockedAt, @@ -6189,6 +6226,18 @@ impl SseDecode for crate::api::types::TradeOutcome { } } +impl SseDecode for crate::api::orders::TradePubkeys { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_buyer = >::sse_decode(deserializer); + let mut var_seller = >::sse_decode(deserializer); + return crate::api::orders::TradePubkeys { + buyer: var_buyer, + seller: var_seller, + }; + } +} + impl SseDecode for crate::api::types::TradeRole { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -6571,6 +6620,9 @@ fn pde_ffi_dispatcher_primary_impl( 114 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), 115 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), 116 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 117 => { + wire__crate__api__orders__trade_pubkeys_default_impl(port, ptr, rust_vec_len, data_len) + } _ => unreachable!(), } } @@ -7702,6 +7754,8 @@ impl flutter_rust_bridge::IntoDart for crate::api::types::TradeInfo { self.started_at.into_into_dart().into_dart(), self.completed_at.into_into_dart().into_dart(), self.outcome.into_into_dart().into_dart(), + self.buyer_trade_pubkey.into_into_dart().into_dart(), + self.seller_trade_pubkey.into_into_dart().into_dart(), self.cashu_mint_url.into_into_dart().into_dart(), self.cashu_escrow_token.into_into_dart().into_dart(), self.cashu_locked_at.into_into_dart().into_dart(), @@ -7763,6 +7817,27 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::orders::TradePubkeys { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.buyer.into_into_dart().into_dart(), + self.seller.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::orders::TradePubkeys +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::orders::TradePubkeys +{ + fn into_into_dart(self) -> crate::api::orders::TradePubkeys { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::types::TradeRole { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { @@ -9037,6 +9112,8 @@ impl SseEncode for crate::api::types::TradeInfo { ::sse_encode(self.started_at, serializer); >::sse_encode(self.completed_at, serializer); >::sse_encode(self.outcome, serializer); + >::sse_encode(self.buyer_trade_pubkey, serializer); + >::sse_encode(self.seller_trade_pubkey, serializer); >::sse_encode(self.cashu_mint_url, serializer); >::sse_encode(self.cashu_escrow_token, serializer); >::sse_encode(self.cashu_locked_at, serializer); @@ -9070,6 +9147,14 @@ impl SseEncode for crate::api::types::TradeOutcome { } } +impl SseEncode for crate::api::orders::TradePubkeys { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.buyer, serializer); + >::sse_encode(self.seller, serializer); + } +} + impl SseEncode for crate::api::types::TradeRole { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/rust/src/mostro/node_fee.rs b/rust/src/mostro/node_fee.rs index 7392e167..97d06126 100644 --- a/rust/src/mostro/node_fee.rs +++ b/rust/src/mostro/node_fee.rs @@ -15,13 +15,19 @@ use std::sync::RwLock; /// before the first successful fetch. static FEE: RwLock> = RwLock::new(None); +/// Anything above this is a malformed tag, not a business decision. +const MAX_FEE_FRACTION: f64 = 1.0; + /// Record the fee fraction the node advertises. /// /// Anything not finite or negative is discarded rather than stored: a garbage /// fee would silently produce a fee token the daemon rejects, and the seller /// would see a lock failure with no clue why. pub fn set_fee(fraction: f64) { - if !fraction.is_finite() || fraction < 0.0 { + // Upper bound as well as lower: a malformed tag of `2.0` would be read as + // 200% and produce a fee token larger than the escrow it accompanies. + // No plausible node charges more than the whole amount. + if !fraction.is_finite() || !(0.0..=MAX_FEE_FRACTION).contains(&fraction) { log::warn!("[node-fee] ignoring malformed fee fraction: {fraction}"); return; } @@ -98,7 +104,8 @@ mod tests { set_fee(0.006); // Act / Assert — each bad value leaves the last good one in place. - for bad in [f64::NAN, f64::INFINITY, -0.01] { + // `2.0` is 200%: a fee token twice the escrow, which no node charges. + for bad in [f64::NAN, f64::INFINITY, -0.01, 2.0] { set_fee(bad); assert_eq!(get_fee(), Some(0.006), "{bad} must not be stored"); } diff --git a/test/features/cashu/screens/lock_escrow_screen_test.dart b/test/features/cashu/screens/lock_escrow_screen_test.dart new file mode 100644 index 00000000..ca924c36 --- /dev/null +++ b/test/features/cashu/screens/lock_escrow_screen_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/cashu/providers/cashu_wallet_provider.dart'; +import 'package:mostro/features/cashu/screens/lock_escrow_screen.dart'; +import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/src/rust/api/types.dart'; + +import '../../../support/provider_harness.dart'; + +/// Stands in for the Rust bridge so nothing reaches a mint or a relay. +class _FakeEscrow extends CashuEscrowController { + const _FakeEscrow({this.quoteResult, this.quoteError, this.lockError}); + + final CashuEscrowQuote? quoteResult; + final Object? quoteError; + final Object? lockError; + + @override + Future quote(String orderId) async { + if (quoteError != null) throw quoteError!; + return quoteResult!; + } + + @override + Future lock(String orderId) async { + if (lockError != null) throw lockError!; + return quoteResult!; + } +} + +class _FakeWallet extends CashuWalletController { + const _FakeWallet(); + + @override + Future connect() async => CashuWalletStatus( + connected: true, + mintUrl: 'https://mint.example.com', + balanceSats: BigInt.from(100000), + missingCapabilities: const [], + ); +} + +CashuEscrowQuote _quote({required int balance}) => CashuEscrowQuote( + orderId: 'order-1', + amountSats: BigInt.from(10000), + feeSats: BigInt.from(60), + totalSats: BigInt.from(10060), + balanceSats: BigInt.from(balance), + mintUrl: 'https://mint.example.com', + locktimeDays: 15, + ); + +Future _pump( + WidgetTester tester, { + required CashuEscrowController escrow, +}) async { + final container = createContainer(overrides: [ + cashuEscrowControllerProvider.overrideWithValue(escrow), + cashuWalletControllerProvider.overrideWithValue(const _FakeWallet()), + ]); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildDarkTheme(), + locale: const Locale('en'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: const LockEscrowScreen(orderId: 'order-1'), + ), + ), + ); + + await tester.pump(); + await tester.pump(); +} + +void main() { + group('LockEscrowScreen', () { + testWidgets('shows what will be locked before anything is committed', + (tester) async { + await _pump( + tester, + escrow: _FakeEscrow(quoteResult: _quote(balance: 100000)), + ); + + // Escrow, fee and total are all stated: the fee is a separate token and + // its size is not obvious from the order. + expect(find.text('10000 Satoshis'), findsOneWidget); + expect(find.text('60 Satoshis'), findsOneWidget); + expect(find.text('10060 Satoshis'), findsOneWidget); + expect(find.text('Lock escrow'), findsOneWidget); + }); + + testWidgets('a short balance offers funding instead of a failure', + (tester) async { + // The most common seller error must not surface as a mint-side message. + await _pump( + tester, + escrow: _FakeEscrow(quoteResult: _quote(balance: 100)), + ); + + expect(find.text('Fund your wallet'), findsOneWidget); + expect(find.text('Lock escrow'), findsNothing); + }); + + testWidgets('a missing escrow request is explained, not shown as a marker', + (tester) async { + await _pump( + tester, + escrow: const _FakeEscrow( + quoteError: 'CashuEscrowRequestMissing: nothing stored', + ), + ); + + expect(find.textContaining('no escrow request yet'), findsOneWidget); + expect(find.textContaining('CashuEscrowRequestMissing'), findsNothing); + }); + + testWidgets('a failure before the mint swap offers no retry', + (tester) async { + // Nothing moved, so offering "retry sending" would misdescribe what + // happened. + await _pump( + tester, + escrow: _FakeEscrow( + quoteResult: _quote(balance: 100000), + lockError: 'CashuWrongTradeKey: order expects abc', + ), + ); + + await tester.tap(find.text('Lock escrow')); + await tester.pumpAndSettle(); + + expect(find.text('Retry sending'), findsNothing); + expect(find.textContaining('does not hold the key'), findsOneWidget); + }); + + testWidgets('a failure after the mint swap offers a safe retry', + (tester) async { + // The funds are locked and the token is persisted; the daemon's handler + // is idempotent, so retrying is the only way out of a lost publish. + await _pump( + tester, + escrow: _FakeEscrow( + quoteResult: _quote(balance: 100000), + lockError: 'relay publish failed', + ), + ); + + await tester.tap(find.text('Lock escrow')); + await tester.pumpAndSettle(); + + expect(find.text('Retry sending'), findsOneWidget); + expect( + find.textContaining('locked but the node has not confirmed'), + findsOneWidget, + ); + }); + }); +} From 4793e6f7092efa6a9f378a59791a002aad88c4dd Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 25 Jul 2026 09:34:56 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(cashu):=20CodeRabbit=20round=20on=20#23?= =?UTF-8?q?8=20=E2=80=94=20parser=20divergence,=20stale=20seed,=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings were valid, three were not. Fixed - `MostroInstance.parseEscrowMode` used `getOptional`, so a *present but blank* `escrow_mode` tag read as `unknown` while Rust's `parse_tags` reads the same event as `Lightning`. Two parsers, one event, different answers — the About screen and the Cashu gate could disagree about the same daemon. Now only an absent (or value-less) tag is unknown, matching Rust exactly. - The dev card's post-frame seed captured `mintUrlOverride` during build and applied it after the frame. An override arriving in that gap is applied by `ref.listen` first, and the captured copy then overwrote it with the older value. The callback reads the current provider value instead. - `api::escrow::snapshot` derived `mode` from one `get_resolved()` and `is_cashu_available` from `is_cashu_mode()`, which reads the globals again — a node switch between the two produced a snapshot whose mode and gate disagreed. `ResolvedEscrowMode::is_cashu_usable()` now expresses the gate against a value the caller already holds, and `is_cashu_mode()` is that applied to the current globals. - `set_from_tags` logged unconditionally while only notifying on a change, so a reconnect that confirmed what we already knew still wrote an info line. Moved inside the `changed` branch. - The C1 "Done when" in the plan still said flipping the override flips "the About section", contradicting the bullet directly above it. About reports what the node advertised; the override moves the resolved mode and the dev card's effective state, nothing else. Not fixed, with reasons - The shared test lock is already in place (`escrow_mode::test_lock`, used by both `api::escrow` and `api::cashu`); the finding describes the pre-fix state. - The `hintText` is the literal `http://localhost:3338`, identical in every locale, on a `kDebugMode`-only card. Five ARB entries that translate nothing is maintenance without a reader. - French `aboutDaysValue`: CLDR puts 0 in the `one` category for French, so "0 jour" is correct and the suggested change would introduce an error. The generated file is also not the source of truth and must not be hand-edited. Tests: blank / whitespace / value-less / absent `escrow_mode` all pinned against the Rust behaviour, and a widget suite for the dev card covering seeding, a newer override winning, and in-progress typing surviving a no-op event. --- docs/cashu/README.md | 5 +- .../about/models/mostro_instance.dart | 10 +- .../widgets/escrow_mode_dev_card.dart | 11 +- rust/src/api/escrow.rs | 5 +- rust/src/mostro/escrow_mode.rs | 27 +++-- .../about/models/mostro_instance_test.dart | 29 +++++ .../widgets/escrow_mode_dev_card_test.dart | 101 ++++++++++++++++++ 7 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 test/features/settings/widgets/escrow_mode_dev_card_test.dart diff --git a/docs/cashu/README.md b/docs/cashu/README.md index 3de48f01..0b9fe6e9 100644 --- a/docs/cashu/README.md +++ b/docs/cashu/README.md @@ -378,8 +378,9 @@ Every phase, without exception, carries these standing requirements: (tracked in #233). - Companion (out of this repo): upstream PR to `mostrod` adding the tags of §4.1. - **Done when:** against any current daemon the app shows Lightning and behaves - identically; flipping the override flips the provider and the About section; unit - tests for tag parsing + resolution order. + identically; flipping the override flips the resolved mode and the dev card's + effective state — **not** the About section, which reports only what the node + advertised; unit tests for tag parsing + resolution order. - Est. size: S (~400–600 lines). #### C2 — Cashu wallet core (Rust, cdk) diff --git a/lib/features/about/models/mostro_instance.dart b/lib/features/about/models/mostro_instance.dart index c53144d8..3b31a545 100644 --- a/lib/features/about/models/mostro_instance.dart +++ b/lib/features/about/models/mostro_instance.dart @@ -239,9 +239,15 @@ class MostroInstance { // unrecognised backend reads as Lightning: we cannot trade Cashu with it // either, and that is the reading that keeps Cashu shut. EscrowMode parseEscrowMode() { - final raw = getOptional('escrow_mode')?.toLowerCase(); + // `get`, not `getOptional`: only an *absent* tag is unknown. A tag that + // is present but blank is a node that answered, and Rust's `parse_tags` + // reads it as Lightning — the two parsers must agree, or the About screen + // and the gate disagree about the same event. + final raw = get('escrow_mode'); if (raw == null) return EscrowMode.unknown; - return raw == 'cashu' ? EscrowMode.cashu : EscrowMode.lightning; + return raw.trim().toLowerCase() == 'cashu' + ? EscrowMode.cashu + : EscrowMode.lightning; } // Parameters are gated on an enabled policy so a disabled or malformed diff --git a/lib/features/settings/widgets/escrow_mode_dev_card.dart b/lib/features/settings/widgets/escrow_mode_dev_card.dart index d75bece5..092606dc 100644 --- a/lib/features/settings/widgets/escrow_mode_dev_card.dart +++ b/lib/features/settings/widgets/escrow_mode_dev_card.dart @@ -86,9 +86,14 @@ class _EscrowModeDevCardState extends ConsumerState { }); if (!_seeded && info != null) { _seeded = true; - final seed = info.mintUrlOverride; - WidgetsBinding.instance - .addPostFrameCallback((_) => _syncMintField(seed)); + // Read at callback time, not build time. An override that arrives in the + // gap between the two is applied by `ref.listen` first, and a captured + // copy would then overwrite it with the older value. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final current = ref.read(escrowModeProvider).valueOrNull; + if (current != null) _syncMintField(current.mintUrlOverride); + }); } return Container( diff --git a/rust/src/api/escrow.rs b/rust/src/api/escrow.rs index 42520138..d668cff4 100644 --- a/rust/src/api/escrow.rs +++ b/rust/src/api/escrow.rs @@ -30,7 +30,10 @@ fn snapshot() -> EscrowModeInfo { escrow_locktime_days: resolved.config.escrow_locktime_days, settlement_margin_days: resolved.config.settlement_margin_days, is_overridden: resolved.is_overridden, - is_cashu_available: escrow_mode::is_cashu_mode(), + // Derived from the resolution above rather than re-reading the globals: + // `is_cashu_mode()` would take a second read, and a node switch between + // the two would produce a snapshot whose mode and gate disagree. + is_cashu_available: resolved.is_cashu_usable(), force_cashu_override: matches!(overrides.mode, EscrowModeOverride::ForceCashu), mint_url_override: overrides.mint_url, } diff --git a/rust/src/mostro/escrow_mode.rs b/rust/src/mostro/escrow_mode.rs index 01d0b8ca..0bdcb935 100644 --- a/rust/src/mostro/escrow_mode.rs +++ b/rust/src/mostro/escrow_mode.rs @@ -99,6 +99,18 @@ pub struct ResolvedEscrowMode { pub is_overridden: bool, } +impl ResolvedEscrowMode { + /// May a Cashu path run against *this* resolution? + /// + /// The gate, expressed against a value the caller already holds — so a + /// snapshot that reports `mode` and this flag together cannot have read + /// them from two different states. [`is_cashu_mode`] is this applied to the + /// current globals. + pub fn is_cashu_usable(&self) -> bool { + self.mode.is_cashu() && self.config.is_usable() + } +} + /// Developer override, for testing against a daemon branch that implements /// Cashu but does not publish the info tags yet (§4.3). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -281,11 +293,7 @@ fn notify() { /// what the node said, and refusing to update it would leave the app pinned to /// a stale node's mode after any unrelated panic. pub fn set_from_tags(mode: EscrowMode, config: CashuNodeConfig) { - log::info!( - "[escrow-mode] active node advertises {} (mint={:?})", - mode.as_marker(), - config.mint_url, - ); + let mint_url = config.mint_url.clone(); let changed = { let mut guard = TAGS.write().unwrap_or_else(|e| e.into_inner()); let next = Some((mode, config)); @@ -294,8 +302,12 @@ pub fn set_from_tags(mode: EscrowMode, config: CashuNodeConfig) { changed }; // A re-fetch that confirms what we already knew is the common case on a - // reconnect; emitting for it would wake every listener for nothing. + // reconnect: it wakes nobody, and it does not deserve a log line either. if changed { + log::info!( + "[escrow-mode] active node advertises {} (mint={mint_url:?})", + mode.as_marker(), + ); notify(); } } @@ -408,8 +420,7 @@ pub fn clear() { /// The About screen must *not* use this: it reads [`get_resolved`], so it can /// say "cashu, but no mint advertised" instead of silently reading Lightning. pub fn is_cashu_mode() -> bool { - let resolved = get_resolved(); - resolved.mode.is_cashu() && resolved.config.is_usable() + get_resolved().is_cashu_usable() } #[cfg(test)] diff --git a/test/features/about/models/mostro_instance_test.dart b/test/features/about/models/mostro_instance_test.dart index 4810413e..f5a97ad4 100644 --- a/test/features/about/models/mostro_instance_test.dart +++ b/test/features/about/models/mostro_instance_test.dart @@ -423,6 +423,35 @@ void main() { expect(instance.cashuEscrowLocktimeDays, isNull); }); + test('a present but blank escrow_mode is lightning, not unknown', () { + // A node that answered is not a node that stayed silent. Rust's + // `parse_tags` reads a blank value as Lightning, and the two parsers read + // the same event — a divergence here would have the About screen and the + // Cashu gate disagreeing about the same daemon. + for (final blank in ['', ' ']) { + expect( + MostroInstance.fromTags(_tagsWith({'escrow_mode': blank})).escrowMode, + EscrowMode.lightning, + reason: 'blank value ${blank.isEmpty ? "(empty)" : "(spaces)"}', + ); + } + + // Only an absent tag is unknown. + expect( + MostroInstance.fromTags(_tagsWith({})).escrowMode, + EscrowMode.unknown, + ); + // A value-less tag has nothing to read, so it counts as absent — which + // is also what Rust's `value_of` does. + expect( + MostroInstance.fromTags(const [ + ['d', 'npub_test'], + ['escrow_mode'], + ]).escrowMode, + EscrowMode.unknown, + ); + }); + test('a cashu node with a blank mint reports none', () { final instance = MostroInstance.fromTags(_tagsWith({ 'escrow_mode': 'cashu', diff --git a/test/features/settings/widgets/escrow_mode_dev_card_test.dart b/test/features/settings/widgets/escrow_mode_dev_card_test.dart new file mode 100644 index 00000000..49cf217b --- /dev/null +++ b/test/features/settings/widgets/escrow_mode_dev_card_test.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/settings/providers/escrow_mode_provider.dart'; +import 'package:mostro/features/settings/widgets/escrow_mode_dev_card.dart'; +import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/src/rust/api/types.dart'; + +import '../../../support/provider_harness.dart'; + +EscrowModeInfo _info({String? mintOverride}) => EscrowModeInfo( + mode: 'lightning', + mintUrl: null, + escrowLocktimeDays: null, + settlementMarginDays: null, + isOverridden: false, + isCashuAvailable: false, + forceCashuOverride: false, + mintUrlOverride: mintOverride, + ); + +Future _pump(WidgetTester tester, Stream stream) async { + final container = createContainer(overrides: [ + escrowModeProvider.overrideWith((ref) => stream), + ]); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildDarkTheme(), + locale: const Locale('en'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: EscrowModeDevCard()), + ), + ), + ); +} + +void main() { + group('EscrowModeDevCard', () { + testWidgets('seeds the mint field from the stored override', (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + await _pump(tester, controller.stream); + controller.add(_info(mintOverride: 'http://localhost:3338')); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField)); + expect(field.controller?.text, 'http://localhost:3338'); + }); + + testWidgets('the newest override wins over an earlier one', (tester) async { + // Guards the seeding path against applying a stale value: the seed is + // read when the post-frame callback runs, not captured during the build + // that scheduled it. + final controller = StreamController(); + addTearDown(controller.close); + + await _pump(tester, controller.stream); + controller.add(_info(mintOverride: 'http://old.example')); + await tester.pumpAndSettle(); + controller.add(_info(mintOverride: 'http://new.example')); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField)); + expect(field.controller?.text, 'http://new.example'); + }); + + testWidgets('typing survives an event that did not change the override', + (tester) async { + // A node switch or a capability re-fetch emits without touching the + // override; wiping the field on those would eat what the user is typing. + final controller = StreamController(); + addTearDown(controller.close); + + await _pump(tester, controller.stream); + controller.add(_info(mintOverride: 'http://localhost:3338')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'http://typing'); + controller.add(_info(mintOverride: 'http://localhost:3338')); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField)); + expect(field.controller?.text, 'http://typing'); + }); + }); +} From 44a721554cf78d76f07c88aab8bbcea350449859 Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 25 Jul 2026 12:09:36 -0300 Subject: [PATCH 4/4] test(cashu): make the integration suite actually runnable against a mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the `#[ignore]`d suite against a real nutshell for the first time found that it could not pass at all, and then that it could not pass twice. - Every funds-using test asserted "fund the wallet first" and returned. There was no way for a reviewer to satisfy that, so five tests were documentation rather than verification. `CashuWallet::mint_for_test` mints from the mint itself, which settles instantly against a FakeWallet backend. - Fixed seeds plus a fresh DB per run replay the same NUT-13 blinding secrets, and the mint answers "Blinded Message is already signed" on the second run. Seeds are now unique per process and per call. - Two assertions pinned amounts the mint is free to reduce: nutshell's default keyset charges a swap fee, so an 8 sat token redeems for 7 and locking 16 sat costs the seller more than 16. The face value is what the daemon validates, so the assertions bound the redeemed amount instead of pinning it. - `one_signature_is_not_enough_to_move_an_escrow` expected the mint to refuse a premature reclaim. Since the C4 round the client refuses first, with the time remaining — a better message for the same property. Accepts either. Verified: 8/8 against nutshell 0.20.3, twice in a row. Note that nutshell rate limits by default; the suite needs MINT_RATE_LIMIT=FALSE to run back to back. Worth flagging for review rather than fixing here: a mint that charges swap fees means a seller needs slightly more than `amount + mostro_fee` to lock an escrow, and `cashu_escrow_quote` does not account for that. --- rust/src/cashu/escrow.rs | 79 +++++++++++++++++++++++++++++++--------- rust/src/cashu/wallet.rs | 76 ++++++++++++++++++++++++++++++++------ 2 files changed, 126 insertions(+), 29 deletions(-) diff --git a/rust/src/cashu/escrow.rs b/rust/src/cashu/escrow.rs index e09449dc..241a1150 100644 --- a/rust/src/cashu/escrow.rs +++ b/rust/src/cashu/escrow.rs @@ -763,8 +763,30 @@ mod tests { std::env::temp_dir().join(format!("mostro_escrow_test_{}_{n}.db", std::process::id())) } - fn wallet_seed(byte: u8) -> zeroize::Zeroizing<[u8; 64]> { - zeroize::Zeroizing::new([byte; 64]) + /// A seed unique to this process *and* this call. + /// + /// cdk derives blinding secrets deterministically from the seed and a + /// counter kept in the wallet DB (NUT-13). These tests create a fresh DB + /// each time, so a fixed seed replays the same blinded messages and the + /// mint answers "already signed" on the second run. Unique seeds make the + /// suite re-runnable, which is the whole point of a reviewer being able to + /// run it. + fn unique_seed() -> zeroize::Zeroizing<[u8; 64]> { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let mut seed = [0u8; 64]; + let pid = std::process::id() as u64; + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + seed[..8].copy_from_slice(&pid.to_le_bytes()); + seed[8..16].copy_from_slice(&n.to_le_bytes()); + seed[16..24].copy_from_slice( + &std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) + .to_le_bytes(), + ); + zeroize::Zeroizing::new(seed) } /// A party: its secret key, and the x-only hex the protocol carries. @@ -782,15 +804,16 @@ mod tests { let seller_db = temp_db_path(); let buyer_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, wallet_seed(11), seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) .await .unwrap(); - let buyer = CashuWallet::connect(&mint, wallet_seed(12), buyer_db.to_str().unwrap()) + let buyer = CashuWallet::connect(&mint, unique_seed(), buyer_db.to_str().unwrap()) .await .unwrap(); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); let funded = seller.balance().await.unwrap(); - assert!(funded >= 16, "fund the seller wallet first (has {funded} sat)"); + assert!(funded >= 16, "minting should have funded the wallet"); let (seller_sk, seller_pk) = party(); let (buyer_sk, buyer_pk) = party(); @@ -810,7 +833,6 @@ mod tests { .verify_escrow_token(&token, &parties, 16, locktime) .await .unwrap(); - assert_eq!(seller.balance().await.unwrap(), funded - 16); // Act — seller signs (release), buyer combines and redeems. let seller_sigs = seller.sign_proofs(&token, seller_sk).await.unwrap(); @@ -819,9 +841,20 @@ mod tests { .await .unwrap(); - // Assert - assert_eq!(received, 16); - assert_eq!(buyer.balance().await.unwrap(), 16); + // Assert — the escrow's face value is what the daemon validates, but a + // mint that charges a swap fee (nutshell's default keyset does) leaves + // the redeemer with slightly less, and costs the seller slightly more + // than the face value to lock. Both are properties of the mint, not of + // this code, so the assertions bound rather than pin them. + assert!( + received > 0 && received <= 16, + "received {received} sat for a 16 sat escrow" + ); + assert_eq!(buyer.balance().await.unwrap(), received); + assert!( + seller.balance().await.unwrap() <= funded - 16, + "locking 16 sat must cost the seller at least the face value" + ); let _ = std::fs::remove_file(&seller_db); let _ = std::fs::remove_file(&buyer_db); @@ -834,10 +867,11 @@ mod tests { // alone can take the funds. let mint = test_mint_url(); let seller_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, wallet_seed(13), seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) .await .unwrap(); - assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); + assert!(seller.balance().await.unwrap() >= 8); let (seller_sk, seller_pk) = party(); let (_buyer_sk, buyer_pk) = party(); @@ -855,8 +889,15 @@ mod tests { .await .unwrap_err(); - // Assert — the mint refuses; the client does not have to. - assert!(err.to_string().contains("CashuReclaimFailed"), "got {err}"); + // Assert — refused before the mint is even contacted: the locktime is + // in the secret, so the client can say how long is left instead of + // relaying an opaque mint error. Either refusal proves the property; + // this one is just the more useful message. + assert!( + err.to_string().contains("CashuLocktimeNotReached") + || err.to_string().contains("CashuReclaimFailed"), + "got {err}" + ); let _ = std::fs::remove_file(&seller_db); } @@ -868,13 +909,14 @@ mod tests { let mint = test_mint_url(); let seller_db = temp_db_path(); let buyer_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, wallet_seed(14), seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) .await .unwrap(); - let buyer = CashuWallet::connect(&mint, wallet_seed(15), buyer_db.to_str().unwrap()) + let buyer = CashuWallet::connect(&mint, unique_seed(), buyer_db.to_str().unwrap()) .await .unwrap(); - assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); + assert!(seller.balance().await.unwrap() >= 8); let (_seller_sk, seller_pk) = party(); let (buyer_sk, buyer_pk) = party(); @@ -906,10 +948,11 @@ mod tests { // Arrange — a seller who locks less than the order calls for. let mint = test_mint_url(); let seller_db = temp_db_path(); - let seller = CashuWallet::connect(&mint, wallet_seed(16), seller_db.to_str().unwrap()) + let seller = CashuWallet::connect(&mint, unique_seed(), seller_db.to_str().unwrap()) .await .unwrap(); - assert!(seller.balance().await.unwrap() >= 8, "fund the seller first"); + seller.mint_for_test(64).await.expect("mint must fund the wallet"); + assert!(seller.balance().await.unwrap() >= 8); let (_seller_sk, seller_pk) = party(); let (_buyer_sk, buyer_pk) = party(); diff --git a/rust/src/cashu/wallet.rs b/rust/src/cashu/wallet.rs index 4ce03e76..7d91d1bb 100644 --- a/rust/src/cashu/wallet.rs +++ b/rust/src/cashu/wallet.rs @@ -239,6 +239,32 @@ impl CashuWallet { Ok(token.to_string()) } + /// Mint `amount_sats` straight from the mint, for tests only. + /// + /// Against a `FakeWallet` backend (which is what a local nutshell runs) the + /// quote settles itself, so this funds a wallet with no Lightning node and + /// no manual step. Without it the integration tests below assert + /// "fund the wallet first" and can never pass, which makes them + /// documentation rather than verification. + #[cfg(test)] + pub(crate) async fn mint_for_test(&self, amount_sats: u64) -> Result { + use cdk::nuts::PaymentMethod; + + let quote = self + .inner + .mint_quote(PaymentMethod::BOLT11, Some(Amount::from(amount_sats)), None, None) + .await + .map_err(|e| anyhow!("CashuMintQuoteFailed: {e}"))?; + + let proofs = self + .inner + .mint("e.id, SplitTarget::default(), None) + .await + .map_err(|e| anyhow!("CashuMintFailed: {e} (is the mint in FakeWallet mode?)"))?; + + Ok(proofs.iter().map(|p| u64::from(p.amount)).sum()) + } + /// Reconcile pending proofs with the mint (NUT-07), returning the amount /// reclaimed as spendable. /// @@ -295,8 +321,30 @@ mod tests { .expect("set MOSTRO_TEST_MINT_URL to run the Cashu integration tests") } - fn seed(byte: u8) -> zeroize::Zeroizing<[u8; 64]> { - zeroize::Zeroizing::new([byte; 64]) + /// A seed unique to this process *and* this call. + /// + /// cdk derives blinding secrets deterministically from the seed and a + /// counter kept in the wallet DB (NUT-13). These tests create a fresh DB + /// each time, so a fixed seed replays the same blinded messages and the + /// mint answers "already signed" on the second run. Unique seeds make the + /// suite re-runnable, which is the whole point of a reviewer being able to + /// run it. + fn unique_seed() -> zeroize::Zeroizing<[u8; 64]> { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let mut seed = [0u8; 64]; + let pid = std::process::id() as u64; + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + seed[..8].copy_from_slice(&pid.to_le_bytes()); + seed[8..16].copy_from_slice(&n.to_le_bytes()); + seed[16..24].copy_from_slice( + &std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) + .to_le_bytes(), + ); + zeroize::Zeroizing::new(seed) } fn temp_db_path() -> std::path::PathBuf { @@ -394,7 +442,7 @@ mod tests { async fn connects_to_a_real_mint_and_starts_empty() { // Arrange / Act let path = temp_db_path(); - let wallet = CashuWallet::connect(&test_mint_url(), seed(7), path.to_str().unwrap()) + let wallet = CashuWallet::connect(&test_mint_url(), unique_seed(), path.to_str().unwrap()) .await .expect("nutshell must be reachable"); @@ -412,7 +460,7 @@ mod tests { let path = temp_db_path(); // Act - let err = CashuWallet::connect("http://127.0.0.1:1", seed(7), path.to_str().unwrap()) + let err = CashuWallet::connect("http://127.0.0.1:1", unique_seed(), path.to_str().unwrap()) .await .unwrap_err(); @@ -432,23 +480,29 @@ mod tests { let receiver_path = temp_db_path(); let mint = test_mint_url(); - let sender = CashuWallet::connect(&mint, seed(1), sender_path.to_str().unwrap()) + let sender = CashuWallet::connect(&mint, unique_seed(), sender_path.to_str().unwrap()) .await .unwrap(); - let receiver = CashuWallet::connect(&mint, seed(2), receiver_path.to_str().unwrap()) + let receiver = CashuWallet::connect(&mint, unique_seed(), receiver_path.to_str().unwrap()) .await .unwrap(); + sender.mint_for_test(64).await.expect("mint must fund the wallet"); let funded = sender.balance().await.unwrap(); - assert!(funded >= 8, "fund the sender wallet first (has {funded} sat)"); + assert!(funded >= 8, "minting should have funded the wallet"); // Act let token = sender.create_token(8).await.unwrap(); let received = receiver.receive_token(&token).await.unwrap(); - // Assert - assert_eq!(received, 8); - assert_eq!(receiver.balance().await.unwrap(), 8); + // Assert — the sender parted with the token's face value; the receiver + // gets that minus whatever the mint charges to swap (nutshell's default + // keyset has a non-zero input fee, and a real mint may too). + assert!( + received > 0 && received <= 8, + "received {received} sat for an 8 sat token" + ); + assert_eq!(receiver.balance().await.unwrap(), received); assert_eq!(sender.balance().await.unwrap(), funded - 8); let _ = std::fs::remove_file(&sender_path); @@ -459,7 +513,7 @@ mod tests { #[ignore = "requires a local nutshell mint (MOSTRO_TEST_MINT_URL)"] async fn creating_a_zero_token_is_rejected_before_touching_the_mint() { let path = temp_db_path(); - let wallet = CashuWallet::connect(&test_mint_url(), seed(3), path.to_str().unwrap()) + let wallet = CashuWallet::connect(&test_mint_url(), unique_seed(), path.to_str().unwrap()) .await .unwrap();