What changed, and why it matters
This commit updates the Mimblewimble (Mweb) wallet code in Stack Wallet. It adds a one-time scan of older transactions when restoring a wallet, fixes a bug where spent Mweb coins were not always marked as used, and changes how transaction fees are calculated. The fee-calculation change removes a previous workaround that added an extra 1 satoshi to every fee, which could have caused users to slightly overpay. There is no clear security vulnerability in the diff, but the changes touch sensitive wallet logic (secret scanning, UTXO state, and fee math), so bugs here could affect funds or privacy.
Treat as a routine functional patch, but recommend a focused review of: (1) the new historical UTXO scan to ensure it does not miss blocks or double-count UTXOs, (2) the fee-rate conversion to confirm Int64(feeRate * 1000) matches the server's expected units and does not underpay, and (3) the empty catch blocks around stream.timeout to ensure silent failures do not leave the wallet in an inconsistent state. No immediate security response is indicated by the diff alone.
Security signals we found
Change to cryptographic secret handling path (scanSecret/spendSecret used in UTXO scan and transaction creation)
Database state-management change for spent Mweb UTXOs
Fee-calculation arithmetic change that removes a +1 satoshi rounding workaround
Addition of a timed stream listener (2-second timeout) for UTXO scanning with empty catch blocks
No explicit security claim, CVE, or advisory in commit message or diff
Evidence from the diff
The patch modifies lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart. Key functional changes: (1) On wallet open/restore it now performs an initial historical UTXO scan from info.restoreHeight using the scan secret, persists results to Drift, then continues live scanning from the stored mwebScanHeight. (2) When confirming an Mweb send, it now explicitly marks used UTXOs with used.copyWith(used: true) before replacing them in the database, with a comment noting the field should already be true. (3) Fee estimation is refactored: feeRate is derived from satsPerVByte or feeRateAmount/1000, then passed as feeRatePerKb = Int64(feeRate * 1000). The previous +1 satoshi rounding band-aid is removed and the peg-in fee increase is computed with BigInt.from(feeRate * 41). The remainder of the diff is formatting/line-wrapping and comment rewording.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dartMweb UTXO scanning and persistence logicMweb transaction fee estimationMweb send confirmation / UTXO markingInspect captured patch +57 / −13
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart
index 35a0059..e183be6 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart
@@ -185,8 +185,45 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
Logging.instance.i("info.restoreHeight: ${info.restoreHeight}");
Logging.instance.i(
- "info.otherData[WalletInfoKeys.mwebScanHeight]: ${info.otherData[WalletInfoKeys.mwebScanHeight]}",
+ "info.otherData[WalletInfoKeys.mwebScanHeight]:"
+ " ${info.otherData[WalletInfoKeys.mwebScanHeight]}",
);
+
+ // =========================================================================
+ final List<MwebUtxosCompanion> utxos = [];
+ final stream = await client.utxos(
+ UtxosRequest(
+ fromHeight: info.restoreHeight,
+ scanSecret: await _scanSecret,
+ ),
+ );
+ try {
+ await for (final utxo in stream.timeout(const Duration(seconds: 2))) {
+ final newUtxo = MwebUtxosCompanion(
+ outputId: Value(utxo.outputId),
+ address: Value(utxo.address),
+ value: Value(utxo.value.toInt()),
+ height: Value(utxo.height),
+ blockTime: Value(utxo.blockTime),
+ blocked: const Value(false),
+ used: const Value(false),
+ );
+ utxos.add(newUtxo);
+ }
+ } catch (_) {}
+
+ try {
+ await stream.cancel();
+ } catch (_) {}
+ final db = Drift.get(walletId);
+ await db.transaction(() async {
+ await db.delete(db.mwebUtxos).go();
+ for (final utxo in utxos) {
+ await db.into(db.mwebUtxos).insert(utxo);
+ }
+ });
+ // =========================================================================
+
final fromHeight =
info.otherData[WalletInfoKeys.mwebScanHeight] as int? ??
info.restoreHeight;
@@ -196,7 +233,6 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
scanSecret: await _scanSecret,
);
- final db = Drift.get(walletId);
_mwebUtxoSubscription = (await client.utxos(request)).listen((utxo) async {
Logging.instance.t(
"Found UTXO in stream: Utxo("
@@ -322,7 +358,8 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
Future<Address> generateNextMwebAddress({bool isChange = false}) async {
if (!info.isMwebEnabled) {
throw Exception(
- "Tried calling generateNextMwebAddress with mweb disabled for $walletId ${info.name}",
+ "Tried calling generateNextMwebAddress with mweb "
+ "disabled for $walletId ${info.name}",
);
}
@@ -382,7 +419,8 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
SpentRequest(outputId: [input.outpoint!.txid]),
);
if (response.outputId.contains(input.outpoint!.txid)) {
- // dummy to show tx as confirmed. Need a better way to handle this as its kind of stupid, resulting in terrible UX
+ // dummy to show tx as confirmed. Need a better way to handle
+ // this as its kind of stupid, resulting in terrible UX
final dummyHeight = await chainHeight;
TransactionV2? transaction = await mainDB.isar.transactionV2s
@@ -480,7 +518,8 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
Future<TxData> _confirmSendMweb({required TxData txData}) async {
if (!info.isMwebEnabled) {
throw Exception(
- "Tried calling _confirmSendMweb with mweb disabled for $walletId ${info.name}",
+ "Tried calling _confirmSendMweb with mweb disabled for"
+ " $walletId ${info.name}",
);
}
@@ -533,7 +572,11 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
final db = Drift.get(walletId);
await db.transaction(() async {
for (final used in usedMwebUtxos) {
- await db.update(db.mwebUtxos).replace(used);
+ await db
+ .update(db.mwebUtxos)
+ .replace(
+ used.copyWith(used: true),
+ ); // used should already be set to true here but...
}
});
}
@@ -578,7 +621,8 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
Future<void> anonymizeAllMweb() async {
if (!info.isMwebEnabled) {
Logging.instance.e(
- "Tried calling anonymizeAllMweb with mweb disabled for $walletId ${info.name}",
+ "Tried calling anonymizeAllMweb with mweb disabled for"
+ " $walletId ${info.name}",
);
return;
}
@@ -909,6 +953,9 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
final preOutputSum = outputs.fold(BigInt.zero, (p, e) => p + e.amount.raw);
final fee = sumOfUtxosValue - preOutputSum;
+ final feeRate =
+ txData.satsPerVByte ?? (txData.feeRateAmount!.toInt() / 1000).ceil();
+
final client = await _client;
final resp = await client.create(
@@ -916,7 +963,7 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
rawTx: txData.raw!.toUint8ListFromHex,
scanSecret: await _scanSecret,
spendSecret: await _spendSecret,
- feeRatePerKb: Int64(txData.feeRateAmount!.toInt()),
+ feeRatePerKb: Int64(feeRate * 1000),
dryRun: true,
),
);
@@ -948,14 +995,11 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
BigInt feeIncrease = posOutputSum - expectedPegin;
if (expectedPegin > BigInt.zero) {
- feeIncrease +=
- BigInt.from((txData.feeRateAmount! / BigInt.from(1000)).ceil()) *
- BigInt.from(41);
+ feeIncrease += BigInt.from(feeRate * 41);
}
- // bandaid: add one to account for a rounding error that happens sometimes
return Amount(
- rawValue: fee + feeIncrease + BigInt.one,
+ rawValue: fee + feeIncrease,
fractionDigits: cryptoCurrency.fractionDigits,
);
}
Why this scored 31/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.