Merge upstream Cake Wallet v6.5.0

Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
This commit is contained in:
jwinterm
2026-10-24 23:49:29 -04:00
1406 changed files with 81062 additions and 48972 deletions
+5
View File
@@ -0,0 +1,5 @@
targets:
$default:
sources:
exclude:
- "**/.secrets.g.dart"
+17 -1
View File
@@ -9,6 +9,7 @@ abstract class BaseBitcoinAddressRecord {
BaseBitcoinAddressRecord(
this.address, {
required this.index,
this.accountIndex = 0,
this.isHidden = false,
this.isLegacyDerivation = false,
int txCount = 0,
@@ -29,6 +30,7 @@ abstract class BaseBitcoinAddressRecord {
bool isHidden;
bool isLegacyDerivation;
final int index;
final int accountIndex;
int _txCount;
int _balance;
String _name;
@@ -63,7 +65,9 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord {
BitcoinAddressRecord(
super.address, {
required super.index,
super.accountIndex = 0,
super.isHidden = false,
this.isHiddenChecked = false,
super.isLegacyDerivation = false,
super.txCount = 0,
super.balance = 0,
@@ -105,7 +109,9 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord {
return BitcoinAddressRecord(
decoded['address'] as String,
index: decoded['index'] as int,
accountIndex: decoded['accountIndex'] as int? ?? 0,
isHidden: decoded['isHidden'] as bool? ?? false,
isHiddenChecked: decoded['isHiddenChecked'] as bool? ?? false,
isLegacyDerivation: parsedIsLegacy,
isUsed: decoded['isUsed'] as bool? ?? false,
txCount: decoded['txCount'] as int? ?? 0,
@@ -117,6 +123,10 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord {
);
}
/// Whether [isHidden] has been checked against the wallet's keys. Records created from the
/// wallet's own keys are correct by construction; older ones are checked once on wallet open.
bool isHiddenChecked;
String? scriptHash;
static int _purposeForType(BitcoinAddressType type) {
@@ -162,7 +172,8 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord {
final coinType = _coinTypeForNetwork();
final purpose = _purposeForType(type);
final accountPath = isLegacyDerivation ? electrum_path : "m/$purpose'/$coinType'/0'";
final accountPath =
isLegacyDerivation ? electrum_path : "m/$purpose'/$coinType'/$accountIndex'";
final chain = isHidden ? 1 : 0;
return "$accountPath/$chain/$index";
@@ -172,7 +183,9 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord {
String toJSON() => json.encode({
'address': address,
'index': index,
'accountIndex': accountIndex,
'isHidden': isHidden,
'isHiddenChecked': isHiddenChecked,
'isLegacyDerivation': isLegacyDerivation,
'isUsed': isUsed,
'txCount': txCount,
@@ -187,6 +200,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord {
BitcoinSilentPaymentAddressRecord(
super.address, {
required super.index,
super.accountIndex = 0,
super.isHidden = false,
super.txCount = 0,
super.balance = 0,
@@ -205,6 +219,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord {
return BitcoinSilentPaymentAddressRecord(
decoded['address'] as String,
index: decoded['index'] as int,
accountIndex: decoded['accountIndex'] as int? ?? 0,
isHidden: decoded['isHidden'] as bool? ?? false,
isUsed: decoded['isUsed'] as bool? ?? false,
txCount: decoded['txCount'] as int? ?? 0,
@@ -233,6 +248,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord {
String toJSON() => json.encode({
'address': address,
'index': index,
'accountIndex': accountIndex,
'isHidden': isHidden,
'isUsed': isUsed,
'txCount': txCount,
+134 -40
View File
@@ -7,16 +7,20 @@ import 'package:cw_bitcoin/.secrets.g.dart' as secrets;
import 'package:cw_bitcoin/address_from_output.dart';
import 'package:cw_bitcoin/bitcoin_address_record.dart';
import 'package:cw_bitcoin/bitcoin_mnemonic.dart';
import "package:cw_bitcoin/bitcoin_receive_page_option.dart";
import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
import 'package:cw_bitcoin/bitcoin_wallet_addresses.dart';
import 'package:cw_bitcoin/electrum_balance.dart';
import 'package:cw_bitcoin/electrum_derivations.dart';
import 'package:cw_bitcoin/electrum_transaction_info.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/electrum_wallet_addresses.dart';
import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
import 'package:cw_bitcoin/locktime.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/lightning/lightning_wallet.dart';
import 'package:cw_bitcoin/hardware/bitcoin_ledger_service.dart';
import 'package:cw_bitcoin/output_ordering.dart';
import 'package:cw_bitcoin/payjoin/manager.dart';
import 'package:cw_bitcoin/payjoin/storage.dart';
import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
@@ -24,12 +28,14 @@ import 'package:cw_bitcoin/psbt/signer.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import 'package:cw_bitcoin/psbt/v0_deserialize.dart';
import 'package:cw_bitcoin/psbt/v0_finalizer.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_core/output_info.dart';
import 'package:cw_core/parse_fixed.dart';
import 'package:cw_core/payjoin_session.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/sync_status.dart';
import "package:cw_core/receive_page_option.dart";
import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/unspent_coins_info.dart';
import 'package:cw_core/utils/print_verbose.dart';
@@ -123,8 +129,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
initialChangeAddressIndex: initialChangeAddressIndex,
initialSilentAddresses: initialSilentAddresses,
initialSilentAddressIndex: initialSilentAddressIndex,
mainHdByType: mainHdByType,
sideHdByType: sideHdByType,
mainHdByTypeAndAccount: mainHdByTypeAndAccount,
sideHdByTypeAndAccount: sideHdByTypeAndAccount,
accountIndexes: [currentAccountIndex],
currentAccountIndex: currentAccountIndex,
legacyMainHd: mainHd,
legacySideHd: sideHd,
network: networkParam ?? network,
@@ -152,9 +160,11 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
lnurlDomain: "cake.cash",
cachedAddress: cachedLightningAddress,
);
walletAddresses.lightningWallet = lightningWallet;
walletAddresses.setLightningAddress(walletInfo.name);
}
} else {
lightningWallet?.close();
lightningWallet = null;
}
});
@@ -345,15 +355,21 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
return balance;
}
final lBalance = await lightningWallet!.getBalance();
try {
final lBalance = await lightningWallet!.getBalance();
this.balance[CryptoCurrency.btcln] =
ElectrumBalance(confirmed: lBalance.toInt(), unconfirmed: 0, frozen: 0);
this.balance[CryptoCurrency.btcln] = ElectrumBalance(
confirmed: lBalance,
unconfirmed: Money.zero(CryptoCurrency.btcln),
frozen: Money.zero(CryptoCurrency.btcln));
} catch (e) {
printV("Error fetching lightning balance: $e");
}
return ElectrumBalance(
confirmed: balance.confirmed,
unconfirmed: balance.unconfirmed,
frozen: balance.frozen.toInt(),
frozen: balance.frozen ?? Money.zero(currency),
);
}
@@ -408,12 +424,18 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
late final PayjoinManager payjoinManager;
@override
bool get hasPayjoinSupport => keys.privateKey.isNotEmpty;
@override
bool get hasLightningSupport => seed != null && LightningWallet.isAvailable;
bool get isPayjoinAvailable => unspentCoinsInfo.values
.where((element) => element.walletId == id && element.isSending && !element.isFrozen)
.isNotEmpty;
Future<PsbtV2> buildPsbt({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required List<OutputInfo> cwOutputs,
required BigInt fee,
required BasedUtxoNetwork network,
@@ -430,24 +452,65 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash);
final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!;
psbtReadyInputs.add(PSBTReadyUtxoWithAddress(
utxo: utxo.utxo,
rawTx: rawTx,
ownerDetails: utxo.ownerDetails,
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
ownerMasterFingerprint: masterFingerprint,
ownerPublicKey: publicKeyAndDerivationPath.publicKey,
));
psbtReadyInputs.add(
PSBTReadyUtxoWithAddress(
utxo: utxo.utxo,
rawTx: rawTx,
ownerDetails: utxo.ownerDetails,
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
ownerMasterFingerprint: masterFingerprint,
ownerPublicKey: publicKeyAndDerivationPath.publicKey,
),
);
}
final psbtReadyOutputs = outputs.map((o) {
final cwOutput = cwOutputs
.where(
(e) => [e.address, e.extractedAddress]
.map((e) => e?.toLowerCase())
.contains(o.address.toAddress().toLowerCase()),
)
.firstOrNull;
if (o.isChange && publicKeys.containsKey(o.address.pubKeyHash())) {
final changeKey = publicKeys[o.address.pubKeyHash()]!;
return PSBTReadyBitcoinOutput(
address: o.address,
value: o.value,
isSilentPayment: o.isSilentPayment,
isChange: o.isChange,
changeMasterFingerprint: masterFingerprint,
changeDerivationPath: changeKey.derivationPath,
changePublicKey: changeKey.publicKey,
outputInfo: cwOutput,
);
}
return PSBTReadyBitcoinOutput(
address: o.address,
value: o.value,
isSilentPayment: o.isSilentPayment,
isChange: o.isChange,
outputInfo: cwOutput,
);
}).toList();
final locktime = antiFeeSnipingLocktime(
chainTip: await getCurrentChainTip(),
synced: syncStatus is SyncedSyncStatus,
);
return PSBTTransactionBuild(
inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF, cwOutputs: cwOutputs)
.psbt;
inputs: psbtReadyInputs,
outputs: psbtReadyOutputs,
enableRBF: enableRBF,
locktime: locktime,
).psbt;
}
@override
Future<BtcTransaction> buildHardwareWalletTransaction({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required BigInt fee,
required BasedUtxoNetwork network,
required List<UtxoWithAddress> utxos,
@@ -461,8 +524,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
final masterFingerprint =
await (hardwareWalletService as BitcoinHardwareWalletService).getMasterFingerprint();
final orderedOutputs = orderOutputs(outputs, outputOrdering);
final psbt = await buildPsbt(
outputs: outputs,
outputs: orderedOutputs,
fee: fee,
network: network,
utxos: utxos,
@@ -472,7 +537,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
memo: memo,
enableRBF: enableRBF,
inputOrdering: inputOrdering,
outputOrdering: outputOrdering,
// Already applied above; don't reorder again.
outputOrdering: BitcoinOrdering.none,
);
final psbtStr = base64Encode(psbt.serialize());
@@ -495,19 +561,19 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
final isLNCompatible = await lightningWallet?.isCompatible(lnAddr);
if ((credentials.coinTypeToSpendFrom == UnspentCoinType.lightning && lightningWallet != null) ||
isLNCompatible == true) {
BigInt amount;
Money amount;
if (credentials.outputs.first.sendAll) {
amount = await lightningWallet!.getBalance();
} else {
amount = parseFixed(
credentials.outputs.first.cryptoAmount?.isNotEmpty == true
? credentials.outputs.first.cryptoAmount!
: "0",
8);
amount = credentials.outputs.first.cryptoAmount;
}
return lightningWallet!.createTransaction(lnAddr, amount > BigInt.zero ? amount : null,
credentials.priority, credentials.outputs.first.sendAll);
return lightningWallet!.createTransaction(
lnAddr,
amount.amount > BigInt.zero ? amount.amount : null,
credentials.priority,
credentials.outputs.first.sendAll,
);
}
final tx = (await super.createTransaction(credentials)) as PendingBitcoinTransaction;
@@ -526,7 +592,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
))
.toList(),
cwOutputs: credentials.outputs,
fee: BigInt.from(tx.fee),
fee: tx.fee.amount,
network: network,
memo: credentials.outputs.first.memo,
outputOrdering: BitcoinOrdering.none,
@@ -545,17 +611,18 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
tx.commitOverride = () async {
final sender =
await payjoinManager.initSender(payjoinUri!, originalPsbt, int.parse(tx.feeRate));
payjoinManager.spawnNewSender(
sender: sender, pjUrl: payjoinUri, amount: BigInt.from(tx.amount));
payjoinManager.spawnNewSender(sender: sender, pjUrl: payjoinUri, amount: tx.amount.amount);
};
return tx;
}
List<UtxoWithPrivateKey> getUtxoWithPrivateKeys() => unspentCoins
.where((e) => (e.isSending && !e.isFrozen))
.map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this))
.toList();
List<UtxoWithPrivateKey> getUtxoWithPrivateKeys({bool confirmedOnly = false}) =>
unspentCoinsForCurrentAccount
.where(
(e) => e.isSending && !e.isFrozen && (!confirmedOnly || (e.confirmations ?? 0) > 0))
.map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this))
.toList();
Future<void> commitPsbt(String finalizedPsbt) {
final psbt = PsbtV2()..deserializeV0(base64.decode(finalizedPsbt));
@@ -565,9 +632,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
return PendingBitcoinTransaction(
btcTx,
type,
accountIndex: currentAccountIndex,
electrumClient: electrumClient,
amount: 0,
fee: 0,
amount: Money.zero(currency),
fee: Money.zero(currency),
feeRate: "",
network: network,
hasChange: true,
@@ -623,9 +691,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
return PendingBitcoinTransaction(
btcTx,
type,
accountIndex: currentAccountIndex,
electrumClient: electrumClient,
amount: 0,
fee: 0,
amount: Money.zero(currency),
fee: Money.zero(currency),
feeRate: "",
network: network,
hasChange: true,
@@ -653,4 +722,29 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
return super.signMessage(message, address: address);
}
@override
bool receiveOptionAvailable(ReceivePageOption option) {
final isExtraAccount = currentAccountIndex > 0;
if (option == BitcoinReceivePageOption.lightning) {
// Lightning is tied to the primary account only
return !isExtraAccount && hasLightningSupport && isLightningInitialized;
}
if (option == BitcoinReceivePageOption.silent_payments) {
// Silent payments are tied to the primary account only
return !isExtraAccount && hasSilentPaymentsScanning;
}
// Restrict extra Bitcoin accounts (accountIndex > 0) to the allowed address types
if (isExtraAccount && option is BitcoinReceivePageOption) {
final addressType = option.toType();
if (!EXTRA_ACCOUNT_ADDRESS_TYPES.contains(addressType)) {
return false;
}
}
return true;
}
}
+25 -9
View File
@@ -23,8 +23,10 @@ class BitcoinWalletAddresses = BitcoinWalletAddressesBase with _$BitcoinWalletAd
abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with Store {
BitcoinWalletAddressesBase(
WalletInfo walletInfo, {
required super.mainHdByType,
required super.sideHdByType,
required super.mainHdByTypeAndAccount,
required super.sideHdByTypeAndAccount,
required super.accountIndexes,
required super.currentAccountIndex,
required super.legacySideHd,
required super.legacyMainHd,
required super.network,
@@ -124,7 +126,7 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S
Future<PaymentURI> getPaymentRequestUri(String amount) async {
if (addressPageType is LightningAddressType && lightningWallet != null) {
final amountSats = amount.isNotEmpty ? parseFixed(amount, 8) : null;
final amountSats = amount.isNotEmpty ? tryParseFixed(amount, 8) : null;
final lnUrl = getLnurlOfLightningAddress(address);
if (amountSats == null) {
return LightningPaymentRequest(address: address, lnURL: lnUrl, amount: amount);
@@ -145,7 +147,11 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S
try {
return super
.allAddresses
.firstWhere((e) => e.type == SegwitAddresType.p2wpkh && !e.isHidden && !e.isUsed)
.firstWhere((e) =>
e.accountIndex == currentAccountIndex &&
e.type == SegwitAddresType.p2wpkh &&
!e.isHidden &&
!e.isUsed)
.address;
} catch (_) {
return super.addressForBuy;
@@ -154,11 +160,21 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S
@override
String get addressForExchange {
if (addressPageType == LightningAddressType.p2l) {
final addresses = receiveAddresses
.where((element) => element.type == SegwitAddresType.p2wpkh && !element.isUsed);
return addresses.first.address;
final current = getFreshAddress();
final availableReceiveAddresses = receiveAddresses.where((element) =>
!element.isUsed && !element.isHidden && !hiddenAddresses.contains(element.address));
final bool isSilentPaymentsPage = addressPageType == SilentPaymentsAddresType.p2sp;
final bool isLightningPage = addressPageType == LightningAddressType.p2l;
if (isSilentPaymentsPage || isLightningPage) {
final segwit = availableReceiveAddresses
.where((e) => e.type == SegwitAddresType.p2wpkh)
.map((e) => e.address)
.toList();
return segwit.isNotEmpty ? segwit.first : current;
}
return address;
return current;
}
}
+15 -4
View File
@@ -1,12 +1,23 @@
class BitcoinWalletKeys {
const BitcoinWalletKeys(
{required this.wif, required this.privateKey, required this.publicKey, required this.xpub});
const BitcoinWalletKeys({
required this.wif,
required this.privateKey,
required this.publicKey,
required this.xpub,
this.masterFingerprint = '',
});
final String wif;
final String privateKey;
final String publicKey;
final String xpub;
final String masterFingerprint;
Map<String, String> toJson() =>
{'wif': wif, 'privateKey': privateKey, 'publicKey': publicKey, 'xpub': xpub};
Map<String, String> toJson() => {
'wif': wif,
'privateKey': privateKey,
'publicKey': publicKey,
'xpub': xpub,
'masterFingerprint': masterFingerprint,
};
}
@@ -8,7 +8,6 @@ import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_core/payjoin_session.dart';
import 'package:cw_core/unspent_coins_info.dart';
import 'package:cw_core/utils/zpub.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_service.dart';
import 'package:cw_bitcoin/bitcoin_wallet.dart';
import 'package:cw_core/pathForWallet.dart';
@@ -133,31 +132,6 @@ class BitcoinWalletService extends WalletService<
}
}
@override
Future<void> rename(String currentName, String password, String newName) async {
final currentWalletInfo = await WalletInfo.get(currentName, getType());
if (currentWalletInfo == null) {
throw Exception('Wallet not found');
}
final currentWallet = await BitcoinWalletBase.open(
password: password,
name: currentName,
walletInfo: currentWalletInfo,
unspentCoinsInfo: unspentCoinsInfoSource,
payjoinBox: payjoinSessionSource,
encryptionFileUtils: encryptionFileUtilsFor(isDirect),
);
await currentWallet.renameWalletFiles(newName);
await saveBackup(newName);
final newWalletInfo = currentWalletInfo;
newWalletInfo.id = WalletBase.idFor(newName, getType());
newWalletInfo.name = newName;
await newWalletInfo.save();
}
@override
Future<BitcoinWallet> restoreFromHardwareWallet(BitcoinRestoreWalletFromHardware credentials,
{bool? isTestnet}) async {
+88
View File
@@ -0,0 +1,88 @@
import 'dart:math';
int effectiveValue(int value, int inputCost) => value - inputCost;
class SelectedCoins {
final List<int> indices;
final bool hasChange;
const SelectedCoins(this.indices, this.hasChange);
}
SelectedCoins? branchAndBound(List<int> effValues, int target, int costOfChange,
{int maxTries = 100000}) {
final n = effValues.length;
final order = List<int>.generate(n, (i) => i)
..sort((a, b) => effValues[b].compareTo(effValues[a]));
final sorted = [for (final i in order) effValues[i]];
final suffix = List<int>.filled(n + 1, 0);
for (var i = n - 1; i >= 0; i--) suffix[i] = suffix[i + 1] + sorted[i];
final upper = target + costOfChange;
List<int>? best;
final picked = <int>[];
var tries = 0;
void dfs(int i, int sum) {
if (best != null || tries++ >= maxTries) return;
if (sum > upper) return;
if (sum >= target) { best = List.of(picked); return; }
if (i >= n || sum + suffix[i] < target) return;
picked.add(order[i]); dfs(i + 1, sum + sorted[i]); picked.removeLast();
dfs(i + 1, sum);
}
dfs(0, 0);
return best == null ? null : SelectedCoins(best!, false);
}
SelectedCoins? singleRandomDraw(List<int> effValues, int target, int minChange, Random rng) {
final order = List<int>.generate(effValues.length, (i) => i)..shuffle(rng);
final picked = <int>[]; var sum = 0;
for (final i in order) {
picked.add(i); sum += effValues[i];
if (sum >= target + minChange) return SelectedCoins(picked, true);
}
return null;
}
/// Branch-and-bound over effective values (value minus the cost of spending the
/// input at the current fee rate). [inputCosts] is parallel to [values] so each
/// input pays for its own script type's size. A match means the excess over
/// [target] stays within [window], so the remainder can be absorbed into the fee
/// instead of creating a change output. Returns null when no such subset exists.
SelectedCoins? changelessMatch({
required List<int> values,
required int target,
required List<int> inputCosts,
required int window,
int maxTries = 100000,
}) {
assert(values.length == inputCosts.length);
final keep = <int>[];
final eff = <int>[];
for (var i = 0; i < values.length; i++) {
final e = effectiveValue(values[i], inputCosts[i]);
if (e > 0) {
keep.add(i);
eff.add(e);
}
}
final match = branchAndBound(eff, target, window, maxTries: maxTries);
if (match == null) return null;
return SelectedCoins([for (final i in match.indices) keep[i]], false);
}
class InsufficientFundsException implements Exception { const InsufficientFundsException(); }
SelectedCoins selectCoins({required List<int> values, required int target,
required int inputCost, required int costOfChange, required int minChange, Random? rng}) {
final keep = <int>[]; final eff = <int>[];
for (var i = 0; i < values.length; i++) {
final e = effectiveValue(values[i], inputCost);
if (e > 0) { keep.add(i); eff.add(e); }
}
SelectedCoins? map(SelectedCoins? s) =>
s == null ? null : SelectedCoins([for (final i in s.indices) keep[i]], s.hasChange);
final bnb = map(branchAndBound(eff, target, costOfChange));
if (bnb != null) return bnb;
final srd = map(singleRandomDraw(eff, target, minChange, rng ?? Random.secure()));
if (srd != null) return srd;
throw const InsufficientFundsException();
}
+96 -6
View File
@@ -173,6 +173,7 @@ class ElectrumClient {
if (isJSONStringCorrect(unterminatedString)) {
final response = json.decode(unterminatedString);
_handleResponse(response);
// unterminatedString = null;
unterminatedString = '';
}
@@ -340,6 +341,80 @@ class ElectrumClient {
return historyMap;
}
Future<Map<String, List<Map<String, dynamic>>>> getBatchUnspent(
List<String> scriptHashes, {
int timeout = 10000,
}) async {
final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
final batchResults = await callBatchWithTimeout(
method: 'blockchain.scripthash.listunspent',
paramsList: paramsList,
timeout: timeout,
);
final unspentMap = <String, List<Map<String, dynamic>>>{};
for (int i = 0; i < scriptHashes.length; i++) {
final sh = scriptHashes[i];
if (i >= batchResults.length) {
unspentMap[sh] = const [];
continue;
}
final result = batchResults[i];
if (result is List) {
unspentMap[sh] = result
.whereType<Map<dynamic, dynamic>>()
.map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
.cast<Map<String, dynamic>>()
.toList();
} else {
unspentMap[sh] = const [];
}
}
return unspentMap;
}
Future<Map<String, Map<String, dynamic>>> getBatchBalance(
List<String> scriptHashes, {
int timeout = 10000,
}) async {
final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
final batchResults = await callBatchWithTimeout(
method: 'blockchain.scripthash.get_balance',
paramsList: paramsList,
timeout: timeout,
);
final balanceMap = <String, Map<String, dynamic>>{};
for (int i = 0; i < scriptHashes.length; i++) {
final sh = scriptHashes[i];
if (i >= batchResults.length) {
balanceMap[sh] = <String, dynamic>{};
continue;
}
final result = batchResults[i];
if (result is Map<String, dynamic>) {
balanceMap[sh] = result;
} else if (result is Map) {
balanceMap[sh] = Map<String, dynamic>.from(result);
} else {
balanceMap[sh] = <String, dynamic>{};
}
}
return balanceMap;
}
Future<Map<String, Map<String, dynamic>>> getBatchTransactionVerbose(
List<String> hashes, {
int timeout = 10000,
@@ -504,14 +579,29 @@ class ElectrumClient {
return [];
});
// Floor at 0 so unavailable/-1 estimates never become negative rates;
// cap at 2000 sat/vB per CW-1597.
static const int _maxFeeRate = 2000;
static int _sanitizeFeeRate(double feeRate) {
final rate = (stringDoubleToBitcoinAmount(feeRate.toString()) / 1000).round();
if (rate < 0) {
return 0;
}
if (rate > _maxFeeRate) {
return _maxFeeRate;
}
return rate;
}
Future<List<int>> feeRates({BasedUtxoNetwork? network}) async {
try {
final topDoubleString = await estimatefee(p: 1);
final middleDoubleString = await estimatefee(p: 5);
final bottomDoubleString = await estimatefee(p: 10);
final top = (stringDoubleToBitcoinAmount(topDoubleString.toString()) / 1000).round();
final middle = (stringDoubleToBitcoinAmount(middleDoubleString.toString()) / 1000).round();
final bottom = (stringDoubleToBitcoinAmount(bottomDoubleString.toString()) / 1000).round();
final topDouble = await estimatefee(p: 1);
final middleDouble = await estimatefee(p: 5);
final bottomDouble = await estimatefee(p: 10);
final top = _sanitizeFeeRate(topDouble);
final middle = _sanitizeFeeRate(middleDouble);
final bottom = _sanitizeFeeRate(bottomDouble);
return [bottom, middle, top];
} catch (_) {
+70 -31
View File
@@ -1,51 +1,90 @@
import 'dart:convert';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/balance.dart';
import "package:cw_core/crypto_currency.dart";
import 'package:cw_core/currency.dart';
class ElectrumBalance extends Balance {
factory ElectrumBalance.zero(CryptoCurrency currency) => ElectrumBalance(
confirmed: Money.zero(currency),
unconfirmed: Money.zero(currency),
frozen: Money.zero(currency),
);
ElectrumBalance({
required this.confirmed,
required this.unconfirmed,
required int frozen,
this.secondConfirmed = 0,
this.secondUnconfirmed = 0,
}) : this._frozen = frozen,
super.fromInt(confirmed, unconfirmed,
secondAvailable: secondConfirmed, secondAdditional: secondUnconfirmed, frozen: frozen);
required this.frozen,
this.secondConfirmed,
this.secondUnconfirmed,
}) : super(confirmed, unconfirmed,
secondAvailable: secondConfirmed, secondUnavailable: secondUnconfirmed);
static ElectrumBalance? fromJSON(String? jsonSource) {
Money confirmed;
Money unconfirmed;
Money? secondConfirmed;
Money? secondUnconfirmed;
@override
Money get available => (confirmed + unconfirmed) - frozen;
@override
Money get unavailable => unconfirmed;
@override
Money frozen;
ElectrumBalance copy() => ElectrumBalance(
confirmed: confirmed,
unconfirmed: unconfirmed,
frozen: frozen,
secondConfirmed: secondConfirmed,
secondUnconfirmed: secondUnconfirmed,
);
static ElectrumBalance? fromJSON(String? jsonSource, Currency currency) {
if (jsonSource == null) return null;
final decoded = json.decode(jsonSource) as Map;
return ElectrumBalance(
confirmed: decoded['confirmed'] as int? ?? 0,
unconfirmed: decoded['unconfirmed'] as int? ?? 0,
frozen: decoded['frozen'] as int? ?? 0,
secondConfirmed: decoded['secondConfirmed'] as int? ?? 0,
secondUnconfirmed: decoded['secondUnconfirmed'] as int? ?? 0,
confirmed: Money.fromInt(decoded['confirmed'] as int? ?? 0, currency),
unconfirmed: Money.fromInt(decoded['unconfirmed'] as int? ?? 0, currency),
frozen: Money.fromInt(decoded['frozen'] as int? ?? 0, currency),
secondConfirmed: Money.fromInt(decoded['secondConfirmed'] as int? ?? 0, currency),
secondUnconfirmed: Money.fromInt(decoded['secondUnconfirmed'] as int? ?? 0, currency),
);
}
int confirmed;
int unconfirmed;
final int _frozen;
@override
BigInt get frozen => BigInt.from(_frozen);
int secondConfirmed = 0;
int secondUnconfirmed = 0;
@override
BigInt get fullAvailableBalance =>
BigInt.from((confirmed + unconfirmed) + secondConfirmed - _frozen);
String toJSON() => json.encode({
'confirmed': confirmed,
'unconfirmed': unconfirmed,
'frozen': _frozen,
'secondConfirmed': secondConfirmed,
'secondUnconfirmed': secondUnconfirmed,
'confirmed': confirmed.amount.toInt(),
'unconfirmed': unavailable.amount.toInt(),
'frozen': frozen.amount.toInt(),
'secondConfirmed': secondAvailable?.amount.toInt() ?? 0,
'secondUnconfirmed': secondUnavailable?.amount.toInt() ?? 0,
});
// Value equality so instances can be compared/deduped (e.g. by MobX reactions via MapEquality)
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ElectrumBalance &&
other.confirmed == confirmed &&
other.unconfirmed == unconfirmed &&
other.frozen == frozen &&
other.secondConfirmed == secondConfirmed &&
other.secondUnconfirmed == secondUnconfirmed);
@override
int get hashCode => Object.hash(
confirmed,
unconfirmed,
frozen,
secondConfirmed,
secondUnconfirmed,
);
}
+37 -34
View File
@@ -5,10 +5,11 @@ import 'package:cw_bitcoin/address_from_output.dart';
import 'package:cw_bitcoin/bitcoin_address_record.dart';
import 'package:cw_bitcoin/bitcoin_amount_format.dart';
import 'package:cw_bitcoin/bitcoin_unspent.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/transaction_info.dart';
import 'package:cw_core/format_amount.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:hex/hex.dart';
@@ -17,12 +18,13 @@ class ElectrumTransactionBundle {
{required this.ins, required this.confirmations, this.time});
final BtcTransaction originalTransaction;
final List<BtcTransaction> ins;
final List<BtcTransaction?> ins;
final int? time;
final int confirmations;
}
class ElectrumTransactionInfo extends TransactionInfo {
int? accountIndex;
List<BitcoinSilentPaymentsUnspent>? unspents;
bool isReceivedSilentPayment;
bool isHogEx;
@@ -31,8 +33,8 @@ class ElectrumTransactionInfo extends TransactionInfo {
this.type, {
required String id,
int? height,
required int amount,
int? fee,
required Money amount,
Money? fee,
List<String>? inputAddresses,
List<String>? outputAddresses,
required TransactionDirection direction,
@@ -40,6 +42,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
bool isReplaced = false,
required DateTime date,
required int confirmations,
this.accountIndex,
String? to,
this.unspents,
this.isReceivedSilentPayment = false,
@@ -106,9 +109,9 @@ class ElectrumTransactionInfo extends TransactionInfo {
height: height,
isPending: false,
isReplaced: false,
fee: fee,
fee: Money.fromInt(fee, walletTypeToCryptoCurrency(type)),
direction: direction,
amount: amount,
amount: Money.fromInt(amount, walletTypeToCryptoCurrency(type)),
date: date,
confirmations: confirmations);
}
@@ -126,9 +129,14 @@ class ElectrumTransactionInfo extends TransactionInfo {
List<String> inputAddresses = [];
List<String> outputAddresses = [];
var hasMissingInputTx = false;
for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) {
final input = bundle.originalTransaction.inputs[i];
final inputTransaction = bundle.ins[i];
final inputTransaction = i < bundle.ins.length ? bundle.ins[i] : null;
if (inputTransaction == null || input.txIndex >= inputTransaction.outputs.length) {
hasMissingInputTx = true;
continue;
}
final outTransaction = inputTransaction.outputs[input.txIndex];
inputAmount += outTransaction.amount.toInt();
if (addresses.contains(addressFromOutputScript(outTransaction.scriptPubKey, network))) {
@@ -182,9 +190,13 @@ class ElectrumTransactionInfo extends TransactionInfo {
final b = tx.outputs.first.scriptPubKey.toBytes();
return b.length == 34 && b[0] == 88 && b[1] == 32;
};
final isHogEx = isHogExTx(bundle.originalTransaction) && isHogExTx(bundle.ins.first);
final firstInput = bundle.ins.isNotEmpty ? bundle.ins.first : null;
final isHogEx =
firstInput != null && isHogExTx(bundle.originalTransaction) && isHogExTx(firstInput);
final fee = inputAmount - totalOutAmount;
final fee = hasMissingInputTx ? null : inputAmount - totalOutAmount;
final walletCurrency = walletTypeToCryptoCurrency(type);
final feeMoney = fee != null ? Money.fromInt(fee, walletCurrency) : null;
return ElectrumTransactionInfo(type,
id: bundle.originalTransaction.txId(),
height: height,
@@ -192,11 +204,12 @@ class ElectrumTransactionInfo extends TransactionInfo {
isReplaced: false,
inputAddresses: inputAddresses,
outputAddresses: outputAddresses,
fee: fee,
fee: feeMoney,
direction: direction,
amount: amount,
amount: Money.fromInt(amount, walletCurrency),
date: date,
isHogEx: isHogEx,
additionalInfo: {'hasMissingInputTx': hasMissingInputTx},
confirmations: bundle.confirmations);
}
@@ -204,18 +217,23 @@ class ElectrumTransactionInfo extends TransactionInfo {
final inputAddresses = data['inputAddresses'] as List<dynamic>? ?? [];
final outputAddresses = data['outputAddresses'] as List<dynamic>? ?? [];
final unspents = data['unspents'] as List<dynamic>? ?? [];
final additionalInfo = data['additionalInfo'] as Map<String, dynamic>?;
final isLightning = (additionalInfo?['isLightning'] as bool?) == true;
final currency = isLightning ? CryptoCurrency.btcln : walletTypeToCryptoCurrency(type);
return ElectrumTransactionInfo(
type,
id: data['id'] as String,
height: data['height'] as int?,
amount: data['amount'] as int,
fee: data['fee'] as int,
amount: Money.fromInt(data['amount'] as int, currency),
fee: data['fee'] != null ? Money.fromInt(data['fee'] as int, currency) : null,
direction: parseTransactionDirectionFromInt(data['direction'] as int),
date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int),
isPending: data['isPending'] as bool,
isReplaced: data['isReplaced'] as bool? ?? false,
confirmations: data['confirmations'] as int,
accountIndex: data['accountIndex'] as int?,
inputAddresses:
inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(),
outputAddresses:
@@ -226,29 +244,12 @@ class ElectrumTransactionInfo extends TransactionInfo {
BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map<String, dynamic>))
.toList(),
isReceivedSilentPayment: data['isReceivedSilentPayment'] as bool? ?? false,
additionalInfo: data['additionalInfo'] as Map<String, dynamic>?,
additionalInfo: additionalInfo,
);
}
final WalletType type;
String? _fiatAmount;
@override
String amountFormatted() =>
'${walletTypeToCryptoCurrency(type).formatAmount(BigInt.from(amount))} ${walletTypeToCryptoCurrency(type).title}';
@override
String? feeFormatted() => fee != null
? '${walletTypeToCryptoCurrency(type).formatAmount(BigInt.from(fee!))} ${walletTypeToCryptoCurrency(type).title}'
: '';
@override
String fiatAmount() => _fiatAmount ?? '';
@override
void changeFiatAmount(String amount) => _fiatAmount = formatAmount(amount);
ElectrumTransactionInfo updated(ElectrumTransactionInfo info) {
return ElectrumTransactionInfo(info.type,
id: id,
@@ -262,6 +263,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
inputAddresses: inputAddresses,
outputAddresses: outputAddresses,
confirmations: info.confirmations,
accountIndex: accountIndex,
additionalInfo: additionalInfo);
}
@@ -269,13 +271,14 @@ class ElectrumTransactionInfo extends TransactionInfo {
final m = <String, dynamic>{};
m['id'] = id;
m['height'] = height;
m['amount'] = amount;
m['amount'] = amount.amount.toInt();
m['direction'] = direction.index;
m['date'] = date.millisecondsSinceEpoch;
m['isPending'] = isPending;
m['isReplaced'] = isReplaced;
m['confirmations'] = confirmations;
m['fee'] = fee;
m['accountIndex'] = accountIndex;
m['fee'] = fee?.amount.toInt();
m['to'] = to;
m['unspents'] = unspents?.map((e) => e.toJson()).toList() ?? [];
m['inputAddresses'] = inputAddresses;
@@ -286,6 +289,6 @@ class ElectrumTransactionInfo extends TransactionInfo {
}
String toString() {
return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses, additionalInfo: $additionalInfo)';
return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, accountIndex: $accountIndex, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses, additionalInfo: $additionalInfo)';
}
}
File diff suppressed because it is too large Load Diff
+407 -132
View File
@@ -1,5 +1,7 @@
import 'dart:async' show Zone;
import 'dart:io' show Platform;
import 'dart:math';
import "package:collection/collection.dart";
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
@@ -20,6 +22,15 @@ import 'package:mobx/mobx.dart';
part 'electrum_wallet_addresses.g.dart';
class UnsupportedAddressTypeForAccountException implements Exception {
UnsupportedAddressTypeForAccountException(this.message);
final String message;
@override
String toString() => message;
}
abstract class ElectrumWalletAddresses = ElectrumWalletAddressesBase with _$ElectrumWalletAddresses;
const List<BitcoinAddressType> BITCOIN_ADDRESS_TYPES = [
@@ -30,6 +41,11 @@ const List<BitcoinAddressType> BITCOIN_ADDRESS_TYPES = [
P2shAddressType.p2wpkhInP2sh,
];
const List<BitcoinAddressType> LEGACY_DUPLICATE_ADDRESS_TYPES = [
SegwitAddresType.p2wpkh,
SegwitAddresType.p2wsh,
];
const List<BitcoinAddressType> LITECOIN_ADDRESS_TYPES = [
SegwitAddresType.p2wpkh,
SegwitAddresType.mweb,
@@ -43,11 +59,15 @@ const List<BitcoinAddressType> DOGECOIN_ADDRESS_TYPES = [
P2pkhAddressType.p2pkh,
];
const List<BitcoinAddressType> EXTRA_ACCOUNT_ADDRESS_TYPES = [SegwitAddresType.p2wpkh];
abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
ElectrumWalletAddressesBase(
WalletInfo walletInfo, {
required this.mainHdByType,
required this.sideHdByType,
required this.mainHdByTypeAndAccount,
required this.sideHdByTypeAndAccount,
required this.accountIndexes,
required this.currentAccountIndex,
required this.legacyMainHd,
required this.legacySideHd,
required this.network,
@@ -72,12 +92,12 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
.toSet()),
currentReceiveAddressIndexByType = initialRegularAddressIndex ?? {},
currentChangeAddressIndexByType = initialChangeAddressIndex ?? {},
_addressPageType = initialAddressPageType ??
(walletInfo.addressPageType != null
? walletInfo.addressPageType == LightningAddressType.p2l.value
? LightningAddressType.p2l
: BitcoinAddressType.fromValue(walletInfo.addressPageType!)
: SegwitAddresType.p2wpkh),
_addressPageType = _resolveInitialAddressPageType(
initialAddressPageType: initialAddressPageType,
walletInfo: walletInfo,
mainHdByTypeAndAccount: mainHdByTypeAndAccount,
currentAccountIndex: currentAccountIndex,
),
silentAddresses = ObservableList<BitcoinSilentPaymentAddressRecord>.of(
(initialSilentAddresses ?? []).toSet()),
currentSilentAddressIndex = initialSilentAddressIndex,
@@ -142,12 +162,16 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
// TODO: add this variable in `litecoin_wallet_addresses` and just add a cast in cw_bitcoin to use it
final ObservableList<BitcoinAddressRecord> mwebAddresses;
final BasedUtxoNetwork network;
Map<BitcoinAddressType, Bip32Slip10Secp256k1> mainHdByType;
Map<BitcoinAddressType, Bip32Slip10Secp256k1> sideHdByType;
final Map<int, Map<BitcoinAddressType, Bip32Slip10Secp256k1>> mainHdByTypeAndAccount;
final Map<int, Map<BitcoinAddressType, Bip32Slip10Secp256k1>> sideHdByTypeAndAccount;
List<int> accountIndexes;
@observable
int currentAccountIndex;
final Bip32Slip10Secp256k1 legacyMainHd;
final Bip32Slip10Secp256k1 legacySideHd;
final bool isHardwareWallet;
final LightningWallet? lightningWallet;
LightningWallet? lightningWallet;
@observable
ObservableMap<BitcoinAddressType, String> lockedReceiveAddressByType;
@@ -170,9 +194,22 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
@computed
List<BitcoinAddressRecord> get allAddresses => _addresses;
@observable
bool addressRefreshToggle = false;
@action
String getFreshAddress() {
addressRefreshToggle = !addressRefreshToggle;
return address;
}
@override
String get addressForExchange => getFreshAddress();
@override
@computed
String get address {
final _ = addressRefreshToggle;
if (addressPageType == SilentPaymentsAddresType.p2sp) {
if (activeSilentAddress != null) {
return activeSilentAddress!;
@@ -186,8 +223,16 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
"Error: Unable to fetch your Lightning address, please check your network connection.";
}
final typeMatchingAddressesAll =
_addresses.where((addr) => !addr.isHidden && _isAddressPageTypeMatch(addr)).toList();
final accountIndexForCheck = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0;
if (!_isAddressTypeSupportedForAccount(addressPageType, accountIndexForCheck)) {
printV("addressPageType $addressPageType is not supported for account $accountIndexForCheck");
return "";
}
final typeMatchingAddressesAll = _addresses
.where((addr) =>
_isCurrentAccountAddress(addr) && !addr.isHidden && _isAddressPageTypeMatch(addr))
.toList();
// Prefer standard derivation addresses for the current/active address,
// but keep legacy addresses present in the overall address lists.
@@ -196,8 +241,9 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
...typeMatchingAddressesAll.where((a) => a.isLegacyDerivation),
];
final typeMatchingReceiveAddressesAll =
typeMatchingAddressesAll.where((addr) => !addr.isUsed).toList();
final typeMatchingReceiveAddressesAll = typeMatchingAddressesAll
.where((addr) => !addr.isUsed && !hiddenAddresses.contains(addr.address))
.toList();
final typeMatchingReceiveAddresses = <BitcoinAddressRecord>[
...typeMatchingReceiveAddressesAll.where((a) => !a.isLegacyDerivation),
...typeMatchingReceiveAddressesAll.where((a) => a.isLegacyDerivation),
@@ -220,10 +266,15 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
}
final locked = lockedReceiveAddressByType[addressPageType];
if (locked != null) return locked;
if (locked != null && !hiddenAddresses.contains(locked)) return locked;
final prev = previousAddressRecord;
if (prev != null && prev.type == addressPageType && !prev.isUsed && !prev.isLegacyDerivation) {
if (prev != null &&
_isCurrentAccountAddress(prev) &&
prev.type == addressPageType &&
!prev.isUsed &&
!prev.isLegacyDerivation &&
!hiddenAddresses.contains(prev.address)) {
return prev.address;
}
@@ -285,8 +336,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
return silentAddress?.toString() ?? '';
}
final mainHd = mainHdByType[addressPageType] ?? mainHdByType.values.first;
return getAddress(index: 0, hd: mainHd, addressType: addressPageType);
return _firstAddressForType(addressPageType);
}
String get payjoinCompatibleAddress {
@@ -295,7 +345,18 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
? SegwitAddresType.p2wpkh
: addressPageType;
final mainHd = mainHdByType[addrType] ?? mainHdByType.values.first;
return _firstAddressForType(addrType);
}
String _firstAddressForType(BitcoinAddressType addrType) {
final mainMap = walletInfo.type == WalletType.bitcoin
? mainHdByTypeAndAccount[currentAccountIndex]
: mainHdByTypeAndAccount[0];
final mainHd = mainMap?[addrType] ?? mainMap?.values.first;
if (mainHd == null) return '';
return getAddress(index: 0, hd: mainHd, addressType: addrType);
}
@@ -337,7 +398,11 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
});
@override
Future<void> init() async {
Future<void> init({List<int> accountIndexes = const []}) async {
final effectiveAccountIndexes =
accountIndexes.isNotEmpty ? accountIndexes : this.accountIndexes;
if (accountIndexes.isNotEmpty) this.accountIndexes = accountIndexes;
if (walletInfo.type == WalletType.bitcoinCash) {
await _generateInitialAddresses(type: P2pkhAddressType.p2pkh);
} else if (walletInfo.type == WalletType.litecoin) {
@@ -348,28 +413,20 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
} else if (walletInfo.type == WalletType.dogecoin) {
await _generateInitialAddresses(type: P2pkhAddressType.p2pkh);
} else if (walletInfo.type == WalletType.bitcoin) {
await _generateInitialAddresses(isLegacyDerivation: true);
await _generateInitialAddresses();
if (!isHardwareWallet) {
await _generateInitialAddresses(type: P2pkhAddressType.p2pkh, isLegacyDerivation: true);
await _generateInitialAddresses(type: P2pkhAddressType.p2pkh);
for (final accountIndex in effectiveAccountIndexes) {
await _generateInitialAddresses(
type: P2shAddressType.p2wpkhInP2sh, isLegacyDerivation: true);
await _generateInitialAddresses(type: P2shAddressType.p2wpkhInP2sh);
await _generateInitialAddresses(type: SegwitAddresType.p2tr, isLegacyDerivation: true);
await _generateInitialAddresses(type: SegwitAddresType.p2tr);
await _generateInitialAddresses(type: SegwitAddresType.p2wsh, isLegacyDerivation: true);
await _generateInitialAddresses(type: SegwitAddresType.p2wsh);
await prepareAccountAddresses(
accountIndex,
types: accountIndex == 0 ? BITCOIN_ADDRESS_TYPES : EXTRA_ACCOUNT_ADDRESS_TYPES,
includeLegacy: accountIndex == 0,
);
}
}
updateAddressesByMatch();
updateReceiveAddresses();
updateChangeAddresses();
_validateAddresses();
await _validateAddresses();
await updateAddressesInBox();
if (currentReceiveAddressIndex >= receiveAddresses.length) {
@@ -389,9 +446,21 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
updateChangeAddresses();
if (changeAddresses.isEmpty) {
final newAddresses = await _createNewAddresses(gap,
startIndex: totalCountOfChangeAddresses > 0 ? totalCountOfChangeAddresses - 1 : 0,
isHidden: true);
final accountChangeCount = _addresses
.where((addressRecord) =>
_isCurrentAccountAddress(addressRecord) &&
addressRecord.isHidden &&
addressRecord.type == addressPageType)
.length;
final newAddresses = await _createNewAddresses(
gap,
startIndex: accountChangeCount,
isHidden: true,
type: addressPageType,
accountIndex: currentAccountIndex,
);
addAddresses(newAddresses);
}
@@ -405,6 +474,11 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
return address;
}
bool _isCurrentAccountAddress(BitcoinAddressRecord addressRecord) {
return walletInfo.type != WalletType.bitcoin ||
addressRecord.accountIndex == currentAccountIndex;
}
Map<String, String> get labels {
final G = ECPublic.fromBytes(BigintUtils.toBytes(Curves.generatorSecp256k1.x, length: 32));
final labels = <String, String>{};
@@ -422,8 +496,35 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
return labels;
}
Future<void> prepareAccountAddresses(
int accountIndex, {
List<BitcoinAddressType> types = BITCOIN_ADDRESS_TYPES,
bool includeLegacy = false,
}) async {
for (final type in types) {
final shouldSkipHardwareWalletType = isHardwareWallet && type != SegwitAddresType.p2wpkh;
if (shouldSkipHardwareWalletType) continue;
await _generateInitialAddresses(accountIndex: accountIndex, type: type);
// Legacy derivation for these types is identical to the standard one.
if (includeLegacy && !LEGACY_DUPLICATE_ADDRESS_TYPES.contains(type)) {
await _generateInitialAddresses(
accountIndex: accountIndex,
type: type,
isLegacyDerivation: true,
);
}
}
}
@action
BaseBitcoinAddressRecord generateNewAddress({String label = ''}) {
if (addressPageType is LightningAddressType) {
throw Exception("Lightning addresses cannot be rotated");
}
if (addressPageType == SilentPaymentsAddresType.p2sp && silentAddress != null) {
final currentSilentAddressIndex = silentAddresses
.where((addressRecord) => addressRecord.type != SegwitAddresType.p2tr)
@@ -449,14 +550,28 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
return address;
}
final newAddressIndex = addressesByReceiveType.fold(
0, (int acc, addressRecord) => addressRecord.isHidden == false ? acc + 1 : acc);
final accountIndex = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0;
final hd = _hdFor(isHidden: false, type: addressPageType, isLegacyDerivation: false);
final newAddressIndex = addressesByReceiveType.fold(
0,
(int acc, addressRecord) => addressRecord is BitcoinAddressRecord &&
addressRecord.accountIndex == accountIndex &&
addressRecord.isHidden == false
? acc + 1
: acc);
final hd = _hdForAddressGeneration(
isHidden: false,
type: addressPageType,
isLegacyDerivation: false,
accountIndex: accountIndex,
);
final address = BitcoinAddressRecord(
getAddress(index: newAddressIndex, hd: hd, addressType: addressPageType),
index: newAddressIndex,
accountIndex: accountIndex,
isHidden: false,
isHiddenChecked: true,
isLegacyDerivation: false,
name: label,
type: addressPageType,
@@ -486,45 +601,54 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
void addBitcoinAddressTypes() {
final lastP2wpkh = _addresses
.where((addressRecord) =>
_isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh))
.toList()
.last;
if (lastP2wpkh.address != address) {
addressesMap[lastP2wpkh.address] = 'P2WPKH';
} else {
addressesMap[address] = 'Active - P2WPKH';
_isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh))
.lastOrNull;
if (lastP2wpkh != null) {
if (lastP2wpkh.address != address) {
addressesMap[lastP2wpkh.address] = 'P2WPKH';
} else {
addressesMap[address] = 'Active - P2WPKH';
}
}
final lastP2pkh = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh));
if (lastP2pkh.address != address) {
addressesMap[lastP2pkh.address] = 'P2PKH';
} else {
addressesMap[address] = 'Active - P2PKH';
final lastP2pkh = _addresses.firstWhereOrNull(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh));
if (lastP2pkh != null) {
if (lastP2pkh.address != address) {
addressesMap[lastP2pkh.address] = 'P2PKH';
} else {
addressesMap[address] = 'Active - P2PKH';
}
}
final lastP2sh = _addresses.firstWhere((addressRecord) =>
final lastP2sh = _addresses.firstWhereOrNull((addressRecord) =>
_isUnusedReceiveAddressByType(addressRecord, P2shAddressType.p2wpkhInP2sh));
if (lastP2sh.address != address) {
addressesMap[lastP2sh.address] = 'P2SH';
} else {
addressesMap[address] = 'Active - P2SH';
if (lastP2sh != null) {
if (lastP2sh.address != address) {
addressesMap[lastP2sh.address] = 'P2SH';
} else {
addressesMap[address] = 'Active - P2SH';
}
}
final lastP2tr = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr));
if (lastP2tr.address != address) {
addressesMap[lastP2tr.address] = 'P2TR';
} else {
addressesMap[address] = 'Active - P2TR';
final lastP2tr = _addresses.firstWhereOrNull(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr));
if (lastP2tr != null) {
if (lastP2tr.address != address) {
addressesMap[lastP2tr.address] = 'P2TR';
} else {
addressesMap[address] = 'Active - P2TR';
}
}
final lastP2wsh = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh));
if (lastP2wsh.address != address) {
addressesMap[lastP2wsh.address] = 'P2WSH';
} else {
addressesMap[address] = 'Active - P2WSH';
final lastP2wsh = _addresses.firstWhereOrNull(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh));
if (lastP2wsh != null) {
if (lastP2wsh.address != address) {
addressesMap[lastP2wsh.address] = 'P2WSH';
} else {
addressesMap[address] = 'Active - P2WSH';
}
}
final firstSilentAddressRecord = silentAddresses.firstOrNull;
@@ -656,15 +780,20 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
addressesByReceiveType.clear();
addressesByReceiveType.addAll(
_addresses.where(_isAddressPageTypeMatch).toList(),
_addresses
.where((addressRecord) =>
_isCurrentAccountAddress(addressRecord) && _isAddressPageTypeMatch(addressRecord))
.toList(),
);
}
@action
void updateReceiveAddresses() {
receiveAddresses.removeRange(0, receiveAddresses.length);
final newAddresses =
_addresses.where((addressRecord) => !addressRecord.isHidden && !addressRecord.isUsed);
final newAddresses = _addresses.where((addressRecord) =>
_isCurrentAccountAddress(addressRecord) &&
!addressRecord.isHidden &&
!addressRecord.isUsed);
receiveAddresses.addAll(newAddresses);
}
@@ -672,6 +801,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
void updateChangeAddresses() {
changeAddresses.removeRange(0, changeAddresses.length);
final newAddresses = _addresses.where((addressRecord) =>
_isCurrentAccountAddress(addressRecord) &&
addressRecord.isHidden &&
!addressRecord.isUsed &&
// TODO: feature to change change address type. For now fixed to p2wpkh, the cheapest type
@@ -686,6 +816,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
Future<String?> Function(BitcoinAddressRecord) getAddressHistory, {
BitcoinAddressType type = SegwitAddresType.p2wpkh,
required bool isLegacyDerivation,
int accountIndex = 0,
}) async {
final newAddresses = await _createNewAddresses(
gap,
@@ -693,6 +824,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
isHidden: isHidden,
isLegacyDerivation: isLegacyDerivation,
type: type,
accountIndex: accountIndex,
);
addAddresses(newAddresses);
@@ -708,58 +840,67 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
getAddressHistory,
type: type,
isLegacyDerivation: isLegacyDerivation,
accountIndex: accountIndex,
);
}
}
@action
Future<bool> discoverAddressesBatch(
Future<List<BitcoinAddressRecord>> discoverAddressesBatch(
List<BitcoinAddressRecord> addressList,
bool isHidden,
Future<Set<String>> Function(List<BitcoinAddressRecord>) getUsedAddresses, {
BitcoinAddressType type = SegwitAddresType.p2wpkh,
required bool isLegacyDerivation,
int accountIndex = 0,
}) async {
final newAddresses = await _createNewAddresses(
gap,
startIndex: addressList.length,
isHidden: isHidden,
type: type,
isLegacyDerivation: isLegacyDerivation,
accountIndex: accountIndex,
);
addAddresses(newAddresses);
final usedAddresses = await getUsedAddresses(newAddresses);
final isLastAddressUsed =
newAddresses.isNotEmpty && usedAddresses.contains(newAddresses.last.address);
if (!isLastAddressUsed) {
return false;
final hasUsedAddressInGap =
newAddresses.any((addressRecord) => usedAddresses.contains(addressRecord.address));
if (!hasUsedAddressInGap) {
return newAddresses;
}
final updatedAddressList = [...addressList, ...newAddresses];
await discoverAddressesBatch(
final moreNewAddresses = await discoverAddressesBatch(
updatedAddressList,
isHidden,
getUsedAddresses,
type: type,
isLegacyDerivation: isLegacyDerivation,
accountIndex: accountIndex,
);
return true;
return [...newAddresses, ...moreNewAddresses];
}
Future<void> _generateInitialAddresses(
{BitcoinAddressType type = SegwitAddresType.p2wpkh, bool isLegacyDerivation = false}) async {
// Legacy derivation produces the same addresses as standard for these types.
// Don't generate a legacy set to avoid duplicates.
if (isLegacyDerivation && (type == SegwitAddresType.p2wpkh || type == SegwitAddresType.p2wsh)) {
return;
}
Future<void> _generateInitialAddresses({
BitcoinAddressType type = SegwitAddresType.p2wpkh,
bool isLegacyDerivation = false,
int accountIndex = 0,
}) async {
if (isLegacyDerivation && accountIndex != 0) return;
var countOfReceiveAddresses = 0;
var countOfHiddenAddresses = 0;
_addresses.forEach((addr) {
if (addr.type == type && addr.isLegacyDerivation == isLegacyDerivation) {
if (addr.accountIndex == accountIndex &&
addr.type == type &&
addr.isLegacyDerivation == isLegacyDerivation) {
if (addr.isHidden) {
countOfHiddenAddresses += 1;
} else {
@@ -774,7 +915,9 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
startIndex: countOfReceiveAddresses,
isHidden: false,
type: type,
isLegacyDerivation: isLegacyDerivation);
isLegacyDerivation: isLegacyDerivation,
accountIndex: accountIndex);
addAddresses(newAddresses);
}
@@ -784,31 +927,49 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
startIndex: countOfHiddenAddresses,
isHidden: true,
type: type,
isLegacyDerivation: isLegacyDerivation);
isLegacyDerivation: isLegacyDerivation,
accountIndex: accountIndex);
addAddresses(newAddresses);
}
}
Future<List<BitcoinAddressRecord>> _createNewAddresses(int count,
{int startIndex = 0,
bool isHidden = false,
BitcoinAddressType? type,
bool isLegacyDerivation = false}) async {
Future<List<BitcoinAddressRecord>> _createNewAddresses(
int count, {
int startIndex = 0,
bool isHidden = false,
BitcoinAddressType? type,
bool isLegacyDerivation = false,
int accountIndex = 0,
}) async {
final list = <BitcoinAddressRecord>[];
for (var i = startIndex; i < count + startIndex; i++) {
final addrType = type ?? addressPageType;
final hd = _hdFor(isHidden: isHidden, type: addrType, isLegacyDerivation: isLegacyDerivation);
final address = BitcoinAddressRecord(
await getAddressAsync(index: i, hd: hd, addressType: addrType),
index: i,
isHidden: isHidden,
isLegacyDerivation: isLegacyDerivation,
type: addrType,
network: network,
);
list.add(address);
try {
final hd = _hdForAddressGeneration(
isHidden: isHidden,
type: addrType,
isLegacyDerivation: isLegacyDerivation,
accountIndex: accountIndex,
);
final address = BitcoinAddressRecord(
await getAddressAsync(index: i, hd: hd, addressType: addrType),
index: i,
isHidden: isHidden,
isHiddenChecked: true,
isLegacyDerivation: isLegacyDerivation,
type: addrType,
network: network,
accountIndex: accountIndex,
);
list.add(address);
} on UnsupportedAddressTypeForAccountException catch (e) {
printV("_createNewAddresses: skipping index $i, type $addrType, "
"account $accountIndex: $e");
return [];
}
}
return list;
@@ -821,6 +982,8 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
this._addresses.clear();
this._addresses.addAll(addressesSet);
updateAddressesByMatch();
updateReceiveAddresses();
updateChangeAddresses();
}
@action
@@ -841,27 +1004,52 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
updateAddressesByMatch();
}
void _validateAddresses() {
_addresses.forEach((element) async {
if (element.type == SegwitAddresType.mweb) {
// this would add a ton of startup lag for mweb addresses since we have 1000 of them
return;
static const _validationTimeSlice = Duration(milliseconds: 16);
Future<void> _validateAddresses() async {
final addresses = _addresses.toList();
final slice = Stopwatch()..start();
for (final element in addresses) {
try {
await _validateAddress(element);
} catch (e, s) {
Zone.current.handleUncaughtError(e, s);
}
final mainHd = _hdFor(
isHidden: false, type: element.type, isLegacyDerivation: element.isLegacyDerivation);
final sideHd = _hdFor(
isHidden: true, type: element.type, isLegacyDerivation: element.isLegacyDerivation);
if (!element.isHidden &&
element.address !=
await getAddressAsync(index: element.index, hd: mainHd, addressType: element.type)) {
element.isHidden = true;
} else if (element.isHidden &&
element.address !=
await getAddressAsync(index: element.index, hd: sideHd, addressType: element.type)) {
element.isHidden = false;
if (slice.elapsed >= _validationTimeSlice) {
await Future<void>.delayed(Duration.zero);
slice.reset();
}
});
}
}
Future<void> _validateAddress(BitcoinAddressRecord element) async {
if (element.isHiddenChecked) return;
if (element.type == SegwitAddresType.mweb) {
// this would add a ton of startup lag for mweb addresses since we have 1000 of them
return;
}
try {
// Relabel only when the address re-derives from the other chain. A record from a path these
// keys don't produce matches neither chain, so it keeps its label instead of flipping.
final otherChainHd = _hdForAddressGeneration(
isHidden: !element.isHidden,
type: element.type,
isLegacyDerivation: element.isLegacyDerivation,
accountIndex: element.accountIndex,
);
final otherChainAddress =
await getAddressAsync(index: element.index, hd: otherChainHd, addressType: element.type);
if (element.address == otherChainAddress) {
element.isHidden = !element.isHidden;
}
element.isHiddenChecked = true;
} on UnsupportedAddressTypeForAccountException catch (e) {
printV("_validateAddresses: skipping ${element.address}: $e");
}
}
@override
@@ -869,7 +1057,19 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
@action
Future<void> setAddressType(BitcoinAddressType type) async {
_addressPageType = type;
final needsAccountScopedHd =
type is! LightningAddressType && type != SilentPaymentsAddresType.p2sp;
BitcoinAddressType resolvedType = type;
if (needsAccountScopedHd) {
final accountIndex = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0;
if (!_isAddressTypeSupportedForAccount(type, accountIndex)) {
resolvedType = EXTRA_ACCOUNT_ADDRESS_TYPES.first;
}
}
_addressPageType = resolvedType;
updateAddressesByMatch();
walletInfo.addressPageType = addressPageType.toString();
await walletInfo.save();
@@ -881,8 +1081,15 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
bool _isAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => addr.type == type;
bool _isUnusedReceiveAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) =>
!addr.isHidden && !addr.isUsed && addr.type == type;
bool _isUnusedReceiveAddressByType(
BitcoinAddressRecord addressRecord,
BitcoinAddressType type,
) {
return _isCurrentAccountAddress(addressRecord) &&
!addressRecord.isHidden &&
!addressRecord.isUsed &&
addressRecord.type == type;
}
@action
void deleteSilentPaymentAddress(String address) {
@@ -893,16 +1100,84 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
updateAddressesByMatch();
}
Bip32Slip10Secp256k1 _hdFor({
// Remove all addresses associated with a specific account index.
@action
Future<void> removeAddressesForAccount(int accountIndex) async {
_addresses.removeWhere((addr) => addr.accountIndex == accountIndex);
updateAddressesByMatch();
updateReceiveAddresses();
updateChangeAddresses();
await updateAddressesInBox();
}
bool _isAddressTypeSupportedForAccount(BitcoinAddressType type, int accountIndex) {
final effectiveAccountIndex = walletInfo.type == WalletType.bitcoin ? accountIndex : 0;
return mainHdByTypeAndAccount[effectiveAccountIndex]?.containsKey(type) ?? false;
}
static BitcoinAddressType _resolveInitialAddressPageType({
required BitcoinAddressType? initialAddressPageType,
required WalletInfo walletInfo,
required Map<int, Map<BitcoinAddressType, Bip32Slip10Secp256k1>> mainHdByTypeAndAccount,
required int currentAccountIndex,
}) {
final resolved = initialAddressPageType ??
(walletInfo.addressPageType != null
? walletInfo.addressPageType == LightningAddressType.p2l.value
? LightningAddressType.p2l
: BitcoinAddressType.fromValue(walletInfo.addressPageType!)
: SegwitAddresType.p2wpkh);
// Non-account-scoped address types (Lightning, Silent Payments) don't need
// an HD map entry.
if (resolved is LightningAddressType || resolved == SilentPaymentsAddresType.p2sp) {
return resolved;
}
final effectiveAccountIndex = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0;
final isValid = mainHdByTypeAndAccount[effectiveAccountIndex]?.containsKey(resolved) ?? false;
if (!isValid) {
return EXTRA_ACCOUNT_ADDRESS_TYPES.first;
}
return resolved;
}
Bip32Slip10Secp256k1 _hdForAddressGeneration({
required bool isHidden,
required BitcoinAddressType type,
required bool isLegacyDerivation,
int accountIndex = 0,
}) {
if (isLegacyDerivation) return isHidden ? legacySideHd : legacyMainHd;
if (isLegacyDerivation) {
if (accountIndex != 0) {
throw UnsupportedAddressTypeForAccountException(
"Legacy derivation is only supported for the first account");
}
return isHidden ? legacySideHd : legacyMainHd;
}
final effectiveAccountIndex = walletInfo.type == WalletType.bitcoin ? accountIndex : 0;
final map = isHidden
? sideHdByTypeAndAccount[effectiveAccountIndex]
: mainHdByTypeAndAccount[effectiveAccountIndex];
if (map == null) {
throw UnsupportedAddressTypeForAccountException(
"HD map not found for account $effectiveAccountIndex");
}
final map = isHidden ? sideHdByType : mainHdByType;
final hd = map[type];
if (hd == null) throw Exception("HD not found for type $type");
if (hd == null) {
throw UnsupportedAddressTypeForAccountException(
"HD not found for account $accountIndex type $type");
}
return hd;
}
+11 -3
View File
@@ -2,6 +2,8 @@ import 'dart:convert';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:cw_bitcoin/bitcoin_address_record.dart';
import 'package:cw_bitcoin/electrum_balance.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_bitcoin/electrum_derivations.dart';
import 'package:cw_core/pathForWallet.dart';
@@ -92,9 +94,15 @@ class ElectrumWalletSnapshot {
final useLightning = data['useLightning'] as bool? ?? true;
final cachedLightningAddress = data['cachedLightningAddress'] as String?;
final balance = ElectrumBalance.fromJSON(data['balance'] as String?) ??
ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0);
final lightningBalance = ElectrumBalance.fromJSON(data['lightningBalance'] as String?);
final currency = walletTypeToCryptoCurrency(type);
final balance = ElectrumBalance.fromJSON(data['balance'] as String?, currency) ??
ElectrumBalance(
confirmed: Money.zero(currency),
unconfirmed: Money.zero(currency),
frozen: Money.zero(currency),
);
final lightningBalance =
ElectrumBalance.fromJSON(data['lightningBalance'] as String?, currency);
var regularAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0};
var changeAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0};
+2 -2
View File
@@ -106,11 +106,11 @@ class LitecoinBitboxService extends HardwareWalletService
@override
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
}) async {
final psbt = PSBTTransactionBuild(inputs: inputs, outputs: outputs, cwOutputs: []).psbt;
final psbt = PSBTTransactionBuild(inputs: inputs, outputs: outputs).psbt;
final signedPsbt = await manager.signBTCPsbt(bitboxCoinType, base64Encode(psbt.asPsbtV0()));
final transactionRes = PsbtV2()
@@ -1,8 +1,7 @@
import 'dart:typed_data';
import "dart:typed_data";
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import "package:cw_bitcoin/electrum_wallet.dart";
import "package:cw_bitcoin/psbt/transaction_builder.dart";
mixin BitcoinHardwareWalletService {
Future<Uint8List> getMasterFingerprint() async => Uint8List.fromList([0, 0, 0, 0]);
@@ -10,7 +9,7 @@ mixin BitcoinHardwareWalletService {
mixin LitecoinHardwareWalletService on BitcoinHardwareWalletService {
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
});
@@ -1,16 +1,16 @@
import 'dart:async';
import 'dart:typed_data';
import "dart:async";
import "dart:typed_data";
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import 'package:cw_bitcoin/utils.dart';
import 'package:cw_core/hardware/hardware_account_data.dart';
import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:ledger_flutter_plus/ledger_flutter_plus.dart';
import 'package:ledger_litecoin/ledger_litecoin.dart';
import "package:bitcoin_base/bitcoin_base.dart";
import "package:blockchain_utils/blockchain_utils.dart";
import "package:cw_bitcoin/electrum_wallet.dart";
import "package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart";
import "package:cw_bitcoin/psbt/transaction_builder.dart";
import "package:cw_bitcoin/utils.dart";
import "package:cw_core/hardware/hardware_account_data.dart";
import "package:cw_core/hardware/hardware_wallet_service.dart";
import "package:ledger_flutter_plus/ledger_flutter_plus.dart";
import "package:ledger_litecoin/ledger_litecoin.dart";
class LitecoinLedgerService extends HardwareWalletService
with BitcoinHardwareWalletService, LitecoinHardwareWalletService {
@@ -31,18 +31,21 @@ class LitecoinLedgerService extends HardwareWalletService
for (final i in indexRange) {
final derivationPath = "m/84'/2'/$i'";
final xpub = await litecoinLedgerApp.getXPubKey(
accountsDerivationPath: derivationPath,
xPubVersion: int.parse(hex.encode(xpubVersion.public), radix: 16));
accountsDerivationPath: derivationPath,
xPubVersion: int.parse(hex.encode(xpubVersion.public), radix: 16),
);
final hd = Bip32Slip10Secp256k1.fromExtendedKey(xpub, xpubVersion).childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet);
accounts.add(HardwareAccountData(
address: address,
accountIndex: i,
derivationPath: derivationPath,
xpub: xpub,
));
accounts.add(
HardwareAccountData(
address: address,
accountIndex: i,
derivationPath: derivationPath,
xpub: xpub,
),
);
}
return accounts;
@@ -50,7 +53,7 @@ class LitecoinLedgerService extends HardwareWalletService
@override
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
}) {
@@ -58,31 +61,39 @@ class LitecoinLedgerService extends HardwareWalletService
for (final utxo in inputs) {
final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!;
readyInputs.add(LedgerTransaction(
rawTx: utxo.rawTx,
outputIndex: utxo.utxo.vout,
ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)),
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
));
readyInputs.add(
LedgerTransaction(
rawTx: utxo.rawTx,
outputIndex: utxo.utxo.vout,
ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)),
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
),
);
}
// Check if we have the key to one of the output addresses to hide change on the device
String? changePath;
for (final output in outputs) {
final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()];
if (maybeChangePath != null) changePath ??= maybeChangePath.derivationPath;
if (output.isChange) {
changePath ??= output.changeDerivationPath;
}
}
return litecoinLedgerApp.createTransaction(
inputs: readyInputs,
outputs: outputs
.map((e) => TransactionOutput.fromBigInt((e as BitcoinOutput).value,
Uint8List.fromList(e.address.toScriptPubKey().toBytes())))
.toList(),
changePath: changePath,
sigHashType: 0x01,
additionals: ["bech32"],
isSegWit: true,
useTrustedInputForSegwit: true);
inputs: readyInputs,
outputs: outputs
.map(
(e) => TransactionOutput.fromBigInt(
(e as BitcoinOutput).value,
Uint8List.fromList(e.address.toScriptPubKey().toBytes()),
),
)
.toList(),
changePath: changePath,
sigHashType: 0x01,
additionals: ["bech32"],
isSegWit: true,
useTrustedInputForSegwit: true,
);
}
}
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import 'package:cw_bitcoin/utils.dart';
import 'package:cw_core/hardware/hardware_account_data.dart';
import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:trezor_connect/trezor_connect.dart';
class LitecoinTrezorService extends HardwareWalletService
with BitcoinHardwareWalletService, LitecoinHardwareWalletService {
LitecoinTrezorService(this.connect);
final TrezorConnect connect;
@override
Future<List<HardwareAccountData>> getAvailableAccounts({int index = 0, int limit = 5}) async {
final indexRange = List.generate(limit, (i) => i + index);
final requestParams = <TrezorGetPublicKeyParams>[];
final xpubVersion = Bip44Conf.litecoinMainNet.altKeyNetVer;
for (final i in indexRange) {
final derivationPath = "m/84'/2'/$i'";
requestParams.add(TrezorGetPublicKeyParams(path: derivationPath, coin: "LTC"));
}
final accounts = await connect.getPublicKeyBundle(requestParams);
return accounts?.map((account) {
final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub, xpubVersion)
.childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet);
return HardwareAccountData(
address: address,
xpub: account.xpub,
accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index
derivationPath: account.serializedPath,
);
}).toList() ??
[];
}
@override
Future<String> signLitecoinTransaction({
required List<PSBTReadyBitcoinOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
}) async {
final readyInputs = inputs
.map((input) => TrezorTxInput(
prevHash: input.utxo.txHash,
prevIndex: input.utxo.vout,
amount: input.utxo.value.toInt(),
addressPath: Bip32PathParser.parse(input.ownerDerivationPath).toList(),
scriptType: "SPENDWITNESS",
))
.toList();
final readyOutputs = outputs.map((output) {
final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()];
return TrezorTxOutput(
amount: output.toOutput.amount.toInt(),
address: maybeChangePath != null
? null
: output.toOutput.scriptPubKey.toAddress(network: LitecoinNetwork.mainnet),
scriptType: _getScriptType(output.toOutput.scriptPubKey.getAddressType()!),
addressPath: maybeChangePath != null
? Bip32PathParser.parse(maybeChangePath.derivationPath).toList()
: null,
);
}).toList();
final signedTx =
await connect.signTransaction(coin: 'LTC', inputs: readyInputs, outputs: readyOutputs);
return signedTx!.serializedTx;
}
}
String _getScriptType(BitcoinAddressType addressType) {
switch (addressType) {
case P2pkhAddressType.p2pkh:
return "PAYTOADDRESS";
case P2shAddressType.p2wpkhInP2sh:
return "PAYTOSCRIPTHASH";
case SegwitAddresType.p2tr:
return "PAYTOTAPROOT";
case SegwitAddresType.p2wsh:
return "PAYTOP2SHWITNESS";
case SegwitAddresType.p2wpkh:
return "PAYTOWITNESS";
default:
throw Exception("Unknown Address Type");
}
}
+131 -133
View File
@@ -1,180 +1,178 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import 'package:cw_bitcoin/utils.dart';
import 'package:cw_core/hardware/hardware_account_data.dart';
import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:flutter/foundation.dart';
import 'package:ledger_bitcoin/psbt.dart';
import 'package:trezor_connect/trezor_connect.dart';
import 'package:ledger_bitcoin/src/utils/uint8list_extension.dart' as ext;
import 'package:trezor_flutter/trezor_flutter.dart' as sdk;
class BitcoinTrezorService extends HardwareWalletService with BitcoinHardwareWalletService {
BitcoinTrezorService(this.connect);
BitcoinTrezorService(this.client) : _trezorBitcoin = sdk.TrezorBitcoin(client);
final TrezorConnect connect;
final sdk.TrezorClient client;
final sdk.TrezorBitcoin _trezorBitcoin;
@override
Future<Uint8List> getMasterFingerprint() async {
final publicKey = await _trezorBitcoin.getPublicKey(derivationPath: "m/84'/0'/0'");
final fingerprintBuffer = ByteData(4)..setUint32(0, publicKey.$2);
return fingerprintBuffer.buffer.asUint8List();
}
@override
Future<List<HardwareAccountData>> getAvailableAccounts({int index = 0, int limit = 5}) async {
final accounts = <HardwareAccountData>[];
final indexRange = List.generate(limit, (i) => i + index);
final requestParams = <TrezorGetPublicKeyParams>[];
for (final i in indexRange) {
requestParams.add(TrezorGetPublicKeyParams(path: "m/84'/0'/$i'"));
final derivationPath = "m/84'/0'/$i'";
final xPub =
await _trezorBitcoin.getPublicKey(derivationPath: derivationPath, ignoreXpubMagic: true);
final hd = Bip32Slip10Secp256k1.fromExtendedKey(xPub.$1).childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: BitcoinNetwork.mainnet);
accounts.add(
HardwareAccountData(
address: address,
accountIndex: i,
derivationPath: derivationPath,
xpub: xPub.$1,
),
);
}
final accounts = await connect.getPublicKeyBundle(requestParams);
return accounts?.map((account) {
final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub).childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: BitcoinNetwork.mainnet);
return HardwareAccountData(
address: address,
xpub: account.xpub,
accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index
derivationPath: account.serializedPath,
);
}).toList() ??
[];
return accounts;
}
@override
Future<Uint8List> signTransaction({required String transaction}) async {
final psbt = PsbtV2()..deserialize(base64Decode(transaction));
final masterFingerprint = await getMasterFingerprint();
final inputs = <sdk.TrezorTxInput>[];
final prevTxs = <String, sdk.TrezorPrevTx>{};
final inputs = <TrezorTxInput>[];
final inputCount = psbt.getGlobalInputCount();
for (var i = 0; i < inputCount; i++) {
final inputTxRaw = psbt.getInputNonWitnessUtxo(i);
final inputTx = BtcTransaction.fromRaw(hex.encode(inputTxRaw!));
final inputOutputIndex = psbt.getInputOutputIndex(i);
final rawPrevTx = psbt.getInputNonWitnessUtxo(i);
if (rawPrevTx == null) {
throw StateError("PSBT input $i is missing its previous transaction");
}
final prevTx = BtcTransaction.fromRaw(hex.encode(rawPrevTx));
final vout = psbt.getInputOutputIndex(i);
final publicKeys = psbt.inputMaps[i].keys.where((e) => e.startsWith("06"));
final pubkey = Uint8List.fromList(hex.decode(publicKeys.first.substring(2)));
final txidBytes = psbt.getInputPreviousTxid(i).reversed.toList();
inputs.add(TrezorTxInput(
prevHash: hex.encode(psbt.getInputPreviousTxid(i).reversed.toList()),
prevIndex: inputOutputIndex,
amount: inputTx.outputs[inputOutputIndex].amount.toInt(),
addressPath: psbt.getInputBip32Derivation(i, pubkey)!.$2,
final pubkey = _firstDerivationPubkey(psbt.inputMaps[i], "06");
if (pubkey == null) {
throw StateError("PSBT input $i is missing its BIP32 derivation");
}
final derivation = psbt.getInputBip32Derivation(i, pubkey)!;
final path = derivation.$2;
if (!listEquals(derivation.$1, masterFingerprint)) {
throw Exception("Fingerprint missmatch with the PSBT");
}
inputs.add(
sdk.TrezorTxInput(
addressPath: path,
prevHash: txidBytes,
prevIndex: vout,
amount: prevTx.outputs[vout].amount.toInt(),
sequence: psbt.getInputSequence(i),
scriptType: "SPENDWITNESS"));
scriptType: "SPENDWITNESS",
),
);
prevTxs[hex.encode(txidBytes)] = sdk.TrezorPrevTx(
meta: sdk.TrezorPrevTxMeta(
version: Uint8List.fromList(prevTx.version).readUint32LE(0),
lockTime: Uint8List.fromList(prevTx.locktime).readUint32LE(0),
inputsCount: prevTx.inputs.length,
outputsCount: prevTx.outputs.length,
),
inputs: prevTx.inputs
.map(
(e) => sdk.TrezorPrevInput(
prevHash: hex.decode(e.txId),
prevIndex: e.txIndex,
scriptSig: e.scriptSig.toBytes(),
sequence: Uint8List.fromList(e.sequence).readUint32LE(0),
),
)
.toList(),
outputs: prevTx.outputs
.map(
(e) => sdk.TrezorPrevOutput(
amount: e.amount.toInt(),
scriptPubkey: e.scriptPubKey.toBytes(),
),
)
.toList(),
);
}
final outputs = <TrezorTxOutput>[];
final outputs = <sdk.TrezorTxOutput>[];
final outputCount = psbt.getGlobalOutputCount();
for (var i = 0; i < outputCount; i++) {
final script = Script.fromRaw(byteData: psbt.getOutputScript(i));
outputs.add(TrezorTxOutput(
amount: psbt.getOutputAmount(i),
address: script.toAddress(),
scriptType: _getScriptType(script.getAddressType()!)
// ToDo: addressPath: psbt.getOutputBip32Derivation(i, pubkey).$2, // To highlight change outputs
final amount = psbt.getOutputAmount(i);
// An output carrying our own BIP32 derivation is change: identify it
// by path so the device verifies it internally instead of displaying
// it as a payment.
final changePubkey = _firstDerivationPubkey(psbt.outputMaps[i], "02");
if (changePubkey != null) {
final (fingerprint, path) = psbt.getOutputBip32Derivation(i, changePubkey);
if (listEquals(fingerprint, masterFingerprint)) {
outputs.add(sdk.TrezorTxOutput(
addressPath: path,
amount: amount,
scriptType: "PAYTOWITNESS",
));
continue;
}
}
final script = Script.fromRaw(byteData: psbt.getOutputScript(i));
outputs.add(sdk.TrezorTxOutput(
address: script.toAddress(),
amount: amount,
scriptType: "PAYTOADDRESS",
));
}
final signedTx = await connect.signTransaction(coin: 'btc', inputs: inputs, outputs: outputs);
final signed = await _trezorBitcoin.signTransaction(
inputs: inputs,
outputs: outputs,
prevTxs: prevTxs,
version: psbt.getGlobalTxVersion(),
lockTime: psbt.getGlobalFallbackLocktime() ?? 0,
);
return Uint8List.fromList(BytesUtils.fromHexString(signedTx!.serializedTx));
return signed.serializedTx;
}
@override
Future<Uint8List> signMessage({required Uint8List message, String? derivationPath}) async {
final sig = await connect.signMessage(derivationPath ?? "m/84'/0'/0'/0/0",
message: hex.encode(message), hex: true);
return base64Decode(sig!.signature);
}
}
class LitecoinTrezorService extends HardwareWalletService
with BitcoinHardwareWalletService, LitecoinHardwareWalletService {
LitecoinTrezorService(this.connect);
final TrezorConnect connect;
@override
Future<List<HardwareAccountData>> getAvailableAccounts({int index = 0, int limit = 5}) async {
final indexRange = List.generate(limit, (i) => i + index);
final requestParams = <TrezorGetPublicKeyParams>[];
final xpubVersion = Bip44Conf.litecoinMainNet.altKeyNetVer;
for (final i in indexRange) {
final derivationPath = "m/84'/2'/$i'";
requestParams.add(TrezorGetPublicKeyParams(path: derivationPath, coin: "LTC"));
}
final accounts = await connect.getPublicKeyBundle(requestParams);
return accounts?.map((account) {
final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub, xpubVersion)
.childKey(Bip32KeyIndex(0));
final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet);
return HardwareAccountData(
address: address,
xpub: account.xpub,
accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index
derivationPath: account.serializedPath,
);
}).toList() ??
[];
}
@override
Future<String> signLitecoinTransaction({
required List<BitcoinBaseOutput> outputs,
required List<PSBTReadyUtxoWithAddress> inputs,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
}) async {
final readyInputs = inputs
.map((input) => TrezorTxInput(
prevHash: input.utxo.txHash,
prevIndex: input.utxo.vout,
amount: input.utxo.value.toInt(),
addressPath: Bip32PathParser.parse(input.ownerDerivationPath).toList(),
scriptType: "SPENDWITNESS",
))
.toList();
final readyOutputs = outputs.map((output) {
final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()];
return TrezorTxOutput(
amount: output.toOutput.amount.toInt(),
address: maybeChangePath != null
? null
: output.toOutput.scriptPubKey.toAddress(network: LitecoinNetwork.mainnet),
scriptType: _getScriptType(output.toOutput.scriptPubKey.getAddressType()!),
addressPath: maybeChangePath != null
? Bip32PathParser.parse(maybeChangePath.derivationPath).toList()
: null,
Future<Uint8List> signMessage({required Uint8List message, String? derivationPath}) =>
_trezorBitcoin.signMessage(
derivationPath: derivationPath ?? "m/84'/0'/0'/0/0",
message: message,
);
}).toList();
final signedTx =
await connect.signTransaction(coin: 'LTC', inputs: readyInputs, outputs: readyOutputs);
return signedTx!.serializedTx;
}
}
String _getScriptType(BitcoinAddressType addressType) {
switch (addressType) {
case P2pkhAddressType.p2pkh:
return "PAYTOADDRESS";
case P2shAddressType.p2wpkhInP2sh:
return "PAYTOSCRIPTHASH";
case SegwitAddresType.p2tr:
return "PAYTOTAPROOT";
case SegwitAddresType.p2wsh:
return "PAYTOP2SHWITNESS";
case SegwitAddresType.p2wpkh:
return "PAYTOWITNESS";
default:
throw Exception("Unknown Address Type");
/// Extracts the pubkey from the first BIP32-derivation record
/// ([keyTypeHex] `06` for inputs, `02` for outputs) of a PSBT key-value map.
Uint8List? _firstDerivationPubkey(Map<String, Uint8List> map, String keyTypeHex) {
final key = map.keys.where((mapKey) => mapKey.startsWith(keyTypeHex)).firstOrNull;
if (key == null) return null;
return Uint8List.fromList(hex.decode(key.substring(2)));
}
}
+137 -81
View File
@@ -6,6 +6,9 @@ import 'package:breez_sdk_spark_flutter/breez_sdk_spark.dart';
import 'package:cw_bitcoin/bitcoin_transaction_priority.dart';
import 'package:cw_bitcoin/electrum_transaction_info.dart';
import 'package:cw_bitcoin/lightning/pending_lightning_transaction.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_type.dart';
@@ -14,30 +17,34 @@ bool _breezSdkSparkLibUninitialized = true;
Stream<LogEntry>? _logStream;
class LightningWallet {
LightningWallet({
required this.mnemonic,
required this.apiKey,
required this.lnurlDomain,
this.network = Network.mainnet,
this.passphrase,
this.seedBytes,
this.cachedAddress,
});
final String mnemonic;
final String? passphrase;
final Uint8List? seedBytes;
final String apiKey;
final String lnurlDomain;
final Network network;
late BreezSdk sdk;
BreezSdk? _sdk;
String? cachedAddress;
static int MAX_RETRIES = 10;
LightningWallet({
required this.mnemonic,
this.passphrase,
this.seedBytes,
required this.apiKey,
required this.lnurlDomain,
this.network = Network.mainnet,
this.cachedAddress,
});
static bool get isAvailable => Platform.isIOS || Platform.isAndroid || Platform.isMacOS;
Currency get currency => CryptoCurrency.btcln;
BreezSdk get sdk => _sdk!;
StreamSubscription<SdkEvent>? _eventSubscription;
Stream<SdkEvent>? _eventStream;
@@ -47,9 +54,25 @@ class LightningWallet {
void _subscribeToLogStream(File logFile) {
_logSubscription = _logStream?.listen((logEntry) {
logFile.writeAsStringSync("[${logEntry.level}] ${logEntry.line}\n", mode: FileMode.append);
try {
// Check if file exists before writing (optional, but safer)
if (!logFile.existsSync()) {
logFile.createSync(recursive: true);
}
logFile.writeAsStringSync("[${logEntry.level}] ${logEntry.line}\n", mode: FileMode.append);
} catch (e) {
// Silently fail or use printV(e) so it doesn't crash the app
printV("Failed to write to log: $e");
}
}, onError: (e) {
logFile.writeAsStringSync("[ERROR] $e\n", mode: FileMode.append);
try {
if (!logFile.existsSync()) {
logFile.createSync(recursive: true);
}
logFile.writeAsStringSync("[ERROR] $e\n", mode: FileMode.append);
} catch (err) {
printV("Failed to write error to log: $err");
}
});
}
@@ -64,10 +87,11 @@ class LightningWallet {
? Seed.entropy(seedBytes!)
: Seed.mnemonic(mnemonic: mnemonic, passphrase: passphrase);
final config = defaultConfig(network: Network.mainnet).copyWith(
lnurlDomain: lnurlDomain,
apiKey: apiKey,
privateEnabledDefault: true,
maxDepositClaimFee: MaxFee.rate(satPerVbyte: BigInt.from(5)));
lnurlDomain: lnurlDomain,
apiKey: apiKey,
privateEnabledDefault: true,
maxDepositClaimFee: MaxFee.rate(satPerVbyte: BigInt.from(5)),
);
final connectRequest = ConnectRequest(
config: config,
@@ -75,7 +99,7 @@ class LightningWallet {
storageDir: "$appPath/.breez/",
);
sdk = await connect(request: connectRequest);
_sdk = await connect(request: connectRequest);
_eventStream ??= sdk.addEventListener().asBroadcastStream();
_logStream ??= initLogging().asBroadcastStream();
@@ -87,7 +111,7 @@ class LightningWallet {
printV(e);
}
await sdk.syncWallet(request: SyncWalletRequest());
await sdk.syncWallet(request: const SyncWalletRequest());
return true;
} catch (e) {
@@ -98,7 +122,9 @@ class LightningWallet {
Future<void> close() async {
_eventSubscription?.cancel();
await sdk.disconnect();
try {
await _sdk?.disconnect();
} catch (_) {}
_logSubscription?.cancel();
}
@@ -114,30 +140,33 @@ class LightningWallet {
}
} catch (_) {} // No need to log here since it should be in the lightning log
retries++;
await Future.delayed(Duration(milliseconds: 500));
await Future.delayed(const Duration(milliseconds: 500));
}
return cachedAddress;
}
Future<String> getDepositAddress() async => (await sdk.receivePayment(
request: ReceivePaymentRequest(paymentMethod: ReceivePaymentMethod.bitcoinAddress())))
.paymentRequest;
request: const ReceivePaymentRequest(paymentMethod: ReceivePaymentMethod.bitcoinAddress()),
))
.paymentRequest;
Future<BigInt> getBalance() async {
Future<Money> getBalance() async {
try {
return (await sdk.getInfo(request: GetInfoRequest(ensureSynced: true))).balanceSats;
return Money(
(await sdk.getInfo(request: const GetInfoRequest(ensureSynced: true))).balanceSats,
CryptoCurrency.btcln,
);
} on SdkError_Generic catch (_) {
} on SdkError_NetworkError catch (_) {}
return BigInt.zero;
return Money.zero(CryptoCurrency.btcln);
}
Future<String> registerAddress(String username) async {
return (await sdk.registerLightningAddress(
request: RegisterLightningAddressRequest(username: username)))
.lightningAddress;
}
Future<String> registerAddress(String username) async => (await sdk.registerLightningAddress(
request: RegisterLightningAddressRequest(username: username),
))
.lightningAddress;
Future<String?> getBolt11Invoice(BigInt? amount, String description) async {
try {
@@ -154,7 +183,9 @@ class LightningWallet {
} on SdkError_NetworkError catch (_) {
return null;
} on SdkError_SparkError catch (e) {
if (!e.field0.contains("dns") && !e.field0.contains("TimedOut")) rethrow;
if (!e.field0.contains("dns") && !e.field0.contains("TimedOut")) {
rethrow;
}
return null;
}
}
@@ -170,17 +201,22 @@ class LightningWallet {
}
}
Future<PendingLightningTransaction> createTransaction(String address, BigInt? amountSats,
BitcoinTransactionPriority? priority, bool feesIncluded) async {
Future<PendingLightningTransaction> createTransaction(
String address,
BigInt? amountSats,
BitcoinTransactionPriority? priority,
bool feesIncluded,
) async {
final inputType = await sdk.parse(input: address);
final feePolicy = feesIncluded ? FeePolicy.feesIncluded : FeePolicy.feesExcluded;
if (inputType is InputType_Bolt11Invoice) {
final request = PrepareSendPaymentRequest(
paymentRequest: inputType.field0.invoice.bolt11,
amount: amountSats,
feePolicy: feePolicy);
paymentRequest: PaymentRequest.input(input: inputType.field0.invoice.bolt11),
amount: amountSats,
feePolicy: feePolicy,
);
final prepareResponse = await sdk.prepareSendPayment(request: request);
final paymentMethod = prepareResponse.paymentMethod;
@@ -188,17 +224,23 @@ class LightningWallet {
final lightningFeeSats = paymentMethod.lightningFeeSats;
final sparkTransferFeeSats = paymentMethod.sparkTransferFeeSats;
final baseAmount = request.amount ?? amountSats;
final amount = baseAmount != null
? Money(baseAmount, currency)
: Money(paymentMethod.invoiceDetails.amountMsat ?? BigInt.zero, currency) /
BigInt.from(1000);
return PendingLightningTransaction(
id: paymentMethod.invoiceDetails.paymentHash,
amount: request.amount?.toInt() ??
amountSats?.toInt() ??
((paymentMethod.invoiceDetails.amountMsat?.toInt() ?? 0) / 1000).round(),
fee: lightningFeeSats.toInt() + (sparkTransferFeeSats?.toInt() ?? 0),
amount: amount,
fee: Money(lightningFeeSats + (sparkTransferFeeSats ?? BigInt.zero), currency),
commitOverride: () async {
try {
final res = await sdk.sendPayment(
request: SendPaymentRequest(prepareResponse: prepareResponse));
request: SendPaymentRequest(prepareResponse: prepareResponse),
);
printV(res.payment.status.name);
return res.payment.id;
} on SdkError_SparkError catch (e) {
if (e.field0.contains("AlreadyExists")) {
throw Exception("Invoice already paid");
@@ -209,19 +251,19 @@ class LightningWallet {
);
}
} else if (inputType is InputType_LightningAddress || inputType is InputType_LnurlPay) {
final optionalValidateSuccessActionUrl = true;
const optionalValidateSuccessActionUrl = true;
PrepareLnurlPayRequest request;
if (inputType is InputType_LightningAddress) {
request = PrepareLnurlPayRequest(
amountSats: amountSats!,
amount: amountSats!,
payRequest: inputType.field0.payRequest,
validateSuccessActionUrl: optionalValidateSuccessActionUrl,
feePolicy: feePolicy,
);
} else {
request = PrepareLnurlPayRequest(
amountSats: amountSats!,
amount: amountSats!,
payRequest: (inputType as InputType_LnurlPay).field0,
validateSuccessActionUrl: optionalValidateSuccessActionUrl,
feePolicy: feePolicy,
@@ -230,21 +272,20 @@ class LightningWallet {
final prepareResponse = await sdk.prepareLnurlPay(request: request);
final feeSats = prepareResponse.feeSats;
return PendingLightningTransaction(
id: prepareResponse.invoiceDetails.paymentHash,
amount: prepareResponse.amountSats.toInt(),
fee: feeSats.toInt(),
amount: Money(prepareResponse.amountSats, currency),
fee: Money(prepareResponse.feeSats, currency),
commitOverride: () async {
final res =
await sdk.lnurlPay(request: LnurlPayRequest(prepareResponse: prepareResponse));
printV(res.payment.status.name);
return res.payment.id;
},
);
} else if (inputType is InputType_BitcoinAddress) {
final request = PrepareSendPaymentRequest(
paymentRequest: inputType.field0.address,
paymentRequest: PaymentRequest.input(input: inputType.field0.address),
amount: amountSats,
feePolicy: feePolicy,
);
@@ -255,32 +296,33 @@ class LightningWallet {
final feeQuote = paymentMethod.feeQuote;
OnchainConfirmationSpeed onchainConfirmationSpeed;
int fee;
BigInt fee;
switch (priority) {
case BitcoinTransactionPriority.fast:
fee = (feeQuote.speedFast.userFeeSat + feeQuote.speedFast.l1BroadcastFeeSat).toInt();
fee = feeQuote.speedFast.userFeeSat + feeQuote.speedFast.l1BroadcastFeeSat;
onchainConfirmationSpeed = OnchainConfirmationSpeed.fast;
break;
case BitcoinTransactionPriority.medium:
fee =
(feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat).toInt();
fee = feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat;
onchainConfirmationSpeed = OnchainConfirmationSpeed.medium;
break;
case BitcoinTransactionPriority.slow:
default:
fee = (feeQuote.speedSlow.userFeeSat + feeQuote.speedSlow.l1BroadcastFeeSat).toInt();
fee = feeQuote.speedSlow.userFeeSat + feeQuote.speedSlow.l1BroadcastFeeSat;
onchainConfirmationSpeed = OnchainConfirmationSpeed.slow;
}
return PendingLightningTransaction(
id: "", // ToDo: Find out where to get it
amount: prepareResponse.amount.toInt(),
fee: fee,
amount: Money(prepareResponse.amount, currency),
fee: Money(fee, currency),
commitOverride: () async {
final options =
SendPaymentOptions.bitcoinAddress(confirmationSpeed: onchainConfirmationSpeed);
await sdk.sendPayment(
request: SendPaymentRequest(prepareResponse: prepareResponse, options: options));
final res = await sdk.sendPayment(
request: SendPaymentRequest(prepareResponse: prepareResponse, options: options),
);
return res.payment.id;
},
);
}
@@ -296,7 +338,7 @@ class LightningWallet {
// statusFilter: [PaymentStatus.completed],
fromTimestamp:
fromDate != null ? BigInt.from((fromDate.millisecondsSinceEpoch / 1000).round()) : null,
assetFilter: AssetFilter.bitcoin(),
assetFilter: const AssetFilter.bitcoin(),
offset: 0,
limit: 50,
sortAscending: false, // Sort order (true = oldest first, false = newest first)
@@ -327,7 +369,7 @@ class LightningWallet {
///
Future<List<Map<String, dynamic>>> getUnclaimedDeposits() async {
final unclaimedDeposits = <Map<String, dynamic>>[];
final response = await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest());
final response = await sdk.listUnclaimedDeposits(request: const ListUnclaimedDepositsRequest());
for (final deposit in response.deposits) {
final unclaimedDeposit = {
"txId": deposit.txid,
@@ -350,7 +392,7 @@ class LightningWallet {
return unclaimedDeposits;
}
Future<ElectrumTransactionInfo> claimDeposit(String txId, int vout, BigInt newFee) async {
Future<ElectrumTransactionInfo?> claimDeposit(String txId, int vout, BigInt newFee) async {
final response = await sdk.claimDeposit(
request: ClaimDepositRequest(
txid: txId,
@@ -359,11 +401,18 @@ class LightningWallet {
),
);
return _getElectrumTransactionInfoFromPayment(response.payment);
if (response.payment == null) {
return null;
}
return _getElectrumTransactionInfoFromPayment(response.payment!);
}
Future<String> refundDeposit(
String txId, int vout, String destinationAddress, BigInt feeRate) async {
String txId,
int vout,
String destinationAddress,
BigInt feeRate,
) async {
final response = await sdk.refundDeposit(
request: RefundDepositRequest(
txid: txId,
@@ -404,7 +453,7 @@ class LightningWallet {
}
ElectrumTransactionInfo _getElectrumTransactionInfoFromPayment(Payment payment) {
TransactionDirection direction = TransactionDirection.outgoing;
var direction = TransactionDirection.outgoing;
if (payment.paymentType == PaymentType.receive) {
direction = TransactionDirection.incoming;
@@ -413,30 +462,37 @@ class LightningWallet {
direction = TransactionDirection.incoming;
}
String? preimage;
if (payment.details != null && payment.details is PaymentDetails_Lightning) {
preimage = (payment.details as PaymentDetails_Lightning).htlcDetails.preimage;
}
return ElectrumTransactionInfo(
WalletType.bitcoin,
id: payment.id,
amount: payment.amount.toInt(),
amount: Money(payment.amount, currency),
direction: direction,
isPending: payment.status == PaymentStatus.pending,
fee: payment.fees.toInt(),
fee: Money(payment.fees, currency),
date: DateTime.fromMillisecondsSinceEpoch(payment.timestamp.toInt() * 1000),
confirmations: payment.status == PaymentStatus.pending ? 0 : 10,
additionalInfo: {"isLightning": true},
additionalInfo: {
"isLightning": true,
if (preimage != null) "preimage": preimage,
},
);
}
ElectrumTransactionInfo _getElectrumTransactionInfoFromDepositInfo(DepositInfo deposit) {
return ElectrumTransactionInfo(
WalletType.bitcoin,
id: deposit.txid,
amount: deposit.amountSats.toInt(),
direction: TransactionDirection.incoming,
isPending: true,
fee: 0,
date: DateTime.now(),
confirmations: 0,
additionalInfo: {"isLightning": true, "isSparkDeposit": true},
);
}
ElectrumTransactionInfo _getElectrumTransactionInfoFromDepositInfo(DepositInfo deposit) =>
ElectrumTransactionInfo(
WalletType.bitcoin,
id: deposit.txid,
amount: Money(deposit.amountSats, currency),
direction: TransactionDirection.incoming,
isPending: true,
fee: Money.zero(currency),
date: DateTime.now(),
confirmations: 0,
additionalInfo: {"isLightning": true, "isSparkDeposit": true},
);
}
@@ -1,4 +1,4 @@
import 'package:cw_bitcoin/bitcoin_amount_format.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/pending_transaction.dart';
class PendingLightningTransaction with PendingTransaction {
@@ -10,33 +10,31 @@ class PendingLightningTransaction with PendingTransaction {
required this.commitOverride,
});
final int amount;
final int fee;
final bool isSendAll;
Future<void> Function() commitOverride;
Future<String> Function() commitOverride;
final List<void Function()> _listeners = [];
@override
final String id;
String id;
@override
final Money amount;
@override
final Money fee;
@override
String get hex => "";
@override
String get amountFormatted => bitcoinAmountToString(amount: amount);
@override
String get feeFormatted => "$feeFormattedValue BTC";
@override
String get feeFormattedValue => bitcoinAmountToString(amount: fee);
String get amountFormatted => amount.toString();
@override
int? get outputCount => 1;
@override
Future<void> commit() async {
await commitOverride.call();
id = await commitOverride.call();
_listeners.forEach((e) => e.call());
}
+79 -61
View File
@@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:cw_bitcoin/address_from_output.dart';
import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/cake_hive.dart';
import 'package:cw_core/mweb_utxo.dart';
import 'package:cw_core/unspent_coin_type.dart';
@@ -17,12 +18,9 @@ import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:bitcoin_base/src/crypto/keypair/sign_utils.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:blockchain_utils/signer/ecdsa_signing_key.dart';
import 'package:collection/collection.dart';
import 'package:convert/convert.dart' as convert;
import 'package:crypto/crypto.dart';
import 'package:cw_bitcoin/bitcoin_address_record.dart';
import 'package:cw_bitcoin/bitcoin_mnemonic.dart';
import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
import 'package:cw_bitcoin/bitcoin_transaction_priority.dart';
import 'package:cw_bitcoin/bitcoin_unspent.dart';
import 'package:cw_bitcoin/electrum_balance.dart';
@@ -32,28 +30,22 @@ import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
import 'package:cw_bitcoin/litecoin_wallet_addresses.dart';
import 'package:cw_bitcoin/output_ordering.dart';
import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
import 'package:cw_bitcoin/utils.dart';
import 'package:cw_core/cake_hive.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_core/mweb_utxo.dart';
import 'package:cw_core/node.dart';
import 'package:cw_core/output_info.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/unspent_coins_info.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_keys_file.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cw_mweb/cw_mweb.dart';
import 'package:cw_mweb/mwebd.pbgrpc.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/foundation.dart';
import 'package:grpc/grpc.dart';
import 'package:hive/hive.dart';
@@ -123,8 +115,10 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
initialRegularAddressIndex: initialRegularAddressIndex,
initialChangeAddressIndex: initialChangeAddressIndex,
initialMwebAddresses: initialMwebAddresses,
mainHdByType: mainHdByType,
sideHdByType: sideHdByType,
mainHdByTypeAndAccount: mainHdByTypeAndAccount,
sideHdByTypeAndAccount: sideHdByTypeAndAccount,
accountIndexes: [currentAccountIndex],
currentAccountIndex: currentAccountIndex,
legacyMainHd: mainHd,
legacySideHd: sideHd,
network: network,
@@ -384,6 +378,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
} catch (e) {
printV("failed to subscribe for updates: $e");
}
await checkIfBatchSupported();
updateFeeRates();
_feeRatesTimer?.cancel();
_feeRatesTimer =
@@ -508,22 +503,25 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
mwebUtxosBox = await CakeHive.openBox<MwebUtxo>(boxName);
}
@override
Future<void> renameWalletFiles(String newWalletName) async {
// rename the hive box:
final oldBoxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
final newBoxName = "${newWalletName.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
static Future<void> copyMwebBox({
required String fromName,
required String toName,
}) async {
final oldBoxName = "${fromName.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
final newBoxName = "${toName.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
if (oldBoxName == newBoxName) return;
final oldBox = await CakeHive.openBox<MwebUtxo>(oldBoxName);
mwebUtxosBox = await CakeHive.openBox<MwebUtxo>(newBoxName);
final newBox = await CakeHive.openBox<MwebUtxo>(newBoxName);
for (final key in oldBox.keys) {
final value = oldBox.get(key);
await oldBox.delete(key);
await mwebUtxosBox.put(key, value!);
await newBox.put(key, oldBox.get(key)!);
}
oldBox.deleteFromDisk();
}
await super.renameWalletFiles(newWalletName);
static Future<void> deleteMwebBox(String name) async {
final boxName = "${name.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
final box = await CakeHive.openBox<MwebUtxo>(boxName);
await box.deleteFromDisk();
}
@action
@@ -589,8 +587,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
WalletType.litecoin,
id: utxo.outputId,
height: utxo.height,
amount: utxo.value.toInt(),
fee: 0,
amount: Money.fromInt(utxo.value, currency),
fee: Money.zero(currency),
direction: TransactionDirection.incoming,
isPending: utxo.height == 0,
date: date,
@@ -736,9 +734,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
Future<void> checkMwebUtxosSpent() async {
printV("checkMwebUtxosSpent() called!");
if (!mwebEnabled) {
return;
}
if (!mwebEnabled) return;
final pendingOutgoingTransactions = transactionHistory.transactions.values
.where((tx) => tx.direction == TransactionDirection.outgoing && tx.isPending);
@@ -765,8 +761,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
final height = await electrumClient.getCurrentBlockChainTip();
if (height == null || status.blockHeaderHeight != height) return;
if (status.mwebUtxosHeight != height) return; // we aren't synced
int amount = 0;
Set<String> inputAddresses = {};
var amount = 0;
var inputAddresses = <String>{};
var output = convert.AccumulatorSink<Digest>();
var input = sha256.startChunkedConversion(output);
@@ -779,8 +775,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
if (!inputAddresses.contains(utxo.address)) {
addressRecord.txCount++;
}
addressRecord.balance -= utxo.value.toInt();
amount += utxo.value.toInt();
addressRecord.balance -= utxo.value;
amount += utxo.value;
inputAddresses.add(utxo.address);
input.add(hex.decode(outputId));
}
@@ -792,8 +788,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
WalletType.litecoin,
id: digest.toString(),
height: height,
amount: amount,
fee: 0,
amount: Money.fromInt(amount, currency),
fee: Money.zero(currency),
direction: TransactionDirection.outgoing,
isPending: false,
date: DateTime.fromMillisecondsSinceEpoch(status.blockTime * 1000),
@@ -923,10 +919,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
// update unspent balances:
await updateUnspent();
int confirmed = balance.confirmed;
int unconfirmed = balance.unconfirmed;
int confirmedMweb = 0;
int unconfirmedMweb = 0;
var confirmedMweb = 0;
var unconfirmedMweb = 0;
try {
mwebUtxosBox.values.forEach((utxo) {
bool isConfirmed = utxo.height > 0;
@@ -935,20 +929,20 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
"utxo: ${isConfirmed ? "confirmed" : "unconfirmed"} ${utxo.spent ? "spent" : "unspent"} ${utxo.outputId} ${utxo.height} ${utxo.value}");
if (isConfirmed) {
confirmedMweb += utxo.value.toInt();
confirmedMweb += utxo.value;
}
if (isConfirmed && utxo.spent) {
unconfirmedMweb -= utxo.value.toInt();
unconfirmedMweb -= utxo.value;
}
if (!isConfirmed && !utxo.spent) {
unconfirmedMweb += utxo.value.toInt();
unconfirmedMweb += utxo.value;
}
});
} catch (_) {}
for (var addressRecord in walletAddresses.allAddresses) {
for (final addressRecord in walletAddresses.allAddresses) {
addressRecord.balance = 0;
addressRecord.txCount = 0;
}
@@ -992,11 +986,11 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
}
return ElectrumBalance(
confirmed: confirmed,
unconfirmed: unconfirmed,
frozen: balance.frozen.toInt(),
secondConfirmed: confirmedMweb,
secondUnconfirmed: unconfirmedMweb,
confirmed: balance.confirmed,
unconfirmed: balance.unconfirmed,
frozen: balance.frozen,
secondConfirmed: Money.fromInt(confirmedMweb, currency),
secondUnconfirmed: Money.fromInt(unconfirmedMweb, currency),
);
}
@@ -1373,9 +1367,10 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
final tx = PendingBitcoinTransaction(
btcTx,
type,
accountIndex: 0,
electrumClient: electrumClient,
amount: 0,
fee: resp.fee.toInt(),
amount: Money.zero(currency),
fee: Money.fromInt(resp.fee.toInt(), currency),
feeRate: "",
network: network,
hasChange: resp.recipient.length > 1,
@@ -1564,7 +1559,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
@override
Future<BtcTransaction> buildHardwareWalletTransaction({
required List<BitcoinBaseOutput> outputs,
required List<BitcoinOutput> outputs,
required BigInt fee,
required BasedUtxoNetwork network,
required List<UtxoWithAddress> utxos,
@@ -1576,25 +1571,48 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
BitcoinOrdering outputOrdering = BitcoinOrdering.bip69,
}) async {
final masterFingerprint =
await (hardwareWalletService as BitcoinHardwareWalletService).getMasterFingerprint();
await (hardwareWalletService! as BitcoinHardwareWalletService).getMasterFingerprint();
final readyInputs = <PSBTReadyUtxoWithAddress>[];
for (final utxo in utxos) {
final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash);
final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!;
readyInputs.add(PSBTReadyUtxoWithAddress(
utxo: utxo.utxo,
rawTx: rawTx,
ownerDetails: utxo.ownerDetails,
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
ownerMasterFingerprint: masterFingerprint,
ownerPublicKey: publicKeyAndDerivationPath.publicKey,
));
readyInputs.add(
PSBTReadyUtxoWithAddress(
utxo: utxo.utxo,
rawTx: rawTx,
ownerDetails: utxo.ownerDetails,
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
ownerMasterFingerprint: masterFingerprint,
ownerPublicKey: publicKeyAndDerivationPath.publicKey,
),
);
}
final rawHex = await (hardwareWalletService as LitecoinHardwareWalletService)
.signLitecoinTransaction(outputs: outputs, inputs: readyInputs, publicKeys: publicKeys);
final orderedOutputs =
orderOutputs(outputs, outputOrdering).map((o) {
if (o.isChange && publicKeys.containsKey(o.address.pubKeyHash())) {
final changeKey = publicKeys[o.address.pubKeyHash()]!;
return PSBTReadyBitcoinOutput(
address: o.address,
value: o.value,
isSilentPayment: o.isSilentPayment,
isChange: o.isChange,
changeMasterFingerprint: masterFingerprint,
changeDerivationPath: changeKey.derivationPath,
changePublicKey: changeKey.publicKey,
);
}
return PSBTReadyBitcoinOutput.fromOutput(o);
});
final rawHex =
await (hardwareWalletService! as LitecoinHardwareWalletService).signLitecoinTransaction(
outputs: orderedOutputs.toList(),
inputs: readyInputs,
publicKeys: publicKeys,
);
return BtcTransaction.fromRaw(rawHex);
}
+24 -10
View File
@@ -25,8 +25,10 @@ class LitecoinWalletAddresses = LitecoinWalletAddressesBase with _$LitecoinWalle
abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with Store {
LitecoinWalletAddressesBase(
WalletInfo walletInfo, {
required super.mainHdByType,
required super.sideHdByType,
required super.mainHdByTypeAndAccount,
required super.sideHdByTypeAndAccount,
required super.accountIndexes,
required super.currentAccountIndex,
required super.legacyMainHd,
required super.legacySideHd,
required super.network,
@@ -66,9 +68,9 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with
List.filled(32, 0);
@override
Future<void> init() async {
Future<void> init({List<int> accountIndexes = const []}) async {
if (!isHardwareWallet) await initMwebAddresses();
await super.init();
await super.init(accountIndexes: accountIndexes);
}
@computed
@@ -230,13 +232,25 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with
@override
String get addressForExchange {
// don't use mweb addresses for exchange refund address:
try {
final addresses = receiveAddresses
.where((element) => element.type == SegwitAddresType.p2wpkh && !element.isUsed);
return addresses.first.address;
} catch (_) {
return receiveAddresses.first.address;
final current = getFreshAddress();
final bool isMweb =
receiveAddresses.any((e) => e.address == current && e.type == SegwitAddresType.mweb);
if (isMweb) {
final segwit = receiveAddresses
.where((e) =>
e.type == SegwitAddresType.p2wpkh &&
!e.isUsed &&
!e.isHidden &&
!hiddenAddresses.contains(e.address))
.map((e) => e.address)
.toList();
return segwit.isNotEmpty ? segwit.first : current;
}
return current;
}
@override
+4 -21
View File
@@ -12,8 +12,6 @@ import 'package:cw_core/wallet_service.dart';
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:collection/collection.dart';
import 'package:bip39/bip39.dart' as bip39;
import 'package:path_provider/path_provider.dart';
@@ -140,26 +138,11 @@ class LitecoinWalletService extends WalletService<
@override
Future<void> rename(String currentName, String password, String newName) async {
final currentWalletInfo = await WalletInfo.get(currentName, getType());
if (currentWalletInfo == null) {
throw Exception('Wallet not found');
}
final currentWallet = await LitecoinWalletBase.open(
password: password,
name: currentName,
walletInfo: currentWalletInfo,
unspentCoinsInfo: unspentCoinsInfoSource,
encryptionFileUtils: encryptionFileUtilsFor(isDirect),
);
if (currentName == newName) return;
await currentWallet.renameWalletFiles(newName);
await saveBackup(newName);
final newWalletInfo = currentWalletInfo;
newWalletInfo.id = WalletBase.idFor(newName, getType());
newWalletInfo.name = newName;
await newWalletInfo.save();
await LitecoinWalletBase.copyMwebBox(fromName: currentName, toName: newName);
await super.rename(currentName, password, newName);
await LitecoinWalletBase.deleteMwebBox(currentName);
}
@override
+20
View File
@@ -0,0 +1,20 @@
import "dart:typed_data";
/// Anti-fee-sniping locktime, set to the current chain tip (exact-tip).
///
/// Matches the exact-tip cluster (payjoin-cli, ldk-node, Bull Bitcoin) and Core's
/// default for externally-constructed transactions, and takes Cake off its unique
/// `nLockTime = 0` fingerprint. It intentionally skips the ~10% backdate
/// Core/Electrum apply for delayed-broadcast privacy, which does not apply here.
/// Returns 0 when not synced or the tip is unknown, to avoid emitting a stale
/// height.
int antiFeeSnipingLocktime({
required int chainTip,
required bool synced,
}) {
if (!synced || chainTip <= 0) return 0;
return chainTip;
}
/// Little-endian 4-byte encoding for `BtcTransaction.locktime`.
List<int> locktimeToBytes(int locktime) => (ByteData(4)..setUint32(0, locktime, Endian.little)).buffer.asUint8List();
+21
View File
@@ -0,0 +1,21 @@
import 'dart:math';
import 'package:bitcoin_base/bitcoin_base.dart';
/// Applies [ordering] to a transaction's [outputs] before building.
///
/// Returns a new list; the input is never mutated. Only [BitcoinOrdering.shuffle]
/// reorders (using a secure RNG by default) so the change output is no longer
/// placed deterministically last. Any other value preserves the given order,
/// matching the pre-existing hardware-wallet behavior.
List<T> orderOutputs<T>(
List<T> outputs,
BitcoinOrdering ordering, {
Random? rng,
}) {
final result = List<T>.of(outputs);
if (ordering == BitcoinOrdering.shuffle) {
result.shuffle(rng ?? Random.secure());
}
return result;
}
+5 -2
View File
@@ -290,11 +290,14 @@ class PayjoinManager {
break;
case PayjoinReceiverRequestTypes.getCandidateInputs:
utxos = _wallet.getUtxoWithPrivateKeys();
utxos = _wallet.getUtxoWithPrivateKeys(confirmedOnly: true);
if (utxos.isEmpty) {
await _wallet.updateAllUnspents();
utxos = _wallet.getUtxoWithPrivateKeys();
utxos = _wallet.getUtxoWithPrivateKeys(confirmedOnly: true);
}
// Candidates arrive in wallet scan order (address, then age), which is
// predictable; shuffle so the receiver's input choice can't mirror it.
utxos.shuffle(Random.secure());
mainToIsolateSendPort?.send({
'requestId': message['requestId'],
'result': utxos,
@@ -175,7 +175,15 @@ class PayjoinReceiverWorker {
final unspent = listUnspent as List<UtxoWithPrivateKey>;
if (unspent.isEmpty) throw RecoverableError('No unspent outputs available');
final selectedUtxo = await _inputPairFromUtxo(unspent[0]);
final candidateInputs = await Future.wait(unspent.map(_inputPairFromUtxo));
// Prefer a UTXO that avoids the Unnecessary Input Heuristic (UIH2);
// fall back to the first candidate if none preserves privacy.
InputPair selectedUtxo = candidateInputs.first;
try {
selectedUtxo = await pj5.tryPreservingPrivacy(candidateInputs: candidateInputs);
} catch (_) {}
final pj6 = await pj5.contributeInputs(replacementInputs: [selectedUtxo]);
final pj7 = await pj6.commitInputs();
+36 -39
View File
@@ -1,7 +1,6 @@
import 'dart:typed_data';
import 'package:bbqrdart/bbqrdart.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_core/amount/money.dart';
import 'package:flutter/foundation.dart';
import 'package:grpc/grpc.dart';
import 'package:cw_bitcoin/exceptions.dart';
@@ -9,7 +8,6 @@ import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_bitcoin/electrum.dart';
import 'package:cw_bitcoin/bitcoin_amount_format.dart';
import 'package:cw_bitcoin/electrum_transaction_info.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/wallet_type.dart';
@@ -23,6 +21,7 @@ class PendingBitcoinTransaction with PendingTransaction {
PendingBitcoinTransaction(
this._tx,
this.type, {
required this.accountIndex,
required this.electrumClient,
required this.amount,
required this.fee,
@@ -33,6 +32,7 @@ class PendingBitcoinTransaction with PendingTransaction {
this.hasTaprootInputs = false,
this.isMweb = false,
this.utxos = const [],
this.derivedOutputs = const [],
this.publicKeys,
this.commitOverride,
this.unsignedPsbt,
@@ -41,9 +41,8 @@ class PendingBitcoinTransaction with PendingTransaction {
final WalletType type;
final BtcTransaction _tx;
final int? accountIndex;
final ElectrumClient electrumClient;
final int amount;
final int fee;
final String feeRate;
final BasedUtxoNetwork? network;
final bool isSendAll;
@@ -51,6 +50,9 @@ class PendingBitcoinTransaction with PendingTransaction {
final bool hasTaprootInputs;
final bool isViewOnly;
List<UtxoWithAddress> utxos;
final List<BitcoinOutput> derivedOutputs;
bool isMweb;
String? changeAddressOverride;
String? idOverride;
@@ -61,6 +63,12 @@ class PendingBitcoinTransaction with PendingTransaction {
Uint8List? unsignedPsbt;
@override
final Money amount;
@override
final Money fee;
@override
String get id => idOverride ?? _tx.txId();
@@ -68,28 +76,7 @@ class PendingBitcoinTransaction with PendingTransaction {
String get hex => hexOverride ?? _tx.serialize();
@override
String get amountFormatted => bitcoinAmountToString(amount: amount);
@override
String get feeFormatted => "$feeFormattedValue ${_feeCurrency}";
String get _feeCurrency {
switch (type) {
case WalletType.bitcoin:
return "BTC";
case WalletType.litecoin:
return "LTC";
case WalletType.bitcoinCash:
return "BCH";
case WalletType.dogecoin:
return "DOGE";
default:
return type.name;
}
}
@override
String get feeFormattedValue => bitcoinAmountToString(amount: fee);
String get amountFormatted => amount.toString();
@override
int? get outputCount => _tx.outputs.length;
@@ -98,6 +85,13 @@ class PendingBitcoinTransaction with PendingTransaction {
bool get hasSilentPayment => _tx.hasSilentPayment;
List<String?> get stealthAddresses => derivedOutputs
.where((out) => !out.isChange)
.map((out) => out.isSilentPayment
? out.address.toAddress(network ?? BitcoinNetwork.mainnet)
: null)
.toList();
PendingChange? get change {
try {
final change = _tx.outputs.firstWhere((out) => out.isChange);
@@ -180,18 +174,21 @@ class PendingBitcoinTransaction with PendingTransaction {
void addListener(void Function(ElectrumTransactionInfo transaction) listener) =>
_listeners.add(listener);
ElectrumTransactionInfo transactionInfo() => ElectrumTransactionInfo(type,
id: id,
height: 0,
amount: amount,
direction: TransactionDirection.outgoing,
date: DateTime.now(),
isPending: true,
isReplaced: false,
confirmations: 0,
inputAddresses: _tx.inputs.map((input) => input.txId).toList(),
outputAddresses: outputAddresses,
fee: fee);
ElectrumTransactionInfo transactionInfo() => ElectrumTransactionInfo(
type,
id: id,
height: 0,
amount: amount,
direction: TransactionDirection.outgoing,
date: DateTime.now(),
isPending: true,
isReplaced: false,
confirmations: 0,
accountIndex: accountIndex,
inputAddresses: _tx.inputs.map((input) => input.txId).toList(),
outputAddresses: outputAddresses,
fee: fee,
);
@override
bool shouldCommitUR() => isViewOnly;
+113 -62
View File
@@ -1,33 +1,31 @@
import 'dart:convert';
import 'dart:typed_data';
import "dart:convert";
import "dart:typed_data";
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:convert/convert.dart';
import 'package:cw_core/output_info.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:ledger_bitcoin/psbt.dart';
import "package:bitcoin_base/bitcoin_base.dart";
import "package:convert/convert.dart";
import "package:cw_core/output_info.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:ledger_bitcoin/psbt.dart";
class PSBTTransactionBuild {
final PsbtV2 psbt = PsbtV2();
PSBTTransactionBuild(
{required List<PSBTReadyUtxoWithAddress> inputs,
required List<BitcoinBaseOutput> outputs,
required List<OutputInfo> cwOutputs,
bool enableRBF = true}) {
PSBTTransactionBuild({
required List<PSBTReadyUtxoWithAddress> inputs,
required List<PSBTReadyBitcoinOutput> outputs,
bool enableRBF = true,
int locktime = 0,
}) {
psbt.setGlobalTxVersion(2);
psbt.setGlobalFallbackLocktime(locktime);
psbt.setGlobalInputCount(inputs.length);
psbt.setGlobalOutputCount(outputs.length);
for (var i = 0; i < inputs.length; i++) {
final input = inputs[i];
printV(input.utxo.isP2tr());
printV(input.utxo.isSegwit());
printV(input.utxo.isP2shSegwit());
psbt.setInputPreviousTxId(
i, Uint8List.fromList(hex.decode(input.utxo.txHash).reversed.toList()));
i,
Uint8List.fromList(hex.decode(input.utxo.txHash).reversed.toList()),
);
psbt.setInputOutputIndex(i, input.utxo.vout);
psbt.setInputSequence(i, enableRBF ? 0xfffffffd : 0xffffffff);
@@ -45,75 +43,98 @@ class PSBTTransactionBuild {
for (var i = 0; i < outputs.length; i++) {
final output = outputs[i];
if (output is BitcoinOutput) {
psbt.setOutputScript(i, Uint8List.fromList(output.address.toScriptPubKey().toBytes()));
psbt.setOutputAmount(i, output.value.toInt());
if (cwOutputs.isNotEmpty) {
try {
final cwOutput = cwOutputs
.where((e) => [e.address, e.extractedAddress]
.map((e) => e?.toLowerCase())
.contains(output.address.toAddress().toLowerCase()))
.firstOrNull;
if (cwOutput != null &&
cwOutput.extra.containsKey('bip353_name') &&
cwOutput.extra.containsKey('bip353_proof')) {
final bip353Name = utf8.encode(cwOutput.extra['bip353_name'] as String);
final bip353Proof = base64.decode(cwOutput.extra['bip353_proof'] as String);
psbt.setOutputScript(i, Uint8List.fromList(output.address.toScriptPubKey().toBytes()));
psbt.setOutputAmount(i, output.value.toInt());
if (bip353Name.length > 255) {
printV('BIP353 name is too long, skipping');
continue;
}
final proof = Uint8List.fromList([
bip353Name.length,
...bip353Name,
...bip353Proof,
]);
if (output.isChange &&
output.changeDerivationPath != null &&
output.changePublicKey != null &&
output.changeMasterFingerprint != null) {
psbt.setOutputBip32Derivation(
i,
Uint8List.fromList(hex.decode(output.changePublicKey!)),
output.changeMasterFingerprint!,
BIPPath.fromString(output.changeDerivationPath!).toPathArray(),
);
}
psbt.setOutputDNSSECProof(i, proof);
if (output.outputInfo != null) {
try {
final cwOutput = output.outputInfo!;
if (cwOutput.extra.containsKey("bip353_name") &&
cwOutput.extra.containsKey("bip353_proof")) {
final bip353Name = utf8.encode(cwOutput.extra["bip353_name"] as String);
final bip353Proof = base64.decode(cwOutput.extra["bip353_proof"] as String);
if (bip353Name.length > 255) {
printV("BIP353 name is too long, skipping");
continue;
}
} catch (e) {
printV('Error setting DNSSEC proof: $e');
final proof = Uint8List.fromList([
bip353Name.length,
...bip353Name,
...bip353Proof,
]);
psbt.setOutputDNSSECProof(i, proof);
}
} catch (e) {
printV("Error setting DNSSEC proof: $e");
}
}
}
}
final PsbtV2 psbt = PsbtV2();
void setInputP2pkh(int i, PSBTReadyUtxoWithAddress input) {
psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx)));
psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray());
psbt.setInputBip32Derivation(
i,
Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint,
BIPPath.fromString(input.ownerDerivationPath).toPathArray(),
);
}
void setInputSegwit(int i, PSBTReadyUtxoWithAddress input) {
psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx)));
psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray());
psbt.setInputBip32Derivation(
i,
Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint,
BIPPath.fromString(input.ownerDerivationPath).toPathArray(),
);
psbt.setInputWitnessUtxo(i, Uint8List.fromList(bigIntToUint64LE(input.utxo.value)),
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()));
psbt.setInputWitnessUtxo(
i,
Uint8List.fromList(bigIntToUint64LE(input.utxo.value)),
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()),
);
}
void setInputP2shSegwit(int i, PSBTReadyUtxoWithAddress input) {
psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx)));
psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray());
psbt.setInputBip32Derivation(
i,
Uint8List.fromList(hex.decode(input.ownerPublicKey)),
input.ownerMasterFingerprint,
BIPPath.fromString(input.ownerDerivationPath).toPathArray(),
);
psbt.setInputRedeemScript(
i, Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()));
psbt.setInputWitnessUtxo(i, Uint8List.fromList(bigIntToUint64LE(input.utxo.value)),
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()));
i,
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()),
);
psbt.setInputWitnessUtxo(
i,
Uint8List.fromList(bigIntToUint64LE(input.utxo.value)),
Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()),
);
}
}
class PSBTReadyUtxoWithAddress extends UtxoWithAddress {
final String rawTx;
final String ownerDerivationPath;
final Uint8List ownerMasterFingerprint;
final String ownerPublicKey;
PSBTReadyUtxoWithAddress({
required super.utxo,
required this.rawTx,
@@ -122,4 +143,34 @@ class PSBTReadyUtxoWithAddress extends UtxoWithAddress {
required this.ownerMasterFingerprint,
required this.ownerPublicKey,
});
final String rawTx;
final String ownerDerivationPath;
final Uint8List ownerMasterFingerprint;
final String ownerPublicKey;
}
class PSBTReadyBitcoinOutput extends BitcoinOutput {
PSBTReadyBitcoinOutput({
required super.address,
required super.value,
super.isSilentPayment = false,
super.isChange = false,
this.changeDerivationPath,
this.changeMasterFingerprint,
this.changePublicKey,
this.outputInfo,
});
factory PSBTReadyBitcoinOutput.fromOutput(BitcoinOutput output) => PSBTReadyBitcoinOutput(
address: output.address,
value: output.value,
isSilentPayment: output.isSilentPayment,
isChange: output.isChange,
);
final String? changeDerivationPath;
final Uint8List? changeMasterFingerprint;
final String? changePublicKey;
final OutputInfo? outputInfo;
}
+1
View File
@@ -20,6 +20,7 @@ extension PsbtSigner on PsbtV2 {
setGlobalInputCount(tx.inputs.length);
setGlobalOutputCount(tx.outputs.length);
setGlobalTxVersion(Uint8List.fromList(tx.version).readUint32LE(0));
setGlobalFallbackLocktime(Uint8List.fromList(tx.locktime).readUint32LE(0));
for (var i = 0; i < getGlobalInputCount(); i++) {
inputMaps.insert(i, <String, Uint8List>{});
File diff suppressed because it is too large Load Diff
+15 -10
View File
@@ -6,7 +6,7 @@ author: Hash Bags
homepage: https://github.com/Such-Software/hash-wallet
environment:
sdk: ">=2.17.5 <3.0.0"
sdk: ">=3.0.0 <4.0.0"
flutter: ">=1.20.0"
dependencies:
@@ -14,8 +14,8 @@ dependencies:
sdk: flutter
path_provider: ^2.0.11
http: ^1.1.0
mobx: ^2.0.7+4
flutter_mobx: ^2.0.6+1
mobx: ^2.6.0
flutter_mobx: ^2.3.0
intl: any
shared_preferences: ^2.0.15
cw_core:
@@ -29,7 +29,7 @@ dependencies:
blockchain_utils:
git:
url: https://github.com/cake-tech/blockchain_utils
ref: cake-update-v2
ref: 59fdf29d72068e0522a96a8953ed7272833a9f57
cw_mweb:
path: ../cw_mweb
grpc: 4.0.1
@@ -55,6 +55,11 @@ dependencies:
git:
url: https://github.com/cake-tech/trezor_connect
ref: d1242cea90f84b00200e7bcab914a5af750e23fb
trezor_flutter:
git:
url: https://github.com/cake-tech/trezor-flutter
ref: 591d4b8c821b9c10561f848b866324a9a8dcb489
path: trezor-flutter
bitbox_flutter:
path: ../scripts/bitbox_flutter
socks_socket:
@@ -72,15 +77,15 @@ dependencies:
breez_sdk_spark_flutter:
git:
url: https://github.com/breez/breez-sdk-spark-flutter
ref: v0.11.0
ref: v0.23.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.15
build_resolvers: ^2.4.4
mobx_codegen: ^2.0.7
hive_generator: ^2.0.1
build_runner: ^2.15.0
mobx_codegen: ^2.7.6
# hive_generator: ^2.0.1
hive: 2.0.1
dependency_overrides:
watcher: ^1.1.0
@@ -88,7 +93,7 @@ dependency_overrides:
bitcoin_base:
git:
url: https://github.com/cake-tech/bitcoin_base
ref: cake-update-v11
ref: 4e41f96f4838139895c65c3f49109d05af5d46aa
pointycastle: 3.7.4
ffi: 2.1.0
intl: any
+1
View File
@@ -0,0 +1 @@
../pubspec_overrides.yaml
+100
View File
@@ -0,0 +1,100 @@
import 'dart:math';
import "package:cw_bitcoin/coin_selection.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
test('effectiveValue', () {
expect(effectiveValue(10000, 136), 9864);
expect(effectiveValue(100, 136), -36);
});
test('BnB finds changeless match in window', () {
final r = branchAndBound([200,100,90,80], 300, 50);
expect(r, isNotNull); expect(r!.hasChange, isFalse);
final vals=[200,100,90,80]; final sum=r.indices.map((i)=>vals[i]).reduce((a,b)=>a+b);
expect(sum>=300 && sum<=350, isTrue);
});
test('BnB null when no subset in window', () {
expect(branchAndBound([1000,900], 300, 5), isNull);
});
test('SRD with change', () {
final r = singleRandomDraw([500,500,500,500], 700, 50, Random(1));
expect(r, isNotNull); expect(r!.hasChange, isTrue);
});
test('SRD randomizes across seeds', () {
final a = (singleRandomDraw([100,101,102,103,104,105],150,10,Random(1))!.indices..sort());
final b = (singleRandomDraw([100,101,102,103,104,105],150,10,Random(9))!.indices..sort());
expect(a, isNot(equals(b)));
});
test('SRD null on insufficient funds', () {
expect(singleRandomDraw([100,100], 500, 10, Random(1)), isNull);
});
test('selectCoins prefers changeless BnB, else SRD', () {
expect(selectCoins(values:[200,100,90],target:300,inputCost:0,costOfChange:20,minChange:10).hasChange, isFalse);
expect(selectCoins(values:[500,500,500],target:700,inputCost:0,costOfChange:5,minChange:10,rng:Random(1)).hasChange, isTrue);
});
test('selectCoins drops non-positive effective values', () {
// coin of value 50 with inputCost 136 -> effective -86 -> dropped; only the 1000 usable
final r = selectCoins(values:[50,1000],target:500,inputCost:136,costOfChange:5,minChange:10,rng:Random(1));
expect(r.indices, equals([1]));
});
test('changelessMatch finds a set whose effective sum lands in the window', () {
// inputCost 100: values [50, 400, 300, 200] -> eff [dropped, 300, 200, 100]
// target 500, window 46: eff {300, 200} = 500, exact
final r = changelessMatch(
values: [50, 400, 300, 200], target: 500, inputCosts: [100, 100, 100, 100], window: 46);
expect(r, isNotNull);
expect(r!.hasChange, isFalse);
final effSum = r.indices.map((i) => [50, 400, 300, 200][i] - 100).reduce((a, b) => a + b);
expect(effSum >= 500 && effSum <= 546, isTrue);
expect(r.indices.contains(0), isFalse); // negative-eff coin never selected
});
test('changelessMatch returns null when no subset lands in the window', () {
expect(
changelessMatch(values: [10000, 9000], target: 500, inputCosts: [100, 100], window: 46),
isNull);
});
test('selectCoins throws when insufficient', () {
expect(() => selectCoins(values:[100,100],target:500,inputCost:0,costOfChange:5,minChange:10),
throwsA(isA<InsufficientFundsException>()));
});
test('changeless pipeline: leftover absorbed into fee is non-negative and below dust', () {
// Mirrors the _createUTXOS / estimateTxForAmount arithmetic with the wallet's
// 68*inputs + 34*outputs + 10 vBytes model, at 10 sat/vB with 1 recipient output.
const feeRate = 10;
const amount = 50000;
final values = [60700, 30000, 21800, 9000, 5000];
const target = amount + (34 * 1 + 10) * feeRate;
final r = changelessMatch(
values: values,
target: target,
inputCosts: List.filled(values.length, 68 * feeRate),
window: 546);
expect(r, isNotNull);
final inAmount = r!.indices.map((i) => values[i]).reduce((a, b) => a + b);
final feeNoChange = (68 * r.indices.length + 34 * 1 + 10) * feeRate;
final leftover = inAmount - amount - feeNoChange;
expect(leftover >= 0, isTrue); // the caller never recurses for more inputs
expect(leftover <= 546, isTrue); // fee overpay is bounded by the dust limit
});
test('BnB terminates and returns null on large pools with no possible match', () {
// 300 even effective values, odd target, zero window: no subset can ever match,
// so the search must stop at maxTries instead of exploring 2^300 branches.
final values = List<int>.generate(300, (i) => 1000000 + i * 2);
final r = changelessMatch(
values: values, target: 1500001, inputCosts: List.filled(300, 10), window: 0);
expect(r, isNull);
});
test('changelessMatch charges each input its own script-type cost', () {
// Legacy input (148 vB) vs segwit input (68 vB) at 10 sat/vB: same value, but
// the legacy coin's effective value is 800 lower. Target only reachable when
// the cheaper segwit coin is chosen: eff segwit = 10000-680 = 9320.
const feeRate = 10;
final r = changelessMatch(
values: [10000, 10000],
target: 9320,
inputCosts: [148 * feeRate, 68 * feeRate],
window: 0,
);
expect(r, isNotNull);
expect(r!.indices, equals([1]));
});
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:cw_bitcoin/locktime.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('antiFeeSnipingLocktime', () {
test('returns 0 when not synced', () {
expect(antiFeeSnipingLocktime(chainTip: 800000, synced: false), 0);
});
test('returns 0 when chainTip <= 0', () {
expect(antiFeeSnipingLocktime(chainTip: 0, synced: true), 0);
});
test('returns the current tip when synced', () {
expect(antiFeeSnipingLocktime(chainTip: 800000, synced: true), 800000);
});
});
group('locktimeToBytes', () {
test('encodes 0 as four zero bytes', () {
expect(locktimeToBytes(0), [0, 0, 0, 0]);
});
test('encodes a block height little-endian', () {
expect(locktimeToBytes(800000), [0x00, 0x35, 0x0C, 0x00]);
});
test('encodes max 32-bit value', () {
expect(locktimeToBytes(0xFFFFFFFF), [0xFF, 0xFF, 0xFF, 0xFF]);
});
});
}
+52
View File
@@ -0,0 +1,52 @@
import 'dart:math';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:cw_bitcoin/output_ordering.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('orderOutputs', () {
test('none preserves the given order', () {
final outputs = [0, 1, 2, 3, 4];
expect(orderOutputs(outputs, BitcoinOrdering.none), outputs);
});
test('does not mutate the input list', () {
final outputs = [0, 1, 2, 3, 4];
orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(1));
expect(outputs, [0, 1, 2, 3, 4]);
});
test('returns a new list instance', () {
final outputs = [0, 1, 2];
expect(identical(orderOutputs(outputs, BitcoinOrdering.none), outputs), isFalse);
});
test('shuffle preserves the multiset of outputs', () {
final outputs = [0, 1, 2, 3, 4, 5, 6, 7];
final shuffled = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(7));
expect(shuffled.length, outputs.length);
expect(shuffled.toSet(), outputs.toSet());
});
test('shuffle is deterministic for a given seed', () {
final outputs = [0, 1, 2, 3, 4, 5];
final a = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(42));
final b = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(42));
expect(a, b);
});
test('shuffle does not keep the change output deterministically last', () {
// Last element (99) models the change output, appended last today.
final outputs = [0, 1, 2, 3, 99];
final changePositions = <int>{};
for (var seed = 0; seed < 50; seed++) {
final shuffled = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(seed));
changePositions.add(shuffled.indexOf(99));
}
// Across seeds the change lands in more than one position, and not always last.
expect(changePositions.length, greaterThan(1));
expect(changePositions, isNot(equals({outputs.length - 1})));
});
});
}