diff --git a/lib/pages/tabs/testchain/mainchain/withdrawal_bundle_tab_page.dart b/lib/pages/tabs/testchain/mainchain/withdrawal_bundle_tab_page.dart index 1c9e04e8..ae7179d6 100644 --- a/lib/pages/tabs/testchain/mainchain/withdrawal_bundle_tab_page.dart +++ b/lib/pages/tabs/testchain/mainchain/withdrawal_bundle_tab_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:dart_coin_rpc/dart_coin_rpc.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; @@ -72,26 +73,31 @@ class WithdrawalBundleTabPage extends StatelessWidget { const SailSpacing(SailStyleValues.padding30), DashboardGroup( title: 'Bundle history', - widgetTrailing: SailText.secondary13('${viewModel.bundleCount} bundle(s)'), + widgetTrailing: SailText.secondary13('${viewModel.bundles.length} bundle(s)'), children: [ SailColumn( spacing: 0, withDivider: true, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!viewModel.hasDoneInitialFetch) LoadingIndicator.overlay(), - viewModel.currentBundle == null - // TODO: proper no bundle view - ? Center( - child: Padding( - padding: const EdgeInsets.all(SailStyleValues.padding30), - child: SailText.primary20('No withdrawal bundle'), - ), - ) - : BundleView( - bundle: viewModel.currentBundle!, - votes: viewModel.votes ?? 0, + ...[ + if (!viewModel.hasDoneInitialFetch) LoadingIndicator.overlay(), + // TODO: proper no bundle view + if (viewModel.bundleCount == 0) + Center( + child: Padding( + padding: const EdgeInsets.all(SailStyleValues.padding30), + child: SailText.primary20('No withdrawal bundle'), ), + ), + ], + ...viewModel.bundles.map( + (bundle) => BundleView( + bundle: bundle, + timesOutIn: viewModel.timesOutIn(bundle.hash), + votes: viewModel.votes(bundle.hash), + ), + ), ], ), ], @@ -118,19 +124,100 @@ class WithdrawalBundleTabPageViewModel extends BaseViewModel { Timer? _withdrawalBundleTimer; bool hasDoneInitialFetch = false; + WithdrawalBundle? currentBundle; + int? votesCurrentBundle; + + List statuses = []; + List successfulBundles = []; + List failedBundles = []; + + int get bundleCount => successfulBundles.length + failedBundles.length + (currentBundle != null ? 1 : 0); + Iterable get bundles => [ + if (currentBundle != null) currentBundle!, + ...successfulBundles, + ...failedBundles, + ] + .sortedByCompare( + ( + b1, + ) => + b1.blockHeight, + (a, b) => a.compareTo(b), + ) + .reversed; // we want the newest first! + FutureWithdrawalBundle? nextBundle; - int? votes; + int votes(String hash) { + bool byHash(WithdrawalBundle bundle) => bundle.hash == hash; - // TODO: add support for historic bundles - int get bundleCount => currentBundle == null ? 0 : 1; + if (successfulBundles.firstWhereOrNull(byHash) != null) { + return bundleVotesRequired; + } + + if (failedBundles.firstWhereOrNull(byHash) != null) { + return 0; + } + + if (hash == currentBundle?.hash) { + return votesCurrentBundle ?? 0; + } + + // TODO; return 0 zero here? + throw 'received hash for unknown bundle: $hash'; + } + + /// Block count until a bundle times out + int timesOutIn(String hash) { + final stat = statuses.firstWhereOrNull((element) => element.hash == hash); + return stat?.blocksLeft ?? 0; + } void _fetchWithdrawalBundle() async { try { - currentBundle = await _sidechain.mainCurrentWithdrawalBundle(); - nextBundle = await _sidechain.mainNextWithdrawalBundle(); - votes = await _mainchain.getWithdrawalBundleWorkScore(_sidechain.chain.slot, currentBundle!.hash); + final statusesFut = _mainchain.listWithdrawalStatus(_sidechain.chain.slot); + final currentFut = _sidechain.mainCurrentWithdrawalBundle(); + final rawSuccessfulBundlesFut = _mainchain.listSpentWithdrawals(); + final rawFailedBundlesFut = _mainchain.listFailedWithdrawals(); + final nextFut = _sidechain.mainNextWithdrawalBundle(); + + statuses = await statusesFut; + final rawSuccessfulBundles = await rawSuccessfulBundlesFut; + final rawFailedBundles = await rawFailedBundlesFut; + + bool removeOtherChains(MainchainWithdrawal w) => w.sidechain == _sidechain.chain.slot; + Future Function(MainchainWithdrawal w) getBundle(BundleStatus status) => + (MainchainWithdrawal w) => _sidechain.lookupWithdrawalBundle(w.hash, status); + + // Cooky town: passing in a status parameter to this... + final successfulBundlesFut = Future.wait( + rawSuccessfulBundles.where(removeOtherChains).map(getBundle(BundleStatus.success)), + ); + + final failedBundlesFut = Future.wait( + rawFailedBundles.where(removeOtherChains).map(getBundle(BundleStatus.failed)), + ); + + successfulBundles = await successfulBundlesFut; + failedBundles = await failedBundlesFut; + + currentBundle = await currentFut.then((bundle) { + // `testchain-cli getwithdrawalbundle` continues to return the most recent bundle, also + // after it shows up in `drivechain-cli listspentwithdrawals/listfailedwithdrawals`. + // Filter that out, so it doesn't show up twice. + final allBundles = [...successfulBundles, ...failedBundles]; + if (allBundles.firstWhereOrNull((success) => success.hash == (bundle?.hash ?? '')) != null) { + return null; + } + return bundle; + }); + + if (currentBundle != null) { + votesCurrentBundle = await _mainchain.getWithdrawalBundleWorkScore(_sidechain.chain.slot, currentBundle!.hash); + } + + nextBundle = await nextFut; hasDoneInitialFetch = true; } on RPCException catch (err) { if (err.errorCode != TestchainRPCError.errNoWithdrawalBundle) { @@ -210,15 +297,32 @@ class _UnbundledWithdrawalViewState extends State { } } +const int bundleVotesRequired = 131; // higher on mainnet. take into consideration, somehow + class BundleView extends StatefulWidget { final WithdrawalBundle bundle; - bool get confirmed => votes >= votesRequired; + + (String, SailSVGAsset) statusAndIcon() { + switch (bundle.status) { + case BundleStatus.pending: + return ('Pending', SailSVGAsset.iconPendingHalf); + + case BundleStatus.failed: + return ('Failed', SailSVGAsset.iconFailed); + + case BundleStatus.success: + return ('Final', SailSVGAsset.iconConfirmed); + } + } final int votes; - final int votesRequired = 131; // higher on mainnet. take into consideration, somehow + + /// Blocks left until the bundle times out. + final int timesOutIn; const BundleView({ super.key, + required this.timesOutIn, required this.votes, required this.bundle, }); @@ -237,6 +341,7 @@ class _BundleViewState extends State { @override Widget build(BuildContext context) { + final (tooltipMessage, icon) = widget.statusAndIcon(); return Padding( padding: const EdgeInsets.symmetric( vertical: SailStyleValues.padding15, @@ -255,19 +360,17 @@ class _BundleViewState extends State { child: SingleValueContainer( width: bundleViewWidth, icon: Tooltip( - message: widget.confirmed ? 'Final' : 'Pending', - child: SailSVG.icon( - widget.confirmed ? SailSVGAsset.iconConfirmed : SailSVGAsset.iconPendingHalf, - width: 13, - ), + message: tooltipMessage, + child: SailSVG.icon(icon, width: 13), ), copyable: false, - label: '${widget.votes}/${widget.votesRequired} ACKs', + label: + widget.bundle.status == BundleStatus.failed ? 'Failed' : '${widget.votes}/$bundleVotesRequired ACKs', value: 'Peg-out of ${widget.bundle.totalBitcoin.toStringAsFixed(8)} BTC in ${widget.bundle.withdrawals.length} transactions', ), ), - if (expanded) ExpandedBundleView(bundle: widget.bundle), + if (expanded) ExpandedBundleView(timesOutIn: widget.timesOutIn, bundle: widget.bundle), ], ), ); @@ -280,13 +383,15 @@ class _BundleViewState extends State { } } -const bundleViewWidth = 130.0; +const bundleViewWidth = 160.0; class ExpandedBundleView extends StatelessWidget { final WithdrawalBundle bundle; + final int timesOutIn; const ExpandedBundleView({ super.key, + required this.timesOutIn, required this.bundle, }); @@ -300,6 +405,7 @@ class ExpandedBundleView extends StatelessWidget { static const double maxWeight = (maxStandardTxWeight / witnessScaleFactor) / 2; Map get _values => { + if (bundle.status == BundleStatus.pending) 'blocks until timeout': timesOutIn, 'block hash': bundle.hash, 'total amount': '${bundle.totalBitcoin.toStringAsFixed(8)} BTC', 'withdrawal count': bundle.withdrawals.length, diff --git a/lib/rpc/rpc_mainchain.dart b/lib/rpc/rpc_mainchain.dart index dc7b6115..bb4e1821 100644 --- a/lib/rpc/rpc_mainchain.dart +++ b/lib/rpc/rpc_mainchain.dart @@ -12,6 +12,9 @@ abstract class MainchainRPC extends RPCConnection { Future estimateFee(); Future getWithdrawalBundleWorkScore(int sidechain, String hash); Future> listTransactions(); + Future> listSpentWithdrawals(); + Future> listFailedWithdrawals(); + Future> listWithdrawalStatus(int slot); } class MainchainRPCLive extends MainchainRPC { @@ -103,6 +106,24 @@ class MainchainRPCLive extends MainchainRPC { } } + @override + Future> listSpentWithdrawals() async { + final withdrawals = await _client?.call('listspentwithdrawals') as List; + return withdrawals.map((w) => MainchainWithdrawal.fromJson(w)).toList(); + } + + @override + Future> listFailedWithdrawals() async { + final withdrawals = await _client?.call('listfailedwithdrawals') as List; + return withdrawals.map((w) => MainchainWithdrawal.fromJson(w)).toList(); + } + + @override + Future> listWithdrawalStatus(int slot) async { + final statuses = await _client?.call('listwithdrawalstatus', [slot]) as List; + return statuses.map((e) => MainchainWithdrawalStatus.fromJson(e)).toList(); + } + @override Future ping() async { await _client?.call('ping') as Map?; @@ -114,3 +135,63 @@ class MainchainRPCLive extends MainchainRPC { super.dispose(); } } + +class MainchainWithdrawalStatus { + /// Blocks left until this withdrawal times out. + int blocksLeft; + + /// Hash of withdrawal + String hash; + + // Amount of votes this withdrawal has received. + int score; + + MainchainWithdrawalStatus({ + required this.blocksLeft, + required this.hash, + required this.score, + }); + + factory MainchainWithdrawalStatus.fromJson(Map json) => MainchainWithdrawalStatus( + blocksLeft: json['nblocksleft'] as int, + hash: json['hash'] as String, + score: json['nworkscore'] as int, + ); + + Map toJson() => { + 'nblocksleft': blocksLeft, + 'hash': hash, + 'nworkscore': score, + }; +} + +class MainchainWithdrawal { + /// Sidechain this withdrawal happened from + int sidechain; + + /// Hash of withdrawal + String hash; + + /// If this is a successful, hash of block Withdrawal was spent in. + /// Otherwise, null. + /// Can be fed into `drivechain-cli getblock` + String? blockHash; + + MainchainWithdrawal({ + required this.sidechain, + required this.hash, + required this.blockHash, + }); + + factory MainchainWithdrawal.fromJson(Map json) => MainchainWithdrawal( + sidechain: json['nsidechain'] as int, + hash: json['hash'] as String, + blockHash: json['hashblock'] as String?, + ); + + Map toJson() => { + 'nsidechain': sidechain, + 'hash': hash, + 'hashblock': blockHash, + }; +} diff --git a/lib/rpc/rpc_testchain.dart b/lib/rpc/rpc_testchain.dart index 7074da8c..b8a1f300 100644 --- a/lib/rpc/rpc_testchain.dart +++ b/lib/rpc/rpc_testchain.dart @@ -17,7 +17,18 @@ import 'package:sidesail/rpc/rpc_withdrawal_bundle.dart'; /// RPC connection the sidechain node. abstract class TestchainRPC extends SidechainRPC { Future refreshBMM(int bidSatoshis); - Future mainCurrentWithdrawalBundle(); + + /// Returns null if there's no current bundle + Future mainCurrentWithdrawalBundle(); + + // TODO: such a mess that this takes in a status... + // This is because the status isn't explicitly returned anywhere, but rather + // deduced by where you got the bundle hash from. + // testchain-cli getwithdrawalbundleinfo => pending + // drivechain-cli listfailedwithdrawals => failed + // drivechain-cli listspentwithdrawals => success + Future lookupWithdrawalBundle(String hash, BundleStatus status); + Future mainNextWithdrawalBundle(); } @@ -208,15 +219,10 @@ class TestchainRPCLive extends TestchainRPC { } @override - Future mainCurrentWithdrawalBundle() async { - final rawWithdrawalBundle = await _client?.call('getwithdrawalbundle'); - - final decoded = await _client?.call('decoderawtransaction', [rawWithdrawalBundle]); - final tx = RawTransaction.fromJson(decoded); - + Future lookupWithdrawalBundle(String hash, BundleStatus status) async { final info = await _client?.call( 'getwithdrawalbundleinfo', - [tx.hash], + [hash], ); final withdrawalIDs = info['withdrawals'] as List; @@ -230,13 +236,31 @@ class TestchainRPCLive extends TestchainRPC { ), ); - return WithdrawalBundle.fromRawTransaction( - tx, + return WithdrawalBundle.fromWithdrawals( + hash, + status, BundleInfo.fromJson(info), withdrawals, ); } + @override + Future mainCurrentWithdrawalBundle() async { + dynamic rawWithdrawalBundle; + try { + rawWithdrawalBundle = await _client?.call('getwithdrawalbundle'); + } on RPCException catch (err) { + if (err.errorCode == RPCError.errNoWithdrawalBundle) { + return null; + } + rethrow; + } + + final decoded = await _client?.call('decoderawtransaction', [rawWithdrawalBundle]); + final tx = RawTransaction.fromJson(decoded); + return lookupWithdrawalBundle(tx.hash, BundleStatus.pending); + } + @override Future mainNextWithdrawalBundle() async { final rawNextBundle = await _client?.call('listnextbundlewithdrawals') as List; diff --git a/lib/rpc/rpc_withdrawal_bundle.dart b/lib/rpc/rpc_withdrawal_bundle.dart index f745e1c2..110b8771 100644 --- a/lib/rpc/rpc_withdrawal_bundle.dart +++ b/lib/rpc/rpc_withdrawal_bundle.dart @@ -1,28 +1,33 @@ import 'package:collection/collection.dart'; import 'package:sidesail/bitcoin.dart'; import 'package:sidesail/rpc/models/bundle_info.dart'; -import 'package:sidesail/rpc/models/raw_transaction.dart'; + +enum BundleStatus { pending, failed, success } class WithdrawalBundle { WithdrawalBundle({ + required this.status, required this.hash, required this.bundleSize, required this.blockHeight, required this.withdrawals, }); - factory WithdrawalBundle.fromRawTransaction( - RawTransaction tx, + factory WithdrawalBundle.fromWithdrawals( + String hash, + BundleStatus status, BundleInfo info, List withdrawals, ) => WithdrawalBundle( - hash: tx.hash, + hash: hash, + status: status, bundleSize: info.weight, blockHeight: info.height, withdrawals: withdrawals, ); + final BundleStatus status; final String hash; final int bundleSize; diff --git a/test/mocks/rpc_mock_mainchain.dart b/test/mocks/rpc_mock_mainchain.dart index 36422a1f..a3298792 100644 --- a/test/mocks/rpc_mock_mainchain.dart +++ b/test/mocks/rpc_mock_mainchain.dart @@ -31,4 +31,19 @@ class MockMainchainRPC extends MainchainRPC { Future> listTransactions() async { return List.empty(); } + + @override + Future> listSpentWithdrawals() async { + return List.empty(); + } + + @override + Future> listWithdrawalStatus(int slot) async { + return List.empty(); + } + + @override + Future> listFailedWithdrawals() async { + return List.empty(); + } }