Skip to content
Closed
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
5 changes: 5 additions & 0 deletions bitwindow/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ Future<(Directory, File, Logger)> init(String arguments) async {
GetIt.I.registerSingleton<CoinShiftRPC>(CoinShiftLive());
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 Expand Up @@ -257,6 +261,7 @@ Future<(Directory, File, Logger)> init(String arguments) async {
GetIt.I.get<PhotonRPC>(),
GetIt.I.get<TruthcoinRPC>(),
GetIt.I.get<CoinShiftRPC>(),
GetIt.I.get<FreeBankRPC>(),
],
),
);
Expand Down
22 changes: 22 additions & 0 deletions bitwindow/lib/pages/sidechains_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:bitwindow/pages/sidechain_activation_management_page.dart';
import 'package:bitwindow/providers/sidechain_provider.dart';
import 'package:bitwindow/providers/transactions_provider.dart';
import 'package:bitwindow/widgets/fast_withdrawal_tab.dart';
import 'package:bitwindow/widgets/freebank_bid_dialog.dart';
import 'package:bitwindow/widgets/starters_tab.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.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 Expand Up @@ -736,6 +754,7 @@ class SidechainsViewModel extends BaseViewModel with ChangeTrackingMixin {
Truthcoin() => SidechainType.SIDECHAIN_TYPE_TRUTHCOIN,
Photon() => SidechainType.SIDECHAIN_TYPE_PHOTON,
CoinShift() => SidechainType.SIDECHAIN_TYPE_COINSHIFT,
FreeBank() => SidechainType.SIDECHAIN_TYPE_FREEBANK,
_ => null,
};

Expand Down Expand Up @@ -898,6 +917,7 @@ class SidechainsViewModel extends BaseViewModel with ChangeTrackingMixin {
var b when b is Truthcoin => GetIt.I.isRegistered<TruthcoinRPC>() ? GetIt.I.get<TruthcoinRPC>() : null,
var b when b is Photon => GetIt.I.isRegistered<PhotonRPC>() ? GetIt.I.get<PhotonRPC>() : null,
var b when b is CoinShift => GetIt.I.isRegistered<CoinShiftRPC>() ? GetIt.I.get<CoinShiftRPC>() : null,
var b when b is FreeBank => GetIt.I.isRegistered<FreeBankRPC>() ? GetIt.I.get<FreeBankRPC>() : null,
_ => null,
};

Expand Down Expand Up @@ -949,6 +969,7 @@ class SidechainsViewModel extends BaseViewModel with ChangeTrackingMixin {
var b when b is Truthcoin => (GetIt.I.isRegistered<TruthcoinRPC>() ? GetIt.I.get<TruthcoinRPC>() : null),
var b when b is Photon => (GetIt.I.isRegistered<PhotonRPC>() ? GetIt.I.get<PhotonRPC>() : null),
var b when b is CoinShift => (GetIt.I.isRegistered<CoinShiftRPC>() ? GetIt.I.get<CoinShiftRPC>() : null),
var b when b is FreeBank => (GetIt.I.isRegistered<FreeBankRPC>() ? GetIt.I.get<FreeBankRPC>() : null),
_ => null,
};
}
Expand Down Expand Up @@ -1645,6 +1666,7 @@ class _DepositModalState extends State<DepositModal> {
ZSide() => GetIt.I.isRegistered<ZSideRPC>() ? GetIt.I.get<ZSideRPC>() : null,
CoinShift() => GetIt.I.isRegistered<CoinShiftRPC>() ? GetIt.I.get<CoinShiftRPC>() : null,
Thunder() => GetIt.I.isRegistered<ThunderRPC>() ? GetIt.I.get<ThunderRPC>() : null,
FreeBank() => GetIt.I.isRegistered<FreeBankRPC>() ? GetIt.I.get<FreeBankRPC>() : null,
_ => null,
};

Expand Down
85 changes: 85 additions & 0 deletions bitwindow/lib/utils/freebank_conf.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/// 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;

/// The name the conf gives, or null when it sets none. The last line wins, as in
/// Core.
String? readCoinbaseTag(String conf) {
String? tag;
for (final line in conf.split('\n')) {
final trimmed = line.trim();
if (trimmed.startsWith('$coinbaseTagKey=')) {
tag = trimmed.substring(coinbaseTagKey.length + 1).trim();
}
}
return tag;
}

/// 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 its name set to [name]: an existing `coinbasetag=` line is
/// replaced in place, and every other line is kept as it was.
String setCoinbaseTag(String conf, String name) {
final line = '$coinbaseTagKey=${name.trim()}';
final lines = conf.isEmpty ? <String>[] : conf.split('\n');
final kept = <String>[];
var replaced = false;
for (final l in lines) {
if (l.trim().startsWith('$coinbaseTagKey=')) {
if (!replaced) {
kept.add(line);
replaced = true;
}
continue;
}
kept.add(l);
}
if (!replaced) {
if (kept.isNotEmpty && kept.last.isEmpty) {
kept.insert(kept.length - 1, line);
} else {
kept.add(line);
}
}
final out = kept.join('\n');
return out.endsWith('\n') ? out : '$out\n';
}

/// 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';
}
160 changes: 160 additions & 0 deletions bitwindow/lib/widgets/freebank_bid_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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';

/// 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');
}
}

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());
} 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) {
final theme = SailTheme.of(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
width: 760,
height: 720,
padding: const EdgeInsets.all(SailStyleValues.padding25),
decoration: BoxDecoration(
color: theme.colors.backgroundSecondary,
borderRadius: SailStyleValues.borderRadius,
),
child: SailColumn(
spacing: SailStyleValues.padding16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SailText.primary20('Bid for FreeBank blocks'),
SailText.secondary13(
'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.',
),
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(
_error ??
(!_hasName
? 'Blocks you win show this name on the explorer. Change it if you like, then save. '
'Saving restarts your FreeBank node.'
: 'Blocks you win show "$_saved" on the explorer. Saving a new name restarts your FreeBank node.'),
color: _error == null ? null : theme.colors.error,
),
Expanded(
// 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.')),
],
),
),
Align(
alignment: Alignment.centerRight,
child: SailButton(label: 'Close', onPressed: () async => Navigator.of(context).pop()),
),
],
),
),
);
}
}
20 changes: 20 additions & 0 deletions bitwindow/test/deposit_modal_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,26 @@ void main() {
expect(field.controller.text, 's9_pastedaddress_abc123');
});

testWidgets('a running FreeBank leaves the address field open for a paste and says why', (tester) async {
GetIt.I.registerSingleton<FreeBankRPC>(FreeBankLive()..connected = true);
await GetIt.I.unregister<BinaryProvider>();
GetIt.I.registerSingleton<BinaryProvider>(
BinaryProvider.test(appDir: Directory.systemTemp, binaries: [FreeBank()]),
);

addTearDown(() => tester.pumpWidget(const SizedBox()));
await tester.pumpSailPage(const DepositModal(slot: 130, sidechainName: 'FreeBank'));
await tester.pump();

final field = tester.widget<SailTextField>(find.byType(SailTextField).first);
expect(field.readOnly, isFalse);
expect(field.controller.text, isEmpty);
expect(
find.text('Unsupported operation: fetching a FreeBank address. Paste one from your FreeBank wallet.'),
findsOneWidget,
);
});

testWidgets('a pasted address with an amount arms the deposit button', (tester) async {
await pumpModal(tester);

Expand Down
Loading
Loading