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
78 changes: 20 additions & 58 deletions bitwindow/lib/pages/wallet/wallet_hd.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import 'package:bip39_mnemonic/bip39_mnemonic.dart';
import 'package:bitwindow/providers/hd_wallet_provider.dart';
import 'package:bitwindow/utils/converter.dart';
import 'package:bs58/bs58.dart';
import 'package:convert/convert.dart';
import 'package:dart_bip32_bip44/dart_bip32_bip44.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart';
import 'package:pointycastle/digests/ripemd160.dart';
import 'package:pointycastle/digests/sha256.dart';
import 'package:sail_ui/sail_ui.dart';
import 'package:stacked/stacked.dart';
Expand Down Expand Up @@ -126,7 +126,7 @@ class _ControlsCard extends StatelessWidget {
child: SailTextField(
label: 'Derivation Path',
controller: model.derivationPathController,
hintText: "m/84'/1'/0'/0/0",
hintText: model.defaultDerivationPath,
size: TextFieldSize.small,
),
),
Expand Down Expand Up @@ -256,7 +256,11 @@ class _AddressesSection extends StatelessWidget {
class HDWalletViewModel extends BaseViewModel {
final HDWalletProvider _hdWalletProvider = GetIt.I.get<HDWalletProvider>();
final TextEditingController mnemonicController = TextEditingController();
final TextEditingController derivationPathController = TextEditingController(text: "m/84'/1'/0'/0/0");
BitcoinConfProvider get _conf => GetIt.I.get<BitcoinConfProvider>();
late final TextEditingController derivationPathController = TextEditingController(text: defaultDerivationPath);

/// The BIP84 receive chain of the wallet on the active network.
String get defaultDerivationPath => "m/84'/${_conf.usesMainnetParams ? 0 : 1}'/0'/0";

List<HDWalletEntry> _derivedEntries = [];
String? _errorMessage;
Expand Down Expand Up @@ -418,7 +422,13 @@ class HDWalletViewModel extends BaseViewModel {
Future<void> _deriveEntries(String mnemonic) async {
try {
final basePath = derivationPathController.text.trim();
final entries = await compute(_deriveEntriesInBackground, [mnemonic, basePath, _currentPage, _entriesPerPage]);
final entries = await compute(_deriveEntriesInBackground, [
mnemonic,
basePath,
_currentPage,
_entriesPerPage,
_conf.network,
]);

_derivedEntries = entries;
} catch (e) {
Expand All @@ -431,26 +441,15 @@ class HDWalletViewModel extends BaseViewModel {
final basePath = params[1] as String;
final currentPage = params[2] as int;
final entriesPerPage = params[3] as int;
final network = params[4] as BitcoinNetwork;

final entries = <HDWalletEntry>[];

try {
final mnemonicObj = Mnemonic.fromSentence(mnemonic, Language.english);
final seedHex = hex.encode(mnemonicObj.seed);
final chain = Chain.seed(seedHex);

final sha256Digest = SHA256Digest();
final ripemd160Digest = RIPEMD160Digest();
final pubKeyHash = Uint8List(20);
final versionedHash = Uint8List(21);
versionedHash[0] = 0x00;
final shaOutput = Uint8List(32);
final doubleSHAOutput = Uint8List(32);
final addressBytes = Uint8List(25);
final wifBytes = Uint8List(38);
final wifBuffer = Uint8List(34);
wifBuffer[0] = 0x80;
wifBuffer[33] = 0x01;
final purpose = purposeOf(basePath);

final startIndex = currentPage * entriesPerPage;

Expand All @@ -460,57 +459,20 @@ class HDWalletViewModel extends BaseViewModel {
final path = basePath.endsWith('/') ? '$basePath$derivationIndex' : '$basePath/$derivationIndex';
final extendedPrivateKey = chain.forPath(path) as ExtendedPrivateKey;
final privateKeyHex = extendedPrivateKey.privateKeyHex();
final publicKey = extendedPrivateKey.publicKey();

final q = publicKey.q;
final q = extendedPrivateKey.publicKey().q;
if (q == null) {
continue;
}

final pubKeyBytes = q.getEncoded(true);
final pubKeyHex = hex.encode(pubKeyBytes);

sha256Digest.reset();
ripemd160Digest.reset();
final sha256Result = sha256Digest.process(Uint8List.fromList(pubKeyBytes));
final ripemdResult = ripemd160Digest.process(sha256Result);
pubKeyHash.setRange(0, 20, ripemdResult);
versionedHash.setRange(1, 21, pubKeyHash);

sha256Digest.reset();
sha256Digest.update(versionedHash, 0, versionedHash.length);
sha256Digest.doFinal(shaOutput, 0);

sha256Digest.reset();
sha256Digest.update(shaOutput, 0, shaOutput.length);
sha256Digest.doFinal(doubleSHAOutput, 0);

addressBytes.setRange(0, 21, versionedHash);
addressBytes.setRange(21, 25, doubleSHAOutput.sublist(0, 4));
final address = base58.encode(addressBytes);

final cleanHex = privateKeyHex.startsWith('00') ? privateKeyHex.substring(2) : privateKeyHex;
final privateKeyBytes = hex.decode(cleanHex);
wifBuffer.setRange(1, 33, privateKeyBytes);

sha256Digest.reset();
sha256Digest.update(wifBuffer, 0, wifBuffer.length);
sha256Digest.doFinal(shaOutput, 0);

sha256Digest.reset();
sha256Digest.update(shaOutput, 0, shaOutput.length);
sha256Digest.doFinal(doubleSHAOutput, 0);

wifBytes.setRange(0, 34, wifBuffer);
wifBytes.setRange(34, 38, doubleSHAOutput.sublist(0, 4));
final wif = base58.encode(wifBytes);

entries.add(
HDWalletEntry(
path: path,
address: address,
publicKey: pubKeyHex,
privateKey: wif,
address: addressForPurpose(pubKeyBytes, purpose, network),
publicKey: hex.encode(pubKeyBytes),
privateKey: compressedWif(hex.decode(cleanHex), network),
),
);
} catch (e) {
Expand Down
3 changes: 3 additions & 0 deletions bitwindow/lib/providers/transactions_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ class TransactionProvider extends ChangeNotifier implements NetworkScoped {
final fetchedTransactions = await orchestratorWallet.listTransactions(
walletId: walletId,
);
// Core gives a replaced or conflicted tx a negative count. It never
// confirms, and the unsigned height field cannot hold it.
final transactions = fetchedTransactions.transactions
.where((tx) => tx.confirmations >= 0)
.map(
(tx) => WalletTransaction(
txid: tx.txid,
Expand Down
39 changes: 39 additions & 0 deletions bitwindow/lib/utils/converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,45 @@ _NetworkPrefixes _prefixesFor(BitcoinNetwork network) {
};
}

/// The purpose level of a BIP32 path such as m/84'/0'/0'/0, or 0 when the
/// path has none.
int purposeOf(String path) {
final levels = path.trim().split('/');
if (levels.length < 2) {
return 0;
}
return int.tryParse(levels[1].replaceAll(RegExp(r"['hH]$"), '')) ?? 0;
}

/// The address that a BIP32 purpose (44, 49, 84 or 86) uses for a compressed
/// public key. Any other purpose gets P2PKH.
String addressForPurpose(List<int> compressedPubKey, int purpose, BitcoinNetwork network) {
final prefixes = _prefixesFor(network);
final pubKeyHash = hash160(compressedPubKey);
return switch (purpose) {
49 => _base58Check(prefixes.p2sh, hash160([0x00, 0x14, ...pubKeyHash])),
84 => bu.SegwitBech32Encoder.encode(prefixes.hrp, 0, pubKeyHash),
86 => bu.SegwitBech32Encoder.encode(
prefixes.hrp,
1,
conv.hex.decode(bb.ECPublic.fromBytes(compressedPubKey).toTaprootAddress().addressProgram),
),
_ => _base58Check(prefixes.p2pkh, pubKeyHash),
};
}

/// The compressed-key WIF of a 32-byte secret on the network.
String compressedWif(List<int> secret, BitcoinNetwork network) =>
_base58Check(_prefixesFor(network).wif, [...secret, 0x01]);

String _base58Check(int version, List<int> payload) {
final encoded = Base58Check.encode(version, payload);
if (encoded == null) {
throw const FormatException('Base58Check encoding failed');
}
return encoded;
}

Uint8List sha256Bytes(List<int> data) => Uint8List.fromList(sha256.convert(data).bytes);

Uint8List doubleSha256(List<int> data) => sha256Bytes(sha256Bytes(data));
Expand Down
45 changes: 45 additions & 0 deletions bitwindow/test/converter_test.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import 'dart:convert';

import 'package:bip39_mnemonic/bip39_mnemonic.dart';
import 'package:bitwindow/utils/converter.dart';
import 'package:convert/convert.dart' as conv;
import 'package:dart_bip32_bip44/dart_bip32_bip44.dart' show Chain, ExtendedPrivateKey;
import 'package:flutter_test/flutter_test.dart';
import 'package:sail_ui/sail_ui.dart';

Expand Down Expand Up @@ -162,4 +164,47 @@ void main() {
expect(_row(result, 'Private key (hex)'), conv.hex.encode(sha256Bytes(utf8.encode('satoshi'))));
expect(_row(result, 'P2PKH address').startsWith('1'), isTrue);
});

group('HD explorer addresses', () {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
final chain = Chain.seed(conv.hex.encode(Mnemonic.fromSentence(mnemonic, Language.english).seed));

List<int> pubKeyAt(String path) => (chain.forPath(path) as ExtendedPrivateKey).publicKey().q!.getEncoded(true);

test('the address type follows the path purpose', () {
const vectors = {
"m/44'/0'/0'/0/0": '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA',
"m/49'/0'/0'/0/0": '37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf',
"m/84'/0'/0'/0/0": 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu',
"m/86'/0'/0'/0/0": 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr',
};
vectors.forEach((path, want) {
expect(addressForPurpose(pubKeyAt(path), purposeOf(path), mainnet), want, reason: path);
});
});

test('eCash gets the mainnet segwit address', () {
const path = "m/84'/0'/0'/0/0";
expect(
addressForPurpose(pubKeyAt(path), purposeOf(path), BitcoinNetwork.BITCOIN_NETWORK_ECASH),
'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu',
);
});

test('a test network gets a tb1 address', () {
const path = "m/84'/1'/0'/0/0";
expect(addressForPurpose(pubKeyAt(path), purposeOf(path), signet).startsWith('tb1q'), isTrue);
});

test('the WIF follows the network', () {
final secret = (chain.forPath("m/84'/0'/0'/0/0") as ExtendedPrivateKey).key!.toRadixString(16).padLeft(64, '0');
expect(compressedWif(conv.hex.decode(secret), mainnet), 'KyZpNDKnfs94vbrwhJneDi77V6jF64PWPF8x5cdJb8ifgg2DUc9d');
});

test('purposeOf reads the purpose level', () {
expect(purposeOf("m/84'/0'/0'/0"), 84);
expect(purposeOf('m/44h/1h/0h/0'), 44);
expect(purposeOf('m'), 0);
});
});
}
19 changes: 19 additions & 0 deletions bitwindow/test/transaction_warning_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -235,4 +235,23 @@ void main() {
);
expect(provider.walletTransactions.every((transaction) => transaction.note.isEmpty), isTrue);
});

test('a conflicted transaction leaves the rest of the history', () async {
orchestrator.wallet.transactions = [
wmpb.TransactionEntry(txid: 'replaced', confirmations: -1, amountSats: Int64(1000)),
wmpb.TransactionEntry(txid: 'kept', confirmations: 3, amountSats: Int64(1000)),
];
final provider = TransactionProvider();
addTearDown(provider.dispose);
final loaded = Completer<void>();
provider.addListener(() {
if ((provider.initialized || provider.error != null) && !loaded.isCompleted) {
loaded.complete();
}
});
await loaded.future;

expect(provider.error, isNull);
expect(provider.walletTransactions.map((transaction) => transaction.txid), ['kept']);
});
}
Loading
Loading