From c8c871f533421a1198865916d82fe2b326d976c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:26:09 +0000 Subject: [PATCH 1/2] Fix intermittent fee-less-than-vSize failures in fee estimation The legacy fee paths estimate fees from a dummy build, then re-sign the final transaction. Signature length can differ between signings, so the final vSize occasionally exceeds the estimate and prepareSend throws "Transaction fee cannot be less than vSize" at 1 sat/vB rates. - _sendAllBuilder: apply the existing recalculate-from-final-tx loop to all tx types, not just mwebPegIn. Overridden and mweb/mwebPegOut fees (recalculated by the caller) still build once as before. - coinSelection two-output branch: replace the one-shot vSize - fee == 1 change adjustment with a loop that rebuilds until the fee covers the final signed size, dropping change and reverting to a single output if it would become dust. - singleOutputTxn: report the whole input excess as the fee instead of the smaller rate-based estimate. - fees getter: clamp server estimates below the coin's defaultFeeRate, consistent with the -1 fallback in ElectrumXClient.estimateFee, so a broken estimate cannot force the guard failure. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFAYSbQoTjdbLmGwNJWzCj --- lib/utilities/extensions/impl/big_int.dart | 2 + .../electrumx_interface.dart | 122 ++++++++---------- 2 files changed, 58 insertions(+), 66 deletions(-) diff --git a/lib/utilities/extensions/impl/big_int.dart b/lib/utilities/extensions/impl/big_int.dart index 866c5103d5..275bb282fa 100644 --- a/lib/utilities/extensions/impl/big_int.dart +++ b/lib/utilities/extensions/impl/big_int.dart @@ -11,6 +11,8 @@ import 'dart:typed_data'; extension BigIntExtensions on BigInt { + BigInt atLeast(BigInt minimum) => this < minimum ? minimum : this; + String get toHex { if (this < BigInt.zero) { throw Exception("BigInt value is negative"); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 100aa9f9fe..6a62dab479 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -404,8 +404,10 @@ mixin ElectrumXInterface ), ); return txnData.copyWith( + // No change output, so the whole difference is the fee (which can + // exceed [feeForOneOutput]). fee: Amount( - rawValue: feeForOneOutput, + rawValue: difference, fractionDigits: cryptoCurrency.fractionDigits, ), usedUTXOs: inputsWithKeys, @@ -470,24 +472,30 @@ mixin ElectrumXInterface ), ); - // make sure minimum fee is accurate if that is being used - if (BigInt.from(txnData.vSize!) - feeBeingPaid == BigInt.one) { - final changeOutputSize = difference - BigInt.from(txnData.vSize!); - feeBeingPaid = difference - changeOutputSize; - recipientsAmtArray.removeLast(); - recipientsAmtArray.add(changeOutputSize); + // The fee was estimated from a differently signed build and + // re-signing can change vSize, so it may no longer cover the final + // signed size. Take any shortfall from change. + while (feeBeingPaid < BigInt.from(txnData.vSize!)) { + final vSize = BigInt.from(txnData.vSize!); + final adjustedChangeSize = difference - vSize; + if (adjustedChangeSize <= cryptoCurrency.dustLimit.raw) { + // Drop the change output entirely. + recipientsArray.removeLast(); + recipientsAmtArray.removeLast(); + Logging.instance.d( + 'Adjusted change would be dust, reverting to 1 output in tx', + ); + return await singleOutputTxn(); + } + feeBeingPaid = vSize; + recipientsAmtArray.last = adjustedChangeSize; - Logging.instance.d('Adjusted Input size: $satoshisBeingUsed'); Logging.instance.d( - 'Adjusted Recipient output size: $satoshiAmountToSend', - ); - Logging.instance.d( - 'Adjusted Change Output Size: $changeOutputSize', + 'Adjusted Change Output Size: $adjustedChangeSize', ); Logging.instance.d( 'Adjusted Difference (fee being paid): $feeBeingPaid sats', ); - Logging.instance.d('Adjusted Estimated fee: $feeForTwoOutputs'); txnData = await buildTransaction( inputsWithKeys: inputsWithKeys, @@ -570,45 +578,8 @@ mixin ElectrumXInterface } late TxData data; - if (txData.type == TxType.mwebPegIn) { - while (true) { - final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput; - if (satoshiAmountToSend.isNegative) { - throw Exception( - "Estimated fee ($feeForOneOutput sats) is greater than balance!", - ); - } - - data = await buildTransaction( - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshiAmountToSend], - ), - ), - inputsWithKeys: inputsWithKeys, - ); - - if (overrideFeeAmount != null) { - break; - } - - // Signing can change vSize, so calculate the fee from the final tx. - final vSize = BigInt.from(data.vSize!); - final feeForFinalVSize = BigInt.from( - satsPerVByte != null - ? satsPerVByte * data.vSize! - : estimateTxFee(vSize: data.vSize!, feeRatePerKB: feeRatePerKB), - ); - final requiredFee = feeForFinalVSize > vSize ? feeForFinalVSize : vSize; - if (feeForOneOutput >= requiredFee) { - break; - } - feeForOneOutput = requiredFee; - } - } else { + while (true) { final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput; - if (satoshiAmountToSend.isNegative) { throw Exception( "Estimated fee ($feeForOneOutput sats) is greater than balance!", @@ -624,6 +595,27 @@ mixin ElectrumXInterface ), inputsWithKeys: inputsWithKeys, ); + + // Stop when the fee is not authoritative: overridden by the caller, or + // MWEB (except peg ins) whose fee is recalculated by the caller later. + if (overrideFeeAmount != null || + txData.type == TxType.mweb || + txData.type == TxType.mwebPegOut) { + break; + } + + // Signing can change vSize, so calculate the fee from the final tx. + final vSize = BigInt.from(data.vSize!); + final feeForFinalVSize = BigInt.from( + satsPerVByte != null + ? satsPerVByte * data.vSize! + : estimateTxFee(vSize: data.vSize!, feeRatePerKB: feeRatePerKB), + ); + final requiredFee = feeForFinalVSize.atLeast(vSize); + if (feeForOneOutput >= requiredFee) { + break; + } + feeForOneOutput = requiredFee; } return data.copyWith( @@ -1655,26 +1647,24 @@ mixin ElectrumXInterface try { const int f = 1, m = 5, s = 20; - final fast = await electrumXClient.estimateFee(blocks: f); - final medium = await electrumXClient.estimateFee(blocks: m); - final slow = await electrumXClient.estimateFee(blocks: s); + // Clamp server responses below the coin's default rate, consistent + // with the -1 fallback in [ElectrumXClient.estimateFee], so a broken + // estimate cannot force the fee-vs-vSize failure in prepareSend. + Future rate(int blocks) async { + final raw = Amount.fromDecimal( + await electrumXClient.estimateFee(blocks: blocks), + fractionDigits: info.coin.fractionDigits, + ).raw; + return raw.atLeast(cryptoCurrency.defaultFeeRate); + } final feeObject = FeeObject( numberOfBlocksFast: f, numberOfBlocksAverage: m, numberOfBlocksSlow: s, - fast: Amount.fromDecimal( - fast, - fractionDigits: info.coin.fractionDigits, - ).raw, - medium: Amount.fromDecimal( - medium, - fractionDigits: info.coin.fractionDigits, - ).raw, - slow: Amount.fromDecimal( - slow, - fractionDigits: info.coin.fractionDigits, - ).raw, + fast: await rate(f), + medium: await rate(m), + slow: await rate(s), ); Logging.instance.d("fetched fees: $feeObject"); From 741bd241e6c7b84085b366fcddf4d12235551673 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:26:09 +0000 Subject: [PATCH 2/2] Fix sends to Firo EX addresses via optimal coin selection _optimalCoinSelection parsed the recipient with bare coinlib.Address.fromString, which throws on Firo EX (exchange) addresses, so normal sends to them failed since the switch to coinlib coin selection. Share buildTransaction's EX fallback as _addressFromString and use it in both places. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NFAYSbQoTjdbLmGwNJWzCj --- .../electrumx_interface.dart | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 6a62dab479..02bc29f7fd 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -627,6 +627,26 @@ mixin ElectrumXInterface ); } + /// Parses [address], falling back to Firo EX (exchange) address parsing + /// which plain [coinlib.Address.fromString] does not support. + coinlib.Address _addressFromString(String address) { + final normalized = normalizeAddress(address); + try { + return coinlib.Address.fromString( + normalized, + cryptoCurrency.networkParams, + ); + } catch (_) { + if (this is FiroWallet) { + return EXP2PKHAddress.fromString( + normalized, + (cryptoCurrency as Firo).exAddressVersion, + ); + } + rethrow; + } + } + coinlib.Input standardInputToCoinlibInput( StandardInput input, { int sequence = 0xffffffff, @@ -720,13 +740,9 @@ mixin ElectrumXInterface candidateBaseInputs[i] = baseInput; } - final coinlib.Address clRecipientAddress = coinlib.Address.fromString( - normalizeAddress(recipientAddress), - cryptoCurrency.networkParams, - ); final coinlib.Output recipientOutput = coinlib.Output.fromAddress( satoshiAmountToSend, - clRecipientAddress, + _addressFromString(recipientAddress), ); final coinlib.Address clChangeAddress = coinlib.Address.fromString( @@ -1002,23 +1018,7 @@ mixin ElectrumXInterface // Add transaction output for (var i = 0; i < txData.recipients!.length; i++) { - late final coinlib.Address address; - - try { - address = coinlib.Address.fromString( - normalizeAddress(txData.recipients![i].address), - cryptoCurrency.networkParams, - ); - } catch (_) { - if (this is FiroWallet) { - address = EXP2PKHAddress.fromString( - normalizeAddress(txData.recipients![i].address), - (cryptoCurrency as Firo).exAddressVersion, - ); - } else { - rethrow; - } - } + final address = _addressFromString(txData.recipients![i].address); final coinlib.Output output; if (address is coinlib.MwebAddress) { isMweb = true;