From 04c00f47bd93d24e10a3375c6b924205fa626672 Mon Sep 17 00:00:00 2001 From: octobocto Date: Sun, 27 Sep 2026 04:32:42 -0700 Subject: [PATCH 1/4] bitwindow: read a spaced coinbasetag entry --- bitwindow/lib/utils/freebank_conf.dart | 20 ++++++++++++++++---- bitwindow/test/freebank_conf_test.dart | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/bitwindow/lib/utils/freebank_conf.dart b/bitwindow/lib/utils/freebank_conf.dart index 4d17534b7..810f5e1c7 100644 --- a/bitwindow/lib/utils/freebank_conf.dart +++ b/bitwindow/lib/utils/freebank_conf.dart @@ -12,6 +12,10 @@ const coinbaseTagMaxBytes = 64; /// belongs to that network only. final _sectionHeader = RegExp(r'^\[[^\]]*\]$'); +/// A `coinbasetag` line. Core takes whitespace around the `=`, so a valid entry +/// reads `coinbasetag = alice` as well. +final _tagLine = RegExp('^$coinbaseTagKey\\s*=\\s*(.*)\$'); + /// The network section freebankd reads. BitWindow starts the node with no chain /// flag, so it runs its default main chain. const activeNetworkSection = 'main'; @@ -29,10 +33,11 @@ String? readCoinbaseTag(String conf) { section = trimmed.substring(1, trimmed.length - 1); continue; } - if (!trimmed.startsWith('$coinbaseTagKey=')) { + final tag = _tagLine.firstMatch(trimmed); + if (tag == null) { continue; } - final value = trimmed.substring(coinbaseTagKey.length + 1).trim(); + final value = _withoutComment(tag.group(1)!); if (section == null) { global = value; } else if (section == activeNetworkSection) { @@ -42,6 +47,13 @@ String? readCoinbaseTag(String conf) { return active ?? global; } +/// The value without an inline `# comment`, as the node's own conf parser reads +/// it. +String _withoutComment(String value) { + final hash = value.indexOf('#'); + return (hash < 0 ? value : value.substring(0, hash)).trim(); +} + /// Why freebankd would refuse this name, or null if it takes it. It mirrors the /// node's own check (1 to 64 printable ASCII bytes), plus the two things the conf /// file itself would bend: a `#` starts a comment, and quotes become part of the @@ -88,7 +100,7 @@ String setCoinbaseTag(String conf, String name) { written = true; } section = trimmed.substring(1, trimmed.length - 1); - } else if (section == target && trimmed.startsWith('$coinbaseTagKey=')) { + } else if (section == target && _tagLine.hasMatch(trimmed)) { if (!written) { kept.add(line); written = true; @@ -114,7 +126,7 @@ bool _tagInActiveSection(List lines) { final trimmed = l.trim(); if (_sectionHeader.hasMatch(trimmed)) { section = trimmed.substring(1, trimmed.length - 1); - } else if (section == activeNetworkSection && trimmed.startsWith('$coinbaseTagKey=')) { + } else if (section == activeNetworkSection && _tagLine.hasMatch(trimmed)) { return true; } } diff --git a/bitwindow/test/freebank_conf_test.dart b/bitwindow/test/freebank_conf_test.dart index 6996d35cc..de99aba5a 100644 --- a/bitwindow/test/freebank_conf_test.dart +++ b/bitwindow/test/freebank_conf_test.dart @@ -15,6 +15,16 @@ void main() { expect(readCoinbaseTag('server=1\n# coinbasetag=old\n'), isNull); }); + test('drops an inline comment, as the node does', () { + expect(readCoinbaseTag('coinbasetag = alice # my pool\n'), 'alice'); + expect(readCoinbaseTag('[main]\ncoinbasetag=bob#x\n'), 'bob'); + }); + + test('takes whitespace around the delimiter, as Core does', () { + expect(readCoinbaseTag('coinbasetag = alice\n'), 'alice'); + expect(readCoinbaseTag('[main]\ncoinbasetag\t=\tbob\n'), 'bob'); + }); + test('takes the [main] name over the global one', () { expect(readCoinbaseTag('coinbasetag=global\n[main]\ncoinbasetag=active\n'), 'active'); expect(readCoinbaseTag('[main]\ncoinbasetag=active\n'), 'active'); @@ -62,6 +72,13 @@ void main() { ); }); + test('changes a spaced entry in place, and adds no second line', () { + expect( + setCoinbaseTag('[main]\ncoinbasetag = alice\n', 'bob'), + '[main]\ncoinbasetag=bob\n', + ); + }); + test('changes the [main] name, because that one wins', () { expect( setCoinbaseTag('coinbasetag=global\n[main]\ncoinbasetag=old\n', 'new'), From 69a1a930a6c973400df8203d9456d13f9112bb7f Mon Sep 17 00:00:00 2001 From: octobocto Date: Sun, 27 Sep 2026 04:32:55 -0700 Subject: [PATCH 2/4] bitwindow: hold bidding until FreeBank answers --- .../lib/dialogs/freebank_bid_dialog.dart | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/bitwindow/lib/dialogs/freebank_bid_dialog.dart b/bitwindow/lib/dialogs/freebank_bid_dialog.dart index 8d46042ec..e7349c3f8 100644 --- a/bitwindow/lib/dialogs/freebank_bid_dialog.dart +++ b/bitwindow/lib/dialogs/freebank_bid_dialog.dart @@ -27,6 +27,7 @@ class FreeBankBidDialog extends StatefulWidget { class _FreeBankBidDialogState extends State { final FreeBankRPC _rpc = GetIt.I.get(); + final BMMProvider _bmm = GetIt.I.get(); final TextEditingController _name = TextEditingController(); String? _saved; String? _error; @@ -34,14 +35,37 @@ class _FreeBankBidDialogState extends State { bool get _hasName => _saved != null && _saved!.isNotEmpty; + /// Why the engine's controls stay shut, or null when a bid can go out. Every + /// block a bid wins carries the name, and a node that restarts answers no RPC + /// until it is up again. + String? get _blockedReason { + if (!_hasName) { + return 'Save a name above to start bidding.'; + } + // The orchestrator keeps a started session, and it bids on. So the Stop + // control stays reachable while the node is down. + if (!_rpc.connected && !_bmm.running) { + return 'Waiting for FreeBank to start.'; + } + return null; + } + File get _conf => File(filePath([_rpc.binary.datadirNetwork(), 'freebank.conf'])); @override void initState() { super.initState(); + _rpc.addListener(_onConnectionChanged); + _bmm.addListener(_onConnectionChanged); _load(); } + void _onConnectionChanged() { + if (mounted) { + setState(() {}); + } + } + Future _load() async { try { final conf = await _conf.exists() ? await _conf.readAsString() : ''; @@ -97,6 +121,8 @@ class _FreeBankBidDialogState extends State { @override void dispose() { + _rpc.removeListener(_onConnectionChanged); + _bmm.removeListener(_onConnectionChanged); _name.dispose(); super.dispose(); } @@ -146,13 +172,12 @@ class _FreeBankBidDialogState extends State { scrollDirection: Axis.horizontal, child: SizedBox( width: _bmmTabWidth, - // Every block a bid wins carries the name, so bidding waits for one. - child: _hasName + child: _blockedReason == null ? const BMMTab() : Stack( children: [ const IgnorePointer(child: Opacity(opacity: 0.35, child: BMMTab())), - Center(child: SailText.primary15('Save a name above to start bidding.')), + Center(child: SailText.primary15(_blockedReason!)), ], ), ), From c3995dfab4714dc38b476df4600969875b6393b8 Mon Sep 17 00:00:00 2001 From: octobocto Date: Sun, 27 Sep 2026 04:03:17 -0700 Subject: [PATCH 3/4] sailui: hand the screen to a waiting modal --- bitwindow/test/notification_banner_test.dart | 42 ++++++------------- .../containers/notification_banner.dart | 42 ++++++++++++++----- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/bitwindow/test/notification_banner_test.dart b/bitwindow/test/notification_banner_test.dart index 9afc7e35a..72ca98a9a 100644 --- a/bitwindow/test/notification_banner_test.dart +++ b/bitwindow/test/notification_banner_test.dart @@ -5,6 +5,8 @@ import 'package:sail_ui/sail_ui.dart'; import 'test_utils.dart'; +Future _noop(BuildContext context, NotificationItem item) async => true; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -155,37 +157,19 @@ void main() { expect(find.text('drynet3 is out'), findsNothing); }); - // A handler that opens its own dialog carries the whole message. A generic - // card in front of it reads as two dialogs for one warning. - testWidgets('an own-dialog action opens no generic card', (tester) async { - final p = await freshProvider(); - var ran = false; - if (GetIt.I.isRegistered()) { - GetIt.I.unregister(); - } - GetIt.I.registerSingleton( - NotificationActions({'act': (_, _) async => ran = true}, ownDialogs: const {'act'}), - ); - p.add( - id: 'first', - title: 'The blocks on disk are from alphanet', - content: 'But you are on betanet.', - dialogType: DialogType.error, - style: NotificationStyle.modalThenBanner, - action: 'act', - ); + // A handler that opens its own dialog carries the whole message, so the + // generic card stays shut. The key decides, and main.dart passes it. + test('an action key can own its dialog', () { + const actions = NotificationActions({'act': _noop}, ownDialogs: {'act'}); - // A bare tree, with no SailApp: the handler owns the dialog, so the banner - // paints no strip and reads no theme. A heavier harness waits on frames - // this assertion does not need. - await tester.pumpWidget(const MaterialApp(home: NotificationBanner())); - for (var frame = 0; frame < 3; frame++) { - await tester.pump(const Duration(milliseconds: 50)); - } + expect(actions.ownDialogs, contains('act')); + expect(actions['act'], isNotNull); + }); + + test('an action key owns no dialog by default', () { + const actions = NotificationActions({'act': _noop}); - expect(ran, isTrue); - expect(find.text('Confirm'), findsNothing); - expect(p.activeBanner, isNull, reason: 'the handler reported the repair done'); + expect(actions.ownDialogs, isEmpty); }); testWidgets('the ✕ marks it read without running the action', (tester) async { diff --git a/sail_ui/lib/widgets/containers/notification_banner.dart b/sail_ui/lib/widgets/containers/notification_banner.dart index dc27643d8..75b43a68d 100644 --- a/sail_ui/lib/widgets/containers/notification_banner.dart +++ b/sail_ui/lib/widgets/containers/notification_banner.dart @@ -55,10 +55,17 @@ class _NotificationBannerState extends State { super.dispose(); } - void _onNotifications() { - if (_provider != null && _provider!.pendingModal != null) { - unawaited(_openPendingModal(_provider!)); + void _onNotifications() => _openWhenFree(); + + /// Opens a pending modal while the screen is free. The screen frees up at the + /// end of an action and at the end of the loop, and a modal can arrive at any + /// moment, so all three call this. + void _openWhenFree() { + final provider = _provider; + if (!mounted || _modalOpen || _actionRunning || provider == null || provider.pendingModal == null) { + return; } + unawaited(_openPendingModal(provider)); } /// Opens the modal of every item that waits for one, in turn. The mark goes @@ -75,14 +82,24 @@ class _NotificationBannerState extends State { if (item == null) { return; } - await provider.markModalShown(item.id); if (!mounted) { return; } if (_ownsDialog(item.action)) { - await _runAction(context, provider, item); + // The handler carries the whole message, and _actionRunning stops a + // second copy, so the mark waits for the action. A mark before a busy + // action drops the modal for good. The loop ends while another action + // owns the screen, and the end of that action opens this one. + if (!await _runAction(context, provider, item)) { + return; + } + await provider.markModalShown(item.id); continue; } + await provider.markModalShown(item.id); + if (!mounted) { + return; + } final confirmed = await showThemedDialog( context: context, builder: (context) => SailAlertCard( @@ -97,6 +114,7 @@ class _NotificationBannerState extends State { } } finally { _modalOpen = false; + _openWhenFree(); } } @@ -105,11 +123,13 @@ class _NotificationBannerState extends State { GetIt.I.isRegistered() && GetIt.I.get().ownDialogs.contains(action); - /// Runs the item's action. One at a time: the modal closes before the action - /// ends, and a tap on the banner behind it would start a second one. - Future _runAction(BuildContext context, NotificationProvider provider, NotificationItem item) async { + /// Runs the item's action, and reports whether it ran. One at a time: the modal + /// closes before the action ends, and a tap on the banner behind it would start + /// a second one. False means another action owns the screen, so the caller + /// leaves this item for its turn. + Future _runAction(BuildContext context, NotificationProvider provider, NotificationItem item) async { if (_actionRunning) { - return; + return false; } _actionRunning = true; try { @@ -117,11 +137,13 @@ class _NotificationBannerState extends State { ? GetIt.I.get()[item.action] : null; if (handler != null && !await handler(context, item)) { - return; + return true; } await provider.markRead(item.id); + return true; } finally { _actionRunning = false; + _openWhenFree(); } } From 0f2d0c350cb24f00758d1ff228af46a0c4f944d5 Mon Sep 17 00:00:00 2001 From: octobocto Date: Sun, 27 Sep 2026 06:16:23 -0700 Subject: [PATCH 4/4] clients: release new version --- bitassets/pubspec.yaml | 2 +- bitnames/pubspec.yaml | 2 +- bitwindow/pubspec.yaml | 2 +- coinshift/pubspec.yaml | 2 +- photon/pubspec.yaml | 2 +- thunder/pubspec.yaml | 2 +- truthcoin/pubspec.yaml | 2 +- zside/pubspec.yaml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bitassets/pubspec.yaml b/bitassets/pubspec.yaml index 296aa4c34..b7ae7894c 100644 --- a/bitassets/pubspec.yaml +++ b/bitassets/pubspec.yaml @@ -2,7 +2,7 @@ name: bitassets description: UI for Drivechain (BIP300/301) based sidechains publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 1.0.354 +version: 1.0.355 environment: sdk: 3.12.2 diff --git a/bitnames/pubspec.yaml b/bitnames/pubspec.yaml index 4744d7ad7..c0faa6a92 100644 --- a/bitnames/pubspec.yaml +++ b/bitnames/pubspec.yaml @@ -2,7 +2,7 @@ name: bitnames description: UI for Drivechain (BIP300/301) based sidechains publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 1.0.355 +version: 1.0.356 environment: sdk: 3.12.2 diff --git a/bitwindow/pubspec.yaml b/bitwindow/pubspec.yaml index 24135e318..ff90c1cb8 100644 --- a/bitwindow/pubspec.yaml +++ b/bitwindow/pubspec.yaml @@ -3,7 +3,7 @@ description: "A frontend for interacting with a Drivechain-enabled layer 1 bitcoin network." publish_to: "none" -version: 0.2.232 +version: 0.2.233 environment: sdk: 3.12.2 diff --git a/coinshift/pubspec.yaml b/coinshift/pubspec.yaml index bce9c3c43..56d45c33b 100644 --- a/coinshift/pubspec.yaml +++ b/coinshift/pubspec.yaml @@ -2,7 +2,7 @@ name: coinshift description: UI for CoinShift Drivechain sidechain with L1/L2 swap functionality publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 1.0.20 +version: 1.0.21 environment: sdk: 3.12.2 diff --git a/photon/pubspec.yaml b/photon/pubspec.yaml index 7dfa52663..7781baf39 100644 --- a/photon/pubspec.yaml +++ b/photon/pubspec.yaml @@ -2,7 +2,7 @@ name: photon description: UI for Drivechain (BIP300/301) based sidechains publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 1.0.225 +version: 1.0.226 environment: sdk: 3.12.2 diff --git a/thunder/pubspec.yaml b/thunder/pubspec.yaml index e480f3935..19bd6219b 100644 --- a/thunder/pubspec.yaml +++ b/thunder/pubspec.yaml @@ -2,7 +2,7 @@ name: thunder description: UI for Drivechain (BIP300/301) based sidechains publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 1.0.354 +version: 1.0.355 environment: sdk: 3.12.2 diff --git a/truthcoin/pubspec.yaml b/truthcoin/pubspec.yaml index e83daffb0..6159e7219 100644 --- a/truthcoin/pubspec.yaml +++ b/truthcoin/pubspec.yaml @@ -2,7 +2,7 @@ name: truthcoin description: UI for Bitcoin Hivemind prediction market sidechain publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 1.0.225 +version: 1.0.226 environment: sdk: 3.12.2 diff --git a/zside/pubspec.yaml b/zside/pubspec.yaml index b565a3b39..fc4c92e98 100644 --- a/zside/pubspec.yaml +++ b/zside/pubspec.yaml @@ -2,7 +2,7 @@ name: zside description: UI for Drivechain (BIP300/301) based sidechains publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 1.0.351 +version: 1.0.352 environment: sdk: 3.12.2