Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 115 additions & 47 deletions lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1546,10 +1547,64 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
.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<TxData> 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<String, _SparkMintSigningKey> 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<Address> 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) {
Expand Down Expand Up @@ -1590,7 +1645,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
}

// if (!MoneyRange(mintedValue) || mintedValue == 0) {
if (mintedValue == BigInt.zero) {
if (mintedValue <= BigInt.zero) {
valueAndUTXOs.remove(itr);
skipCoin = true;
break;
Expand All @@ -1609,11 +1664,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>

if (autoMintAll) {
singleTxOutputs.add(
MutableSparkRecipient(
(await getCurrentReceivingSparkAddress())!.value,
mintedValue,
"",
),
MutableSparkRecipient(autoMintSparkAddress!, mintedValue, ""),
);
} else {
BigInt remainingMintValue = BigInt.parse(mintedValue.toString());
Expand Down Expand Up @@ -1641,25 +1692,34 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
}
}

if (subtractFeeFromAmount) {
final BigInt singleFee =
nFeeRet ~/ BigInt.from(singleTxOutputs.length);
BigInt remainder = nFeeRet % BigInt.from(singleTxOutputs.length);

for (int i = 0; i < singleTxOutputs.length; ++i) {
if (singleTxOutputs[i].value <= singleFee) {
singleTxOutputs.removeAt(i);
remainder += singleTxOutputs[i].value - singleFee;
--i;
if (subtractFeeFromAmount && nFeeRet > BigInt.zero) {
var remainingFee = nFeeRet;
var outputIndex = 0;
while (outputIndex < singleTxOutputs.length &&
remainingFee > BigInt.zero) {
final outputsLeft = BigInt.from(
singleTxOutputs.length - outputIndex,
);
var feeShare = remainingFee ~/ outputsLeft;
if (remainingFee % outputsLeft != BigInt.zero) {
feeShare += BigInt.one;
}
singleTxOutputs[i].value -= singleFee;
if (remainder > BigInt.zero &&
singleTxOutputs[i].value >
nFeeRet % BigInt.from(singleTxOutputs.length)) {
// first receiver pays the remainder not divisible by output count
singleTxOutputs[i].value -= remainder;
remainder = BigInt.zero;

if (singleTxOutputs[outputIndex].value <= feeShare) {
remainingFee -= singleTxOutputs[outputIndex].value;
singleTxOutputs.removeAt(outputIndex);
continue;
}

singleTxOutputs[outputIndex].value -= feeShare;
remainingFee -= feeShare;
++outputIndex;
}

if (singleTxOutputs.isEmpty) {
valueAndUTXOs.remove(itr);
skipCoin = true;
break;
}
}

Expand Down Expand Up @@ -1694,11 +1754,13 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
BigInt nValueIn = BigInt.zero;
for (final utxo in itr) {
if (nValueToSelect > nValueIn) {
setCoins.add(
(await addSigningKeys([
StandardInput(utxo),
])).whereType<StandardInput>().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);
}
}
Expand All @@ -1720,9 +1782,9 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
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,
));
Expand Down Expand Up @@ -1817,13 +1879,19 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
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) {
Expand Down Expand Up @@ -1984,19 +2052,11 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
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,
),
),
);
}
Expand Down Expand Up @@ -2076,11 +2136,14 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
);

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);
Expand Down Expand Up @@ -2507,6 +2570,11 @@ BigInt _sum(List<UTXO> 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;
Expand Down