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
17 changes: 13 additions & 4 deletions docs/cashu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,12 +366,21 @@ Every phase, without exception, carries these standing requirements:
(`rust/src/api/settings.rs`, new `rust/src/api/` entries as needed).
- Dart: parse the same tags in `MostroInstance.fromTags`
(`lib/features/about/models/mostro_instance.dart`, mirroring `BondPolicy`);
`escrowModeProvider`; show a "Payment backend: Lightning / Cashu (mint URL,
locktime)" section in the About screen; dev-only override toggle in settings.
`escrowModeProvider`. About reports **what the node advertises**, so the two
backends are mutually exclusive on screen: a node advertising Cashu gets a
"Cashu escrow" section (mint, locktime, settlement margin) *instead of* the
Lightning Network section, while a Lightning or silent node renders exactly
what it does today. The client-side override never changes what About says —
it is surfaced only in the `kDebugMode` settings card, which shows the
effective resolution next to the toggle.
- Persistence note: the overrides live in the settings k/v store, which is real
on SQLite and a stub on IndexedDB, so on web they apply for the session only
(tracked in #233).
- Companion (out of this repo): upstream PR to `mostrod` adding the tags of §4.1.
- **Done when:** against any current daemon the app shows Lightning and behaves
identically; flipping the override flips the provider and the About section; unit
tests for tag parsing + resolution order.
identically; flipping the override flips the resolved mode and the dev card's
effective state — **not** the About section, which reports only what the node
advertised; unit tests for tag parsing + resolution order.
- Est. size: S (~400–600 lines).

#### C2 — Cashu wallet core (Rust, cdk)
Expand Down
10 changes: 10 additions & 0 deletions lib/core/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
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/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 @@ -52,6 +53,11 @@ abstract final class AppRoute {
static const logs = '/logs';
static const disputeChat = '/dispute_chat/:disputeId';

/// Embedded Cashu wallet. Only reachable from Settings when the active node
/// runs Cashu — the route is always registered, and the screen shows a
/// disconnected wallet anywhere else.
static const cashuWallet = '/cashu_wallet';

/// Build a path with a single [id] substituted for the `:orderId` segment.
static String tradeDetailPath(String orderId) =>
'/trade_detail/$orderId';
Expand Down Expand Up @@ -224,6 +230,10 @@ final GoRouter appRouter = GoRouter(
disputeId: state.pathParameters['disputeId']!,
),
),
GoRoute(
path: AppRoute.cashuWallet,
builder: (_, __) => const CashuWalletScreen(),
),
],
);

66 changes: 66 additions & 0 deletions lib/features/about/models/mostro_instance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ enum BondPolicy { unsupported, disabled, enabled }
/// Which side of a trade a bond applies to (`bond_apply_to` tag).
enum BondApplyTo { take, make, both }

/// Settlement backend a Mostro daemon runs, from its `escrow_mode` tag.
///
/// Tri-state for the same reason as [BondPolicy]:
/// - [unknown]: the tag is absent — a daemon that predates the Cashu feature.
/// Not the same as knowing it runs Lightning.
/// - [lightning]: `escrow_mode="lightning"`, or any value this client does not
/// implement. A backend we cannot trade Cashu with reads as Lightning, the
/// setting that leaves every Cashu path shut.
/// - [cashu]: `escrow_mode="cashu"` — the remaining `cashu_*` tags are
/// meaningful.
///
/// Mirrors Rust's `EscrowMode` (`rust/src/mostro/escrow_mode.rs`). This models
/// what the *node advertises*; whether a Cashu path may actually run is a
/// separate, stricter question answered by Rust (`is_cashu_mode()`, surfaced as
/// `isCashuAvailableProvider`).
enum EscrowMode { unknown, lightning, cashu }

/// Dart model parsed from a Nostr Kind 38385 (Mostro instance status) event.
///
/// All fields are derived from individual tags — the event `content` is empty
Expand Down Expand Up @@ -50,6 +67,10 @@ class MostroInstance {
this.bondBaseAmountSats,
this.bondSlashNodeSharePct,
this.bondPayoutClaimWindowDays,
this.escrowMode = EscrowMode.unknown,
this.cashuMintUrl,
this.cashuEscrowLocktimeDays,
this.cashuSettlementMarginDays,
});

