diff --git a/docs/cashu/README.md b/docs/cashu/README.md index aedce43d..f5e0b833 100644 --- a/docs/cashu/README.md +++ b/docs/cashu/README.md @@ -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) diff --git a/lib/features/about/models/mostro_instance.dart b/lib/features/about/models/mostro_instance.dart index 6d929355..3b31a545 100644 --- a/lib/features/about/models/mostro_instance.dart +++ b/lib/features/about/models/mostro_instance.dart @@ -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 @@ -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). @@ -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]. @@ -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'), @@ -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, ); } diff --git a/lib/features/about/screens/about_screen.dart b/lib/features/about/screens/about_screen.dart index f7cf7ed9..20b20ead 100644 --- a/lib/features/about/screens/about_screen.dart +++ b/lib/features/about/screens/about_screen.dart @@ -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, + ), + ], ], ); } diff --git a/lib/features/settings/providers/escrow_mode_provider.dart b/lib/features/settings/providers/escrow_mode_provider.dart new file mode 100644 index 00000000..e2e94858 --- /dev/null +++ b/lib/features/settings/providers/escrow_mode_provider.dart @@ -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((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((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 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 setMintUrl(String? mintUrl) { + assert(kDebugMode, 'the escrow override is a debug-only affordance'); + return escrow_api.setCashuMintUrlOverride(mintUrl: mintUrl); + } +} + +final escrowOverrideControllerProvider = Provider( + (ref) => const EscrowOverrideController(), +); diff --git a/lib/features/settings/screens/settings_screen.dart b/lib/features/settings/screens/settings_screen.dart index 8c06658d..4430bd12 100644 --- a/lib/features/settings/screens/settings_screen.dart +++ b/lib/features/settings/screens/settings_screen.dart @@ -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'; @@ -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'; @@ -192,6 +194,11 @@ class _SettingsScreenState extends ConsumerState { 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(), ], ), ); diff --git a/lib/features/settings/widgets/escrow_mode_dev_card.dart b/lib/features/settings/widgets/escrow_mode_dev_card.dart new file mode 100644 index 00000000..092606dc --- /dev/null +++ b/lib/features/settings/widgets/escrow_mode_dev_card.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/settings/providers/escrow_mode_provider.dart'; +import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/src/rust/api/types.dart'; + +/// Developer-only control over the escrow-mode override (§4.3 of +/// `docs/cashu/README.md`). +/// +/// It exists so a tester can work against a daemon branch that implements Cashu +/// before it publishes the Kind 38385 tags. **Mount it behind `kDebugMode`** — +/// users must never be able to force a backend their node does not run. +/// +/// The card shows the *effective* resolution, which is deliberately not what +/// the About screen shows: About reports what the node advertises, so with the +/// override on the two disagree, and this is the surface that says so. +class EscrowModeDevCard extends ConsumerStatefulWidget { + const EscrowModeDevCard({super.key}); + + @override + ConsumerState createState() => _EscrowModeDevCardState(); +} + +class _EscrowModeDevCardState extends ConsumerState { + final _mintController = TextEditingController(); + + /// The value the field was last populated from, so a stream event that did + /// not change the override never overwrites what the user is typing. + String? _syncedMintOverride; + bool _seeded = false; + + @override + void dispose() { + _mintController.dispose(); + super.dispose(); + } + + /// Populate the field from the stored override. + /// + /// Deliberately **not** called from `build`: assigning `.text` notifies the + /// controller's listeners, and doing that during a build marks the + /// `TextField` dirty in the middle of laying it out. The guard matters too — + /// without it every unrelated escrow event (a node switch, a capability + /// re-fetch) would wipe whatever the user is halfway through typing. + void _syncMintField(String? stored) { + if (stored == _syncedMintOverride) return; + _syncedMintOverride = stored; + _mintController.text = stored ?? ''; + } + + Future _applyMintUrl() async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + await ref + .read(escrowOverrideControllerProvider) + .setMintUrl(_mintController.text); + } catch (_) { + // Rust returns an `InvalidMintUrl` marker, not prose — the localized + // string lives here. + messenger.showSnackBar( + SnackBar(content: Text(l10n.settingsCashuMintOverrideInvalid)), + ); + } + } + + String _modeLabel(String marker, AppLocalizations l10n) => switch (marker) { + 'cashu' => l10n.escrowModeCashu, + 'lightning' => l10n.escrowModeLightning, + _ => l10n.escrowModeUnknown, + }; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).extension()!; + final info = ref.watch(escrowModeProvider).valueOrNull; + + // Seed once when the first value arrives, then follow real changes. Both + // paths run outside build — see [_syncMintField]. + ref.listen(escrowModeProvider, (_, next) { + final stored = next.valueOrNull; + if (stored != null) _syncMintField(stored.mintUrlOverride); + }); + if (!_seeded && info != null) { + _seeded = true; + // Read at callback time, not build time. An override that arrives in the + // gap between the two is applied by `ref.listen` first, and a captured + // copy would then overwrite it with the older value. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final current = ref.read(escrowModeProvider).valueOrNull; + if (current != null) _syncMintField(current.mintUrlOverride); + }); + } + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: colors.backgroundCard, + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.science_outlined, color: colors.mostroGreen, size: 22), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + l10n.settingsEscrowOverrideTitle, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.settingsEscrowOverrideSubtitle, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: AppSpacing.md), + _effectiveState(info, l10n, colors), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.settingsForceCashuLabel), + value: info?.forceCashuOverride ?? false, + onChanged: info == null + ? null + : (value) => ref + .read(escrowOverrideControllerProvider) + .setForceCashu(value), + ), + TextField( + controller: _mintController, + enabled: info != null, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: InputDecoration( + labelText: l10n.settingsCashuMintOverrideLabel, + hintText: 'http://localhost:3338', + suffixIcon: IconButton( + icon: const Icon(Icons.check), + tooltip: l10n.settingsCashuMintOverrideApply, + onPressed: info == null ? null : _applyMintUrl, + ), + ), + onSubmitted: (_) => _applyMintUrl(), + ), + ], + ), + ); + } + + /// The resolution the app actually acts on — mode, effective mint, and + /// whether a Cashu path may run at all (Cashu mode with no mint may not). + Widget _effectiveState( + EscrowModeInfo? info, + AppLocalizations l10n, + AppColors colors, + ) { + if (info == null) { + return Text( + l10n.escrowModeUnknown, + style: TextStyle(color: colors.textSubtle), + ); + } + + final mint = info.mintUrl ?? l10n.aboutCashuMintNotAdvertised; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.settingsEscrowEffectiveMode(_modeLabel(info.mode, l10n)), + style: TextStyle(color: colors.textSubtle), + ), + Text( + l10n.settingsEscrowEffectiveMint(mint), + style: TextStyle(color: colors.textSubtle), + ), + if (info.mode == 'cashu' && !info.isCashuAvailable) + Text( + l10n.settingsEscrowCashuUnavailable, + style: TextStyle(color: colors.destructiveRed), + ), + ], + ), + ); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 97dd5349..8df24916 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -690,5 +690,26 @@ "bondSlashedDetailAmount": "Kautionsbetrag", "bondSlashedDetailCause": "Grund", "bondSlashedDetailFiat": "Fiat", - "bondSlashedDetailPaymentMethod": "Zahlungsmethode" + "bondSlashedDetailPaymentMethod": "Zahlungsmethode", + "aboutDaysValue": "{count, plural, =1{{count} Tag} other{{count} Tage}}", + "aboutCashuEscrowSection": "Cashu-Treuhand", + "aboutCashuMintUrlLabel": "Mint", + "aboutCashuMintUrlExplanation": "Die Cashu-Mint, die dieser Node für jede Treuhand verwendet. Das für einen Handel gesperrte E-Cash stammt von dieser Mint; pro Order gibt es keine Wahl.", + "aboutCashuMintNotAdvertised": "Nicht angegeben", + "aboutCashuLocktimeLabel": "Treuhand-Sperrfrist", + "aboutCashuLocktimeExplanation": "Wie lange das E-Cash des Verkäufers in der Treuhand gesperrt bleibt. Nach Ablauf kann der Verkäufer die Mittel ohne Zutun des Nodes zurückholen.", + "aboutCashuSettlementMarginLabel": "Abwicklungspuffer", + "aboutCashuSettlementMarginExplanation": "Wie lange vor Ablauf der Treuhand dieser Node „Fiat gesendet“ nicht mehr annimmt, damit kein Handel mit zu wenig verbleibender Zeit abgewickelt wird.", + "escrowModeLightning": "Lightning", + "escrowModeCashu": "Cashu", + "escrowModeUnknown": "Nicht angegeben", + "settingsEscrowOverrideTitle": "Treuhand-Backend (Entwicklung)", + "settingsEscrowOverrideSubtitle": "Cashu gegen einen Node testen, der es noch nicht angibt. Nur in Debug-Builds.", + "settingsForceCashuLabel": "Cashu-Treuhand erzwingen", + "settingsCashuMintOverrideLabel": "Abweichende Mint-URL", + "settingsCashuMintOverrideApply": "Übernehmen", + "settingsCashuMintOverrideInvalid": "Das ist keine gültige Mint-URL. Verwende http oder https mit einem Host.", + "settingsEscrowEffectiveMode": "Effektives Backend: {mode}", + "settingsEscrowEffectiveMint": "Effektive Mint: {mint}", + "settingsEscrowCashuUnavailable": "Cashu funktioniert ohne Mint nicht – unten eine festlegen." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ea50bc57..374f1f34 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1528,5 +1528,47 @@ "bondSlashedDetailFiat": "Fiat", "@bondSlashedDetailFiat": {"description": "Bond-slashed detail label for the fiat amount and currency"}, "bondSlashedDetailPaymentMethod": "Payment method", - "@bondSlashedDetailPaymentMethod": {"description": "Bond-slashed detail label for the payment method"} + "@bondSlashedDetailPaymentMethod": {"description": "Bond-slashed detail label for the payment method"}, + "aboutDaysValue": "{count, plural, =1{{count} day} other{{count} days}}", + "@aboutDaysValue": {"description": "About screen — a pluralized day count", "placeholders": {"count": {"type": "int"}}}, + "aboutCashuEscrowSection": "Cashu escrow", + "@aboutCashuEscrowSection": {"description": "About screen — section header shown when the node settles trades with Cashu ecash instead of Lightning"}, + "aboutCashuMintUrlLabel": "Mint", + "@aboutCashuMintUrlLabel": {"description": "About screen — Cashu mint URL row label"}, + "aboutCashuMintUrlExplanation": "The Cashu mint this node uses for every escrow. Ecash locked for a trade is issued by this mint; there is no per-order choice.", + "@aboutCashuMintUrlExplanation": {"description": "Info dialog explanation for the Cashu Mint field"}, + "aboutCashuMintNotAdvertised": "Not advertised", + "@aboutCashuMintNotAdvertised": {"description": "About screen — shown when a node says it runs Cashu but publishes no mint, which means trades cannot run"}, + "aboutCashuLocktimeLabel": "Escrow locktime", + "@aboutCashuLocktimeLabel": {"description": "About screen — Cashu escrow locktime row label"}, + "aboutCashuLocktimeExplanation": "How long the seller's ecash stays locked in escrow. Once it expires the seller can reclaim the funds without the node's help.", + "@aboutCashuLocktimeExplanation": {"description": "Info dialog explanation for the Cashu Escrow locktime field"}, + "aboutCashuSettlementMarginLabel": "Settlement margin", + "@aboutCashuSettlementMarginLabel": {"description": "About screen — Cashu settlement margin row label"}, + "aboutCashuSettlementMarginExplanation": "How close to the escrow expiry this node stops accepting 'fiat sent', so a trade is never settled with too little time left to complete it.", + "@aboutCashuSettlementMarginExplanation": {"description": "Info dialog explanation for the Cashu Settlement margin field"}, + "escrowModeLightning": "Lightning", + "@escrowModeLightning": {"description": "Name of the Lightning settlement backend"}, + "escrowModeCashu": "Cashu", + "@escrowModeCashu": {"description": "Name of the Cashu ecash settlement backend"}, + "escrowModeUnknown": "Not advertised", + "@escrowModeUnknown": {"description": "Shown when the node publishes no settlement backend, which is not the same as knowing it uses Lightning"}, + "settingsEscrowOverrideTitle": "Escrow backend (developer)", + "@settingsEscrowOverrideTitle": {"description": "Settings — title of the debug-only card that forces the escrow backend"}, + "settingsEscrowOverrideSubtitle": "Test Cashu against a node that does not advertise it yet. Debug builds only.", + "@settingsEscrowOverrideSubtitle": {"description": "Settings — subtitle of the debug-only escrow backend override card"}, + "settingsForceCashuLabel": "Force Cashu escrow", + "@settingsForceCashuLabel": {"description": "Settings — switch that makes the app treat the node as running Cashu regardless of what it advertises"}, + "settingsCashuMintOverrideLabel": "Mint URL override", + "@settingsCashuMintOverrideLabel": {"description": "Settings — text field for a mint URL to use instead of the node's"}, + "settingsCashuMintOverrideApply": "Apply", + "@settingsCashuMintOverrideApply": {"description": "Settings — button that saves the mint URL override"}, + "settingsCashuMintOverrideInvalid": "That is not a valid mint URL. Use http or https with a host.", + "@settingsCashuMintOverrideInvalid": {"description": "Settings — error shown when the entered mint URL is rejected"}, + "settingsEscrowEffectiveMode": "Effective backend: {mode}", + "@settingsEscrowEffectiveMode": {"description": "Settings — the backend the app is actually acting on, overrides included", "placeholders": {"mode": {"type": "String"}}}, + "settingsEscrowEffectiveMint": "Effective mint: {mint}", + "@settingsEscrowEffectiveMint": {"description": "Settings — the mint the app is actually acting on, overrides included", "placeholders": {"mint": {"type": "String"}}}, + "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"} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index a2191be0..a437c847 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -690,5 +690,26 @@ "bondSlashedDetailAmount": "Monto de la fianza", "bondSlashedDetailCause": "Motivo", "bondSlashedDetailFiat": "Fiat", - "bondSlashedDetailPaymentMethod": "Método de pago" + "bondSlashedDetailPaymentMethod": "Método de pago", + "aboutDaysValue": "{count, plural, =1{{count} día} other{{count} días}}", + "aboutCashuEscrowSection": "Custodia Cashu", + "aboutCashuMintUrlLabel": "Mint", + "aboutCashuMintUrlExplanation": "El mint Cashu que este nodo usa para toda custodia. El ecash bloqueado en un intercambio lo emite este mint; no se elige por orden.", + "aboutCashuMintNotAdvertised": "No anunciado", + "aboutCashuLocktimeLabel": "Bloqueo de la custodia", + "aboutCashuLocktimeExplanation": "Cuánto tiempo permanece bloqueado el ecash del vendedor. Al vencer, el vendedor puede recuperar los fondos sin la ayuda del nodo.", + "aboutCashuSettlementMarginLabel": "Margen de liquidación", + "aboutCashuSettlementMarginExplanation": "Con cuánta antelación al vencimiento de la custodia el nodo deja de aceptar «fiat enviado», para que ningún intercambio se liquide con demasiado poco tiempo para completarse.", + "escrowModeLightning": "Lightning", + "escrowModeCashu": "Cashu", + "escrowModeUnknown": "No anunciado", + "settingsEscrowOverrideTitle": "Backend de custodia (desarrollo)", + "settingsEscrowOverrideSubtitle": "Prueba Cashu contra un nodo que aún no lo anuncia. Solo en compilaciones de depuración.", + "settingsForceCashuLabel": "Forzar custodia Cashu", + "settingsCashuMintOverrideLabel": "URL de mint alternativa", + "settingsCashuMintOverrideApply": "Aplicar", + "settingsCashuMintOverrideInvalid": "Esa no es una URL de mint válida. Usa http o https con un host.", + "settingsEscrowEffectiveMode": "Backend efectivo: {mode}", + "settingsEscrowEffectiveMint": "Mint efectivo: {mint}", + "settingsEscrowCashuUnavailable": "Cashu no puede funcionar sin un mint: configura uno abajo." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ec24e90c..78c9858a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -690,5 +690,26 @@ "bondSlashedDetailAmount": "Montant de la caution", "bondSlashedDetailCause": "Cause", "bondSlashedDetailFiat": "Fiat", - "bondSlashedDetailPaymentMethod": "Moyen de paiement" + "bondSlashedDetailPaymentMethod": "Moyen de paiement", + "aboutDaysValue": "{count, plural, =1{{count} jour} other{{count} jours}}", + "aboutCashuEscrowSection": "Séquestre Cashu", + "aboutCashuMintUrlLabel": "Mint", + "aboutCashuMintUrlExplanation": "Le mint Cashu que ce nœud utilise pour tous les séquestres. L'ecash bloqué pour un échange est émis par ce mint ; il n'y a pas de choix par ordre.", + "aboutCashuMintNotAdvertised": "Non annoncé", + "aboutCashuLocktimeLabel": "Verrouillage du séquestre", + "aboutCashuLocktimeExplanation": "Durée pendant laquelle l'ecash du vendeur reste bloqué en séquestre. À l'expiration, le vendeur peut récupérer les fonds sans l'aide du nœud.", + "aboutCashuSettlementMarginLabel": "Marge de règlement", + "aboutCashuSettlementMarginExplanation": "Combien de temps avant l'expiration du séquestre ce nœud cesse d'accepter « fiat envoyé », afin qu'aucun échange ne soit réglé sans temps suffisant pour être mené à terme.", + "escrowModeLightning": "Lightning", + "escrowModeCashu": "Cashu", + "escrowModeUnknown": "Non annoncé", + "settingsEscrowOverrideTitle": "Backend de séquestre (développeur)", + "settingsEscrowOverrideSubtitle": "Testez Cashu avec un nœud qui ne l'annonce pas encore. Builds de débogage uniquement.", + "settingsForceCashuLabel": "Forcer le séquestre Cashu", + "settingsCashuMintOverrideLabel": "URL de mint alternative", + "settingsCashuMintOverrideApply": "Appliquer", + "settingsCashuMintOverrideInvalid": "Ce n'est pas une URL de mint valide. Utilisez http ou https avec un hôte.", + "settingsEscrowEffectiveMode": "Backend effectif : {mode}", + "settingsEscrowEffectiveMint": "Mint effectif : {mint}", + "settingsEscrowCashuUnavailable": "Cashu ne peut pas fonctionner sans mint : configurez-en un ci-dessous." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9ee1a190..5eff90c9 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -690,5 +690,26 @@ "bondSlashedDetailAmount": "Importo della cauzione", "bondSlashedDetailCause": "Motivo", "bondSlashedDetailFiat": "Fiat", - "bondSlashedDetailPaymentMethod": "Metodo di pagamento" + "bondSlashedDetailPaymentMethod": "Metodo di pagamento", + "aboutDaysValue": "{count, plural, =1{{count} giorno} other{{count} giorni}}", + "aboutCashuEscrowSection": "Deposito Cashu", + "aboutCashuMintUrlLabel": "Mint", + "aboutCashuMintUrlExplanation": "La mint Cashu che questo nodo usa per ogni deposito. L'ecash bloccato per uno scambio è emesso da questa mint; non si sceglie per singolo ordine.", + "aboutCashuMintNotAdvertised": "Non dichiarata", + "aboutCashuLocktimeLabel": "Blocco del deposito", + "aboutCashuLocktimeExplanation": "Per quanto tempo l'ecash del venditore resta bloccato in deposito. Alla scadenza il venditore può recuperare i fondi senza l'intervento del nodo.", + "aboutCashuSettlementMarginLabel": "Margine di liquidazione", + "aboutCashuSettlementMarginExplanation": "Quanto prima della scadenza del deposito questo nodo smette di accettare «fiat inviato», così nessuno scambio viene liquidato con troppo poco tempo per concludersi.", + "escrowModeLightning": "Lightning", + "escrowModeCashu": "Cashu", + "escrowModeUnknown": "Non dichiarato", + "settingsEscrowOverrideTitle": "Backend di deposito (sviluppo)", + "settingsEscrowOverrideSubtitle": "Prova Cashu con un nodo che non lo dichiara ancora. Solo nelle build di debug.", + "settingsForceCashuLabel": "Forza il deposito Cashu", + "settingsCashuMintOverrideLabel": "URL mint alternativo", + "settingsCashuMintOverrideApply": "Applica", + "settingsCashuMintOverrideInvalid": "Non è un URL di mint valido. Usa http o https con un host.", + "settingsEscrowEffectiveMode": "Backend effettivo: {mode}", + "settingsEscrowEffectiveMint": "Mint effettiva: {mint}", + "settingsEscrowCashuUnavailable": "Cashu non può funzionare senza una mint: impostane una qui sotto." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 84858b02..85d8f146 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4111,6 +4111,132 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Payment method'** String get bondSlashedDetailPaymentMethod; + + /// About screen — a pluralized day count + /// + /// In en, this message translates to: + /// **'{count, plural, =1{{count} day} other{{count} days}}'** + String aboutDaysValue(int count); + + /// About screen — section header shown when the node settles trades with Cashu ecash instead of Lightning + /// + /// In en, this message translates to: + /// **'Cashu escrow'** + String get aboutCashuEscrowSection; + + /// About screen — Cashu mint URL row label + /// + /// In en, this message translates to: + /// **'Mint'** + String get aboutCashuMintUrlLabel; + + /// Info dialog explanation for the Cashu Mint field + /// + /// In en, this message translates to: + /// **'The Cashu mint this node uses for every escrow. Ecash locked for a trade is issued by this mint; there is no per-order choice.'** + String get aboutCashuMintUrlExplanation; + + /// About screen — shown when a node says it runs Cashu but publishes no mint, which means trades cannot run + /// + /// In en, this message translates to: + /// **'Not advertised'** + String get aboutCashuMintNotAdvertised; + + /// About screen — Cashu escrow locktime row label + /// + /// In en, this message translates to: + /// **'Escrow locktime'** + String get aboutCashuLocktimeLabel; + + /// Info dialog explanation for the Cashu Escrow locktime field + /// + /// In en, this message translates to: + /// **'How long the seller\'s ecash stays locked in escrow. Once it expires the seller can reclaim the funds without the node\'s help.'** + String get aboutCashuLocktimeExplanation; + + /// About screen — Cashu settlement margin row label + /// + /// In en, this message translates to: + /// **'Settlement margin'** + String get aboutCashuSettlementMarginLabel; + + /// Info dialog explanation for the Cashu Settlement margin field + /// + /// In en, this message translates to: + /// **'How close to the escrow expiry this node stops accepting \'fiat sent\', so a trade is never settled with too little time left to complete it.'** + String get aboutCashuSettlementMarginExplanation; + + /// Name of the Lightning settlement backend + /// + /// In en, this message translates to: + /// **'Lightning'** + String get escrowModeLightning; + + /// Name of the Cashu ecash settlement backend + /// + /// In en, this message translates to: + /// **'Cashu'** + String get escrowModeCashu; + + /// Shown when the node publishes no settlement backend, which is not the same as knowing it uses Lightning + /// + /// In en, this message translates to: + /// **'Not advertised'** + String get escrowModeUnknown; + + /// Settings — title of the debug-only card that forces the escrow backend + /// + /// In en, this message translates to: + /// **'Escrow backend (developer)'** + String get settingsEscrowOverrideTitle; + + /// Settings — subtitle of the debug-only escrow backend override card + /// + /// In en, this message translates to: + /// **'Test Cashu against a node that does not advertise it yet. Debug builds only.'** + String get settingsEscrowOverrideSubtitle; + + /// Settings — switch that makes the app treat the node as running Cashu regardless of what it advertises + /// + /// In en, this message translates to: + /// **'Force Cashu escrow'** + String get settingsForceCashuLabel; + + /// Settings — text field for a mint URL to use instead of the node's + /// + /// In en, this message translates to: + /// **'Mint URL override'** + String get settingsCashuMintOverrideLabel; + + /// Settings — button that saves the mint URL override + /// + /// In en, this message translates to: + /// **'Apply'** + String get settingsCashuMintOverrideApply; + + /// Settings — error shown when the entered mint URL is rejected + /// + /// In en, this message translates to: + /// **'That is not a valid mint URL. Use http or https with a host.'** + String get settingsCashuMintOverrideInvalid; + + /// Settings — the backend the app is actually acting on, overrides included + /// + /// In en, this message translates to: + /// **'Effective backend: {mode}'** + String settingsEscrowEffectiveMode(String mode); + + /// Settings — the mint the app is actually acting on, overrides included + /// + /// In en, this message translates to: + /// **'Effective mint: {mint}'** + String settingsEscrowEffectiveMint(String mint); + + /// Settings — warning shown when Cashu mode is on but no mint is available, so no Cashu path can run + /// + /// In en, this message translates to: + /// **'Cashu cannot run without a mint — set one below.'** + String get settingsEscrowCashuUnavailable; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9cf4f063..87b004ba 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2333,4 +2333,85 @@ class AppLocalizationsDe extends AppLocalizations { @override String get bondSlashedDetailPaymentMethod => 'Zahlungsmethode'; + + @override + String aboutDaysValue(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Tage', + one: '$count Tag', + ); + return '$_temp0'; + } + + @override + String get aboutCashuEscrowSection => 'Cashu-Treuhand'; + + @override + String get aboutCashuMintUrlLabel => 'Mint'; + + @override + String get aboutCashuMintUrlExplanation => + 'Die Cashu-Mint, die dieser Node für jede Treuhand verwendet. Das für einen Handel gesperrte E-Cash stammt von dieser Mint; pro Order gibt es keine Wahl.'; + + @override + String get aboutCashuMintNotAdvertised => 'Nicht angegeben'; + + @override + String get aboutCashuLocktimeLabel => 'Treuhand-Sperrfrist'; + + @override + String get aboutCashuLocktimeExplanation => + 'Wie lange das E-Cash des Verkäufers in der Treuhand gesperrt bleibt. Nach Ablauf kann der Verkäufer die Mittel ohne Zutun des Nodes zurückholen.'; + + @override + String get aboutCashuSettlementMarginLabel => 'Abwicklungspuffer'; + + @override + String get aboutCashuSettlementMarginExplanation => + 'Wie lange vor Ablauf der Treuhand dieser Node „Fiat gesendet“ nicht mehr annimmt, damit kein Handel mit zu wenig verbleibender Zeit abgewickelt wird.'; + + @override + String get escrowModeLightning => 'Lightning'; + + @override + String get escrowModeCashu => 'Cashu'; + + @override + String get escrowModeUnknown => 'Nicht angegeben'; + + @override + String get settingsEscrowOverrideTitle => 'Treuhand-Backend (Entwicklung)'; + + @override + String get settingsEscrowOverrideSubtitle => + 'Cashu gegen einen Node testen, der es noch nicht angibt. Nur in Debug-Builds.'; + + @override + String get settingsForceCashuLabel => 'Cashu-Treuhand erzwingen'; + + @override + String get settingsCashuMintOverrideLabel => 'Abweichende Mint-URL'; + + @override + String get settingsCashuMintOverrideApply => 'Übernehmen'; + + @override + String get settingsCashuMintOverrideInvalid => + 'Das ist keine gültige Mint-URL. Verwende http oder https mit einem Host.'; + + @override + String settingsEscrowEffectiveMode(String mode) { + return 'Effektives Backend: $mode'; + } + + @override + String settingsEscrowEffectiveMint(String mint) { + return 'Effektive Mint: $mint'; + } + + @override + String get settingsEscrowCashuUnavailable => + 'Cashu funktioniert ohne Mint nicht – unten eine festlegen.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7437dd28..4a750a6f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2297,4 +2297,85 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bondSlashedDetailPaymentMethod => 'Payment method'; + + @override + String aboutDaysValue(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count days', + one: '$count day', + ); + return '$_temp0'; + } + + @override + String get aboutCashuEscrowSection => 'Cashu escrow'; + + @override + String get aboutCashuMintUrlLabel => 'Mint'; + + @override + String get aboutCashuMintUrlExplanation => + 'The Cashu mint this node uses for every escrow. Ecash locked for a trade is issued by this mint; there is no per-order choice.'; + + @override + String get aboutCashuMintNotAdvertised => 'Not advertised'; + + @override + String get aboutCashuLocktimeLabel => 'Escrow locktime'; + + @override + String get aboutCashuLocktimeExplanation => + 'How long the seller\'s ecash stays locked in escrow. Once it expires the seller can reclaim the funds without the node\'s help.'; + + @override + String get aboutCashuSettlementMarginLabel => 'Settlement margin'; + + @override + String get aboutCashuSettlementMarginExplanation => + 'How close to the escrow expiry this node stops accepting \'fiat sent\', so a trade is never settled with too little time left to complete it.'; + + @override + String get escrowModeLightning => 'Lightning'; + + @override + String get escrowModeCashu => 'Cashu'; + + @override + String get escrowModeUnknown => 'Not advertised'; + + @override + String get settingsEscrowOverrideTitle => 'Escrow backend (developer)'; + + @override + String get settingsEscrowOverrideSubtitle => + 'Test Cashu against a node that does not advertise it yet. Debug builds only.'; + + @override + String get settingsForceCashuLabel => 'Force Cashu escrow'; + + @override + String get settingsCashuMintOverrideLabel => 'Mint URL override'; + + @override + String get settingsCashuMintOverrideApply => 'Apply'; + + @override + String get settingsCashuMintOverrideInvalid => + 'That is not a valid mint URL. Use http or https with a host.'; + + @override + String settingsEscrowEffectiveMode(String mode) { + return 'Effective backend: $mode'; + } + + @override + String settingsEscrowEffectiveMint(String mint) { + return 'Effective mint: $mint'; + } + + @override + String get settingsEscrowCashuUnavailable => + 'Cashu cannot run without a mint — set one below.'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 83345333..7917f31d 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2323,4 +2323,85 @@ class AppLocalizationsEs extends AppLocalizations { @override String get bondSlashedDetailPaymentMethod => 'Método de pago'; + + @override + String aboutDaysValue(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count días', + one: '$count día', + ); + return '$_temp0'; + } + + @override + String get aboutCashuEscrowSection => 'Custodia Cashu'; + + @override + String get aboutCashuMintUrlLabel => 'Mint'; + + @override + String get aboutCashuMintUrlExplanation => + 'El mint Cashu que este nodo usa para toda custodia. El ecash bloqueado en un intercambio lo emite este mint; no se elige por orden.'; + + @override + String get aboutCashuMintNotAdvertised => 'No anunciado'; + + @override + String get aboutCashuLocktimeLabel => 'Bloqueo de la custodia'; + + @override + String get aboutCashuLocktimeExplanation => + 'Cuánto tiempo permanece bloqueado el ecash del vendedor. Al vencer, el vendedor puede recuperar los fondos sin la ayuda del nodo.'; + + @override + String get aboutCashuSettlementMarginLabel => 'Margen de liquidación'; + + @override + String get aboutCashuSettlementMarginExplanation => + 'Con cuánta antelación al vencimiento de la custodia el nodo deja de aceptar «fiat enviado», para que ningún intercambio se liquide con demasiado poco tiempo para completarse.'; + + @override + String get escrowModeLightning => 'Lightning'; + + @override + String get escrowModeCashu => 'Cashu'; + + @override + String get escrowModeUnknown => 'No anunciado'; + + @override + String get settingsEscrowOverrideTitle => 'Backend de custodia (desarrollo)'; + + @override + String get settingsEscrowOverrideSubtitle => + 'Prueba Cashu contra un nodo que aún no lo anuncia. Solo en compilaciones de depuración.'; + + @override + String get settingsForceCashuLabel => 'Forzar custodia Cashu'; + + @override + String get settingsCashuMintOverrideLabel => 'URL de mint alternativa'; + + @override + String get settingsCashuMintOverrideApply => 'Aplicar'; + + @override + String get settingsCashuMintOverrideInvalid => + 'Esa no es una URL de mint válida. Usa http o https con un host.'; + + @override + String settingsEscrowEffectiveMode(String mode) { + return 'Backend efectivo: $mode'; + } + + @override + String settingsEscrowEffectiveMint(String mint) { + return 'Mint efectivo: $mint'; + } + + @override + String get settingsEscrowCashuUnavailable => + 'Cashu no puede funcionar sin un mint: configura uno abajo.'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 722c9f0b..8fc1deb1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2332,4 +2332,86 @@ class AppLocalizationsFr extends AppLocalizations { @override String get bondSlashedDetailPaymentMethod => 'Moyen de paiement'; + + @override + String aboutDaysValue(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count jours', + one: '$count jour', + ); + return '$_temp0'; + } + + @override + String get aboutCashuEscrowSection => 'Séquestre Cashu'; + + @override + String get aboutCashuMintUrlLabel => 'Mint'; + + @override + String get aboutCashuMintUrlExplanation => + 'Le mint Cashu que ce nœud utilise pour tous les séquestres. L\'ecash bloqué pour un échange est émis par ce mint ; il n\'y a pas de choix par ordre.'; + + @override + String get aboutCashuMintNotAdvertised => 'Non annoncé'; + + @override + String get aboutCashuLocktimeLabel => 'Verrouillage du séquestre'; + + @override + String get aboutCashuLocktimeExplanation => + 'Durée pendant laquelle l\'ecash du vendeur reste bloqué en séquestre. À l\'expiration, le vendeur peut récupérer les fonds sans l\'aide du nœud.'; + + @override + String get aboutCashuSettlementMarginLabel => 'Marge de règlement'; + + @override + String get aboutCashuSettlementMarginExplanation => + 'Combien de temps avant l\'expiration du séquestre ce nœud cesse d\'accepter « fiat envoyé », afin qu\'aucun échange ne soit réglé sans temps suffisant pour être mené à terme.'; + + @override + String get escrowModeLightning => 'Lightning'; + + @override + String get escrowModeCashu => 'Cashu'; + + @override + String get escrowModeUnknown => 'Non annoncé'; + + @override + String get settingsEscrowOverrideTitle => + 'Backend de séquestre (développeur)'; + + @override + String get settingsEscrowOverrideSubtitle => + 'Testez Cashu avec un nœud qui ne l\'annonce pas encore. Builds de débogage uniquement.'; + + @override + String get settingsForceCashuLabel => 'Forcer le séquestre Cashu'; + + @override + String get settingsCashuMintOverrideLabel => 'URL de mint alternative'; + + @override + String get settingsCashuMintOverrideApply => 'Appliquer'; + + @override + String get settingsCashuMintOverrideInvalid => + 'Ce n\'est pas une URL de mint valide. Utilisez http ou https avec un hôte.'; + + @override + String settingsEscrowEffectiveMode(String mode) { + return 'Backend effectif : $mode'; + } + + @override + String settingsEscrowEffectiveMint(String mint) { + return 'Mint effectif : $mint'; + } + + @override + String get settingsEscrowCashuUnavailable => + 'Cashu ne peut pas fonctionner sans mint : configurez-en un ci-dessous.'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 708b63b1..ab941bcd 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2324,4 +2324,85 @@ class AppLocalizationsIt extends AppLocalizations { @override String get bondSlashedDetailPaymentMethod => 'Metodo di pagamento'; + + @override + String aboutDaysValue(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count giorni', + one: '$count giorno', + ); + return '$_temp0'; + } + + @override + String get aboutCashuEscrowSection => 'Deposito Cashu'; + + @override + String get aboutCashuMintUrlLabel => 'Mint'; + + @override + String get aboutCashuMintUrlExplanation => + 'La mint Cashu che questo nodo usa per ogni deposito. L\'ecash bloccato per uno scambio è emesso da questa mint; non si sceglie per singolo ordine.'; + + @override + String get aboutCashuMintNotAdvertised => 'Non dichiarata'; + + @override + String get aboutCashuLocktimeLabel => 'Blocco del deposito'; + + @override + String get aboutCashuLocktimeExplanation => + 'Per quanto tempo l\'ecash del venditore resta bloccato in deposito. Alla scadenza il venditore può recuperare i fondi senza l\'intervento del nodo.'; + + @override + String get aboutCashuSettlementMarginLabel => 'Margine di liquidazione'; + + @override + String get aboutCashuSettlementMarginExplanation => + 'Quanto prima della scadenza del deposito questo nodo smette di accettare «fiat inviato», così nessuno scambio viene liquidato con troppo poco tempo per concludersi.'; + + @override + String get escrowModeLightning => 'Lightning'; + + @override + String get escrowModeCashu => 'Cashu'; + + @override + String get escrowModeUnknown => 'Non dichiarato'; + + @override + String get settingsEscrowOverrideTitle => 'Backend di deposito (sviluppo)'; + + @override + String get settingsEscrowOverrideSubtitle => + 'Prova Cashu con un nodo che non lo dichiara ancora. Solo nelle build di debug.'; + + @override + String get settingsForceCashuLabel => 'Forza il deposito Cashu'; + + @override + String get settingsCashuMintOverrideLabel => 'URL mint alternativo'; + + @override + String get settingsCashuMintOverrideApply => 'Applica'; + + @override + String get settingsCashuMintOverrideInvalid => + 'Non è un URL di mint valido. Usa http o https con un host.'; + + @override + String settingsEscrowEffectiveMode(String mode) { + return 'Backend effettivo: $mode'; + } + + @override + String settingsEscrowEffectiveMint(String mint) { + return 'Mint effettiva: $mint'; + } + + @override + String get settingsEscrowCashuUnavailable => + 'Cashu non può funzionare senza una mint: impostane una qui sotto.'; } diff --git a/lib/main.dart b/lib/main.dart index 5993dae5..eec5dfdd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'package:mostro/firebase_options.dart'; import 'package:mostro/src/rust/frb_generated.dart'; import 'package:mostro/src/rust/api.dart' as rust_api; import 'package:mostro/features/settings/providers/nwc_provider.dart'; +import 'package:mostro/src/rust/api/escrow.dart' as escrow_api; import 'package:mostro/src/rust/api/nwc.dart' as nwc_api; import 'package:mostro/src/rust/api/nostr.dart' as nostr_api; import 'package:mostro/src/rust/api/orders.dart' as orders_api; @@ -80,6 +81,10 @@ Future main() async { try { await settings_api.rehydrateActiveMostroNode(); activeMostroPubkey = await settings_api.getMostroPubkey(); + // Load the escrow-mode overrides before the relay pool starts, so the first + // capability fetch already resolves against them. Nothing can have written + // them in a release build (docs/cashu/README.md §4.3). + await escrow_api.rehydrateEscrowOverrides(); markBridgeReady(); } catch (e) { debugPrint('[main] rehydrate active Mostro node failed: $e'); diff --git a/rust/src/api/escrow.rs b/rust/src/api/escrow.rs new file mode 100644 index 00000000..7c66bff0 --- /dev/null +++ b/rust/src/api/escrow.rs @@ -0,0 +1,440 @@ +//! Escrow-mode surface for the UI — phase C1b of `docs/cashu/README.md`. +//! +//! The domain logic lives in [`crate::mostro::escrow_mode`]; this module is the +//! bridge half: it converts the resolved mode into a Dart-friendly struct, +//! persists the two developer overrides through the settings k/v store, and +//! broadcasts changes so the UI never polls. +//! +//! Two rules this module exists to keep: +//! - **Rust does not translate.** [`EscrowModeInfo::mode`] is a stable marker +//! (`"unknown" | "lightning" | "cashu"`); Dart maps it to a localized string. +//! - **The gate is `is_cashu_available`, not `mode == "cashu"`.** A node can +//! advertise Cashu and still publish no usable mint. + +use anyhow::{bail, Result}; +use tokio::sync::broadcast::error::RecvError; + +use crate::api::types::EscrowModeInfo; +use crate::db::{settings_keys, Storage}; +use crate::mostro::escrow_mode::{self, EscrowModeOverride, EscrowOverrides}; + +// ── Conversion ──────────────────────────────────────────────────────────────── + +fn snapshot() -> EscrowModeInfo { + let resolved = escrow_mode::get_resolved(); + let overrides = escrow_mode::get_overrides(); + + EscrowModeInfo { + mode: resolved.mode.as_marker().to_string(), + mint_url: resolved.config.mint_url.clone(), + escrow_locktime_days: resolved.config.escrow_locktime_days, + settlement_margin_days: resolved.config.settlement_margin_days, + is_overridden: resolved.is_overridden, + // Derived from the resolution above rather than re-reading the globals: + // `is_cashu_mode()` would take a second read, and a node switch between + // the two would produce a snapshot whose mode and gate disagree. + is_cashu_available: resolved.is_cashu_usable(), + force_cashu_override: matches!(overrides.mode, EscrowModeOverride::ForceCashu), + mint_url_override: overrides.mint_url, + } +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/// Accept only an `http(s)` URL with a host. +/// +/// Deliberately strict: the mint override exists to point a tester at a local +/// nutshell, and a typo that silently became the "mint" would surface much +/// later, as a connection failure with no obvious cause. +fn validate_mint_url(url: &str) -> Result<()> { + // `Url` comes from nostr-sdk's re-export of the `url` crate — no new + // dependency for one validation. + let parsed = nostr_sdk::Url::parse(url) + .map_err(|e| anyhow::anyhow!("InvalidMintUrl: '{url}' is not a URL ({e})"))?; + + if !matches!(parsed.scheme(), "http" | "https") { + bail!("InvalidMintUrl: '{url}' must use http or https"); + } + if parsed.host_str().is_none_or(str::is_empty) { + bail!("InvalidMintUrl: '{url}' has no host"); + } + Ok(()) +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// The active node's settlement backend, overrides applied. +pub fn get_escrow_mode() -> EscrowModeInfo { + snapshot() +} + +/// Force the client to treat the active node as running Cashu escrow, or go +/// back to trusting the node's own tags. +/// +/// Developer affordance (§4.3): it exists to test against a daemon branch that +/// implements Cashu without publishing the 38385 tags yet. The Flutter surface +/// that calls it is `kDebugMode`-only, so release builds cannot reach it. +pub async fn set_escrow_mode_override(force_cashu: bool) -> Result<()> { + let mode = if force_cashu { + EscrowModeOverride::ForceCashu + } else { + EscrowModeOverride::Auto + }; + + persist(settings_keys::ESCROW_MODE_OVERRIDE, Some(mode.as_stored())).await?; + // One field, under one lock: the mint URL is set from the same surface, and + // a read-modify-write of the whole struct would race with it. + escrow_mode::update_overrides(|o| o.mode = mode); + Ok(()) +} + +/// Point Cashu at a specific mint instead of the one the node advertises. +/// +/// `None` (or a blank string) clears the override, restoring the node's value. +/// +/// **Errors**: `InvalidMintUrl` when the URL is not an `http(s)` URL with a host. +pub async fn set_cashu_mint_url_override(mint_url: Option) -> Result<()> { + let normalized = mint_url + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty()); + + if let Some(ref url) = normalized { + validate_mint_url(url)?; + } + + persist(settings_keys::CASHU_MINT_URL_OVERRIDE, normalized.as_deref()).await?; + escrow_mode::update_overrides(|o| o.mint_url = normalized); + Ok(()) +} + +/// Load the persisted overrides into memory. +/// +/// Call once at startup, after `init_db` and **before** the relay pool starts, +/// so the first capability fetch already resolves against the user's overrides. +/// No-op when the DB is unavailable (the `Auto` default then applies, which +/// keeps every Cashu path shut). +pub async fn rehydrate_escrow_overrides() -> Result<()> { + let Some(db) = crate::db::app_db::db() else { + return Ok(()); + }; + + let mode = match db.get_setting(settings_keys::ESCROW_MODE_OVERRIDE).await? { + Some(stored) => EscrowModeOverride::from_stored(&stored), + None => EscrowModeOverride::Auto, + }; + + // A persisted mint URL is re-validated rather than trusted: the rules can + // tighten between releases, and a stored value that no longer passes them + // must be dropped, not silently used. + let mint_url = db + .get_setting(settings_keys::CASHU_MINT_URL_OVERRIDE) + .await? + .filter(|url| match validate_mint_url(url) { + Ok(()) => true, + Err(e) => { + log::warn!("[escrow] discarding persisted mint override: {e}"); + false + } + }); + + escrow_mode::set_overrides(EscrowOverrides { mode, mint_url }); + Ok(()) +} + +/// Write (or remove) a settings key, tolerating an uninitialised DB. +/// +/// A missing DB is not an error: the in-memory override still applies for this +/// session, exactly as on web, where the IndexedDB backend is a stub (#233). +async fn persist(key: &str, value: Option<&str>) -> Result<()> { + let Some(db) = crate::db::app_db::db() else { + log::warn!("[escrow] no DB — '{key}' applies to this session only"); + return Ok(()); + }; + match value { + Some(v) => db.set_setting(key, v).await, + None => db.delete_setting(key).await, + } +} + +// ── Stream ──────────────────────────────────────────────────────────────────── + +/// A stream that emits the resolved escrow mode whenever it changes: a +/// capability fetch, a node switch, or an override flip. +pub struct EscrowModeStream { + rx: tokio::sync::broadcast::Receiver<()>, +} + +impl EscrowModeStream { + /// Poll for the next escrow-mode-changed event. + /// + /// A lagged receiver skips the dropped snapshots and continues: the value + /// is a current-state snapshot, so only the latest one matters. + pub async fn next(&mut self) -> Result { + loop { + match self.rx.recv().await { + // The event is a bare wake-up; the snapshot is rebuilt from the + // globals so the override fields can never disagree with the + // mode inside the same struct. + Ok(()) => return Ok(snapshot()), + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => { + bail!("EscrowModeStream closed: channel sender dropped") + } + } + } + } +} + +/// Subscribe to escrow-mode changes. +pub fn on_escrow_mode_changed() -> EscrowModeStream { + EscrowModeStream { + rx: escrow_mode::subscribe(), + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +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 + } + + #[tokio::test] + async fn a_fresh_client_reports_unknown_and_no_cashu() { + // Arrange + let _g = escrow_lock(); + + // Act + let info = get_escrow_mode(); + + // Assert — the marker is stable, and the gate is shut. + assert_eq!(info.mode, "unknown"); + assert!(!info.is_cashu_available); + assert!(!info.is_overridden); + assert!(!info.force_cashu_override); + assert_eq!(info.mint_url, None); + } + + #[tokio::test] + async fn a_cashu_node_is_reported_with_its_parameters() { + // Arrange + let _g = escrow_lock(); + escrow_mode::set_from_tags( + EscrowMode::Cashu, + CashuNodeConfig { + mint_url: Some("https://mint.example.com".to_string()), + escrow_locktime_days: Some(15), + settlement_margin_days: Some(3), + }, + ); + + // Act + let info = get_escrow_mode(); + + // Assert + assert_eq!(info.mode, "cashu"); + assert_eq!(info.mint_url.as_deref(), Some("https://mint.example.com")); + assert_eq!(info.escrow_locktime_days, Some(15)); + assert_eq!(info.settlement_margin_days, Some(3)); + assert!(info.is_cashu_available); + assert!(!info.is_overridden); + } + + #[tokio::test] + async fn forcing_cashu_without_a_mint_does_not_open_the_gate() { + // Arrange — a Lightning node and a developer who forgot the mint. + let _g = escrow_lock(); + escrow_mode::set_from_tags(EscrowMode::Lightning, CashuNodeConfig::default()); + + // Act + set_escrow_mode_override(true).await.unwrap(); + let info = get_escrow_mode(); + + // Assert — the mode is reported honestly, but nothing may run: there is + // no mint to connect to. + assert_eq!(info.mode, "cashu"); + assert!(info.is_overridden); + assert!(!info.is_cashu_available); + + // Act — now with a mint. + set_cashu_mint_url_override(Some("http://localhost:3338".to_string())) + .await + .unwrap(); + + // Assert + let info = get_escrow_mode(); + assert!(info.is_cashu_available); + assert_eq!(info.mint_url.as_deref(), Some("http://localhost:3338")); + assert_eq!( + info.mint_url_override.as_deref(), + Some("http://localhost:3338") + ); + } + + #[tokio::test] + async fn turning_the_override_off_restores_what_the_node_said() { + // Arrange + let _g = escrow_lock(); + escrow_mode::set_from_tags(EscrowMode::Lightning, CashuNodeConfig::default()); + set_escrow_mode_override(true).await.unwrap(); + set_cashu_mint_url_override(Some("http://localhost:3338".to_string())) + .await + .unwrap(); + + // Act + set_escrow_mode_override(false).await.unwrap(); + + // Assert — the mint override is independent and survives; the mode is + // the node's again. + let info = get_escrow_mode(); + assert_eq!(info.mode, "lightning"); + assert!(!info.is_cashu_available); + assert_eq!( + info.mint_url_override.as_deref(), + Some("http://localhost:3338") + ); + } + + #[tokio::test] + async fn a_blank_mint_override_clears_it() { + // Arrange + let _g = escrow_lock(); + set_cashu_mint_url_override(Some("http://localhost:3338".to_string())) + .await + .unwrap(); + + // Act + set_cashu_mint_url_override(Some(" ".to_string())) + .await + .unwrap(); + + // Assert + assert_eq!(get_escrow_mode().mint_url_override, None); + } + + #[tokio::test] + async fn a_malformed_mint_override_is_rejected_and_changes_nothing() { + // Arrange + let _g = escrow_lock(); + set_cashu_mint_url_override(Some("https://mint.example.com".to_string())) + .await + .unwrap(); + + // Act / Assert — every rejected shape. + for bad in ["not a url", "ftp://mint.example.com", "file:///etc/passwd"] { + let err = set_cashu_mint_url_override(Some(bad.to_string())) + .await + .unwrap_err(); + assert!( + err.to_string().contains("InvalidMintUrl"), + "expected InvalidMintUrl for {bad:?}, got {err}" + ); + } + + // Assert — a rejected write leaves the previous value in place. + assert_eq!( + get_escrow_mode().mint_url_override.as_deref(), + Some("https://mint.example.com") + ); + } + + #[tokio::test] + async fn overrides_survive_a_restart_through_the_settings_store() { + // Arrange — a real store, so this covers the persist/rehydrate pair + // rather than just the halves. Without an initialised DB the setters + // apply in memory only, which is the web behaviour (#233) and would + // make this test pass for the wrong reason. + let _g = escrow_lock(); + let path = std::env::temp_dir().join(format!( + "mostro_escrow_rehydrate_{}.db", + std::process::id() + )); + // `init_db` is a OnceCell — the first test to call it wins and the rest + // share that store, which is what we want: this test needs *a* real + // store, not its own. + let _ = crate::db::app_db::init_db(path.to_str().unwrap()).await; + assert!( + crate::db::app_db::db().is_some(), + "a real settings store is the point of this test" + ); + + // Act — the developer sets both overrides. + set_escrow_mode_override(true).await.unwrap(); + set_cashu_mint_url_override(Some("http://localhost:3338".to_string())) + .await + .unwrap(); + + // Act — the app restarts: memory is empty, only the store survives. + escrow_mode::set_overrides(EscrowOverrides::default()); + assert!(!get_escrow_mode().force_cashu_override, "precondition"); + rehydrate_escrow_overrides().await.unwrap(); + + // Assert — both came back. + let info = get_escrow_mode(); + assert!(info.force_cashu_override); + assert_eq!( + info.mint_url_override.as_deref(), + Some("http://localhost:3338") + ); + + // Cleanup — clear the overrides, but leave the file alone. `init_db` + // is a process-wide OnceCell: deleting the file here would leave every + // later test holding a pool onto a database that no longer has tables. + set_escrow_mode_override(false).await.unwrap(); + set_cashu_mint_url_override(None).await.unwrap(); + } + + #[tokio::test] + async fn setting_one_override_never_clobbers_the_other() { + // Arrange — the read-modify-write hazard: both fields are written from + // the same screen, and the mode setter used to overwrite the whole + // struct with a separately-read copy. + let _g = escrow_lock(); + set_cashu_mint_url_override(Some("http://localhost:3338".to_string())) + .await + .unwrap(); + + // Act + set_escrow_mode_override(true).await.unwrap(); + + // Assert — the mint override is still there. + let info = get_escrow_mode(); + assert!(info.force_cashu_override); + assert_eq!( + info.mint_url_override.as_deref(), + Some("http://localhost:3338"), + "setting the mode must not drop the mint override" + ); + } + + #[tokio::test] + async fn the_stream_emits_a_consistent_snapshot() { + // Arrange + let _g = escrow_lock(); + let mut stream = on_escrow_mode_changed(); + + // Act + escrow_mode::set_from_tags( + EscrowMode::Cashu, + CashuNodeConfig { + mint_url: Some("https://mint.example.com".to_string()), + ..Default::default() + }, + ); + + // Assert + let info = stream.next().await.unwrap(); + assert_eq!(info.mode, "cashu"); + assert!(info.is_cashu_available); + } +} diff --git a/rust/src/api/mod.rs b/rust/src/api/mod.rs index c3f6a65a..407b78b1 100644 --- a/rust/src/api/mod.rs +++ b/rust/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod bond; pub mod disputes; +pub mod escrow; pub mod identity; pub mod logging; pub mod messages; diff --git a/rust/src/api/nostr.rs b/rust/src/api/nostr.rs index 2c3add0b..8bf560fe 100644 --- a/rust/src/api/nostr.rs +++ b/rust/src/api/nostr.rs @@ -242,11 +242,7 @@ pub(crate) async fn fetch_and_set_node_capabilities() { // Today's daemons publish no escrow tags at all, so this resolves // to Unknown — which keeps every Cashu path shut. See escrow_mode. let (mode, config) = escrow_mode::parse_tags(&tags); - escrow_mode::set_resolved(escrow_mode::resolve(&escrow_mode::EscrowModeInputs { - from_tags: mode, - tag_config: config, - ..Default::default() - })); + escrow_mode::set_from_tags(mode, config); } Ok(None) => { log::warn!("[nostr] no Kind 38385 event found — PoW defaults to 0"); diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index 0d432231..12af0c16 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -518,6 +518,36 @@ pub struct Dispute { pub is_read: bool, } +/// The settlement backend the active Mostro node runs, as resolved by +/// [`crate::mostro::escrow_mode`] with the developer overrides applied. +/// +/// Phase C1b of `docs/cashu/README.md`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct EscrowModeInfo { + /// Stable marker — `"unknown"`, `"lightning"` or `"cashu"`. Rust does not + /// translate; Dart maps this to a localized string. + pub mode: String, + /// Mint the node pins for every escrow, override applied. `None` on a + /// Lightning node, or on a Cashu node that published none. + pub mint_url: Option, + /// NUT-11 locktime the seller must set, in days. + pub escrow_locktime_days: Option, + /// How close to expiry the daemon stops accepting `fiat-sent`, in days. + pub settlement_margin_days: Option, + /// True when [`Self::mode`] came from the developer override rather than + /// the node's own tags. + pub is_overridden: bool, + /// **The gate.** True only when the mode is Cashu *and* there is a usable + /// mint to connect to. `mode == "cashu"` alone is not enough — a node can + /// advertise Cashu and publish no mint. + pub is_cashu_available: bool, + /// Developer override state, mirrored so the dev-only settings surface can + /// render its own controls without a second call. + pub force_cashu_override: bool, + /// Mint URL override as stored, independent of what the node advertises. + pub mint_url_override: Option, +} + /// Aggregated user-facing application settings. /// /// `privacy_mode` is a read-only mirror of `IdentityInfo.privacy_mode` — diff --git a/rust/src/db/indexeddb.rs b/rust/src/db/indexeddb.rs index b6d4cf15..f06bb7d7 100644 --- a/rust/src/db/indexeddb.rs +++ b/rust/src/db/indexeddb.rs @@ -104,6 +104,28 @@ impl Storage for IndexedDbStorage { Ok(()) // IndexedDB not yet implemented } + // Settings k/v: same stub contract as the node pubkey below — writes are + // dropped and reads answer "nothing stored", so callers fall back to their + // defaults instead of failing. Tracked in #233. + + async fn get_setting(&self, _key: &str) -> Result> { + log::warn!("get_setting: IndexedDB backend not implemented — falling back to default"); + Ok(None) + } + + async fn set_setting(&self, _key: &str, _value: &str) -> Result<()> { + log::warn!("set_setting: IndexedDB backend not implemented — preference will not survive reload"); + Ok(()) + } + + async fn delete_setting(&self, _key: &str) -> Result<()> { + // Nothing was ever stored, so removal is already satisfied — but say so + // for the same reason the writes do: a silent no-op in a storage layer + // is indistinguishable from working storage when reading a log. + log::warn!("delete_setting: IndexedDB backend not implemented — nothing was stored to remove"); + Ok(()) + } + async fn save_active_mostro_pubkey(&self, _pubkey: &str) -> Result<()> { // IndexedDB not yet implemented — node selection will not survive reload. log::warn!("save_active_mostro_pubkey: IndexedDB backend not implemented — node selection will not survive reload"); diff --git a/rust/src/db/mod.rs b/rust/src/db/mod.rs index 23725876..cb5bda57 100644 --- a/rust/src/db/mod.rs +++ b/rust/src/db/mod.rs @@ -8,6 +8,25 @@ pub mod indexeddb; use anyhow::Result; +/// Keys used in the generic key-value settings store. +/// +/// Collected here so the namespace is greppable in one place: the store has no +/// schema, so a typo in a key string is a silently-lost preference rather than +/// a compile error. +pub mod settings_keys { + /// Active Mostro node pubkey (hex). Written through the dedicated + /// [`super::Storage::save_active_mostro_pubkey`] accessor. + pub const ACTIVE_MOSTRO_PUBKEY: &str = "active_mostro_pubkey"; + + /// Developer escrow-mode override — `"auto"` or `"force_cashu"`. + /// See [`crate::mostro::escrow_mode::EscrowModeOverride`]. + pub const ESCROW_MODE_OVERRIDE: &str = "escrow_mode_override"; + + /// Developer mint-URL override, pointing Cashu at a local mint instead of + /// the one the node advertises. + pub const CASHU_MINT_URL_OVERRIDE: &str = "cashu_mint_url_override"; +} + /// Storage trait — implemented by both SQLite (native) and IndexedDB (WASM). /// /// **Send-safety note**: `#[allow(async_fn_in_trait)]` is used here instead of @@ -78,6 +97,21 @@ pub trait Storage: Send + Sync { // ── Settings / Mostro node ──────────────────────────────────────────────── + /// Read a value from the generic key-value settings store, or `None` when + /// the key was never written. + /// + /// The store is for small, self-contained preferences — anything with + /// structure gets its own table. See [`settings_keys`] for the keys in use. + async fn get_setting(&self, key: &str) -> Result>; + + /// Write a value to the generic key-value settings store, replacing any + /// previous value for `key`. + async fn set_setting(&self, key: &str, value: &str) -> Result<()>; + + /// Remove a key from the generic settings store. Absent keys are not an + /// error — clearing an unset preference is a no-op by design. + async fn delete_setting(&self, key: &str) -> Result<()>; + /// Persist the active Mostro node's pubkey (hex). This is the *identity* of /// the selected node — node metadata (kind 0 / 38385) is a separate concern. async fn save_active_mostro_pubkey(&self, pubkey: &str) -> Result<()>; diff --git a/rust/src/db/sqlite.rs b/rust/src/db/sqlite.rs index b243d326..979a582a 100644 --- a/rust/src/db/sqlite.rs +++ b/rust/src/db/sqlite.rs @@ -5,7 +5,7 @@ use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; use crate::api::types::{ ChatMessage, IdentityInfo, OrderInfo, QueuedMessageStatus, RelayInfo, TradeInfo, }; -use crate::db::{schema::SQLITE_INIT_SQL, Storage}; +use crate::db::{schema::SQLITE_INIT_SQL, settings_keys, Storage}; use crate::queue::outbox::QueuedMessage; pub struct SqliteStorage { @@ -388,23 +388,43 @@ impl Storage for SqliteStorage { Ok(()) } - async fn save_active_mostro_pubkey(&self, pubkey: &str) -> Result<()> { - sqlx::query( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('active_mostro_pubkey', ?)", - ) - .bind(pubkey) - .execute(&self.pool) - .await?; + async fn get_setting(&self, key: &str) -> Result> { + let row: Option<(String,)> = + sqlx::query_as("SELECT value FROM settings WHERE key = ?") + .bind(key) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|(v,)| v)) + } + + async fn set_setting(&self, key: &str, value: &str) -> Result<()> { + sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") + .bind(key) + .bind(value) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn delete_setting(&self, key: &str) -> Result<()> { + sqlx::query("DELETE FROM settings WHERE key = ?") + .bind(key) + .execute(&self.pool) + .await?; Ok(()) } + // The active node lives in the same k/v table under a fixed key. These two + // stay as named accessors so callers never handle the key string, but they + // delegate rather than duplicate the SQL. + + async fn save_active_mostro_pubkey(&self, pubkey: &str) -> Result<()> { + self.set_setting(settings_keys::ACTIVE_MOSTRO_PUBKEY, pubkey) + .await + } + async fn get_active_mostro_pubkey(&self) -> Result> { - let row: Option<(String,)> = sqlx::query_as( - "SELECT value FROM settings WHERE key = 'active_mostro_pubkey'", - ) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|(v,)| v)) + self.get_setting(settings_keys::ACTIVE_MOSTRO_PUBKEY).await } async fn get_trade_by_order_id(&self, order_id: &str) -> Result> { @@ -539,6 +559,104 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[tokio::test] + async fn settings_kv_round_trip() { + // Arrange + let path = temp_db_path(); + let path_str = path.to_str().unwrap().to_string(); + let storage = SqliteStorage::open(&path_str).await.unwrap(); + + // Assert — an unwritten key reads as absent, not as an empty string. + assert_eq!( + storage + .get_setting(settings_keys::ESCROW_MODE_OVERRIDE) + .await + .unwrap(), + None + ); + + // Act / Assert — write, overwrite, read back. + storage + .set_setting(settings_keys::ESCROW_MODE_OVERRIDE, "auto") + .await + .unwrap(); + storage + .set_setting(settings_keys::ESCROW_MODE_OVERRIDE, "force_cashu") + .await + .unwrap(); + assert_eq!( + storage + .get_setting(settings_keys::ESCROW_MODE_OVERRIDE) + .await + .unwrap() + .as_deref(), + Some("force_cashu") + ); + + // Assert — keys are independent; writing one does not disturb another. + storage + .set_setting(settings_keys::CASHU_MINT_URL_OVERRIDE, "http://localhost:3338") + .await + .unwrap(); + assert_eq!( + storage + .get_setting(settings_keys::ESCROW_MODE_OVERRIDE) + .await + .unwrap() + .as_deref(), + Some("force_cashu") + ); + + // Act — clearing a preference. + storage + .delete_setting(settings_keys::CASHU_MINT_URL_OVERRIDE) + .await + .unwrap(); + + // Assert — deleted reads as absent, and deleting again is not an error. + assert_eq!( + storage + .get_setting(settings_keys::CASHU_MINT_URL_OVERRIDE) + .await + .unwrap(), + None + ); + storage + .delete_setting(settings_keys::CASHU_MINT_URL_OVERRIDE) + .await + .unwrap(); + + drop(storage); + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn the_active_node_accessors_share_the_kv_store() { + // Arrange — the named accessors are wrappers; a value written through + // one must be visible through the other, or a future refactor could + // silently split them into two rows. + let path = temp_db_path(); + let path_str = path.to_str().unwrap().to_string(); + let storage = SqliteStorage::open(&path_str).await.unwrap(); + let pk = "82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390"; + + // Act + storage.save_active_mostro_pubkey(pk).await.unwrap(); + + // Assert + assert_eq!( + storage + .get_setting(settings_keys::ACTIVE_MOSTRO_PUBKEY) + .await + .unwrap() + .as_deref(), + Some(pk) + ); + + drop(storage); + let _ = std::fs::remove_file(&path); + } + #[tokio::test] async fn identity_round_trip_preserves_trade_key_index() { let path = temp_db_path(); diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 088e776a..30db8b1d 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -27,6 +27,7 @@ use crate::api::bond::*; use crate::api::disputes::*; +use crate::api::escrow::*; use crate::api::logging::*; use crate::api::messages::*; use crate::api::nostr::*; @@ -46,7 +47,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 = -609202515; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -612460854; // Section: executor @@ -284,6 +285,63 @@ fn wire__crate__api__disputes__DisputeStream_next_impl( }, ) } +fn wire__crate__api__escrow__EscrowModeStream_next_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: "EscrowModeStream_next", + 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_that = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, true, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref_mut().await) + } + _ => unreachable!(), + } + } + let mut api_that_guard = api_that_guard.unwrap(); + let output_ok = + crate::api::escrow::EscrowModeStream::next(&mut *api_that_guard) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__logging__LogEntryStream_next_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1873,6 +1931,38 @@ fn wire__crate__api__disputes__get_dispute_impl( }, ) } +fn wire__crate__api__escrow__get_escrow_mode_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: "get_escrow_mode", + 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::escrow::get_escrow_mode())?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__identity__get_identity_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2547,13 +2637,13 @@ fn wire__crate__api__identity__import_from_mnemonic_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_words = >::sse_decode(&mut deserializer); - let api__recover = ::sse_decode(&mut deserializer); + let api_recover = ::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::identity::import_from_mnemonic(api_words, api__recover) + crate::api::identity::import_from_mnemonic(api_words, api_recover) .await?; Ok(output_ok) })() @@ -3000,6 +3090,39 @@ fn wire__crate__api__disputes__on_dispute_updated_impl( }, ) } +fn wire__crate__api__escrow__on_escrow_mode_changed_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: "on_escrow_mode_changed", + 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::escrow::on_escrow_mode_changed())?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__logging__on_log_entry_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3447,6 +3570,41 @@ fn wire__crate__api__settings__rehydrate_active_mostro_node_impl( }, ) } +fn wire__crate__api__escrow__rehydrate_escrow_overrides_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: "rehydrate_escrow_overrides", + 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| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::escrow::rehydrate_escrow_overrides().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__orders__release_order_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3755,6 +3913,43 @@ fn wire__crate__api__settings__set_active_mostro_node_impl( }, ) } +fn wire__crate__api__escrow__set_cashu_mint_url_override_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: "set_cashu_mint_url_override", + 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_mint_url = >::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::escrow::set_cashu_mint_url_override(api_mint_url).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__settings__set_default_fiat_code_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3830,6 +4025,43 @@ fn wire__crate__api__settings__set_default_lightning_address_impl( }, ) } +fn wire__crate__api__escrow__set_escrow_mode_override_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: "set_escrow_mode_override", + 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_force_cashu = ::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::escrow::set_escrow_mode_override(api_force_cashu).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__settings__set_language_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4140,6 +4372,9 @@ flutter_rust_bridge::frb_generated_moi_arc_impl_value!( flutter_rust_bridge::frb_generated_moi_arc_impl_value!( flutter_rust_bridge::for_generated::RustAutoOpaqueInner ); +flutter_rust_bridge::frb_generated_moi_arc_impl_value!( + flutter_rust_bridge::for_generated::RustAutoOpaqueInner +); flutter_rust_bridge::frb_generated_moi_arc_impl_value!( flutter_rust_bridge::for_generated::RustAutoOpaqueInner ); @@ -4218,6 +4453,16 @@ impl SseDecode for DisputeStream { } } +impl SseDecode for EscrowModeStream { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = , + >>::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::rust_auto_opaque_decode_owned(inner); + } +} + impl SseDecode for LogEntryStream { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -4352,6 +4597,16 @@ impl SseDecode } } +impl SseDecode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return decode_rust_opaque_moi(inner); + } +} + impl SseDecode for RustOpaqueMoi> { @@ -4659,6 +4914,30 @@ impl SseDecode for crate::api::types::DownloadStatus { } } +impl SseDecode for crate::api::types::EscrowModeInfo { + // 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_mode = ::sse_decode(deserializer); + let mut var_mintUrl = >::sse_decode(deserializer); + let mut var_escrowLocktimeDays = >::sse_decode(deserializer); + let mut var_settlementMarginDays = >::sse_decode(deserializer); + let mut var_isOverridden = ::sse_decode(deserializer); + let mut var_isCashuAvailable = ::sse_decode(deserializer); + let mut var_forceCashuOverride = ::sse_decode(deserializer); + let mut var_mintUrlOverride = >::sse_decode(deserializer); + return crate::api::types::EscrowModeInfo { + mode: var_mode, + mint_url: var_mintUrl, + escrow_locktime_days: var_escrowLocktimeDays, + settlement_margin_days: var_settlementMarginDays, + is_overridden: var_isOverridden, + is_cashu_available: var_isCashuAvailable, + force_cashu_override: var_forceCashuOverride, + mint_url_override: var_mintUrlOverride, + }; + } +} + impl SseDecode for f64 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -5628,233 +5907,258 @@ fn pde_ffi_dispatcher_primary_impl( data_len, ), 4 => wire__crate__api__disputes__DisputeStream_next_impl(port, ptr, rust_vec_len, data_len), - 5 => wire__crate__api__logging__LogEntryStream_next_impl(port, ptr, rust_vec_len, data_len), - 6 => wire__crate__api__messages__MessageStream_next_impl(port, ptr, rust_vec_len, data_len), - 7 => wire__crate__api__orders__OrderBook_clear_impl(port, ptr, rust_vec_len, data_len), - 8 => wire__crate__api__orders__OrderBook_default_impl(port, ptr, rust_vec_len, data_len), - 9 => wire__crate__api__orders__OrderBook_get_order_impl(port, ptr, rust_vec_len, data_len), - 10 => { + 5 => { + wire__crate__api__escrow__EscrowModeStream_next_impl(port, ptr, rust_vec_len, data_len) + } + 6 => wire__crate__api__logging__LogEntryStream_next_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__api__messages__MessageStream_next_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__api__orders__OrderBook_clear_impl(port, ptr, rust_vec_len, data_len), + 9 => wire__crate__api__orders__OrderBook_default_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__orders__OrderBook_get_order_impl(port, ptr, rust_vec_len, data_len), + 11 => { wire__crate__api__orders__OrderBook_get_orders_impl(port, ptr, rust_vec_len, data_len) } - 11 => wire__crate__api__orders__OrderBook_new_impl(port, ptr, rust_vec_len, data_len), - 12 => { + 12 => wire__crate__api__orders__OrderBook_new_impl(port, ptr, rust_vec_len, data_len), + 13 => { wire__crate__api__orders__OrderBook_remove_order_impl(port, ptr, rust_vec_len, data_len) } - 13 => { + 14 => { wire__crate__api__orders__OrderBook_set_orders_impl(port, ptr, rust_vec_len, data_len) } - 14 => wire__crate__api__orders__OrderBook_update_order_status_impl( + 15 => wire__crate__api__orders__OrderBook_update_order_status_impl( port, ptr, rust_vec_len, data_len, ), - 15 => { + 16 => { wire__crate__api__orders__OrderBook_upsert_order_impl(port, ptr, rust_vec_len, data_len) } - 16 => wire__crate__api__orders__OrdersStream_next_impl(port, ptr, rust_vec_len, data_len), - 17 => { + 17 => wire__crate__api__orders__OrdersStream_next_impl(port, ptr, rust_vec_len, data_len), + 18 => { wire__crate__api__reputation__RatingStream_next_impl(port, ptr, rust_vec_len, data_len) } - 18 => { + 19 => { wire__crate__api__nostr__RelayStatusStream_next_impl(port, ptr, rust_vec_len, data_len) } - 19 => { + 20 => { wire__crate__api__settings__SettingsStream_next_impl(port, ptr, rust_vec_len, data_len) } - 20 => wire__crate__api__messages__UnreadCountStream_next_impl( + 21 => wire__crate__api__messages__UnreadCountStream_next_impl( port, ptr, rust_vec_len, data_len, ), - 21 => { + 22 => { wire__crate__api__nwc__WalletStatusStream_next_impl(port, ptr, rust_vec_len, data_len) } - 22 => wire__crate__api__nostr__add_relay_impl(port, ptr, rust_vec_len, data_len), - 23 => wire__crate__api__orders__cancel_order_impl(port, ptr, rust_vec_len, data_len), - 24 => wire__crate__api__logging__clear_logs_impl(port, ptr, rust_vec_len, data_len), - 25 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), - 26 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), - 27 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), - 28 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), - 29 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), - 30 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), - 31 => { + 23 => wire__crate__api__nostr__add_relay_impl(port, ptr, rust_vec_len, data_len), + 24 => wire__crate__api__orders__cancel_order_impl(port, ptr, rust_vec_len, data_len), + 25 => wire__crate__api__logging__clear_logs_impl(port, ptr, rust_vec_len, data_len), + 26 => wire__crate__api__nwc__connect_wallet_impl(port, ptr, rust_vec_len, data_len), + 27 => wire__crate__api__identity__create_identity_impl(port, ptr, rust_vec_len, data_len), + 28 => wire__crate__api__orders__create_order_impl(port, ptr, rust_vec_len, data_len), + 29 => wire__crate__api__identity__delete_identity_impl(port, ptr, rust_vec_len, data_len), + 30 => wire__crate__api__identity__derive_trade_key_impl(port, ptr, rust_vec_len, data_len), + 31 => wire__crate__api__nwc__disconnect_wallet_impl(port, ptr, rust_vec_len, data_len), + 32 => { wire__crate__api__messages__download_attachment_impl(port, ptr, rust_vec_len, data_len) } - 32 => wire__crate__api__identity__export_encrypted_backup_impl( + 33 => wire__crate__api__identity__export_encrypted_backup_impl( port, ptr, rust_vec_len, data_len, ), - 33 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( + 34 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( port, ptr, rust_vec_len, data_len, ), - 34 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), - 35 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), - 36 => wire__crate__api__messages__get_attachment_status_impl( + 35 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), + 36 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), + 37 => wire__crate__api__messages__get_attachment_status_impl( port, ptr, rust_vec_len, data_len, ), - 37 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), - 38 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), - 39 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), - 40 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), - 45 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), - 46 => { + 38 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), + 40 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), + 41 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), + 45 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), + 46 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), + 48 => { wire__crate__api__reputation__get_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 47 => wire__crate__api__reputation__get_rating_for_trade_impl( + 49 => wire__crate__api__reputation__get_rating_for_trade_impl( port, ptr, rust_vec_len, data_len, ), - 48 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), - 50 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), - 51 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), - 52 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), - 54 => wire__crate__api__disputes__handle_admin_canceled_impl( + 50 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), + 51 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), + 52 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), + 53 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), + 54 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), + 55 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), + 56 => wire__crate__api__disputes__handle_admin_canceled_impl( port, ptr, rust_vec_len, data_len, ), - 55 => { + 57 => { wire__crate__api__disputes__handle_admin_settled_impl(port, ptr, rust_vec_len, data_len) } - 56 => wire__crate__api__disputes__handle_admin_took_dispute_impl( + 58 => wire__crate__api__disputes__handle_admin_took_dispute_impl( port, ptr, rust_vec_len, data_len, ), - 57 => wire__crate__api__reputation__handle_rating_received_impl( + 59 => wire__crate__api__reputation__handle_rating_received_impl( port, ptr, rust_vec_len, data_len, ), - 58 => { + 60 => { wire__crate__api__identity__import_from_mnemonic_impl(port, ptr, rust_vec_len, data_len) } - 59 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), - 60 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), - 61 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), - 62 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), - 63 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), - 64 => wire__crate__api__identity__load_identity_from_mnemonic_impl( + 61 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), + 62 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), + 63 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), + 64 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), + 65 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), + 66 => wire__crate__api__identity__load_identity_from_mnemonic_impl( port, ptr, rust_vec_len, data_len, ), - 65 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), - 66 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), - 67 => wire__crate__api__messages__on_attachment_progress_impl( + 67 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), + 68 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), + 69 => wire__crate__api__messages__on_attachment_progress_impl( port, ptr, rust_vec_len, data_len, ), - 68 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), - 69 => wire__crate__api__nostr__on_connection_state_changed_impl( + 70 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), + 71 => wire__crate__api__nostr__on_connection_state_changed_impl( port, ptr, rust_vec_len, data_len, ), - 70 => { + 72 => { wire__crate__api__disputes__on_dispute_updated_impl(port, ptr, rust_vec_len, data_len) } - 71 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), - 72 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), - 73 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), - 74 => { + 73 => { + wire__crate__api__escrow__on_escrow_mode_changed_impl(port, ptr, rust_vec_len, data_len) + } + 74 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), + 75 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), + 76 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), + 77 => { wire__crate__api__reputation__on_rating_received_impl(port, ptr, rust_vec_len, data_len) } - 75 => { + 78 => { wire__crate__api__nostr__on_relay_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 76 => { + 79 => { wire__crate__api__settings__on_settings_changed_impl(port, ptr, rust_vec_len, data_len) } - 77 => wire__crate__api__messages__on_unread_count_changed_impl( + 80 => wire__crate__api__messages__on_unread_count_changed_impl( port, ptr, rust_vec_len, data_len, ), - 78 => { + 81 => { wire__crate__api__nwc__on_wallet_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 79 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), - 80 => { + 82 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), + 83 => { wire__crate__api__orders__order_filters_default_impl(port, ptr, rust_vec_len, data_len) } - 81 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), - 82 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), - 83 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( + 84 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), + 85 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), + 86 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 87 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 88 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), + 89 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), + 90 => wire__crate__api__orders__restart_orders_subscription_impl( port, ptr, rust_vec_len, data_len, ), - 84 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), - 85 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), - 86 => wire__crate__api__orders__restart_orders_subscription_impl( + 91 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), + 92 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), + 93 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), + 94 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), + 95 => wire__crate__api__settings__set_active_mostro_node_impl( port, ptr, rust_vec_len, data_len, ), - 87 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), - 88 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), - 89 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), - 90 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), - 91 => wire__crate__api__settings__set_active_mostro_node_impl( + 96 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( port, ptr, rust_vec_len, data_len, ), - 92 => wire__crate__api__settings__set_default_fiat_code_impl( + 97 => wire__crate__api__settings__set_default_fiat_code_impl( port, ptr, rust_vec_len, data_len, ), - 93 => wire__crate__api__settings__set_default_lightning_address_impl( + 98 => wire__crate__api__settings__set_default_lightning_address_impl( port, ptr, rust_vec_len, data_len, ), - 94 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), - 95 => { + 99 => wire__crate__api__escrow__set_escrow_mode_override_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 100 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), + 101 => { wire__crate__api__settings__set_logging_enabled_impl(port, ptr, rust_vec_len, data_len) } - 96 => { + 102 => { wire__crate__api__reputation__set_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 97 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), - 98 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), - 99 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), - 100 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), - 101 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 103 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), + 104 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), + 105 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), + 106 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), + 107 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -5943,6 +6247,21 @@ impl flutter_rust_bridge::IntoIntoDart> for DisputeStr } } +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for FrbWrapper { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self.0) + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for FrbWrapper {} + +impl flutter_rust_bridge::IntoIntoDart> for EscrowModeStream { + fn into_into_dart(self) -> FrbWrapper { + self.into() + } +} + // Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for FrbWrapper { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { @@ -6344,6 +6663,33 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::types::EscrowModeInfo { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.mode.into_into_dart().into_dart(), + self.mint_url.into_into_dart().into_dart(), + self.escrow_locktime_days.into_into_dart().into_dart(), + self.settlement_margin_days.into_into_dart().into_dart(), + self.is_overridden.into_into_dart().into_dart(), + self.is_cashu_available.into_into_dart().into_dart(), + self.force_cashu_override.into_into_dart().into_dart(), + self.mint_url_override.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::types::EscrowModeInfo +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::types::EscrowModeInfo +{ + fn into_into_dart(self) -> crate::api::types::EscrowModeInfo { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::messages::FileDownloadResult { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -7052,6 +7398,13 @@ impl SseEncode for DisputeStream { } } +impl SseEncode for EscrowModeStream { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >>::sse_encode(flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self), serializer); + } +} + impl SseEncode for LogEntryStream { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -7163,6 +7516,17 @@ impl SseEncode } } +impl SseEncode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + let (ptr, size) = self.sse_encode_raw(); + ::sse_encode(ptr, serializer); + ::sse_encode(size, serializer); + } +} + impl SseEncode for RustOpaqueMoi> { @@ -7451,6 +7815,20 @@ impl SseEncode for crate::api::types::DownloadStatus { } } +impl SseEncode for crate::api::types::EscrowModeInfo { + // 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.mode, serializer); + >::sse_encode(self.mint_url, serializer); + >::sse_encode(self.escrow_locktime_days, serializer); + >::sse_encode(self.settlement_margin_days, serializer); + ::sse_encode(self.is_overridden, serializer); + ::sse_encode(self.is_cashu_available, serializer); + ::sse_encode(self.force_cashu_override, serializer); + >::sse_encode(self.mint_url_override, serializer); + } +} + impl SseEncode for f64 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -8294,6 +8672,7 @@ mod io { use super::*; use crate::api::bond::*; use crate::api::disputes::*; + use crate::api::escrow::*; use crate::api::logging::*; use crate::api::messages::*; use crate::api::nostr::*; @@ -8367,6 +8746,20 @@ mod io { MoiArc::>::decrement_strong_count(ptr as _); } + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_mostro_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerEscrowModeStream( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_mostro_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerEscrowModeStream( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + #[unsafe(no_mangle)] pub extern "C" fn frbgen_mostro_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerLogEntryStream( ptr: *const std::ffi::c_void, @@ -8507,6 +8900,7 @@ mod web { use super::*; use crate::api::bond::*; use crate::api::disputes::*; + use crate::api::escrow::*; use crate::api::logging::*; use crate::api::messages::*; use crate::api::nostr::*; @@ -8582,6 +8976,20 @@ mod web { MoiArc::>::decrement_strong_count(ptr as _); } + #[wasm_bindgen] + pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerEscrowModeStream( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerEscrowModeStream( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + #[wasm_bindgen] pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerLogEntryStream( ptr: *const std::ffi::c_void, diff --git a/rust/src/mostro/escrow_mode.rs b/rust/src/mostro/escrow_mode.rs index e31112db..790bd826 100644 --- a/rust/src/mostro/escrow_mode.rs +++ b/rust/src/mostro/escrow_mode.rs @@ -17,7 +17,8 @@ //! that never answers therefore behaves exactly like today's Lightning-only //! client. -use std::sync::RwLock; +use std::sync::{OnceLock, RwLock}; +use tokio::sync::broadcast; /// Settlement backend advertised by the active Mostro node. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -98,6 +99,18 @@ pub struct ResolvedEscrowMode { pub is_overridden: bool, } +impl ResolvedEscrowMode { + /// May a Cashu path run against *this* resolution? + /// + /// The gate, expressed against a value the caller already holds — so a + /// snapshot that reports `mode` and this flag together cannot have read + /// them from two different states. [`is_cashu_mode`] is this applied to the + /// current globals. + pub fn is_cashu_usable(&self) -> bool { + self.mode.is_cashu() && self.config.is_usable() + } +} + /// Developer override, for testing against a daemon branch that implements /// Cashu but does not publish the info tags yet (§4.3). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -148,13 +161,19 @@ pub struct EscrowModeInputs { /// that speaks Cashu without advertising it, while overriding the mint is for /// pointing a tester at a local nutshell instead of the node's mint. pub fn resolve(inputs: &EscrowModeInputs) -> ResolvedEscrowMode { - let overridden = matches!(inputs.override_mode, EscrowModeOverride::ForceCashu); - let mode = if overridden { + let forcing = matches!(inputs.override_mode, EscrowModeOverride::ForceCashu); + let mode = if forcing { EscrowMode::Cashu } else { inputs.from_tags }; + // `is_overridden` exists to warn a tester that the mode is not the node's + // own. Forcing Cashu on a node that already advertises Cashu changes + // nothing, so flagging it would cry wolf on the one configuration where the + // override is irrelevant. + let overridden = forcing && inputs.from_tags != EscrowMode::Cashu; + let mut config = inputs.tag_config.clone(); if let Some(url) = inputs .mint_url_override @@ -216,41 +235,161 @@ pub fn parse_tags(tags: &[Vec]) -> (EscrowMode, CashuNodeConfig) { (mode, config) } +/// The two developer overrides, as persisted and as applied. +/// +/// Kept together because they are read together on every resolution and are +/// written by the same dev-only settings surface. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EscrowOverrides { + pub mode: EscrowModeOverride, + /// Mint URL to use instead of the node's. `None` (or blank) leaves the + /// node's own value in place — see [`resolve`]. + pub mint_url: Option, +} + // ── Process-global state ──────────────────────────────────────────────────── -static RESOLVED: RwLock> = RwLock::new(None); +/// What the active node's 38385 tags said, or `None` before the first +/// successful fetch. Node-scoped: cleared on every node switch. +static TAGS: RwLock> = RwLock::new(None); -/// Replace the resolved mode for the active node. +/// The developer overrides. **Not** node-scoped: forcing Cashu is a statement +/// about this build, not about a particular node, so it survives a node switch +/// exactly as the user left it. The surface that writes it is `kDebugMode`-only. +static OVERRIDES: RwLock = RwLock::new(EscrowOverrides { + mode: EscrowModeOverride::Auto, + mint_url: None, +}); + +/// Broadcast that *something* changed, so the UI re-reads without polling. +/// +/// The event carries no payload on purpose. Subscribers rebuild the snapshot +/// from the globals anyway — that is what keeps a snapshot's mode and override +/// fields from ever disagreeing — so sending the resolution too would just +/// resolve it twice per change. /// -/// A poisoned lock is recovered from rather than propagated: escrow mode is a -/// cache of what the node advertised, and refusing to update it would leave the -/// app pinned to a stale node's mode after any unrelated panic. -pub fn set_resolved(resolved: ResolvedEscrowMode) { - log::info!( - "[escrow-mode] active node resolved to {} (overridden={}, mint={:?})", - resolved.mode.as_marker(), - resolved.is_overridden, - resolved.config.mint_url, - ); - let mut guard = RESOLVED.write().unwrap_or_else(|e| e.into_inner()); - *guard = Some(resolved); +/// Every mutator below emits on it, and only when it actually changed +/// something; nothing else may write the globals. +static CHANGES: OnceLock> = OnceLock::new(); + +fn changes() -> &'static broadcast::Sender<()> { + CHANGES.get_or_init(|| broadcast::channel(32).0) +} + +/// Subscribe to escrow-mode changes. +pub fn subscribe() -> broadcast::Receiver<()> { + changes().subscribe() +} + +/// Wake subscribers. A send error means "no listeners", which is the normal +/// state before the UI attaches. +fn notify() { + let _ = changes().send(()); +} + +/// Record what the active node advertised. +/// +/// A poisoned lock is recovered from rather than propagated: this is a cache of +/// what the node said, and refusing to update it would leave the app pinned to +/// a stale node's mode after any unrelated panic. +pub fn set_from_tags(mode: EscrowMode, config: CashuNodeConfig) { + let mint_url = config.mint_url.clone(); + let changed = { + let mut guard = TAGS.write().unwrap_or_else(|e| e.into_inner()); + let next = Some((mode, config)); + let changed = *guard != next; + *guard = next; + changed + }; + // A re-fetch that confirms what we already knew is the common case on a + // reconnect: it wakes nobody, and it does not deserve a log line either. + if changed { + log::info!( + "[escrow-mode] active node advertises {} (mint={mint_url:?})", + mode.as_marker(), + ); + notify(); + } } /// Current resolution, or the `Unknown` default before the first fetch. +/// +/// Resolution happens on read rather than on write, so flipping an override +/// takes effect immediately instead of waiting for the next relay fetch — and +/// there is no second copy of the answer that could go stale. pub fn get_resolved() -> ResolvedEscrowMode { - RESOLVED + let (from_tags, tag_config) = TAGS .read() .unwrap_or_else(|e| e.into_inner()) .clone() - .unwrap_or_default() + .unwrap_or_default(); + let overrides = get_overrides(); + + resolve(&EscrowModeInputs { + from_tags, + tag_config, + override_mode: overrides.mode, + mint_url_override: overrides.mint_url, + }) +} + +/// The developer overrides currently in force. +pub fn get_overrides() -> EscrowOverrides { + OVERRIDES.read().unwrap_or_else(|e| e.into_inner()).clone() +} + +/// Replace the developer overrides wholesale. Persistence is the caller's job +/// (`crate::api::escrow`); this is the in-memory half. +/// +/// Prefer [`update_overrides`] when changing one field: this one overwrites +/// both, so a caller that read-modify-writes races with any concurrent change +/// to the other field. +pub fn set_overrides(overrides: EscrowOverrides) { + update_overrides(|current| *current = overrides); } -/// Forget the cached mode. 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. +/// Mutate the overrides under a single write lock. +/// +/// The lock spans the read *and* the write, which is the point: the two +/// overrides are set from the same surface, and a read-modify-write of one +/// field would otherwise interleave with the other and silently discard it. +pub fn update_overrides(f: impl FnOnce(&mut EscrowOverrides)) { + let changed = { + let mut guard = OVERRIDES.write().unwrap_or_else(|e| e.into_inner()); + let before = guard.clone(); + f(&mut guard); + if *guard != before { + log::info!( + "[escrow-mode] override set to {} (mint override={:?})", + guard.mode.as_stored(), + guard.mint_url, + ); + true + } else { + false + } + }; + if changed { + notify(); + } +} + +/// 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 +/// alone — see [`OVERRIDES`]. pub fn clear() { - let mut guard = RESOLVED.write().unwrap_or_else(|e| e.into_inner()); - *guard = None; + let changed = { + let mut guard = TAGS.write().unwrap_or_else(|e| e.into_inner()); + let changed = guard.is_some(); + *guard = None; + changed + }; + // Clearing an already-clear cache is a no-op, and a node whose capability + // fetch keeps failing would otherwise emit on every retry. + if changed { + notify(); + } } /// The one question the rest of the app asks: may a Cashu path run against the @@ -266,8 +405,7 @@ pub fn clear() { /// The About screen must *not* use this: it reads [`get_resolved`], so it can /// say "cashu, but no mint advertised" instead of silently reading Lightning. pub fn is_cashu_mode() -> bool { - let resolved = get_resolved(); - resolved.mode.is_cashu() && resolved.config.is_usable() + get_resolved().is_cashu_usable() } #[cfg(test)] @@ -278,13 +416,18 @@ mod tests { vec![name.to_string(), value.to_string()] } - /// Tests that touch `RESOLVED` run in the same process and would otherwise + /// Tests that touch the globals run in the same process and would otherwise /// race each other. A poisoned lock is recovered from so one failing test /// does not cascade into the others. static GLOBAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Take the globals and reset them, so each test starts from the state a + /// freshly-launched app has: nothing fetched, no override. fn own_the_global() -> std::sync::MutexGuard<'static, ()> { - GLOBAL.lock().unwrap_or_else(|e| e.into_inner()) + let guard = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + clear(); + set_overrides(EscrowOverrides::default()); + guard } #[test] @@ -507,23 +650,23 @@ mod tests { ); } + fn cashu_tags() -> (EscrowMode, CashuNodeConfig) { + parse_tags(&[ + tag("escrow_mode", "cashu"), + tag("cashu_mint_url", "https://mint.example.com"), + ]) + } + #[test] fn the_global_defaults_to_unknown_and_clears_on_node_switch() { // Arrange — this test owns the global; keep it self-contained. let _guard = own_the_global(); - clear(); assert_eq!(get_resolved().mode, EscrowMode::Unknown); assert!(!is_cashu_mode()); // Act — a Cashu node is detected, then the user switches nodes. - set_resolved(ResolvedEscrowMode { - mode: EscrowMode::Cashu, - config: CashuNodeConfig { - mint_url: Some("https://mint.example.com".to_string()), - ..Default::default() - }, - is_overridden: false, - }); + let (mode, config) = cashu_tags(); + set_from_tags(mode, config); assert!(is_cashu_mode()); clear(); @@ -536,13 +679,8 @@ mod tests { fn a_cashu_node_without_a_usable_mint_keeps_the_gate_shut() { // Arrange — a node that says cashu but published no mint URL. let _guard = own_the_global(); - clear(); let (mode, config) = parse_tags(&[tag("escrow_mode", "cashu"), tag("cashu_mint_url", " ")]); - set_resolved(resolve(&EscrowModeInputs { - from_tags: mode, - tag_config: config, - ..Default::default() - })); + set_from_tags(mode, config); // Assert — the mode is reported honestly for the About screen, but // there is no mint to connect to, so no Cashu path may run. @@ -551,14 +689,145 @@ mod tests { assert!(!is_cashu_mode()); // Act — the tester points it at a local mint (§4.3). - set_resolved(resolve(&EscrowModeInputs { - from_tags: EscrowMode::Cashu, - mint_url_override: Some("http://localhost:3338".to_string()), + set_overrides(EscrowOverrides { + mint_url: Some("http://localhost:3338".to_string()), ..Default::default() - })); + }); // Assert — now there is something to connect to. assert!(is_cashu_mode()); + } + + #[test] + fn flipping_the_override_re_resolves_without_another_fetch() { + // Arrange — a plain Lightning node, already fetched. + let _guard = own_the_global(); + set_from_tags(EscrowMode::Lightning, CashuNodeConfig::default()); + assert!(!is_cashu_mode()); + + // Act — the developer forces Cashu at a local mint. No fetch happens. + set_overrides(EscrowOverrides { + mode: EscrowModeOverride::ForceCashu, + mint_url: Some("http://localhost:3338".to_string()), + }); + + // Assert — resolution is computed on read, so the change is immediate. + let resolved = get_resolved(); + assert_eq!(resolved.mode, EscrowMode::Cashu); + assert!(resolved.is_overridden); + assert!(is_cashu_mode()); + + // Act — and turning it off restores what the node actually said. + set_overrides(EscrowOverrides::default()); + + // Assert + assert_eq!(get_resolved().mode, EscrowMode::Lightning); + assert!(!is_cashu_mode()); + } + + #[test] + fn a_node_switch_clears_the_tags_but_keeps_the_override() { + // Arrange — override on, against some node. + let _guard = own_the_global(); + let (mode, config) = cashu_tags(); + set_from_tags(mode, config); + set_overrides(EscrowOverrides { + mode: EscrowModeOverride::ForceCashu, + mint_url: Some("http://localhost:3338".to_string()), + }); + + // Act — the user switches nodes. + clear(); + + // Assert — the override is a statement about this build, not about the + // node, so it survives; the node's own tags do not. + assert_eq!( + get_overrides().mode, + EscrowModeOverride::ForceCashu, + "the override must not be reset by a node switch" + ); + assert_eq!(get_resolved().mode, EscrowMode::Cashu); + assert!(get_resolved().is_overridden); + } + + #[tokio::test] + async fn every_mutator_notifies_subscribers() { + // Arrange — a Lightning node, so each step below is a real change. + let _guard = own_the_global(); + set_from_tags(EscrowMode::Lightning, CashuNodeConfig::default()); + let mut rx = subscribe(); + + // Act / Assert — tags in. The event is a bare wake-up; the state is + // read from the globals, which is what subscribers do. + let (mode, config) = cashu_tags(); + set_from_tags(mode, config); + rx.recv().await.unwrap(); + assert_eq!(get_resolved().mode, EscrowMode::Cashu); + + // Act / Assert — override changed. + set_overrides(EscrowOverrides { + mode: EscrowModeOverride::ForceCashu, + mint_url: Some("http://localhost:3338".to_string()), + }); + rx.recv().await.unwrap(); + assert_eq!( + get_resolved().config.mint_url.as_deref(), + Some("http://localhost:3338") + ); + + // Act / Assert — node switch. The override still forces Cashu, but the + // tags changed, so subscribers must be woken. + clear(); + rx.recv().await.unwrap(); + assert!(get_resolved().is_overridden); + } + + #[tokio::test] + async fn a_change_that_changes_nothing_does_not_wake_subscribers() { + // Arrange — a node that has already been fetched. + let _guard = own_the_global(); + let (mode, config) = cashu_tags(); + set_from_tags(mode, config.clone()); + let mut rx = subscribe(); + + // Act — a reconnect re-fetches the same event, and a settings screen + // re-writes the override it already had. Neither changed anything. + set_from_tags(mode, config); + set_overrides(EscrowOverrides::default()); clear(); + clear(); + + // Assert — exactly one wake-up, from the `clear()` that emptied the + // cache. On a flaky relay the alternative is a stream of identical + // events that every listener has to re-render. + rx.recv().await.unwrap(); + assert!( + rx.try_recv().is_err(), + "only a real change may wake subscribers" + ); + } + + #[test] + fn forcing_cashu_on_a_cashu_node_is_not_flagged_as_an_override() { + // Arrange — the node genuinely advertises Cashu and the developer has + // the override on anyway. + let inputs = EscrowModeInputs { + from_tags: EscrowMode::Cashu, + tag_config: CashuNodeConfig { + mint_url: Some("https://mint.example.com".to_string()), + ..Default::default() + }, + override_mode: EscrowModeOverride::ForceCashu, + ..Default::default() + }; + + // Act + let resolved = resolve(&inputs); + + // Assert — the flag warns "this is not what the node said". Here it is + // exactly what the node said, so raising it would cry wolf on the one + // configuration where the override changes nothing. + assert_eq!(resolved.mode, EscrowMode::Cashu); + assert!(!resolved.is_overridden); } } diff --git a/test/features/about/models/mostro_instance_test.dart b/test/features/about/models/mostro_instance_test.dart index caa0128d..f5a97ad4 100644 --- a/test/features/about/models/mostro_instance_test.dart +++ b/test/features/about/models/mostro_instance_test.dart @@ -367,6 +367,114 @@ void main() { expect(instance.bondAmountPercent, isNull); }); + test('a legacy node without the tag is unknown, not lightning', () { + // Arrange — today's daemons publish no escrow tags at all. + final instance = MostroInstance.fromTags(_tagsWith({'pow': '8'})); + + // Assert — the distinction is what lets About stay honest instead of + // claiming the node confirmed Lightning. + expect(instance.escrowMode, EscrowMode.unknown); + expect(instance.cashuMintUrl, isNull); + }); + + test('an explicit lightning tag is lightning', () { + expect( + MostroInstance.fromTags( + _tagsWith({'escrow_mode': 'lightning'}), + ).escrowMode, + EscrowMode.lightning, + ); + }); + + test('a backend this client does not implement reads as lightning', () { + // Arrange / Act — a future backend we cannot trade Cashu with either. + final instance = MostroInstance.fromTags( + _tagsWith({'escrow_mode': 'fedimint'}), + ); + + // Assert — the reading that keeps every Cashu path shut. + expect(instance.escrowMode, EscrowMode.lightning); + }); + + test('a cashu node exposes its parameters', () { + final instance = MostroInstance.fromTags(_tagsWith({ + 'escrow_mode': ' Cashu ', + 'cashu_mint_url': 'https://mint.example.com', + 'cashu_escrow_locktime_days': '15', + 'cashu_settlement_margin_days': '3', + })); + + expect(instance.escrowMode, EscrowMode.cashu); + expect(instance.cashuMintUrl, 'https://mint.example.com'); + expect(instance.cashuEscrowLocktimeDays, 15); + expect(instance.cashuSettlementMarginDays, 3); + }); + + test('cashu parameters are gated on the mode', () { + // Arrange — a Lightning node carrying a stale mint tag. + final instance = MostroInstance.fromTags(_tagsWith({ + 'escrow_mode': 'lightning', + 'cashu_mint_url': 'https://mint.example.com', + 'cashu_escrow_locktime_days': '15', + })); + + // Assert — consumers key off nullability; a stale tag is not live data. + expect(instance.cashuMintUrl, isNull); + expect(instance.cashuEscrowLocktimeDays, isNull); + }); + + test('a present but blank escrow_mode is lightning, not unknown', () { + // A node that answered is not a node that stayed silent. Rust's + // `parse_tags` reads a blank value as Lightning, and the two parsers read + // the same event — a divergence here would have the About screen and the + // Cashu gate disagreeing about the same daemon. + for (final blank in ['', ' ']) { + expect( + MostroInstance.fromTags(_tagsWith({'escrow_mode': blank})).escrowMode, + EscrowMode.lightning, + reason: 'blank value ${blank.isEmpty ? "(empty)" : "(spaces)"}', + ); + } + + // Only an absent tag is unknown. + expect( + MostroInstance.fromTags(_tagsWith({})).escrowMode, + EscrowMode.unknown, + ); + // A value-less tag has nothing to read, so it counts as absent — which + // is also what Rust's `value_of` does. + expect( + MostroInstance.fromTags(const [ + ['d', 'npub_test'], + ['escrow_mode'], + ]).escrowMode, + EscrowMode.unknown, + ); + }); + + test('a cashu node with a blank mint reports none', () { + final instance = MostroInstance.fromTags(_tagsWith({ + 'escrow_mode': 'cashu', + 'cashu_mint_url': ' ', + })); + + expect(instance.escrowMode, EscrowMode.cashu); + expect(instance.cashuMintUrl, isNull); + }); + + test('malformed day counts are dropped without costing the mint', () { + final instance = MostroInstance.fromTags(_tagsWith({ + 'escrow_mode': 'cashu', + 'cashu_mint_url': 'https://mint.example.com', + 'cashu_escrow_locktime_days': 'fifteen', + 'cashu_settlement_margin_days': '-1', + })); + + expect(instance.cashuEscrowLocktimeDays, isNull); + expect(instance.cashuSettlementMarginDays, isNull); + expect(instance.cashuMintUrl, 'https://mint.example.com'); + }); + test('fee percentage formatting is unchanged', () { expect( MostroInstance.fromTags(_tagsWith({'fee': '0.006'})).feePercent, diff --git a/test/features/about/screens/about_screen_test.dart b/test/features/about/screens/about_screen_test.dart index 8f76e28b..650cb907 100644 --- a/test/features/about/screens/about_screen_test.dart +++ b/test/features/about/screens/about_screen_test.dart @@ -201,4 +201,91 @@ void main() { expect(find.text('Bond amount'), findsNothing); }); }); + + group('AboutScreen — settlement backend section', () { + /// Every string that would betray Cashu to a user whose node does not run + /// it. None may appear unless the node itself advertised Cashu. + const cashuStrings = [ + 'Cashu escrow', + 'Mint', + 'Escrow locktime', + 'Settlement margin', + 'https://mint.example.com', + ]; + + const lndTags = { + 'lnd_version': '0.18.0', + 'lnd_node_alias': 'test-node', + }; + + const cashuTags = { + 'escrow_mode': 'cashu', + 'cashu_mint_url': 'https://mint.example.com', + 'cashu_escrow_locktime_days': '15', + 'cashu_settlement_margin_days': '3', + }; + + testWidgets('a legacy node shows Lightning and no trace of Cashu', + (tester) async { + // Arrange — no escrow tags at all: every daemon in the wild today. + await _pumpWithNode(tester, MostroInstance.fromTags(_tags(lndTags))); + + // Assert — unchanged from before this feature existed. + expect(find.text('Lightning Network'), findsOneWidget); + expect(find.text('0.18.0'), findsOneWidget); + for (final s in cashuStrings) { + expect(find.text(s), findsNothing, reason: '$s must not be shown'); + } + }); + + testWidgets('a Lightning node shows no trace of Cashu either', + (tester) async { + // Arrange — explicitly Lightning, and carrying stale cashu tags the + // parser must gate away. + await _pumpWithNode( + tester, + MostroInstance.fromTags( + _tags({...lndTags, ...cashuTags, 'escrow_mode': 'lightning'}), + ), + ); + + // Assert + expect(find.text('Lightning Network'), findsOneWidget); + for (final s in cashuStrings) { + expect(find.text(s), findsNothing, reason: '$s must not be shown'); + } + }); + + testWidgets('a Cashu node shows its parameters and no Lightning section', + (tester) async { + // Arrange — About reports what this node runs, so the two backends are + // mutually exclusive on screen. + await _pumpWithNode( + tester, + MostroInstance.fromTags(_tags({...lndTags, ...cashuTags})), + ); + + // Assert + expect(find.text('Cashu escrow'), findsOneWidget); + expect(find.text('https://mint.example.com'), findsOneWidget); + expect(find.text('15 days'), findsOneWidget); + expect(find.text('3 days'), findsOneWidget); + expect(find.text('Lightning Network'), findsNothing); + expect(find.text('0.18.0'), findsNothing); + }); + + testWidgets('a Cashu node with no mint says so rather than showing nothing', + (tester) async { + // Arrange — a misconfigured node: it claims Cashu but published no mint, + // so no trade can run against it. + await _pumpWithNode( + tester, + MostroInstance.fromTags(_tags({'escrow_mode': 'cashu'})), + ); + + // Assert + expect(find.text('Cashu escrow'), findsOneWidget); + expect(find.text('Not advertised'), findsOneWidget); + }); + }); } diff --git a/test/features/settings/widgets/escrow_mode_dev_card_test.dart b/test/features/settings/widgets/escrow_mode_dev_card_test.dart new file mode 100644 index 00000000..7ec1b42d --- /dev/null +++ b/test/features/settings/widgets/escrow_mode_dev_card_test.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/settings/providers/escrow_mode_provider.dart'; +import 'package:mostro/features/settings/widgets/escrow_mode_dev_card.dart'; +import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/src/rust/api/types.dart'; + +import '../../../support/provider_harness.dart'; + +EscrowModeInfo _info({String? mintOverride}) => EscrowModeInfo( + mode: 'lightning', + mintUrl: null, + escrowLocktimeDays: null, + settlementMarginDays: null, + isOverridden: false, + isCashuAvailable: false, + forceCashuOverride: false, + mintUrlOverride: mintOverride, + ); + +Future _pump(WidgetTester tester, Stream stream) async { + final container = createContainer(overrides: [ + escrowModeProvider.overrideWith((ref) => stream), + ]); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildDarkTheme(), + locale: const Locale('en'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: EscrowModeDevCard()), + ), + ), + ); +} + +void main() { + group('EscrowModeDevCard', () { + testWidgets('seeds the mint field from the stored override', (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + await _pump(tester, controller.stream); + controller.add(_info(mintOverride: 'http://localhost:3338')); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField)); + expect(field.controller?.text, 'http://localhost:3338'); + }); + + testWidgets('a newer override arriving mid-frame is not overwritten', + (tester) async { + // The race the seeding path has to survive: the widget schedules its seed + // callback during build, and a newer override lands before that callback + // runs. Capturing the value at build time would restore the stale one. + // + // Reproducing it needs two things: a *synchronous* controller, so the + // listener fires inside the frame rather than in a later microtask; and a + // post-frame callback registered *before* the widget's, so the newer + // value is emitted while the widget's callback is still queued behind it. + final controller = StreamController.broadcast(sync: true); + addTearDown(controller.close); + + await _pump(tester, controller.stream); + controller.add(_info(mintOverride: 'http://old.example')); + + // Runs ahead of the seed callback the next build will schedule. + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.add(_info(mintOverride: 'http://new.example')); + }); + + await tester.pump(); + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + expect( + field.controller?.text, + 'http://new.example', + reason: 'the seed callback must read the current value, not a copy ' + 'captured during build', + ); + }); + + testWidgets('typing survives an event that did not change the override', + (tester) async { + // A node switch or a capability re-fetch emits without touching the + // override; wiping the field on those would eat what the user is typing. + final controller = StreamController(); + addTearDown(controller.close); + + await _pump(tester, controller.stream); + controller.add(_info(mintOverride: 'http://localhost:3338')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'http://typing'); + controller.add(_info(mintOverride: 'http://localhost:3338')); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField)); + expect(field.controller?.text, 'http://typing'); + }); + }); +}