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..02bc29f7fd 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( @@ -635,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, @@ -728,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( @@ -1010,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; @@ -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");