What changed, and why it matters
This commit fixes a wallet bug where Litecoin MWEB peg-out transactions could be spent before they were actually valid on the network. MWEB peg-outs require six confirmations to mature, but Stack Wallet was treating them like normal transactions that only need one confirmation. The patch detects these special outputs and enforces the six-block wait, preventing users from accidentally creating invalid or rejected transactions.
No immediate action needed beyond normal review and merge. Users should update to the fixed version to avoid attempting premature MWEB peg-out spends. Consider whether any existing UTXOs in user wallets were incorrectly marked spendable and might need rescanning or balance refresh after update.
Security signals we found
Fixes incorrect spendability check that allowed premature spending of MWEB peg-outs
Adds protocol-level maturity enforcement for Litecoin MWEB peg-out outputs
Includes regression tests for detection and confirmation logic
Prevents potential creation of invalid transactions that would be rejected by the network
Evidence from the diff
The change introduces MWEB peg-out maturity handling. It adds an mwebPegoutMaturity field (set to 6 for Litecoin), detects peg-out outputs in ElectrumX transaction parsing by checking if output 0 is a witness_mweb_hogaddr or matches the HogAddr script pattern 5820<64 hex chars>, and stores the required maturity in the UTXO’s otherData. The UTXO.isConfirmed() method now uses max(requiredConfirmations, mwebPegoutMaturity ?? 0), ensuring peg-outs cannot be spent until they reach six confirmations. A regression test is included.
Changed components
lib/models/isar/models/blockchain_data/utxo.dartlib/wallets/crypto_currency/coins/litecoin.dartlib/wallets/crypto_currency/crypto_currency.dartlib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dartlib/db/isar/main_db.dartInspect captured patch +156 / −11
diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart
index b39757f..e8efa7d 100644
--- a/lib/db/isar/main_db.dart
+++ b/lib/db/isar/main_db.dart
@@ -349,6 +349,7 @@ class MainDB {
blockTime: utxo.blockTime,
blockHeight: utxo.blockHeight,
blockHash: utxo.blockHash,
+ otherData: utxo.otherData,
// passing null keeps the stored value
isBlocked: applyAutoBlock ? true : null,
blockedReason: applyAutoBlock ? utxo.blockedReason : null,
diff --git a/lib/models/isar/models/blockchain_data/utxo.dart b/lib/models/isar/models/blockchain_data/utxo.dart
index 988a713..871b553 100644
--- a/lib/models/isar/models/blockchain_data/utxo.dart
+++ b/lib/models/isar/models/blockchain_data/utxo.dart
@@ -86,12 +86,10 @@ class UTXO {
int? overrideMinConfirms, // added to handle namecoin name op outputs
}) {
final confirmations = getConfirmations(currentChainHeight);
-
- if (overrideMinConfirms != null) {
- return confirmations >= overrideMinConfirms;
- }
- return confirmations >=
+ final requiredConfirmations =
+ overrideMinConfirms ??
(isCoinbase ? minimumCoinbaseConfirms : minimumConfirms);
+ return confirmations >= max(requiredConfirmations, mwebPegoutMaturity ?? 0);
}
/// A lingering [blockedReason] on an unblocked utxo means the wallet
@@ -107,6 +105,24 @@ class UTXO {
return keyImage != null;
}
+ @ignore
+ int? get mwebPegoutMaturity {
+ if (otherData == null) {
+ return null;
+ }
+
+ try {
+ final value =
+ (jsonDecode(otherData!) as Map)[UTXOOtherDataKeys.mwebPegoutMaturity];
+ return value is int && value > 0 ? value : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ @ignore
+ bool get isMwebPegout => mwebPegoutMaturity != null;
+
@ignore
String? get keyImage {
if (otherData == null) {
@@ -189,6 +205,7 @@ class UTXO {
abstract final class UTXOOtherDataKeys {
static const keyImage = "keyImage";
+ static const mwebPegoutMaturity = "mwebPegoutMaturity";
static const spent = "spent";
static const nameOpData = "nameOpData";
}
diff --git a/lib/wallets/crypto_currency/coins/litecoin.dart b/lib/wallets/crypto_currency/coins/litecoin.dart
index 1b830c4..0d3ce6f 100644
--- a/lib/wallets/crypto_currency/coins/litecoin.dart
+++ b/lib/wallets/crypto_currency/coins/litecoin.dart
@@ -51,6 +51,9 @@ class Litecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
// change this to change the number of confirms a tx needs in order to show as confirmed
int get minConfirms => 1;
+ @override
+ int get mwebPegoutMaturity => 6;
+
@override
bool get torSupport => true;
@@ -169,11 +172,10 @@ class Litecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
return (address: addr, addressType: AddressType.p2pkh);
case DerivePathType.bip49:
- final p2wpkhScript =
- coinlib.P2WPKHAddress.fromPublicKey(
- publicKey,
- hrp: networkParams.bech32Hrp,
- ).program.script;
+ final p2wpkhScript = coinlib.P2WPKHAddress.fromPublicKey(
+ publicKey,
+ hrp: networkParams.bech32Hrp,
+ ).program.script;
final addr = coinlib.P2SHAddress.fromRedeemScript(
p2wpkhScript,
diff --git a/lib/wallets/crypto_currency/crypto_currency.dart b/lib/wallets/crypto_currency/crypto_currency.dart
index 8d02b13..16bba3f 100644
--- a/lib/wallets/crypto_currency/crypto_currency.dart
+++ b/lib/wallets/crypto_currency/crypto_currency.dart
@@ -11,11 +11,11 @@ export 'coins/dash.dart';
export 'coins/dogecoin.dart';
export 'coins/ecash.dart';
export 'coins/epiccash.dart';
-export 'coins/mimblewimblecoin.dart';
export 'coins/ethereum.dart';
export 'coins/fact0rn.dart';
export 'coins/firo.dart';
export 'coins/litecoin.dart';
+export 'coins/mimblewimblecoin.dart';
export 'coins/monero.dart';
export 'coins/namecoin.dart';
export 'coins/nano.dart';
@@ -65,6 +65,7 @@ abstract class CryptoCurrency {
int get minConfirms;
int get minCoinbaseConfirms => minConfirms;
+ int? get mwebPegoutMaturity => null;
// TODO: [prio=low] could be handled differently as (at least) epiccash/mimblewimblecoin does not use this
String get genesisHash;
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
index 9a2f2f3..eaf21d9 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
@@ -41,6 +41,34 @@ import 'rbf_interface.dart';
import 'sign_verify_interface.dart';
import 'view_only_option_interface.dart';
+@visibleForTesting
+bool isMwebPegoutOutput(List<dynamic> outputs, int vout) {
+ if (vout <= 0) {
+ return false;
+ }
+
+ for (final output in outputs) {
+ if (output is! Map || output["n"] != 0) {
+ continue;
+ }
+
+ final scriptPubKey = output["scriptPubKey"];
+ if (scriptPubKey is! Map) {
+ return false;
+ }
+
+ if (scriptPubKey["type"] == "witness_mweb_hogaddr") {
+ return true;
+ }
+
+ final scriptHex = scriptPubKey["hex"];
+ return scriptHex is String &&
+ RegExp(r'^5820[0-9a-fA-F]{64}$').hasMatch(scriptHex);
+ }
+
+ return false;
+}
+
mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
on Bip39HDWallet<T>
implements ViewOnlyOptionInterface<T>, SignVerifyInterface {
@@ -1363,6 +1391,9 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
final vout = jsonUTXO["tx_pos"] as int;
final outputs = txn["vout"] as List;
+ final mwebPegoutMaturity = cryptoCurrency.mwebPegoutMaturity;
+ final isMwebPegout =
+ mwebPegoutMaturity != null && isMwebPegoutOutput(outputs, vout);
String? scriptPubKey;
String? utxoOwnerAddress;
@@ -1400,6 +1431,11 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
blockHeight: jsonUTXO["height"] as int?,
blockTime: txn["blocktime"] as int?,
address: utxoOwnerAddress,
+ otherData: isMwebPegout
+ ? jsonEncode({
+ UTXOOtherDataKeys.mwebPegoutMaturity: mwebPegoutMaturity,
+ })
+ : null,
);
return utxo;
diff --git a/test/models/isar/mweb_pegout_test.dart b/test/models/isar/mweb_pegout_test.dart
new file mode 100644
index 0000000..0639019
--- /dev/null
+++ b/test/models/isar/mweb_pegout_test.dart
@@ -0,0 +1,88 @@
+import 'dart:convert';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart';
+import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
+import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart';
+
+void main() {
+ UTXO utxo({String? otherData}) => UTXO(
+ walletId: "walletId",
+ txid: "txid",
+ vout: 1,
+ value: 1000,
+ name: "",
+ isBlocked: false,
+ blockedReason: null,
+ isCoinbase: false,
+ blockHash: "blockHash",
+ blockHeight: 100,
+ blockTime: 1,
+ otherData: otherData,
+ );
+
+ group("MWEB pegout detection", () {
+ test("recognizes outputs after the HogAddr output", () {
+ final outputs = [
+ {
+ "n": 0,
+ "scriptPubKey": {"type": "witness_mweb_hogaddr"},
+ },
+ {
+ "n": 1,
+ "scriptPubKey": {"type": "witness_v0_keyhash"},
+ },
+ ];
+
+ expect(isMwebPegoutOutput(outputs, 0), isFalse);
+ expect(isMwebPegoutOutput(outputs, 1), isTrue);
+ });
+
+ test("recognizes the HogAddr script when type is unavailable", () {
+ final outputs = [
+ {
+ "n": 0,
+ "scriptPubKey": {
+ "hex":
+ "5820000000000000000000000000000000"
+ "0000000000000000000000000000000000",
+ },
+ },
+ ];
+
+ expect(isMwebPegoutOutput(outputs, 1), isTrue);
+ });
+
+ test("does not classify native MWEB or ordinary outputs as pegouts", () {
+ final outputs = [
+ {
+ "n": 0,
+ "ismweb": true,
+ "scriptPubKey": {"type": "witness_v0_keyhash"},
+ },
+ ];
+
+ expect(isMwebPegoutOutput(outputs, 1), isFalse);
+ });
+ });
+
+ test("Litecoin pegouts require six confirmations", () {
+ final maturity = Litecoin(CryptoCurrencyNetwork.main).mwebPegoutMaturity;
+ final pegout = utxo(
+ otherData: jsonEncode({UTXOOtherDataKeys.mwebPegoutMaturity: maturity}),
+ );
+
+ expect(pegout.isMwebPegout, isTrue);
+ expect(pegout.getConfirmations(104), 5);
+ expect(pegout.isConfirmed(104, 1, 1), isFalse);
+ expect(pegout.isConfirmed(104, 1, 1, overrideMinConfirms: 1), isFalse);
+ expect(pegout.isConfirmed(105, 1, 1), isTrue);
+ });
+
+ test("ordinary outputs retain the currency confirmation policy", () {
+ final ordinary = utxo();
+
+ expect(ordinary.isMwebPegout, isFalse);
+ expect(ordinary.isConfirmed(100, 1, 1), isTrue);
+ });
+}
Why this scored 37/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.