/// Mostro daemon pubkey (from `d` tag).
Expand Down Expand Up @@ -113,6 +134,24 @@ class MostroInstance {
/// Days to claim a payout before forfeit, `> 0`.
final int? bondPayoutClaimWindowDays;

// ── Settlement backend ──────────────────────────────────────────────────────

/// Which backend settles trades on this node; defaults to
/// [EscrowMode.unknown]. See [EscrowMode].
final EscrowMode escrowMode;

// The three Cashu parameters are non-null only when [escrowMode] is
// [EscrowMode.cashu]; otherwise, or on an absent/invalid tag, they are null.

/// Mint this node pins for every escrow. There is no per-order negotiation.
final String? cashuMintUrl;

/// NUT-11 locktime the seller must set on the escrow token, in days.
final int? cashuEscrowLocktimeDays;

/// How close to escrow expiry the node stops accepting `fiat-sent`, in days.
final int? cashuSettlementMarginDays;

// ── Factory ─────────────────────────────────────────────────────────────────

/// Parse a Kind 38385 event's tag list into a [MostroInstance].
Expand Down Expand Up @@ -195,11 +234,32 @@ class MostroInstance {
return value > 0 ? value : null;
}

// An absent tag stays `unknown`, which is what lets the About screen say
// "not advertised" instead of claiming the node confirmed Lightning. An
// unrecognised backend reads as Lightning: we cannot trade Cashu with it
// either, and that is the reading that keeps Cashu shut.
EscrowMode parseEscrowMode() {
// `get`, not `getOptional`: only an *absent* tag is unknown. A tag that
// is present but blank is a node that answered, and Rust's `parse_tags`
// reads it as Lightning — the two parsers must agree, or the About screen
// and the gate disagree about the same event.
final raw = get('escrow_mode');
if (raw == null) return EscrowMode.unknown;
return raw.trim().toLowerCase() == 'cashu'
? EscrowMode.cashu
: EscrowMode.lightning;
}

// Parameters are gated on an enabled policy so a disabled or malformed
// event never exposes live bond values (consumers key off nullability).
final bondPolicy = parseBondPolicy();
final isEnabled = bondPolicy == BondPolicy.enabled;

// Same gating for the Cashu parameters: a Lightning node that happens to
// carry a stale `cashu_mint_url` tag must not surface it as live.
final escrowMode = parseEscrowMode();
final isCashu = escrowMode == EscrowMode.cashu;

return MostroInstance(
pubKey: get('d') ?? '',
mostroVersion: get('mostro_version'),
Expand Down Expand Up @@ -239,6 +299,12 @@ class MostroInstance {
: null,
bondPayoutClaimWindowDays:
isEnabled ? parsePositiveInt('bond_payout_claim_window_days') : null,
escrowMode: escrowMode,
cashuMintUrl: isCashu ? getOptional('cashu_mint_url') : null,
cashuEscrowLocktimeDays:
isCashu ? parsePositiveInt('cashu_escrow_locktime_days') : null,
cashuSettlementMarginDays:
isCashu ? parseNonNegativeInt('cashu_settlement_margin_days') : null,
);
}

Expand Down
121 changes: 77 additions & 44 deletions lib/features/about/screens/about_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -422,51 +422,84 @@ class _MostroNodeContent extends StatelessWidget {
explanation: l10n.aboutMaxOrdersPerResponseExplanation,
),

const SizedBox(height: AppSpacing.xl),
_SectionHeader(title: l10n.aboutLightningNetworkSection),
const SizedBox(height: AppSpacing.md),
if (node.lndVersion != null)
_NodeInfoRowInfo(
label: l10n.aboutLndVersionLabel,
value: node.lndVersion!,
explanation: l10n.aboutLndVersionExplanation,
),
if (node.lndNodePublicKey != null)
_NodeInfoRowCopyable(
label: l10n.aboutLndNodePublicKeyLabel,
value: node.lndNodePublicKey!,
explanation: l10n.aboutLndNodePublicKeyExplanation,
),
if (node.lndCommitHash != null)
_NodeInfoRowInfo(
label: l10n.aboutLndCommitLabel,
value: _truncateHash(node.lndCommitHash!),
explanation: l10n.aboutLndCommitExplanation,
),
if (node.lndNodeAlias != null)
_NodeInfoRowInfo(
label: l10n.aboutLndNodeAliasLabel,
value: node.lndNodeAlias!,
explanation: l10n.aboutLndNodeAliasExplanation,
),
if (node.lndChains != null)
// Settlement backend. About reports what *this node* runs, so the two
// backends are mutually exclusive here: a Cashu node gets the Cashu
// section and no Lightning one. A node that advertises nothing
// (EscrowMode.unknown — every daemon in the wild today) keeps the
// Lightning section it has always shown.
if (node.escrowMode == EscrowMode.cashu) ...[
const SizedBox(height: AppSpacing.xl),
_SectionHeader(title: l10n.aboutCashuEscrowSection),
const SizedBox(height: AppSpacing.md),
// Shown unconditionally: a Cashu node with no mint is misconfigured,
// and saying so is more useful than an empty section. Rendered in
// full rather than through the copyable row, whose 20-character
// abbreviation would hide the mint's host — the one part that
// matters here.
_NodeInfoRowInfo(
label: l10n.aboutSupportedChainsLabel,
value: node.lndChains!,
explanation: l10n.aboutSupportedChainsExplanation,
),
if (node.lndNetworks != null)
_NodeInfoRowInfo(
label: l10n.aboutSupportedNetworksLabel,
value: node.lndNetworks!,
explanation: l10n.aboutSupportedNetworksExplanation,
),
if (node.lndUris != null)
_NodeInfoRowCopyable(
label: l10n.aboutLndNodeUriLabel,
value: node.lndUris!,
explanation: l10n.aboutLndNodeUriExplanation,
),
label: l10n.aboutCashuMintUrlLabel,
value: node.cashuMintUrl ?? l10n.aboutCashuMintNotAdvertised,
explanation: l10n.aboutCashuMintUrlExplanation,
),
if (node.cashuEscrowLocktimeDays != null)
_NodeInfoRowInfo(
label: l10n.aboutCashuLocktimeLabel,
value: l10n.aboutDaysValue(node.cashuEscrowLocktimeDays!),
explanation: l10n.aboutCashuLocktimeExplanation,
),
if (node.cashuSettlementMarginDays != null)
_NodeInfoRowInfo(
label: l10n.aboutCashuSettlementMarginLabel,
value: l10n.aboutDaysValue(node.cashuSettlementMarginDays!),
explanation: l10n.aboutCashuSettlementMarginExplanation,
),
] else ...[
const SizedBox(height: AppSpacing.xl),
_SectionHeader(title: l10n.aboutLightningNetworkSection),
const SizedBox(height: AppSpacing.md),
if (node.lndVersion != null)
_NodeInfoRowInfo(
label: l10n.aboutLndVersionLabel,
value: node.lndVersion!,
explanation: l10n.aboutLndVersionExplanation,
),
if (node.lndNodePublicKey != null)
_NodeInfoRowCopyable(
label: l10n.aboutLndNodePublicKeyLabel,
value: node.lndNodePublicKey!,
explanation: l10n.aboutLndNodePublicKeyExplanation,
),
if (node.lndCommitHash != null)
_NodeInfoRowInfo(
label: l10n.aboutLndCommitLabel,
value: _truncateHash(node.lndCommitHash!),
explanation: l10n.aboutLndCommitExplanation,
),
if (node.lndNodeAlias != null)
_NodeInfoRowInfo(
label: l10n.aboutLndNodeAliasLabel,
value: node.lndNodeAlias!,
explanation: l10n.aboutLndNodeAliasExplanation,
),
if (node.lndChains != null)
_NodeInfoRowInfo(
label: l10n.aboutSupportedChainsLabel,
value: node.lndChains!,
explanation: l10n.aboutSupportedChainsExplanation,
),
if (node.lndNetworks != null)
_NodeInfoRowInfo(
label: l10n.aboutSupportedNetworksLabel,
value: node.lndNetworks!,
explanation: l10n.aboutSupportedNetworksExplanation,
),
if (node.lndUris != null)
_NodeInfoRowCopyable(
label: l10n.aboutLndNodeUriLabel,
value: node.lndUris!,
explanation: l10n.aboutLndNodeUriExplanation,
),
],
],
);
}
Expand Down
34 changes: 34 additions & 0 deletions lib/features/cashu/cashu_error_messages.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import 'package:mostro/l10n/app_localizations.dart';

