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
2 changes: 2 additions & 0 deletions bitwindow/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ 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());

registerNodeMode();

Expand Down Expand Up @@ -257,6 +258,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
4 changes: 4 additions & 0 deletions bitwindow/lib/pages/sidechains_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,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 +899,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 +951,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 +1648,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
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
117 changes: 117 additions & 0 deletions bitwindow/test/sidechains_table_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ class _CoinShiftRPC extends MockCoinShiftRPC {
Future<(double, double)> balance() async => wallet;
}

class _Orchestrator implements OrchestratorRPC {
final balanceReads = <BinaryType>[];
GetSidechainBalanceResponse balance = GetSidechainBalanceResponse();

@override
Future<GetSidechainBalanceResponse> getSidechainBalance(BinaryType sidechain) async {
balanceReads.add(sidechain);
return balance;
}

@override
Future<GetBinaryVersionResponse> getBinaryVersion(String name, {bool forceBackend = false}) async =>
GetBinaryVersionResponse(version: '$name 1.0');

@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

Thunder _thunder({bool downloaded = true, bool updateAvailable = false}) {
final base = Thunder();
return base.copyWith(
Expand All @@ -116,6 +134,22 @@ Thunder _thunder({bool downloaded = true, bool updateAvailable = false}) {
);
}

FreeBank _freeBank({bool downloaded = true}) {
final base = FreeBank();
return base.copyWith(
metadata: base.metadata.copyWith(
remoteTimestamp: null,
downloadedTimestamp: downloaded ? DateTime(2026, 1) : null,
binaryPath: downloaded ? File('/tmp/freebankd') : null,
updateable: true,
),
);
}

final Finder _settingsButton = find.byWidgetPredicate(
(widget) => widget is SailButton && widget.icon == SailSVGAsset.settings,
);

Finder _button(String label) => find.byWidgetPredicate((widget) => widget is SailButton && widget.label == label);

final Finder _updateButton = find.byWidgetPredicate(
Expand Down Expand Up @@ -268,6 +302,89 @@ void main() {
expect(find.text('—'), findsOneWidget);
});

// FreeBank reads all its state through the orchestrator.
group('FreeBank', () {
late _Orchestrator orchestrator;
late FreeBankRPC freeBankRPC;

Future<void> setUpFreeBank({bool downloaded = true}) async {
GetIt.I.registerSingleton<BinaryProvider>(_Binaries([_freeBank(downloaded: downloaded)]));
orchestrator = _Orchestrator();
GetIt.I.registerSingleton<OrchestratorRPC>(orchestrator);
freeBankRPC = FreeBankLive();
GetIt.I.registerSingleton<FreeBankRPC>(freeBankRPC);
await GetIt.I.unregister<BalanceProvider>();
balances = BalanceProvider(connections: [thunderRPC, coinShiftRPC, freeBankRPC]);
GetIt.I.registerSingleton<BalanceProvider>(balances);
GetIt.I.get<SidechainProvider>().sidechains[130] = SidechainOverview(
ListSidechainsResponse_Sidechain(title: 'FreeBank', slot: 130, balanceSatoshi: Int64(300000000)),
[],
[],
);
}

testWidgets('the row has a settings button that opens the chain settings', (tester) async {
await setUpFreeBank();
await pumpTable(tester);

expect(_settingsButton, findsOneWidget);
await tester.tap(_settingsButton);
await tester.pump(kDoubleTapTimeout);
await tester.pump(const Duration(milliseconds: 500));

expect(find.byType(ChainSettingsModal), findsOneWidget);
expect(find.descendant(of: find.byType(ChainSettingsModal), matching: find.text('FreeBank')), findsOneWidget);
expect(find.textContaining('version freebank 1.0'), findsOneWidget);
expect(tester.takeException(), isNull);
});

testWidgets('a stopped FreeBank offers Start and reads no balance', (tester) async {
await setUpFreeBank();
await balances.fetch();
await pumpTable(tester);

expect(_buttonWidget(tester, 'Start').variant, ButtonVariant.primary);
expect(_button('Stop'), findsNothing);
expect(_settingsButton, findsOneWidget);
expect(orchestrator.balanceReads, isEmpty);
expect(tester.takeException(), isNull);
});

testWidgets('a FreeBank that is not installed offers Download', (tester) async {
await setUpFreeBank(downloaded: false);
await pumpTable(tester);

expect(_buttonWidget(tester, 'Download').variant, ButtonVariant.primary);
expect(_settingsButton, findsOneWidget);
expect(tester.takeException(), isNull);
});

testWidgets('a running FreeBank offers Stop and shows the balance the orchestrator reads', (tester) async {
await setUpFreeBank();
orchestrator.balance = GetSidechainBalanceResponse(confirmedSats: Int64(250000000));
freeBankRPC.connected = true;
await balances.fetch();
await pumpTable(tester);

expect(_buttonWidget(tester, 'Stop').variant, ButtonVariant.outline);
expect(_tooltip('Connected'), findsOneWidget);
expect(orchestrator.balanceReads, contains(BinaryType.BINARY_TYPE_FREEBANK));
expect(find.text(GetIt.I.get<FormatterProvider>().formatBTC(2.5)), findsOneWidget);
});

testWidgets('a running FreeBank behind its headers shows the sync bar', (tester) async {
await setUpFreeBank();
freeBankRPC.connected = true;
sync.sidechains = {
SidechainType.SIDECHAIN_TYPE_FREEBANK: SyncInfo(progressCurrent: 100, progressGoal: 294, lastBlockAt: null),
};
await pumpTable(tester);

expect(find.text('34%'), findsOneWidget);
expect(_button('Stop'), findsOneWidget);
});
});

testWidgets('a chain that is not downloaded offers a primary Download', (tester) async {
setUpChain(_thunder(downloaded: false));
await pumpTable(tester);
Expand Down
2 changes: 2 additions & 0 deletions sidechain_core/lib/providers/backend_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:sidechain_core/rpcs/coinshift_rpc.dart';
import 'package:sidechain_core/rpcs/enforcer_rpc.dart';
import 'package:sidechain_core/rpcs/bitcoind_connection.dart';
import 'package:sidechain_core/env.dart';
import 'package:sidechain_core/rpcs/freebank_rpc.dart';
import 'package:sidechain_core/rpcs/orchestrator_rpc.dart';
import 'package:sidechain_core/rpcs/photon_rpc.dart';
import 'package:sidechain_core/rpcs/thunder_rpc.dart';
Expand Down Expand Up @@ -219,6 +220,7 @@ class BackendStateProvider extends ChangeNotifier {
'truthcoin' => GetIt.I.isRegistered<TruthcoinRPC>() ? GetIt.I.get<TruthcoinRPC>() : null,
'photon' => GetIt.I.isRegistered<PhotonRPC>() ? GetIt.I.get<PhotonRPC>() : null,
'coinshift' => GetIt.I.isRegistered<CoinShiftRPC>() ? GetIt.I.get<CoinShiftRPC>() : null,
'freebank' => GetIt.I.isRegistered<FreeBankRPC>() ? GetIt.I.get<FreeBankRPC>() : null,
_ => null,
};
}
Expand Down
1 change: 1 addition & 0 deletions sidechain_core/lib/providers/binaries/binary_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ class BinaryProvider extends ChangeNotifier {
var b when b is BitAssets => _getRegistered<BitAssetsRPC>(),
var b when b is ZSide => _getRegistered<ZSideRPC>(),
var b when b is CoinShift => _getRegistered<CoinShiftRPC>(),
var b when b is FreeBank => _getRegistered<FreeBankRPC>(),
_ => null,
};
}
Expand Down
120 changes: 120 additions & 0 deletions sidechain_core/lib/rpcs/freebank_rpc.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import 'package:collection/collection.dart';
import 'package:get_it/get_it.dart';
import 'package:sidechain_core/bitcoin.dart' as bitcoin;
import 'package:sidechain_core/classes/rpc_connection.dart';
import 'package:sidechain_core/gen/orchestrator/v1/orchestrator.pb.dart';
import 'package:sidechain_core/models/core_transaction.dart';
import 'package:sidechain_core/rpcs/orchestrator_rpc.dart';
import 'package:sidechain_core/rpcs/rpc_sidechain.dart';
import 'package:sidechain_core/rpcs/thunder_utxo.dart';

/// API to the FreeBank node, read through the orchestrator's generic routes.
abstract class FreeBankRPC extends SidechainRPC {
FreeBankRPC({required super.binaryType});
}

class FreeBankLive extends FreeBankRPC {
FreeBankLive() : super(binaryType: BinaryType.BINARY_TYPE_FREEBANK);

OrchestratorRPC get _orchestrator => GetIt.I.get<OrchestratorRPC>();

/// The orchestrator builds the node's arguments.
@override
Future<List<String>> binaryArgs() async => [];

@override
Future<void> stopRPC() async {
await _orchestrator.stopBinary('freebank');
}

@override
Future<(double, double)> balance() async {
final resp = await _orchestrator.getSidechainBalance(binaryType);
return (
bitcoin.satoshiToBTC(resp.confirmedSats.toInt()),
bitcoin.satoshiToBTC(resp.pendingSats.toInt()),
);
}

@override
Future<int> getBlockCount() async => (await _sync()).blocks;

@override
Future<BlockchainInfo> getBlockchainInfo() async {
final sync = await _sync();
return BlockchainInfo(
chain: '',
blocks: sync.blocks,
headers: sync.headers,
bestBlockHash: '',
difficulty: 0,
time: sync.time.toInt(),
medianTime: 0,
verificationProgress: sync.headers > 0 ? sync.blocks / sync.headers : 0,
initialBlockDownload: sync.blocks < sync.headers,
chainWork: '',
sizeOnDisk: 0,
pruned: false,
warnings: const [],
);
}

/// The node's sync state as the orchestrator last read it.
Future<ChainSync> _sync() async {
final status = await _orchestrator.getSyncStatus();
final entry = status.sidechains.firstWhereOrNull((s) => s.type == SidechainType.SIDECHAIN_TYPE_FREEBANK);
if (entry == null) {
throw StateError('the orchestrator reports no FreeBank sync status');
}
if (entry.sync.error.isNotEmpty) {
throw StateError(entry.sync.error);
}
return entry.sync;
}

// ── Not served through the orchestrator ────────────────────────────────────

@override
Future<String> getDepositAddress() async =>
throw UnsupportedError('fetching a FreeBank address. Paste one from your FreeBank wallet.');

@override
Future<String> getSideAddress() async => throw _unsupported('addresses');

@override
Future<String> sideSend(String address, double amount, bool subtractFeeFromAmount) async =>
throw _unsupported('sends');

@override
Future<double> sideEstimateFee() async => throw _unsupported('fees');

@override
Future<List<SidechainUTXO>> listUTXOs() async => throw _unsupported('coins');

@override
Future<List<SidechainUTXO>> listAllUTXOs() async => throw _unsupported('coins');

@override
Future<List<CoreTransaction>> listTransactions() async => throw _unsupported('transactions');

@override
Future<BmmResult> mine(int feeSats) async => throw _unsupported('mining');

@override
Future<PendingWithdrawalBundle?> getPendingWithdrawalBundle() async => throw _unsupported('withdrawal bundles');

@override
Future<int?> getLatestFailedWithdrawalBundleHeight() async => throw _unsupported('withdrawal bundles');

@override
Future<String> withdraw(String address, int amountSats, int sidechainFeeSats, int mainchainFeeSats) async =>
throw _unsupported('withdrawals');

@override
Future<dynamic> callRAW(String method, [List<dynamic>? params]) async => throw _unsupported('raw calls');

@override
List<String> getMethods() => const [];

UnsupportedError _unsupported(String what) => UnsupportedError('FreeBank $what through the orchestrator');
}
1 change: 1 addition & 0 deletions sidechain_core/lib/sidechain_core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export 'rpcs/bitnames_rpc.dart';
export 'rpcs/bitwindow_api.dart';
export 'rpcs/coinshift_rpc.dart';
export 'rpcs/enforcer_rpc.dart';
export 'rpcs/freebank_rpc.dart';
export 'rpcs/keepalive_http_client.dart';
export 'rpcs/orchestrator_multisig_lounge_rpc.dart';
export 'rpcs/orchestrator_rpc.dart';
Expand Down
Loading
Loading