Skip to content

Commit f212403

Browse files
Merge pull request #1295 from reubenyap/claude/review-branch-quality-WssvC
Optimize Firo Spark mint tx generation and fix fee < vSize error
2 parents a521b2a + 83d2888 commit f212403

1 file changed

Lines changed: 139 additions & 47 deletions

File tree

lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart

Lines changed: 139 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:isolate';
44
import 'dart:math';
55

66
import 'package:bitcoindart/bitcoindart.dart' as btc;
7+
import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib;
78
import 'package:decimal/decimal.dart';
89
import 'package:flutter/foundation.dart';
910
import 'package:isar_community/isar.dart';
@@ -1546,10 +1547,64 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
15461547
.map((e) => MutableSparkRecipient(e.address, e.value, e.memo))
15471548
.toList(); // deep copy
15481549
final feesObject = await fees;
1550+
final minRelayFeeRatePerKB = BigInt.from(1000);
1551+
final mintFeeRatePerKB = feesObject.medium < minRelayFeeRatePerKB
1552+
? minRelayFeeRatePerKB
1553+
: feesObject.medium;
15491554
final currentHeight = await chainHeight;
15501555
final random = Random.secure();
15511556
final List<TxData> results = [];
15521557

1558+
final String? autoMintSparkAddress = autoMintAll
1559+
? (await getCurrentReceivingSparkAddress())?.value
1560+
: null;
1561+
if (autoMintAll && autoMintSparkAddress == null) {
1562+
throw Exception("No current Spark receiving address found.");
1563+
}
1564+
1565+
// Cache signing keys lazily for selected inputs. This mirrors the subset
1566+
// of addSigningKeys used by Firo Spark mints; Firo currently supports only
1567+
// BIP44 transparent inputs, so caching from the wallet root is valid here.
1568+
final root = await getRootHDNode();
1569+
final Map<String, _SparkMintSigningKey> signingKeyCache = {};
1570+
Future<_SparkMintSigningKey> getCachedSigningKey(String address) async {
1571+
final existing = signingKeyCache[address];
1572+
if (existing != null) {
1573+
return existing;
1574+
}
1575+
1576+
final derivePathType = cryptoCurrency.addressType(address: address);
1577+
final dbAddress = await mainDB.getAddress(walletId, address);
1578+
if (dbAddress?.derivationPath == null) {
1579+
throw Exception(
1580+
"Signing key not found for address $address. "
1581+
"Local db may be corrupt. Rescan wallet.",
1582+
);
1583+
}
1584+
1585+
final key = root.derivePath(dbAddress!.derivationPath!.value);
1586+
final cached = (derivePathType: derivePathType, key: key);
1587+
signingKeyCache[address] = cached;
1588+
return cached;
1589+
}
1590+
1591+
Address? cachedChangeAddress;
1592+
Future<Address> getMintChangeAddress() async {
1593+
cachedChangeAddress ??= await getCurrentChangeAddress();
1594+
if (cachedChangeAddress == null) {
1595+
throw Exception("No current change address found.");
1596+
}
1597+
return cachedChangeAddress!;
1598+
}
1599+
1600+
// Pre-fetch wallet-owned addresses for output ownership checks.
1601+
final walletAddresses = await mainDB.isar.addresses
1602+
.where()
1603+
.walletIdEqualTo(walletId)
1604+
.valueProperty()
1605+
.findAll();
1606+
final walletAddressSet = walletAddresses.toSet();
1607+
15531608
valueAndUTXOs.shuffle(random);
15541609

15551610
while (valueAndUTXOs.isNotEmpty) {
@@ -1590,7 +1645,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
15901645
}
15911646

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

16101665
if (autoMintAll) {
16111666
singleTxOutputs.add(
1612-
MutableSparkRecipient(
1613-
(await getCurrentReceivingSparkAddress())!.value,
1614-
mintedValue,
1615-
"",
1616-
),
1667+
MutableSparkRecipient(autoMintSparkAddress!, mintedValue, ""),
16171668
);
16181669
} else {
16191670
BigInt remainingMintValue = BigInt.parse(mintedValue.toString());
@@ -1641,25 +1692,42 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
16411692
}
16421693
}
16431694