/// Maps the stable markers Rust returns onto localized text.
///
/// Rust never returns prose (repo translation rule), so every Cashu failure
/// arrives as a marker like `CashuNotEnabled` with an opaque tail. One mapper
/// rather than one per screen: the list only grows as later phases add flows,
/// and a screen that forgets a marker would silently show the generic message
/// instead of the right one.
///
/// An **unrecognised** marker deliberately falls back rather than being shown —
/// the tail carries mint URLs, amounts and cdk internals, none of which belong
/// in front of a user.
String cashuErrorMessage(Object error, AppLocalizations l10n) {
final raw = error.toString();
for (final entry in _messages.entries) {
if (raw.contains(entry.key)) return entry.value(l10n);
}
return l10n.cashuErrorGeneric;
}

/// 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 = {
'CashuNotEnabled': (l) => l.cashuErrorNotEnabled,
'CashuNotConnected': (l) => l.cashuErrorNotConnected,
'CashuMintUnreachable': (l) => l.cashuErrorMintUnreachable,
'CashuMintUnusable': (l) => l.cashuErrorMintUnusable,
'CashuUnsupportedOnWeb': (l) => l.cashuErrorUnsupportedOnWeb,
'CashuAmountZero': (l) => l.cashuErrorAmountZero,
'CashuReceiveFailed': (l) => l.cashuErrorReceiveFailed,
'CashuSendFailed': (l) => l.cashuErrorSendFailed,
'NoIdentity': (l) => l.cashuErrorNoIdentity,
};
55 changes: 55 additions & 0 deletions lib/features/cashu/providers/cashu_wallet_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'package:mostro/src/rust/api/cashu.dart' as cashu_api;
import 'package:mostro/src/rust/api/types.dart';

