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
12 changes: 6 additions & 6 deletions lib/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions lib/data/models/mempool_block_timestamp_response.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) {
return MempoolBlockTimestampResponse(
height: json['height'] as int,
hash: json['hash'] as String,
timestamp: json['timestamp'] as String,
);
}
}
49 changes: 49 additions & 0 deletions lib/data/models/mempool_blocks.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> 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(),
);
}
}
23 changes: 23 additions & 0 deletions lib/extensions/date_time.dart
Original file line number Diff line number Diff line change
@@ -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);
}
}
14 changes: 0 additions & 14 deletions lib/extensions/network.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
43 changes: 43 additions & 0 deletions lib/repositories/mempool_api_repository.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -61,4 +63,45 @@ class MempoolApiRepository {
throw Exception("Unexpected status code: ${response.statusCode}");
}
}

Future<MempoolGetBlockResponse> 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<String> 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<MempoolBlockTimestampResponse> 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<String, dynamic>;
return MempoolBlockTimestampResponse.fromJson(json);
} catch (e) {
throw Exception('Failed to parse block timestamp response: $e');
}
}
}
35 changes: 22 additions & 13 deletions lib/repositories/wallet_repository.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,8 +48,8 @@ class WalletRepository {
});
}

Future<SpWallet> setupWallet(
WalletSetupResult walletSetup, ApiNetwork network, int birthday) async {
Future<SpWallet> setupWallet(WalletSetupResult walletSetup,
ApiNetwork network, DateTime birthday, int? lastScan) async {
if ((await secureStorage.readAll()).isNotEmpty) {
throw Exception('Previous wallet not properly deleted');
}
Expand All @@ -61,15 +62,15 @@ 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) {
await secureStorage.write(key: _keySeedPhrase, value: seedPhrase);
}

// set default values for new wallet
await saveLastScan(birthday);
await saveLastScan(lastScan);
await saveHistory(TxHistory.empty());
await saveOwnedOutputs(OwnedOutputs.empty());

Expand Down Expand Up @@ -133,18 +134,26 @@ class WalletRepository {
return TxHistory.decode(encodedHistory: encodedHistory!);
}

Future<int> readBirthday() async {
final birthday = await nonSecureStorage.getInt(_keyBirthday);
return birthday!;
Future<void> saveBirthday(DateTime birthday) async {
await nonSecureStorage.setInt(_keyBirthday, birthday.toSeconds());
}

Future<void> saveLastScan(int lastScan) async {
await nonSecureStorage.setInt(_keyLastScan, lastScan);
Future<DateTime> readBirthday() async {
final timestamp = await nonSecureStorage.getInt(_keyBirthday);
return timestamp!.toDate();
}

Future<int> readLastScan() async {
Future<void> saveLastScan(int? lastScan) async {
if (lastScan != null) {
await nonSecureStorage.setInt(_keyLastScan, lastScan);
} else {
await nonSecureStorage.remove(_keyLastScan);
}
}

Future<int?> readLastScan() async {
final lastScan = await nonSecureStorage.getInt(_keyLastScan);
return lastScan!;
return lastScan;
}

Future<void> saveOwnedOutputs(OwnedOutputs ownedOutputs) async {
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions lib/screens/onboarding/overview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,13 @@ class _OverviewScreenState extends State<OverviewScreen> {
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) {
Expand Down
74 changes: 74 additions & 0 deletions lib/screens/onboarding/recovery/birthday_picker_screen.dart
Original file line number Diff line number Diff line change
@@ -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<BirthdayPickerScreen> createState() => _BirthdayPickerScreenState();
}

class _BirthdayPickerScreenState extends State<BirthdayPickerScreen> {
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),
);
}
}
Loading