1644-
if (subtractFeeFromAmount) {
1645-
final BigInt singleFee =
1646-
nFeeRet ~/ BigInt.from(singleTxOutputs.length);
1647-
BigInt remainder = nFeeRet % BigInt.from(singleTxOutputs.length);
1648-
1649-
for (int i = 0; i < singleTxOutputs.length; ++i) {
1650-
if (singleTxOutputs[i].value <= singleFee) {
1651-
singleTxOutputs.removeAt(i);
1652-
remainder += singleTxOutputs[i].value - singleFee;
1653-
--i;
1695+
if (subtractFeeFromAmount && nFeeRet > BigInt.zero) {
1696+
var remainingFee = nFeeRet;
1697+
var outputIndex = 0;
1698+
while (singleTxOutputs.isNotEmpty && remainingFee > BigInt.zero) {
1699+
if (outputIndex >= singleTxOutputs.length) {
1700+
outputIndex = 0;
1701+
}
1702+
1703+
final outputsLeft = BigInt.from(
1704+
singleTxOutputs.length - outputIndex,
1705+
);
1706+
var feeShare = remainingFee ~/ outputsLeft;
1707+
if (remainingFee % outputsLeft != BigInt.zero) {
1708+
feeShare += BigInt.one;
16541709
}
1655-
singleTxOutputs[i].value -= singleFee;
1656-
if (remainder > BigInt.zero &&
1657-
singleTxOutputs[i].value >
1658-
nFeeRet % BigInt.from(singleTxOutputs.length)) {
1659-
// first receiver pays the remainder not divisible by output count
1660-
singleTxOutputs[i].value -= remainder;
1661-
remainder = BigInt.zero;
1710+
1711+
if (singleTxOutputs[outputIndex].value <= feeShare) {
1712+
remainingFee -= singleTxOutputs[outputIndex].value;
1713+
singleTxOutputs.removeAt(outputIndex);
1714+
continue;
16621715
}
1716+
1717+
singleTxOutputs[outputIndex].value -= feeShare;
1718+
remainingFee -= feeShare;
1719+
++outputIndex;
1720+
}
1721+
1722+
if (singleTxOutputs.isEmpty) {
1723+
if (autoMintAll) {
1724+
throw Exception(
1725+
"UTXO value is too small to cover Spark mint fee",
1726+
);
1727+
}
1728+
valueAndUTXOs.remove(itr);
1729+
skipCoin = true;
1730+
break;
16631731
}
16641732
}
16651733

@@ -1694,11 +1762,13 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
16941762
BigInt nValueIn = BigInt.zero;
16951763
for (final utxo in itr) {
16961764
if (nValueToSelect > nValueIn) {
1697-
setCoins.add(
1698-
(await addSigningKeys([
1699-
StandardInput(utxo),
1700-
])).whereType<StandardInput>().first,
1765+
final cached = await getCachedSigningKey(utxo.address!);
1766+
final input = StandardInput(
1767+
utxo,
1768+
derivePathType: cached.derivePathType,
17011769
);
1770+
input.key = cached.key;
1771+
setCoins.add(input);
17021772
nValueIn += BigInt.from(utxo.value);
17031773
}
17041774
}
@@ -1720,9 +1790,9 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
17201790
throw Exception("Change index out of range");
17211791
}
17221792

1723-
final changeAddress = await getCurrentChangeAddress();
1793+
final changeAddress = await getMintChangeAddress();
17241794
vout.insert(nChangePosInOut, (
1725-
changeAddress!.value,
1795+
changeAddress.value,
17261796
nChange.toInt(),
17271797
null,
17281798
));
@@ -1817,13 +1887,19 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
18171887
throw Exception("Transaction too large");
18181888
}
18191889

1820-
const nBytesBuffer = 10;
1890+
// ECDSA DER signatures are not fixed-size. Even with low-S
1891+
// normalization, the encoded signature length can vary across
1892+
// signatures, so the dummy signed transaction used for fee estimation
1893+
// can be smaller than the final signed transaction. Use a per-input
1894+
// safety margin so fee estimation remains an upper bound for many-input
1895+
// Spark mints.
1896+
final nBytesBuffer = 10 + 4 * setCoins.length;
18211897
final nFeeNeeded = BigInt.from(
18221898
estimateTxFee(
18231899
vSize: nBytes + nBytesBuffer,
1824-
feeRatePerKB: feesObject.medium,
1900+
feeRatePerKB: mintFeeRatePerKB,
18251901
),
1826-
); // One day we'll do this properly
1902+
);
18271903

18281904
if (nFeeRet >= nFeeNeeded) {
18291905
for (final usedCoin in setCoins) {
@@ -1984,19 +2060,11 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
19842060
addresses: [
19852061
if (addressOrScript is String) addressOrScript.toString(),
19862062
],
1987-
walletOwns:
1988-
(await mainDB.isar.addresses
1989-
.where()
1990-
.walletIdEqualTo(walletId)
1991-
.filter()
1992-
.valueEqualTo(
1993-
addressOrScript is Uint8List
1994-
? output.$3!
1995-
: addressOrScript as String,
1996-
)
1997-
.valueProperty()
1998-
.findFirst()) !=
1999-
null,
2063+
walletOwns: walletAddressSet.contains(
2064+
addressOrScript is Uint8List
2065+
? output.$3!
2066+
: addressOrScript as String,
2067+
),
20002068
),
20012069
);
20022070
}
@@ -2026,6 +2094,18 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
20262094
rethrow;
20272095
}
20282096
final builtTx = txb.build();
2097+
final actualFee =
2098+
vin
2099+
.map((e) => BigInt.from(e.utxo.value))
2100+
.fold(BigInt.zero, (p, e) => p + e) -
2101+
vout.map((e) => BigInt.from(e.$2)).fold(BigInt.zero, (p, e) => p + e);
2102+
if (actualFee != nFeeRet) {
2103+
Logging.instance.e(
2104+
"Spark mint fee accounting mismatch: "
2105+
"expected=$nFeeRet, actual=$actualFee",
2106+
);
2107+
throw Exception("Spark mint fee accounting mismatch");
2108+
}
20292109