/// Live state of the embedded Cashu wallet — phase C3 of `docs/cashu/README.md`.
///
/// Emits the current status immediately, then on every change: connect,
/// receive, send, reclaim, disconnect.
///
/// Safe to watch on any node. On a Lightning one Rust answers "not connected"
/// and nothing else happens — no mint is contacted and no proof store opens.
/// Whether the *UI* should exist at all is a separate question, answered by
/// `isCashuAvailableProvider`.
final cashuWalletProvider = StreamProvider<CashuWalletStatus>((ref) async* {
// Subscribe before the snapshot so no change is missed in between.
final stream = await cashu_api.onCashuWalletChanged();
yield await cashu_api.cashuStatus();

while (true) {
yield await stream.next();
}
});

/// Commands against the wallet.
///
/// Thin by design: each is a single Rust call, and all the gating, mint traffic
/// and cryptography lives there (repo golden rule — no crypto in Dart). Errors
/// surface as stable markers the UI localizes.
class CashuWalletController {
const CashuWalletController();

/// Bind the wallet to the mint the active node pins, if it is not already.
///
/// Throws `CashuNotEnabled` on a node that does not run Cashu, `NoIdentity`
/// before an identity is loaded, or a `CashuMint*` marker when the mint is
/// unreachable or unusable.
Future<CashuWalletStatus> connect() => cashu_api.cashuConnect();

/// Redeem a token into the wallet, returning the amount received in sats.
Future<BigInt> receiveToken(String encoded) =>
cashu_api.cashuReceiveToken(encoded: encoded);

/// Export `amountSats` as an encoded token.
Future<String> createToken(BigInt amountSats) =>
cashu_api.cashuCreateToken(amountSats: amountSats);

/// Reconcile proofs left reserved by an interrupted send, returning the
/// amount reclaimed.
Future<BigInt> checkProofsState() => cashu_api.cashuCheckProofsState();
}

final cashuWalletControllerProvider = Provider<CashuWalletController>(
(ref) => const CashuWalletController(),
);
Loading
Loading