diff --git a/lib/constants.dart b/lib/constants.dart index 312e4cc7..6522838e 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -7,12 +7,12 @@ const String defaultTestnet = "https://silentpayments.dev/blindbit/testnet"; const String defaultSignet = "https://silentpayments.dev/blindbit/signet"; const String defaultRegtest = "https://silentpayments.dev/blindbit/regtest"; -// Default birthdays used in case we can't get the block height from blindbit -// These values are pretty arbitrary, they can be updated for newer heights later -const int defaultMainnetBirthday = 900000; -const int defaultTestnetBirthday = 2900000; -const int defaultSignetBirthday = 200000; -const int defaultRegtestBirthday = 80000; +// Default birthday, this value is based on the first Dana release +final DateTime defaultBirthday = DateTime.utc(2025, 6, 1); + +// minimum birthday allowed during recovery. This value is set to the moment BIP352 got merged, +// see: https://github.com/bitcoin/bips/pull/1458 +final DateTime minimumAllowedBirthday = DateTime.utc(2024, 5, 8); // default dust limit. this is used in syncing, as well as sending // for syncing, amounts < dust limit will be ignored diff --git a/lib/data/models/mempool_block_timestamp_response.dart b/lib/data/models/mempool_block_timestamp_response.dart new file mode 100644 index 00000000..c0d08693 --- /dev/null +++ b/lib/data/models/mempool_block_timestamp_response.dart @@ -0,0 +1,20 @@ +/// Response from mempool.space `/v1/mining/blocks/timestamp/{timestamp}` API. +class MempoolBlockTimestampResponse { + final int height; + final String hash; + final String timestamp; + + const MempoolBlockTimestampResponse({ + required this.height, + required this.hash, + required this.timestamp, + }); + + factory MempoolBlockTimestampResponse.fromJson(Map json) { + return MempoolBlockTimestampResponse( + height: json['height'] as int, + hash: json['hash'] as String, + timestamp: json['timestamp'] as String, + ); + } +} diff --git a/lib/data/models/mempool_blocks.dart b/lib/data/models/mempool_blocks.dart new file mode 100644 index 00000000..eb070410 --- /dev/null +++ b/lib/data/models/mempool_blocks.dart @@ -0,0 +1,49 @@ +class MempoolGetBlockResponse { + final String id; + final int height; + final int version; + final int timestamp; + final int txCount; + final int size; + final int weight; + final String merkleRoot; + final String previousblockhash; + final int mediantime; + final int nonce; + final int bits; + final double difficulty; + + const MempoolGetBlockResponse({ + required this.id, + required this.height, + required this.version, + required this.timestamp, + required this.txCount, + required this.size, + required this.weight, + required this.merkleRoot, + required this.previousblockhash, + required this.mediantime, + required this.nonce, + required this.bits, + required this.difficulty, + }); + + factory MempoolGetBlockResponse.fromJson(Map json) { + return MempoolGetBlockResponse( + id: json['id'] as String, + height: json['height'] as int, + version: json['version'] as int, + timestamp: json['timestamp'] as int, + txCount: json['tx_count'] as int, + size: json['size'] as int, + weight: json['weight'] as int, + merkleRoot: json['merkle_root'] as String, + previousblockhash: json['previousblockhash'] as String, + mediantime: json['mediantime'] as int, + nonce: json['nonce'] as int, + bits: json['bits'] as int, + difficulty: (json['difficulty'] as num).toDouble(), + ); + } +} diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart new file mode 100644 index 00000000..bb048a55 --- /dev/null +++ b/lib/extensions/date_time.dart @@ -0,0 +1,23 @@ +extension DateTimeExtension on DateTime { + int toSeconds() => millisecondsSinceEpoch ~/ 1000; +} + +extension TimestampExtension on int { + DateTime toDate() { + const max32BitUnsigned = 4294967295; // 2^32 - 1 + + if (this < 0) { + throw ArgumentError( + 'Timestamp cannot be negative: $this', + ); + } + + if (this > max32BitUnsigned) { + throw ArgumentError( + 'Timestamp exceeds 32-bit unsigned int range: $this. Maximum value is $max32BitUnsigned', + ); + } + + return DateTime.fromMillisecondsSinceEpoch(this * 1000, isUtc: true); + } +} diff --git a/lib/extensions/network.dart b/lib/extensions/network.dart index 360818ee..c943ef00 100644 --- a/lib/extensions/network.dart +++ b/lib/extensions/network.dart @@ -48,18 +48,4 @@ extension NetworkExtension on ApiNetwork { return Bitcoin.blue; } } - - int get defaultBirthday { - switch (this) { - case ApiNetwork.mainnet: - return defaultMainnetBirthday; - case ApiNetwork.testnet3: - case ApiNetwork.testnet4: - return defaultTestnetBirthday; - case ApiNetwork.signet: - return defaultSignetBirthday; - case ApiNetwork.regtest: - return defaultRegtestBirthday; - } - } } diff --git a/lib/repositories/mempool_api_repository.dart b/lib/repositories/mempool_api_repository.dart index 8a21a9d6..ebb21515 100644 --- a/lib/repositories/mempool_api_repository.dart +++ b/lib/repositories/mempool_api_repository.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:danawallet/data/models/mempool_block_timestamp_response.dart'; +import 'package:danawallet/data/models/mempool_blocks.dart'; import 'package:danawallet/data/models/mempool_prices_response.dart'; import 'package:danawallet/generated/rust/api/structs/network.dart'; import 'package:danawallet/services/fee_api_converter.dart'; @@ -61,4 +63,45 @@ class MempoolApiRepository { throw Exception("Unexpected status code: ${response.statusCode}"); } } + + Future getBlockForHash(String blockHash) async { + final response = await http.get(Uri.parse('$baseUrl/block/$blockHash')); + if (response.statusCode == 200) { + return MempoolGetBlockResponse.fromJson(jsonDecode(response.body)); + } else { + throw Exception('Unexpected status code: ${response.statusCode}'); + } + } + + Future getBlockHashForHeight(int height) async { + // First get the block hash from height + final hashResponse = await http.get(Uri.parse('$baseUrl/block-height/$height')); + if (hashResponse.statusCode != 200) { + throw Exception('Unexpected status code: ${hashResponse.statusCode}'); + } + + // Then get the full block data using the hash + return hashResponse.body.trim(); + } + + /// Converts a Unix timestamp to block info using mempool API. + /// Returns the block closest to the given timestamp. + Future getBlockFromTimestamp(int timestamp) async { + final response = await http.get( + Uri.parse('$baseUrl/v1/mining/blocks/timestamp/$timestamp'), + ); + + if (response.statusCode != 200) { + throw Exception( + 'Failed to get block from timestamp: ${response.statusCode}', + ); + } + + try { + final json = jsonDecode(response.body) as Map; + return MempoolBlockTimestampResponse.fromJson(json); + } catch (e) { + throw Exception('Failed to parse block timestamp response: $e'); + } + } } diff --git a/lib/repositories/wallet_repository.dart b/lib/repositories/wallet_repository.dart index 8f35fcc9..4d04a7d5 100644 --- a/lib/repositories/wallet_repository.dart +++ b/lib/repositories/wallet_repository.dart @@ -1,4 +1,5 @@ import 'package:danawallet/data/models/bip353_address.dart'; +import 'package:danawallet/extensions/date_time.dart'; import 'package:danawallet/generated/rust/api/backup.dart'; import 'package:danawallet/generated/rust/api/history.dart'; import 'package:danawallet/generated/rust/api/outputs.dart'; @@ -47,8 +48,8 @@ class WalletRepository { }); } - Future setupWallet( - WalletSetupResult walletSetup, ApiNetwork network, int birthday) async { + Future setupWallet(WalletSetupResult walletSetup, + ApiNetwork network, DateTime birthday, int? lastScan) async { if ((await secureStorage.readAll()).isNotEmpty) { throw Exception('Previous wallet not properly deleted'); } @@ -61,7 +62,7 @@ class WalletRepository { // insert new values await secureStorage.write(key: _keyScanSk, value: scanKey.encode()); await secureStorage.write(key: _keySpendKey, value: spendKey.encode()); - await nonSecureStorage.setInt(_keyBirthday, birthday); + await nonSecureStorage.setInt(_keyBirthday, birthday.toSeconds()); await nonSecureStorage.setString(_keyNetwork, network.name); if (seedPhrase != null) { @@ -69,7 +70,7 @@ class WalletRepository { } // set default values for new wallet - await saveLastScan(birthday); + await saveLastScan(lastScan); await saveHistory(TxHistory.empty()); await saveOwnedOutputs(OwnedOutputs.empty()); @@ -133,18 +134,26 @@ class WalletRepository { return TxHistory.decode(encodedHistory: encodedHistory!); } - Future readBirthday() async { - final birthday = await nonSecureStorage.getInt(_keyBirthday); - return birthday!; + Future saveBirthday(DateTime birthday) async { + await nonSecureStorage.setInt(_keyBirthday, birthday.toSeconds()); } - Future saveLastScan(int lastScan) async { - await nonSecureStorage.setInt(_keyLastScan, lastScan); + Future readBirthday() async { + final timestamp = await nonSecureStorage.getInt(_keyBirthday); + return timestamp!.toDate(); } - Future readLastScan() async { + Future saveLastScan(int? lastScan) async { + if (lastScan != null) { + await nonSecureStorage.setInt(_keyLastScan, lastScan); + } else { + await nonSecureStorage.remove(_keyLastScan); + } + } + + Future readLastScan() async { final lastScan = await nonSecureStorage.getInt(_keyLastScan); - return lastScan!; + return lastScan; } Future saveOwnedOutputs(OwnedOutputs ownedOutputs) async { @@ -185,8 +194,8 @@ class WalletRepository { return WalletBackup( wallet: wallet!, - birthday: birthday, - lastScan: lastScan, + birthday: birthday.toSeconds(), + lastScan: lastScan!, txHistory: history, ownedOutputs: outputs, seedPhrase: seedPhrase, diff --git a/lib/screens/onboarding/overview.dart b/lib/screens/onboarding/overview.dart index 4e06dfaa..d0c13f45 100644 --- a/lib/screens/onboarding/overview.dart +++ b/lib/screens/onboarding/overview.dart @@ -119,12 +119,13 @@ class _OverviewScreenState extends State { chainState.initialize(network); final connected = await chainState.connect(blindbitUrl); - // we *must* be connected to get the wallet birthday + // we *must* be connected to get the current block height if (connected) { - chainState.startSyncService(walletState, scanProgress, false); - final chainTip = chainState.tip; - await walletState.createNewWallet(network, chainTip); + final currentTip = chainState.tip; + await walletState.createNewWallet(network, currentTip); + // start chain sync service only *after* we created the wallet + chainState.startSyncService(walletState, scanProgress, false); // initialize contacts state with the user's payment code contactsState.initialize(walletState.receivePaymentCode, null); if (network == ApiNetwork.regtest && context.mounted) { diff --git a/lib/screens/onboarding/recovery/birthday_picker_screen.dart b/lib/screens/onboarding/recovery/birthday_picker_screen.dart new file mode 100644 index 00000000..a7cd47a1 --- /dev/null +++ b/lib/screens/onboarding/recovery/birthday_picker_screen.dart @@ -0,0 +1,74 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:bitcoin_ui/bitcoin_ui.dart'; +import 'package:danawallet/constants.dart'; +import 'package:danawallet/widgets/buttons/footer/footer_button.dart'; +import 'package:danawallet/widgets/skeletons/screen_skeleton.dart'; +import 'package:flutter/material.dart'; + +/// Screen for selecting the wallet creation date (birthday). +/// Returns the selected [DateTime] when user confirms, or null if user goes back. +class BirthdayPickerScreen extends StatefulWidget { + const BirthdayPickerScreen({super.key}); + + @override + State createState() => _BirthdayPickerScreenState(); +} + +class _BirthdayPickerScreenState extends State { + late DateTime _selectedDate; + + @override + void initState() { + super.initState(); + // Use UTC to avoid timezone issues with calendar dates + final now = DateTime.now().toUtc(); + _selectedDate = DateTime.utc(now.year, now.month, now.day); + } + + void _onConfirm() { + Navigator.of(context).pop(_selectedDate); + } + + @override + Widget build(BuildContext context) { + final subtitle = AutoSizeText( + "Choose the date when your wallet was created. This will make restoration faster.", + style: BitcoinTextStyle.body3(Bitcoin.neutral7).copyWith( + fontFamily: 'Inter', + ), + textAlign: TextAlign.center, + maxLines: 3, + ); + + final body = Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: subtitle, + ), + Expanded( + child: SingleChildScrollView( + child: CalendarDatePicker( + initialDate: _selectedDate, + firstDate: minimumAllowedBirthday, + lastDate: DateTime.now().toUtc(), + currentDate: DateTime.now().toUtc(), + onDateChanged: (date) { + setState(() { + _selectedDate = date; + }); + }, + ), + ), + ), + ], + ); + + return ScreenSkeleton( + title: "Select wallet birthday", + showBackButton: true, + body: body, + footer: FooterButton(title: "Continue", onPressed: _onConfirm), + ); + } +} diff --git a/lib/screens/onboarding/recovery/seed_phrase.dart b/lib/screens/onboarding/recovery/seed_phrase.dart index 0dfccf15..78192bf9 100644 --- a/lib/screens/onboarding/recovery/seed_phrase.dart +++ b/lib/screens/onboarding/recovery/seed_phrase.dart @@ -1,18 +1,21 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:bitcoin_ui/bitcoin_ui.dart'; +import 'package:danawallet/constants.dart'; import 'package:danawallet/data/enums/warning_type.dart'; import 'package:danawallet/extensions/network.dart'; import 'package:danawallet/generated/rust/api/structs/network.dart'; import 'package:danawallet/global_functions.dart'; +import 'package:danawallet/screens/onboarding/recovery/birthday_picker_screen.dart'; import 'package:danawallet/screens/onboarding/register_dana_address.dart'; import 'package:danawallet/states/chain_state.dart'; import 'package:danawallet/states/contacts_state.dart'; import 'package:danawallet/states/scan_progress_notifier.dart'; import 'package:danawallet/states/wallet_state.dart'; -import 'package:danawallet/widgets/back_button.dart'; import 'package:danawallet/widgets/buttons/footer/footer_button.dart'; +import 'package:danawallet/widgets/loading_widget.dart'; import 'package:danawallet/widgets/pills/mnemonic_input_pill_box.dart'; import 'package:danawallet/widgets/pin_guard.dart'; +import 'package:danawallet/widgets/skeletons/screen_skeleton.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:sizer/sizer.dart'; @@ -39,6 +42,8 @@ class SeedPhraseScreenState extends State { late List controllers; late List focusNodes; late MnemonicInputPillBox pills; + bool _knowsBirthday = false; + bool _isLoading = false; Future onRestore(BuildContext context) async { try { @@ -49,13 +54,48 @@ class SeedPhraseScreenState extends State { final scanProgress = Provider.of(context, listen: false); - final blindbitUrl = widget.network.defaultBlindbitUrl; + // Get birthday: navigate to picker if user knows it, else use default + DateTime birthday = defaultBirthday; + if (_knowsBirthday) { + final pickedDate = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BirthdayPickerScreen(), + ), + ); + if (!context.mounted) { + return; // Context lost, abort restore + } + if (pickedDate == null) { + return; // User pressed back, stay on seed phrase screen + } + // pickedDate is already in UTC from BirthdayPickerScreen + // Use 1am UTC to avoid edge issues at midnight + birthday = DateTime.utc( + pickedDate.year, pickedDate.month, pickedDate.day, 1); + } + + setState(() { + _isLoading = true; + }); - await walletState.restoreWallet(widget.network, mnemonic); + await walletState.restoreWallet(widget.network, mnemonic, birthday); chainState.initialize(widget.network); - // we can safely ignore the result of connecting, since we use the default birthday - await chainState.connect(blindbitUrl); + + // Try to connect, but continue even if it fails (offline mode) + final connected = await chainState.connect(widget.network.defaultBlindbitUrl); + if (!connected) { + // Connection failed, but continue anyway - sync will happen when network is available + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Unable to connect to network. Wallet will sync when connection is restored.'), + duration: Duration(seconds: 3), + ), + ); + } + } chainState.startSyncService(walletState, scanProgress, true); @@ -77,6 +117,9 @@ class SeedPhraseScreenState extends State { } } catch (e) { if (context.mounted) { + setState(() { + _isLoading = false; + }); displayError("Restore failed", e); } } @@ -112,14 +155,11 @@ class SeedPhraseScreenState extends State { @override Widget build(BuildContext context) { - final title = AutoSizeText( - "Enter your recovery phrase", - style: BitcoinTextStyle.title2(Colors.black) - .copyWith(height: 1.8, fontFamily: 'Inter'), - maxLines: 1, - ); + if (_isLoading) { + return const LoadingWidget(); + } - final text = AutoSizeText( + final subtitle = AutoSizeText( "Enter your recovery phrase. Don't enter a recovery phrase that wasn't generated by Dana!", style: BitcoinTextStyle.body3(Bitcoin.neutral7).copyWith( fontFamily: 'Inter', @@ -128,43 +168,46 @@ class SeedPhraseScreenState extends State { maxLines: 3, ); + final body = Column( + children: [ + Padding( + padding: EdgeInsets.symmetric( + vertical: Adaptive.h(3), + horizontal: Adaptive.w(2), + ), + child: subtitle, + ), + Expanded(child: pills), + Padding( + padding: EdgeInsets.symmetric(vertical: Adaptive.h(1.5)), + child: CheckboxListTile( + value: _knowsBirthday, + onChanged: (value) { + setState(() { + _knowsBirthday = value ?? false; + }); + }, + title: Text( + "I know when my wallet was created (birthday)", + style: BitcoinTextStyle.body3(Bitcoin.neutral7).copyWith( + fontFamily: 'Inter', + ), + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + ), + ], + ); + final footer = FooterButton(title: "Import", onPressed: () => onRestore(context)); - // footer padding, reduced when keyboard is open - final keyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; - final bottomPaddingPercentage = keyboardOpen ? 1 : 5; - - return Scaffold( - appBar: AppBar( - automaticallyImplyLeading: false, - title: const BackButtonWidget(), - ), - body: SafeArea( - child: Padding( - padding: EdgeInsets.fromLTRB( - Adaptive.w(5), // Responsive left padding - 0, - Adaptive.w(5), // Responsive right padding - Adaptive.h( - bottomPaddingPercentage), // Responsive bottom padding - ), - child: Column( - children: [ - Column( - children: [ - title, - Padding( - padding: EdgeInsets.symmetric( - vertical: Adaptive.h(3), - horizontal: Adaptive.w(2)), - child: text), - ], - ), - Expanded(child: pills), - footer, - ], - )), - )); + return ScreenSkeleton( + title: "Enter your recovery phrase", + showBackButton: true, + body: body, + footer: footer, + ); } } diff --git a/lib/screens/recovery/view_mnemonic_screen.dart b/lib/screens/recovery/view_mnemonic_screen.dart index 1f1cb8dc..7c477fd8 100644 --- a/lib/screens/recovery/view_mnemonic_screen.dart +++ b/lib/screens/recovery/view_mnemonic_screen.dart @@ -4,26 +4,29 @@ import 'package:danawallet/global_functions.dart'; import 'package:danawallet/widgets/buttons/footer/footer_button.dart'; import 'package:danawallet/widgets/pills/mnemonic_pill_box.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:sizer/sizer.dart'; class ViewMnemonicScreen extends StatelessWidget { final String mnemonic; + final DateTime? birthday; const ViewMnemonicScreen({ super.key, required this.mnemonic, + this.birthday, }); @override Widget build(BuildContext context) { final title = AutoSizeText( - "This is your recovery phrase", + "This is your wallet backup phrase", style: BitcoinTextStyle.title2(Colors.black) .copyWith(height: 1.8, fontFamily: 'Inter'), maxLines: 1, ); final text = AutoSizeText( - "Make sure to write it down as shown here, including both numbers and words.", + "You can recover this wallet by using this backup phrase.", style: BitcoinTextStyle.body3(Bitcoin.neutral7).copyWith( fontFamily: 'Inter', ), @@ -31,6 +34,20 @@ class ViewMnemonicScreen extends StatelessWidget { maxLines: 3, ); + Widget? birthdayText; + if (birthday != null) { + final locale = Localizations.localeOf(context); + final birthdayDateString = DateFormat('d MMM yyyy', locale.toString()).format(birthday!); + birthdayText = AutoSizeText( + "Wallet birthday: $birthdayDateString", + style: BitcoinTextStyle.body3(Bitcoin.neutral1Dark).copyWith( + fontFamily: 'Inter', + ), + textAlign: TextAlign.center, + maxLines: 1, + ); + } + final pills = MnemonicPillBox(mnemonic: mnemonic); final footer = FooterButton( title: "I wrote it down", onPressed: () => goBack(context)); @@ -57,6 +74,12 @@ class ViewMnemonicScreen extends StatelessWidget { vertical: Adaptive.h(3), horizontal: Adaptive.w(2)), child: text), + if (birthdayText != null) + Padding( + padding: EdgeInsets.symmetric( + vertical: Adaptive.h(1), + horizontal: Adaptive.w(2)), + child: birthdayText), ], ), Expanded(child: pills), diff --git a/lib/screens/settings/network/network_settings_screen.dart b/lib/screens/settings/network/network_settings_screen.dart index 06964b65..c6880f97 100644 --- a/lib/screens/settings/network/network_settings_screen.dart +++ b/lib/screens/settings/network/network_settings_screen.dart @@ -41,6 +41,7 @@ class NetworkSettingsScreen extends StatelessWidget { Future _onSetLastScan(BuildContext context) async { final walletState = Provider.of(context, listen: false); final homeState = Provider.of(context, listen: false); + final chainState = Provider.of(context, listen: false); TextEditingController controller = TextEditingController(); final scanHeight = await showInputAlertDialog( @@ -53,7 +54,9 @@ class NetworkSettingsScreen extends StatelessWidget { homeState.showMainScreen(); } else if (scanHeight is bool && scanHeight) { final birthday = walletState.birthday; - await walletState.resetToScanHeight(birthday); + // TODO probably better and simpler to set lastScan to null and let the synchronization service set it to the birthday height + final height = await chainState.getBlockHeightFromDate(birthday!); + await walletState.resetToScanHeight(height); homeState.showMainScreen(); } } diff --git a/lib/screens/settings/wallet/wallet_settings_screen.dart b/lib/screens/settings/wallet/wallet_settings_screen.dart index dcf77b17..bc490010 100644 --- a/lib/screens/settings/wallet/wallet_settings_screen.dart +++ b/lib/screens/settings/wallet/wallet_settings_screen.dart @@ -107,7 +107,7 @@ class WalletSettingsScreen extends StatelessWidget { if (context.mounted) { if (mnemonic != null) { - goToScreen(context, ViewMnemonicScreen(mnemonic: mnemonic)); + goToScreen(context, ViewMnemonicScreen(mnemonic: mnemonic, birthday: wallet.birthday)); } else { showAlertDialog("Seed phrase unknown", "Seed phrase unknown! Did you import from keys?"); diff --git a/lib/screens/spend/amount_selection.dart b/lib/screens/spend/amount_selection.dart index 8537c82c..4f437d90 100644 --- a/lib/screens/spend/amount_selection.dart +++ b/lib/screens/spend/amount_selection.dart @@ -74,7 +74,10 @@ class AmountSelectionScreenState extends State { final chainState = Provider.of(context, listen: false); final availableBalance = walletState.amount; - final blocksToScan = chainState.tip - walletState.lastScan; + int blocksToScan = 0; + if (walletState.lastScan != null) { + blocksToScan = chainState.tip - walletState.lastScan!; + } String recipientName = form.recipient!.displayName; TextStyle recipientTextStyle = BitcoinTextStyle.body4(Bitcoin.neutral7); diff --git a/lib/services/synchronization_service.dart b/lib/services/synchronization_service.dart index d6517481..58c6bd16 100644 --- a/lib/services/synchronization_service.dart +++ b/lib/services/synchronization_service.dart @@ -82,19 +82,37 @@ class SynchronizationService { } Future _performSynchronizationTask() async { - if (walletState.lastScan < chainState.tip) { + if (walletState.lastScan == null) { + // if we just recovered a wallet, we haven't set the lastScan variable yet. + Logger().d("Setting last scan to block height of birthday"); + try { + await _initializeLastScan(); + } catch (e) { + Logger().e("Error initializing last scan: $e"); + return; + } + } + + if (walletState.lastScan! < chainState.tip) { if (!scanProgress.scanning) { Logger().i("Starting sync"); await scanProgress.scan(walletState); } } - if (chainState.tip < walletState.lastScan) { + if (chainState.tip < walletState.lastScan!) { // not sure what we should do here, that's really bad Logger().e('Current height is less than wallet last scan'); } } + Future _initializeLastScan() async { + final blockHeight = + await chainState.getBlockHeightFromDate(walletState.birthday); + + walletState.lastScan = blockHeight; + } + void stopSyncTimer() { Logger().i("Stopping sync service"); _timer?.cancel(); diff --git a/lib/states/chain_state.dart b/lib/states/chain_state.dart index a2b7a315..54b08b70 100644 --- a/lib/states/chain_state.dart +++ b/lib/states/chain_state.dart @@ -1,4 +1,5 @@ import 'package:danawallet/data/models/recommended_fee_model.dart'; +import 'package:danawallet/extensions/date_time.dart'; import 'package:danawallet/extensions/network.dart'; import 'package:danawallet/generated/rust/api/chain.dart'; import 'package:danawallet/generated/rust/api/structs/network.dart'; @@ -148,4 +149,10 @@ class ChainState extends ChangeNotifier { return response; } } + + Future getBlockHeightFromDate(DateTime date) async { + final mempoolApiRepository = MempoolApiRepository(network: network); + final block = await mempoolApiRepository.getBlockFromTimestamp(date.toSeconds()); + return block.height; + } } diff --git a/lib/states/scan_progress_notifier.dart b/lib/states/scan_progress_notifier.dart index 966d2a55..51cec822 100644 --- a/lib/states/scan_progress_notifier.dart +++ b/lib/states/scan_progress_notifier.dart @@ -69,17 +69,19 @@ class ScanProgressNotifier extends ChangeNotifier { walletState.network.defaultBlindbitUrl; final dustLimit = await settings.getDustLimit() ?? defaultDustLimit; - final lastScan = walletState.lastScan; + if (walletState.lastScan == null) { + throw Exception("Last scan is null"); + } final ownedOutPoints = walletState.ownedOutputs.getUnconfirmedSpentOutpoints(); - activate(walletState.lastScan); + activate(walletState.lastScan!); await wallet.scanToTip( blindbitUrl: blindbitUrl, dustLimit: BigInt.from(dustLimit), ownedOutpoints: ownedOutPoints, - lastScan: lastScan); + lastScan: walletState.lastScan!); } catch (e) { deactivate(); rethrow; diff --git a/lib/states/wallet_state.dart b/lib/states/wallet_state.dart index 436e1505..6dd12d44 100644 --- a/lib/states/wallet_state.dart +++ b/lib/states/wallet_state.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:danawallet/constants.dart'; import 'package:danawallet/data/models/bip353_address.dart'; import 'package:danawallet/data/models/recipient_form_filled.dart'; +import 'package:danawallet/extensions/date_time.dart'; import 'package:danawallet/extensions/network.dart'; import 'package:danawallet/generated/rust/api/history.dart'; import 'package:danawallet/generated/rust/api/outputs.dart'; @@ -26,12 +28,12 @@ class WalletState extends ChangeNotifier { late ApiNetwork network; late String receivePaymentCode; late String changePaymentCode; - late int birthday; + late DateTime birthday; // variables that change late ApiAmount amount; late ApiAmount unconfirmedChange; - late int lastScan; + late int? lastScan; late TxHistory txHistory; late OwnedOutputs ownedOutputs; @@ -79,7 +81,7 @@ class WalletState extends ChangeNotifier { // since the wallet data is present, the following items must also be present network = await walletRepository.readNetwork(); - birthday = await walletRepository.readBirthday(); + birthday = await _getBirthday(); danaAddress = await walletRepository.readDanaAddress(); // we calculate these based on our wallet data (scan key, spend key, network) @@ -102,38 +104,41 @@ class WalletState extends ChangeNotifier { await walletRepository.reset(); } - Future restoreWallet(ApiNetwork network, String mnemonic) async { - // set birthday to default for current network - final birthday = network.defaultBirthday; - + Future restoreWallet( + ApiNetwork network, String mnemonic, DateTime birthday) async { final args = WalletSetupArgs( setupType: WalletSetupType.mnemonic(mnemonic), network: network); final setupResult = SpWallet.setupWallet(setupArgs: args); - final wallet = - await walletRepository.setupWallet(setupResult, network, birthday); + final wallet = await walletRepository.setupWallet( + setupResult, network, birthday, null); // fill current state variables receivePaymentCode = wallet.getReceivingAddress(); changePaymentCode = wallet.getChangeAddress(); this.birthday = birthday; this.network = network; + + // lastScan will be initialized by chainState synchronization service + lastScan = null; + await _updateWalletState(); } - Future createNewWallet(ApiNetwork network, int currentTip) async { - final birthday = currentTip; + Future createNewWallet(ApiNetwork network, int? currentTip) async { + final now = DateTime.now().toUtc(); final args = WalletSetupArgs( setupType: const WalletSetupType.newWallet(), network: network); final setupResult = SpWallet.setupWallet(setupArgs: args); - final wallet = - await walletRepository.setupWallet(setupResult, network, birthday); + final wallet = await walletRepository.setupWallet( + setupResult, network, now, currentTip); // fill current state variables receivePaymentCode = wallet.getReceivingAddress(); changePaymentCode = wallet.getChangeAddress(); - this.birthday = birthday; + birthday = now; this.network = network; + lastScan = currentTip; await _updateWalletState(); } @@ -150,6 +155,36 @@ class WalletState extends ChangeNotifier { return await walletRepository.readSeedPhrase(); } + Future _getBirthday() async { + final storedBirthday = await walletRepository.readBirthday(); + if (storedBirthday.isAfter(minimumAllowedBirthday)) { + // This is a timestamp, we can use it directly + return storedBirthday; + } else { + // if the birthday is older than the minimum allowed birthday, + // this value must be from an earlier version where we stored the birthday as a block height. + // to fix this, we convert the stored birthday back to an integer, + // and fetch the date from that block + final blockHeight = storedBirthday.toSeconds(); + try { + final mempoolApi = MempoolApiRepository(network: network); + final block = await mempoolApi.getBlockForHash( + await mempoolApi.getBlockHashForHeight(blockHeight)); + final newBirthday = block.timestamp.toDate(); + Logger().i("Resolved block height $blockHeight to date $newBirthday"); + // store converted birthday in persistent storage before returning + await walletRepository.saveBirthday(newBirthday); + return newBirthday; + } catch (e) { + Logger() + .w("Error resolving block height $storedBirthday to timestamp: $e"); + // if this process fails, just use the default birthday. + // this value isn't crucial to enough to fail over + return defaultBirthday; + } + } + } + Future resetToScanHeight(int height) async { lastScan = height; diff --git a/pubspec.lock b/pubspec.lock index 5c5828b4..ceeb6f4c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -474,6 +474,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6befc819..7f056d04 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: path: ^1.9.1 collection: ^1.19.1 package_info_plus: ^9.0.0 + intl: ^0.19.0 dependency_overrides: # Pin url_launcher_android to avoid AGP 8.9.1 requirement (androidx.browser:1.9.0, androidx.core:1.17.0)