diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 0dec8aab2..e3c02ee66 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -4,6 +4,7 @@ import 'dart:isolate'; import 'dart:math'; import 'package:bitcoindart/bitcoindart.dart' as btc; +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; import 'package:decimal/decimal.dart'; import 'package:flutter/foundation.dart'; import 'package:isar_community/isar.dart'; @@ -1546,10 +1547,64 @@ mixin SparkInterface .map((e) => MutableSparkRecipient(e.address, e.value, e.memo)) .toList(); // deep copy final feesObject = await fees; + final minRelayFeeRatePerKB = BigInt.from(1000); + final mintFeeRatePerKB = feesObject.medium < minRelayFeeRatePerKB + ? minRelayFeeRatePerKB + : feesObject.medium; final currentHeight = await chainHeight; final random = Random.secure(); final List results = []; + final String? autoMintSparkAddress = autoMintAll + ? (await getCurrentReceivingSparkAddress())?.value + : null; + if (autoMintAll && autoMintSparkAddress == null) { + throw Exception("No current Spark receiving address found."); + } + + // Cache signing keys lazily for selected inputs. This mirrors the subset + // of addSigningKeys used by Firo Spark mints; Firo currently supports only + // BIP44 transparent inputs, so caching from the wallet root is valid here. + final root = await getRootHDNode(); + final Map signingKeyCache = {}; + Future<_SparkMintSigningKey> getCachedSigningKey(String address) async { + final existing = signingKeyCache[address]; + if (existing != null) { + return existing; + } + + final derivePathType = cryptoCurrency.addressType(address: address); + final dbAddress = await mainDB.getAddress(walletId, address); + if (dbAddress?.derivationPath == null) { + throw Exception( + "Signing key not found for address $address. " + "Local db may be corrupt. Rescan wallet.", + ); + } + + final key = root.derivePath(dbAddress!.derivationPath!.value); + final cached = (derivePathType: derivePathType, key: key); + signingKeyCache[address] = cached; + return cached; + } + + Address? cachedChangeAddress; + Future
getMintChangeAddress() async { + cachedChangeAddress ??= await getCurrentChangeAddress(); + if (cachedChangeAddress == null) { + throw Exception("No current change address found."); + } + return cachedChangeAddress!; + } + + // Pre-fetch wallet-owned addresses for output ownership checks. + final walletAddresses = await mainDB.isar.addresses + .where() + .walletIdEqualTo(walletId) + .valueProperty() + .findAll(); + final walletAddressSet = walletAddresses.toSet(); + valueAndUTXOs.shuffle(random); while (valueAndUTXOs.isNotEmpty) { @@ -1590,7 +1645,7 @@ mixin SparkInterface } // if (!MoneyRange(mintedValue) || mintedValue == 0) { - if (mintedValue == BigInt.zero) { + if (mintedValue <= BigInt.zero) { valueAndUTXOs.remove(itr); skipCoin = true; break; @@ -1610,7 +1665,7 @@ mixin SparkInterface if (autoMintAll) { singleTxOutputs.add( MutableSparkRecipient( - (await getCurrentReceivingSparkAddress())!.value, + autoMintSparkAddress!, mintedValue, "", ), @@ -1648,9 +1703,10 @@ mixin SparkInterface for (int i = 0; i < singleTxOutputs.length; ++i) { if (singleTxOutputs[i].value <= singleFee) { - singleTxOutputs.removeAt(i); - remainder += singleTxOutputs[i].value - singleFee; + final removed = singleTxOutputs.removeAt(i); + remainder += removed.value - singleFee; --i; + continue; } singleTxOutputs[i].value -= singleFee; if (remainder > BigInt.zero && @@ -1694,11 +1750,13 @@ mixin SparkInterface BigInt nValueIn = BigInt.zero; for (final utxo in itr) { if (nValueToSelect > nValueIn) { - setCoins.add( - (await addSigningKeys([ - StandardInput(utxo), - ])).whereType().first, + final cached = await getCachedSigningKey(utxo.address!); + final input = StandardInput( + utxo, + derivePathType: cached.derivePathType, ); + input.key = cached.key; + setCoins.add(input); nValueIn += BigInt.from(utxo.value); } } @@ -1720,9 +1778,9 @@ mixin SparkInterface throw Exception("Change index out of range"); } - final changeAddress = await getCurrentChangeAddress(); + final changeAddress = await getMintChangeAddress(); vout.insert(nChangePosInOut, ( - changeAddress!.value, + changeAddress.value, nChange.toInt(), null, )); @@ -1817,13 +1875,19 @@ mixin SparkInterface throw Exception("Transaction too large"); } - const nBytesBuffer = 10; + // ECDSA DER signatures are not fixed-size. Even with low-S + // normalization, the encoded signature length can vary across + // signatures, so the dummy signed transaction used for fee estimation + // can be smaller than the final signed transaction. Use a per-input + // safety margin so fee estimation remains an upper bound for many-input + // Spark mints. + final nBytesBuffer = 10 + 4 * setCoins.length; final nFeeNeeded = BigInt.from( estimateTxFee( vSize: nBytes + nBytesBuffer, - feeRatePerKB: feesObject.medium, + feeRatePerKB: mintFeeRatePerKB, ), - ); // One day we'll do this properly + ); if (nFeeRet >= nFeeNeeded) { for (final usedCoin in setCoins) { @@ -1984,19 +2048,11 @@ mixin SparkInterface addresses: [ if (addressOrScript is String) addressOrScript.toString(), ], - walletOwns: - (await mainDB.isar.addresses - .where() - .walletIdEqualTo(walletId) - .filter() - .valueEqualTo( - addressOrScript is Uint8List - ? output.$3! - : addressOrScript as String, - ) - .valueProperty() - .findFirst()) != - null, + walletOwns: walletAddressSet.contains( + addressOrScript is Uint8List + ? output.$3! + : addressOrScript as String, + ), ), ); } @@ -2076,11 +2132,14 @@ mixin SparkInterface ); Logging.instance.i("nFeeRet=$nFeeRet, vSize=${data.vSize}"); + // Sanity check: with the fee rate clamped to at least 1 sat/vbyte, this + // should only fire if fee accounting or size estimation regresses. if (nFeeRet.toInt() < data.vSize!) { Logging.instance.w( - "Spark mint transaction failed: $nFeeRet is less than ${data.vSize}", + "Fee rate below 1 sat/byte minimum relay fee: " + "fee=$nFeeRet sats, vSize=${data.vSize} bytes", ); - throw Exception("fee is less than vSize"); + throw Exception("Fee rate below 1 sat/byte minimum relay fee"); } results.add(data); @@ -2507,6 +2566,11 @@ BigInt _sum(List utxos) => utxos .map((e) => BigInt.from(e.value)) .fold(BigInt.zero, (previousValue, element) => previousValue + element); +typedef _SparkMintSigningKey = ({ + DerivePathType derivePathType, + coinlib.HDPrivateKey key, +}); + class MutableSparkRecipient { String address; BigInt value;