20302110
// TODO: see todo at top of this function
20312111
assert(outputs.length == 1);
@@ -2076,11 +2156,14 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
20762156
);
20772157

20782158
Logging.instance.i("nFeeRet=$nFeeRet, vSize=${data.vSize}");
2159+
// Sanity check: with the fee rate clamped to at least 1 sat/vbyte, this
2160+
// should only fire if fee accounting or size estimation regresses.
20792161
if (nFeeRet.toInt() < data.vSize!) {
20802162
Logging.instance.w(
2081-
"Spark mint transaction failed: $nFeeRet is less than ${data.vSize}",
2163+
"Fee rate below 1 sat/byte minimum relay fee: "
2164+
"fee=$nFeeRet sats, vSize=${data.vSize} bytes",
20822165
);
2083-
throw Exception("fee is less than vSize");
2166+
throw Exception("Fee rate below 1 sat/byte minimum relay fee");
20842167
}
20852168

20862169
results.add(data);
@@ -2130,6 +2213,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
21302213
throw Exception("Failed to mint expected amounts");
21312214
}
21322215

2216+
if (autoMintAll && results.isEmpty) {
2217+
throw Exception("No Spark mint transactions were created");
2218+
}
2219+
21332220
return results;
21342221
}
21352222

@@ -2507,6 +2594,11 @@ BigInt _sum(List<UTXO> utxos) => utxos
25072594
.map((e) => BigInt.from(e.value))
25082595
.fold(BigInt.zero, (previousValue, element) => previousValue + element);
25092596

2597+
typedef _SparkMintSigningKey = ({
2598+
DerivePathType derivePathType,
2599+
coinlib.HDPrivateKey key,
2600+
});
2601+
25102602
class MutableSparkRecipient {
25112603
String address;
25122604
BigInt value;

0 commit comments

Comments
 (0)