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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions bitwindow/lib/dialogs/freebank_bid_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import 'dart:io';

import 'package:bitwindow/utils/freebank_conf.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:logger/logger.dart';
import 'package:sail_ui/pages/sidechains/bmm_tab.dart';
import 'package:sail_ui/sail_ui.dart';

// The BMM tab scrolls on its own, so the dialog gives it a fixed slot.
const _bmmTabHeight = 420.0;

// The tab's control row holds two buttons and three fields, with a 44 px gap
// between them, and it does not wrap. Below this width Flutter clips the
// max-bid field and the manual-bid button.
const _bmmTabWidth = 1160.0;

/// Bidding for FreeBank blocks from BitWindow: the name the node writes into the
/// blocks it wins, then the BMM engine's own controls. FreeBank has no app of its
/// own, so BitWindow is where a user starts it.
class FreeBankBidDialog extends StatefulWidget {
const FreeBankBidDialog({super.key});

@override
State<FreeBankBidDialog> createState() => _FreeBankBidDialogState();
}

class _FreeBankBidDialogState extends State<FreeBankBidDialog> {
final FreeBankRPC _rpc = GetIt.I.get<FreeBankRPC>();
final TextEditingController _name = TextEditingController();
String? _saved;
String? _error;
bool _saving = false;

bool get _hasName => _saved != null && _saved!.isNotEmpty;

File get _conf => File(filePath([_rpc.binary.datadirNetwork(), 'freebank.conf']));

@override
void initState() {
super.initState();
_load();
}

Future<void> _load() async {
try {
final conf = await _conf.exists() ? await _conf.readAsString() : '';
final tag = readCoinbaseTag(conf);
if (!mounted) {
return;
}
setState(() {
_saved = tag;
// A name is required before the first bid, so offer one to change.
_name.text = (tag == null || tag.isEmpty) ? suggestCoinbaseTag() : tag;
});
} catch (e) {
GetIt.I.get<Logger>().w('FreeBank: could not read freebank.conf: $e');
if (!mounted) {
return;
}
setState(() => _error = 'Could not read the name: $e');
}
}

Future<void> _save() async {
final problem = coinbaseTagProblem(_name.text);
if (problem != null) {
setState(() => _error = problem);
return;
}
setState(() {
_error = null;
_saving = true;
});
try {
final conf = await _conf.exists() ? await _conf.readAsString() : '';
await _conf.parent.create(recursive: true);
await _conf.writeAsString(setCoinbaseTag(conf, _name.text));
// freebankd reads the name at startup only.
await GetIt.I.get<BinaryProvider>().restart(_rpc.binary);
if (!mounted) {
return;
}
setState(() => _saved = _name.text.trim());
Comment thread
octobocto marked this conversation as resolved.
} catch (e) {
if (!mounted) {
return;
}
setState(() => _error = 'Could not save the name: $e');
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}

@override
void dispose() {
_name.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return SailDialog(
title: 'Bid for FreeBank blocks',
subtitle:
'For each eCash block, your node offers a small fee to have its next FreeBank block included. '
'A bid is paid from your BitWindow wallet, and only when it wins.',
error: _error,
maxWidth: _bmmTabWidth + 80,
maxHeight: 720,
Comment thread
octobocto marked this conversation as resolved.
withCloseButton: true,
child: SailColumn(
spacing: SailStyleValues.padding16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SailText.primary13('Name on your blocks', bold: true),
SailRow(
spacing: SailStyleValues.padding08,
children: [
Expanded(
child: SailTextField(
controller: _name,
hintText: "Your name, or your pool's name",
enabled: !_saving,
),
),
SailButton(
label: 'Save',
loading: _saving,
onPressed: _save,
),
],
),
SailText.secondary12(
_hasName
? 'Blocks you win show "$_saved" on the explorer. Saving a new name restarts your FreeBank node.'
: 'Blocks you win show this name on the explorer. Change it if you like, then save. '
'Saving restarts your FreeBank node.',
),
SizedBox(
height: _bmmTabHeight,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: _bmmTabWidth,
// Every block a bid wins carries the name, so bidding waits for one.
child: _hasName
? const BMMTab()
: Stack(
children: [
const IgnorePointer(child: Opacity(opacity: 0.35, child: BMMTab())),
Center(child: SailText.primary15('Save a name above to start bidding.')),
],
),
),
),
),
],
),
);
}
}
3 changes: 3 additions & 0 deletions bitwindow/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ Future<(Directory, File, Logger)> init(String arguments) async {
GetIt.I.registerSingleton<PhotonRPC>(PhotonLive());
GetIt.I.registerSingleton<TruthcoinRPC>(TruthcoinLive());
GetIt.I.registerSingleton<FreeBankRPC>(FreeBankLive());
// FreeBank has no app of its own, so its BMM controls live in BitWindow. Lazy:
// the provider only starts watching the engine once the bid dialog opens.
GetIt.I.registerLazySingleton<BMMProvider>(() => BMMProvider(sidechainRPC: GetIt.I.get<FreeBankRPC>()));

registerNodeMode();

Expand Down
18 changes: 18 additions & 0 deletions bitwindow/lib/pages/sidechains_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math';

import 'package:auto_route/auto_route.dart';
import 'package:bitwindow/dialogs/freebank_bid_dialog.dart';
import 'package:bitwindow/log_window.dart';
import 'package:bitwindow/utils/deposit_fee.dart';
import 'package:bitwindow/pages/explorer/block_explorer_dialog.dart';
Expand Down Expand Up @@ -615,6 +616,23 @@ class _SidechainActions extends StatelessWidget {
],
_depositButton(context),
const SizedBox(width: SailStyleValues.padding08),
if (viewModel.sidechainForSlot(slot) is FreeBank && settingsConnection != null) ...[
SizedBox.square(
dimension: 32,
child: SailTooltip(
message: 'Bid for FreeBank blocks',
child: SailButton(
variant: ButtonVariant.icon,
icon: SailSVGAsset.iconTabBmm,
small: true,
onPressed: () async {
await showThemedDialog(context: context, builder: (context) => const FreeBankBidDialog());
},
),
),
),
const SizedBox(width: SailStyleValues.padding08),
],
SizedBox.square(
dimension: 32,
child: settingsConnection == null
Expand Down
131 changes: 131 additions & 0 deletions bitwindow/lib/utils/freebank_conf.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/// The one line of freebank.conf that BitWindow edits: `coinbasetag=`, the name
/// a FreeBank node writes into the coinbase of every block it makes, so block
/// explorers can credit the block to it. freebankd reads it at startup only.
library;

import 'dart:math';

const coinbaseTagKey = 'coinbasetag';
const coinbaseTagMaxBytes = 64;

/// A Core-style network header, for example `[regtest]`. Every line after one
/// belongs to that network only.
final _sectionHeader = RegExp(r'^\[[^\]]*\]$');

/// The network section freebankd reads. BitWindow starts the node with no chain
/// flag, so it runs its default main chain.
const activeNetworkSection = 'main';

/// The name the node takes, or null when the conf sets none. A `[main]` entry
/// beats the global part, and any other network section counts for nothing. The
/// last line of a scope wins, as in Core.
String? readCoinbaseTag(String conf) {
String? global;
String? active;
String? section;
for (final line in conf.split('\n')) {
final trimmed = line.trim();
if (_sectionHeader.hasMatch(trimmed)) {
section = trimmed.substring(1, trimmed.length - 1);
continue;
}
if (!trimmed.startsWith('$coinbaseTagKey=')) {
continue;
}
final value = trimmed.substring(coinbaseTagKey.length + 1).trim();
if (section == null) {
global = value;
} else if (section == activeNetworkSection) {
active = value;
}
}
return active ?? global;
}

/// 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
/// name.
String? coinbaseTagProblem(String name) {
final tag = name.trim();
if (tag.isEmpty) {
return 'Give a name.';
}
if (tag.length > coinbaseTagMaxBytes) {
return 'Use at most $coinbaseTagMaxBytes characters.';
}
for (final unit in tag.codeUnits) {
if (unit < 0x20 || unit > 0x7e) {
return 'Use plain letters, digits, punctuation and spaces.';
}
}
if (tag.contains('#')) {
return 'Leave out #: it starts a comment in freebank.conf.';
}
if (tag.startsWith('"') || tag.startsWith("'") || tag.endsWith('"') || tag.endsWith("'")) {
return 'Leave out the quotes: they would become part of the name.';
}
return null;
}

/// The conf with the name the node takes set to [name]. A `[main]` entry beats
/// the global part, so the change goes there when one exists. Otherwise the line
/// goes in the global part, before the first network header. Every other line
/// stays as it was.
String setCoinbaseTag(String conf, String name) {
final line = '$coinbaseTagKey=${name.trim()}';
final lines = conf.isEmpty ? <String>[] : conf.split('\n');
final target = _tagInActiveSection(lines) ? activeNetworkSection : null;

final kept = <String>[];
var written = false;
String? section;
for (final l in lines) {
final trimmed = l.trim();
if (_sectionHeader.hasMatch(trimmed)) {
if (!written && target == null) {
kept.add(line);
written = true;
}
section = trimmed.substring(1, trimmed.length - 1);
} else if (section == target && trimmed.startsWith('$coinbaseTagKey=')) {
Comment thread
octobocto marked this conversation as resolved.
if (!written) {
kept.add(line);
written = true;
}
continue;
}
kept.add(l);
}
if (!written) {
if (kept.isNotEmpty && kept.last.isEmpty) {
kept.insert(kept.length - 1, line);
} else {
kept.add(line);
Comment thread
octobocto marked this conversation as resolved.
}
}
final out = kept.join('\n');
return out.endsWith('\n') ? out : '$out\n';
}

bool _tagInActiveSection(List<String> lines) {
String? section;
for (final l in lines) {
final trimmed = l.trim();
if (_sectionHeader.hasMatch(trimmed)) {
section = trimmed.substring(1, trimmed.length - 1);
} else if (section == activeNetworkSection && trimmed.startsWith('$coinbaseTagKey=')) {
return true;
}
}
return false;
}

/// A name to offer a user who has none yet: unique enough to spot their own
/// blocks, it says where they came from, and it carries nothing personal.
String suggestCoinbaseTag([Random? random]) {
const letters = 'abcdefghjkmnpqrstuvwxyz23456789';
final rng = random ?? Random.secure();
final suffix = List.generate(4, (_) => letters[rng.nextInt(letters.length)]).join();
return 'bitwindow-$suffix';
}
Loading
Loading