Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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: 10 additions & 2 deletions docs/cashu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,16 @@ 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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
Expand Down
60 changes: 60 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,26 @@ 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() {
final raw = getOptional('escrow_mode')?.toLowerCase();
if (raw == null) return EscrowMode.unknown;
return raw == 'cashu' ? EscrowMode.cashu : EscrowMode.lightning;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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 +293,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
66 changes: 66 additions & 0 deletions lib/features/settings/providers/escrow_mode_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'package:mostro/src/rust/api/escrow.dart' as escrow_api;
import 'package:mostro/src/rust/api/types.dart';

/// The settlement backend the active Mostro node runs, as resolved by Rust.
///
/// Emits the current value immediately, then every time it changes: a node
/// capability fetch, a node switch, or a developer override flip.
///
/// This is the **client-side resolution**, developer overrides included — it is
/// what gates behaviour. It is deliberately *not* what the About screen shows:
/// About reports what the node itself advertises (see `MostroInstance`), so a
/// forced override can never make About claim a node said something it did not.
final escrowModeProvider = StreamProvider<EscrowModeInfo>((ref) async* {
// Subscribe before reading the snapshot so no change is missed in between.
final stream = await escrow_api.onEscrowModeChanged();
yield await escrow_api.getEscrowMode();

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

/// Whether a Cashu path may run against the active node.
///
/// The single question the rest of the app asks. Mirrors Rust's
/// `is_cashu_mode()`: the mode must be Cashu **and** there must be a usable
/// mint. False while loading and on error, so every Cashu path stays shut
/// unless the node was positively identified.
final isCashuAvailableProvider = Provider<bool>((ref) {
return ref.watch(escrowModeProvider).valueOrNull?.isCashuAvailable ?? false;
});

// ── Developer override ────────────────────────────────────────────────────────

/// Writes the developer escrow overrides (§4.3 of `docs/cashu/README.md`).
///
/// Exists so a tester can work against a daemon branch that implements Cashu
/// before it publishes the Kind 38385 tags. Every caller must be behind
/// [kDebugMode] — release builds must not be able to force a backend the node
/// does not run. The assertions below make a misuse fail loudly in a debug
/// build rather than silently ship a switch to users.
class EscrowOverrideController {
const EscrowOverrideController();

/// Force (or stop forcing) Cashu mode regardless of the node's tags.
Future<void> setForceCashu(bool forceCashu) {
assert(kDebugMode, 'the escrow override is a debug-only affordance');
return escrow_api.setEscrowModeOverride(forceCashu: forceCashu);
}

/// Point Cashu at a specific mint. `null` or blank clears the override.
///
/// Throws when the URL is not an `http(s)` URL with a host — the Rust side
/// validates and returns an `InvalidMintUrl` marker.
Future<void> setMintUrl(String? mintUrl) {
assert(kDebugMode, 'the escrow override is a debug-only affordance');
return escrow_api.setCashuMintUrlOverride(mintUrl: mintUrl);
}
}

final escrowOverrideControllerProvider = Provider<EscrowOverrideController>(
(ref) => const EscrowOverrideController(),
);
7 changes: 7 additions & 0 deletions lib/features/settings/screens/settings_screen.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
Expand All @@ -8,6 +9,7 @@ import 'package:mostro/l10n/app_localizations.dart';
import 'package:mostro/features/settings/providers/nwc_provider.dart';
import 'package:mostro/features/settings/providers/settings_provider.dart';
import 'package:mostro/features/settings/widgets/currency_selector_dialog.dart';
import 'package:mostro/features/settings/widgets/escrow_mode_dev_card.dart';
import 'package:mostro/features/settings/widgets/language_selector.dart';
import 'package:mostro/features/settings/widgets/mostro_node_selector.dart';
import 'package:mostro/features/settings/widgets/relay_management_card.dart';
Expand Down Expand Up @@ -192,6 +194,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
subtitle: truncatePubkey(mostroPubkey),
onTap: () => showMostroNodeSelector(context),
),

// 9 — Escrow backend override. Debug builds only: forcing a backend
// the node does not run is a testing affordance, never a user
// setting. See docs/cashu/README.md §4.3.
if (kDebugMode) const EscrowModeDevCard(),
],
),
);
Expand Down
Loading
Loading