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/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/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..ed5d3c14 --- /dev/null +++ b/lib/features/cashu/screens/lock_escrow_screen.dart @@ -0,0 +1,215 @@ +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/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'; + +/// 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; + + /// 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(); + 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(); + // 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()); + }); + } + } + } + + /// 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) { + 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 (_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( + cashuErrorMessage(_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(_needsRetry + ? l10n.lockEscrowRetry + : 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..4ccd46fb 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,24 @@ 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. + // + // 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 { + 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 b3c6d0cb..bddab91f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -739,7 +739,29 @@ "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.", + "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", "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 a4d23b04..feee35be 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1622,10 +1622,54 @@ "@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"}, + "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"}}}, "cashuLastTokenPending": "You exported a token. It is money until someone redeems it \u2014 keep it until you are sure it arrived.", "@cashuLastTokenPending": {"description": "Cashu wallet \u2014 reminder shown while an exported token has not been marked as handed over"}, "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 90805688..522fd2d0 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -739,7 +739,29 @@ "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.", + "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", "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 3bee7fbc..9dee5fbe 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -739,7 +739,29 @@ "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.", + "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", "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 762121b7..a4a8d2b0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -739,7 +739,29 @@ "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.", + "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", "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 e712f862..f232081a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4400,6 +4400,102 @@ abstract class AppLocalizations { /// **'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); + /// Cashu wallet — reminder shown while an exported token has not been marked as handed over /// /// In en, this message translates to: @@ -4417,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 a00dfb1e..a66056b6 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2499,6 +2499,64 @@ class AppLocalizationsDe extends AppLocalizations { 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'; + } + @override String get cashuLastTokenPending => 'Du hast ein Token exportiert. Es ist Geld, bis jemand es einlöst — behalte es, bis du sicher bist, dass es angekommen ist.'; @@ -2508,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 726060a4..5a36cfa6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2465,6 +2465,63 @@ class AppLocalizationsEn extends AppLocalizations { 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'; + } + @override String get cashuLastTokenPending => 'You exported a token. It is money until someone redeems it — keep it until you are sure it arrived.'; @@ -2474,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 1af97226..60403ffc 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2491,6 +2491,63 @@ class AppLocalizationsEs extends AppLocalizations { 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'; + } + @override String get cashuLastTokenPending => 'Exportaste un token. Es dinero hasta que alguien lo canjee: guardalo hasta estar seguro de que llegó.'; @@ -2500,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 c67587bd..49e4cdb4 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2502,6 +2502,63 @@ class AppLocalizationsFr extends AppLocalizations { 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'; + } + @override String get cashuLastTokenPending => 'Vous avez exporté un token. C\'est de l\'argent jusqu\'à ce que quelqu\'un l\'encaisse — gardez-le jusqu\'à confirmation.'; @@ -2511,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 dcd655bc..fe022a8d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2493,6 +2493,64 @@ class AppLocalizationsIt extends AppLocalizations { 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'; + } + @override String get cashuLastTokenPending => 'Hai esportato un token. È denaro finché qualcuno non lo riscuote: conservalo finché non sei sicuro che sia arrivato.'; @@ -2502,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 d5a23595..f17843f7 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 ───────────────────────────────────────────────────────────── @@ -272,6 +273,242 @@ 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(); + + // Connect before reading the balance. An unconnected wallet reports zero, + // and a quote that reports zero turns into "insufficient funds" on a wallet + // that is fully funded — the screen connects first, but a retry from + // anywhere else would not. + cashu_connect().await?; + + let balance = { + let guard = wallet_lock().read().await; + match guard.as_ref() { + Some(wallet) => wallet + .balance() + .await + .map_err(|e| anyhow::anyhow!("CashuBalanceUnknown: {e}"))?, + None => bail!("CashuNotConnected"), + } + }; + + 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(); + + // The daemon re-derives {P_B, P_S, P_M} from the order and rejects a proof + // that names any others, so these must be the per-order **trade** keys it + // stated in the escrow request. `counterparty_pubkey` is not that: it holds + // the maker's order-book key for a taker, and nothing at all for a maker. + let buyer_hex = trade + .buyer_trade_pubkey + .clone() + .ok_or_else(|| anyhow::anyhow!("CashuEscrowRequestMissing"))?; + + // The daemon also checks the seller key against the order. If the trade key + // this device would sign with is not the one it recorded, the escrow would + // be locked to a key nobody here holds — worse than a rejection, because + // the swap happens first. + if let Some(expected) = trade.seller_trade_pubkey.as_deref() { + if expected != seller_hex { + bail!("CashuWrongTradeKey: order expects {expected}, this device holds {seller_hex}"); + } + } + + let parties = crate::cashu::escrow::EscrowParties::from_xonly_hex( + &buyer_hex, + &seller_hex, + &mostro_hex, + )?; + + // The daemon's floor is `now + escrow_locktime_days` evaluated when it + // *validates* the submission, which is strictly later than our `now` by the + // publish and propagation delay. Matching the floor exactly would make every + // lock a race against the network, with the funds already swapped by the + // time it is lost. + let locktime = now_secs()? + .saturating_add(u64::from(quote.locktime_days).saturating_mul(SECONDS_PER_DAY)) + .saturating_add(LOCKTIME_SUBMISSION_MARGIN_SECS); + + let (escrow_token, fee_token) = { + let guard = wallet_lock().read().await; + let wallet = guard + .as_ref() + .ok_or_else(|| anyhow::anyhow!("CashuNotConnected"))?; + + // Fee first. Both tokens are irreversible once built, and the fee is the + // smaller of the two: if the wallet cannot cover both after mint-side + // fees, failing here strands a few satoshis instead of the whole escrow. + let fee = if quote.fee_sats > 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?; + + // 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?; + + (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 = now_secs().ok().map(|t| t 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; + +/// 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()) + .map_err(|_| anyhow::anyhow!("DeviceClockInvalid: system time is before 1970")) +} + +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 @@ -309,14 +546,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() { @@ -333,6 +565,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"), @@ -393,6 +629,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/escrow.rs b/rust/src/api/escrow.rs index 7c66bff0..d668cff4 100644 --- a/rust/src/api/escrow.rs +++ b/rust/src/api/escrow.rs @@ -199,15 +199,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..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(), }, } } @@ -386,7 +467,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 +932,12 @@ 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). + buyer_trade_pubkey: None, + seller_trade_pubkey: None, + 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 { @@ -1007,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!( @@ -1029,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 @@ -1071,6 +1159,14 @@ pub async fn take_order( started_at: now, 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, }; store_trade_key_index(&order_id, trade_index).await; @@ -1929,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); @@ -2298,7 +2400,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 +2575,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 7f24f096..88c78913 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -247,6 +247,43 @@ pub struct TradeInfo { pub started_at: i64, 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 + // 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)] @@ -528,6 +565,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/escrow.rs b/rust/src/cashu/escrow.rs index 8c3573d8..94a8bdf0 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 48f28002..388985c9 100644 --- a/rust/src/cashu/mod.rs +++ b/rust/src/cashu/mod.rs @@ -96,4 +96,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 a95158d9..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 = 267382495; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1169277549; // 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, @@ -4630,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 @@ -5086,6 +5191,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 +6171,11 @@ 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); return crate::api::types::TradeInfo { id: var_id, order: var_order, @@ -6058,6 +6190,11 @@ 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, }; } } @@ -6089,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 { @@ -6261,214 +6410,219 @@ 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), + 117 => { + wire__crate__api__orders__trade_pubkeys_default_impl(port, ptr, rust_vec_len, data_len) + } _ => unreachable!(), } } @@ -6824,6 +6978,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 +7754,11 @@ 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(), ] .into_dart() } @@ -7632,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 { @@ -8060,6 +8266,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 +9112,11 @@ 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); } } @@ -8923,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/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 790bd826..0bdcb935 100644 --- a/rust/src/mostro/escrow_mode.rs +++ b/rust/src/mostro/escrow_mode.rs @@ -374,6 +374,21 @@ pub fn update_overrides(f: impl FnOnce(&mut EscrowOverrides)) { } } +/// 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..97d06126 --- /dev/null +++ b/rust/src/mostro/node_fee.rs @@ -0,0 +1,141 @@ +//! 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); + +/// 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) { + // 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; + } + 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. + // `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"); + } + } + + #[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); + } +} 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, + ); + }); + }); +}