Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/core/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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']!,
),
),
],
);

10 changes: 10 additions & 0 deletions lib/features/cashu/cashu_error_messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String Function(AppLocalizations)> _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,
Expand Down
26 changes: 26 additions & 0 deletions lib/features/cashu/providers/cashu_wallet_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,29 @@ class CashuWalletController {
final cashuWalletControllerProvider = Provider<CashuWalletController>(
(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<CashuEscrowQuote> 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<CashuEscrowQuote> lock(String orderId) =>
cashu_api.lockEscrow(orderId: orderId);
}

final cashuEscrowControllerProvider = Provider<CashuEscrowController>(
(ref) => const CashuEscrowController(),
);
215 changes: 215 additions & 0 deletions lib/features/cashu/screens/lock_escrow_screen.dart
Original file line number Diff line number Diff line change
@@ -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<LockEscrowScreen> createState() => _LockEscrowScreenState();
}

class _LockEscrowScreenState extends ConsumerState<LockEscrowScreen> {
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<void> _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<void> _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<AppColors>()!;
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),
],
),
);
}
}
19 changes: 19 additions & 0 deletions lib/features/order/screens/take_order_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -132,6 +133,24 @@ class _TakeOrderScreenState extends ConsumerState<TakeOrderScreen> {
(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.
Expand Down
24 changes: 23 additions & 1 deletion lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Loading
Loading