From 749f09d17bb65667a6fdad6029564ea6f37bd17c Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Sat, 2 May 2026 04:51:06 +0000 Subject: [PATCH] feat: use CoinSelection class from coinlib for coin selection Replace the legacy FIFO algorithm used so far, except for cases that can not be treated by new coin selection algorithms (mweb input, override fee, send all, coin control) --- .../electrumx_interface.dart | 261 +++++++++++++++--- pubspec.lock | 38 +-- .../templates/pubspec.template.yaml | 4 +- 3 files changed, 237 insertions(+), 66 deletions(-) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index e963566b6..f5fe65928 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -223,6 +223,30 @@ mixin ElectrumXInterface Logging.instance.d("spendableSatoshiValue: $spendableSatoshiValue"); Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend"); + // Use coinlib CoinSelection algorithms except for + // "coinControl", "SendAll", "MWEB", "overrideFeeAmount", + // because they do not need a selection or + // do not meet the requirements for the algorithms + final bool useOptimalSelection = !coinControl && + !isSendAll && + !isSendAllCoinControlUtxos && + overrideFeeAmount == null && + txData.type != TxType.mweb && + txData.type != TxType.mwebPegOut && + txData.type != TxType.mwebPegIn; + + if (useOptimalSelection) { + return await _optimalCoinSelection( + txData: txData, + spendableOutputs: spendableOutputs.whereType().toList(), + recipientAddress: recipientAddress, + satoshiAmountToSend: satoshiAmountToSend, + satsPerVByte: satsPerVByte, + feeRatePerKB: selectedTxFeeRate, + changeAddress: await changeAddress(), + ); + } + BigInt satoshisBeingUsed = BigInt.zero; int inputsBeingConsumed = 0; final List utxoObjectsToUse = []; @@ -571,6 +595,197 @@ mixin ElectrumXInterface ); } + coinlib.Input standardInputToCoinlibInput( + StandardInput input, { + int sequence = 0xffffffff, + }) { + final hash = Uint8List.fromList( + input.utxo.txid.toUint8ListFromHex.reversed.toList(), + ); + final prevOut = coinlib.OutPoint(hash, input.utxo.vout); + + switch (input.derivePathType) { + case DerivePathType.bip44: + case DerivePathType.bch44: + return coinlib.P2PKHInput( + prevOut: prevOut, + publicKey: input.key!.publicKey, + sequence: sequence, + ); + + // TODO: fix this as it is (probably) wrong! + case DerivePathType.bip49: + throw Exception("TODO p2sh"); + // return coinlib.P2SHMultisigInput( + // prevOut: prevOut, + // program: coinlib.MultisigProgram.decompile( + // input.redeemScript!, + // ), + // sequence: sequence, + // ); + + case DerivePathType.bip84: + return coinlib.P2WPKHInput( + prevOut: prevOut, + publicKey: input.key!.publicKey, + sequence: sequence, + ); + + case DerivePathType.bip86: + return coinlib.TaprootKeyInput(prevOut: prevOut); + + default: + throw UnsupportedError( + "Unknown derivation path type found: ${input.derivePathType}", + ); + } + } + + /// Helper that will convert BaseInput into InputCandidates + /// and use [coinlib.CoinSelection.optimal] to select the good candidates. + Future _optimalCoinSelection({ + required TxData txData, + required List spendableOutputs, + required String recipientAddress, + required BigInt satoshiAmountToSend, + required int? satsPerVByte, + required BigInt feeRatePerKB, + required Address changeAddress, + }) async { + final List candidateInputs = + await addSigningKeys(spendableOutputs); + + final BigInt feePerKb = satsPerVByte != null + ? BigInt.from(satsPerVByte * 1000) + : feeRatePerKB; + + // minFee should be equal or above the Vsize of the tx, which should happen + // since coin selection algorithms will respect feeRatePerKB. So there is no + // need to define a minFee + final BigInt minFee = BigInt.zero; + + final List candidates = []; + final Map candidateBaseInputs = {}; + + for (int i = 0; i < candidateInputs.length; i++) { + + final baseInput = candidateInputs[i]; + + if (baseInput is! StandardInput) { + // This shouldn't be happening since only non MWEB inputs + // will be given to this helper + throw Exception( + ''' + Unexpected input type ${baseInput.runtimeType} + only StandardInput are supported + ''', + ); + } + + final input = standardInputToCoinlibInput(baseInput); + + candidates.add( + coinlib.InputCandidate(input: input, value: baseInput.value), + ); + candidateBaseInputs[i] = baseInput; + } + + final coinlib.Address clRecipientAddress = coinlib.Address.fromString( + normalizeAddress(recipientAddress), + cryptoCurrency.networkParams, + ); + final coinlib.Output recipientOutput = coinlib.Output.fromAddress( + satoshiAmountToSend, + clRecipientAddress, + ); + + final coinlib.Address clChangeAddress = coinlib.Address.fromString( + normalizeAddress(changeAddress.value), + cryptoCurrency.networkParams, + ); + + final coinlib.Program changeProgram = clChangeAddress.program; + + final coinlib.CoinSelection selection = + coinlib.CoinSelection.optimal( + candidates: candidates, + recipients: [recipientOutput], + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: cryptoCurrency.dustLimit.raw, + ); + + if (selection.tooLarge) { + throw Exception("Selected transaction would be too large"); + } + if (!selection.ready) { + throw Exception("Selection of coins was not successful"); + } + + // Going back from InputCandidates to BaseInput + // This could be avoided since buildTransaction will do the exact opposite ? + final List selectedBaseInputs = []; + for (final picked in selection.selected) { + final pickedTxid = + Uint8List.fromList(picked.input.prevOut.hash.reversed.toList()).toHex; + final pickedVout = picked.input.prevOut.n; + bool matched = false; + for (final entry in candidateBaseInputs.entries) { + final base = entry.value; + if (base is StandardInput && + base.utxo.txid == pickedTxid && + base.utxo.vout == pickedVout) { + selectedBaseInputs.add(base); + matched = true; + break; + } + } + if (!matched) { + throw Exception( + "Selected input not found among candidates (txid=$pickedTxid" + " vout=$pickedVout)", + ); + } + } + + Logging.instance.d( + "Optimal selection: picked ${selectedBaseInputs.length} input(s)," + " inputValue=${selection.inputValue}, fee=${selection.fee}," + " changeValue=${selection.changeValue}," + " signedSize=${selection.signedSize}", + ); + + /// Add the change if there is one + final List recipientsArray = [recipientAddress]; + final List recipientsAmtArray = [satoshiAmountToSend]; + if (!selection.changeless) { + await checkChangeAddressForTransactions(); + final freshChange = (await getCurrentChangeAddress())!; + recipientsArray.add(freshChange.value); + recipientsAmtArray.add(selection.changeValue); + } + + final TxData txBuilt = await buildTransaction( + inputsWithKeys: selectedBaseInputs, + txData: txData.copyWith( + recipients: await helperRecipientsConvert( + recipientsArray, + recipientsAmtArray, + ), + usedUTXOs: selectedBaseInputs, + ), + ); + + return txBuilt.copyWith( + fee: Amount( + rawValue: selection.fee, + fractionDigits: cryptoCurrency.fractionDigits, + ), + usedUTXOs: selectedBaseInputs, + ); + } + Future> addSigningKeys(List utxosToUse) async { // return data final List inputsWithKeys = []; @@ -715,14 +930,6 @@ mixin ElectrumXInterface ), ); } else if (data is StandardInput) { - final txid = data.utxo.txid; - - final hash = Uint8List.fromList( - txid.toUint8ListFromHex.reversed.toList(), - ); - - final prevOutpoint = coinlib.OutPoint(hash, data.utxo.vout); - final prevOutput = coinlib.Output.fromAddress( BigInt.from(data.utxo.value), coinlib.Address.fromString( @@ -733,43 +940,7 @@ mixin ElectrumXInterface prevOuts.add(prevOutput); - final coinlib.Input input; - - switch (data.derivePathType) { - case DerivePathType.bip44: - case DerivePathType.bch44: - input = coinlib.P2PKHInput( - prevOut: prevOutpoint, - publicKey: data.key!.publicKey, - sequence: sequence, - ); - - // TODO: fix this as it is (probably) wrong! - case DerivePathType.bip49: - throw Exception("TODO p2sh"); - // input = coinlib.P2SHMultisigInput( - // prevOut: prevOutpoint, - // program: coinlib.MultisigProgram.decompile( - // data.redeemScript!, - // ), - // sequence: sequence, - // ); - - case DerivePathType.bip84: - input = coinlib.P2WPKHInput( - prevOut: prevOutpoint, - publicKey: data.key!.publicKey, - sequence: sequence, - ); - - case DerivePathType.bip86: - input = coinlib.TaprootKeyInput(prevOut: prevOutpoint); - - default: - throw UnsupportedError( - "Unknown derivation path type found: ${data.derivePathType}", - ); - } + final input = standardInputToCoinlibInput(data, sequence: sequence); if (input is! coinlib.WitnessInput) { hasNonWitnessInput = true; diff --git a/pubspec.lock b/pubspec.lock index 0aedf7867..6539203fe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -285,10 +285,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -341,9 +341,9 @@ packages: dependency: "direct overridden" description: path: coinlib - ref: "5c59c7e7d120d9c981f23008fa03421d39fe8631" - resolved-ref: "5c59c7e7d120d9c981f23008fa03421d39fe8631" - url: "https://www.github.com/julian-CStack/coinlib" + ref: "390aa75277b56828879f13e0c8defa779544888e" + resolved-ref: "390aa75277b56828879f13e0c8defa779544888e" + url: "https://www.github.com/Cyrix126/coinlib" source: git version: "4.1.0" coinlib_flutter: @@ -1570,18 +1570,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" memoize: dependency: transitive description: @@ -1594,10 +1594,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.2" mime: dependency: transitive description: @@ -2252,26 +2252,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.17" tezart: dependency: "direct main" description: @@ -2462,10 +2462,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "47a1b32ee755c3fcffa33db52a7258c137f97bdb2209a1075be847809fac4ccf" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" very_good_analysis: dependency: transitive description: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 6b551d4c4..b48782611 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -316,9 +316,9 @@ dependency_overrides: # coinlib_flutter requires this coinlib: git: - url: https://www.github.com/julian-CStack/coinlib + url: https://www.github.com/Cyrix126/coinlib path: coinlib - ref: 5c59c7e7d120d9c981f23008fa03421d39fe8631 + ref: 390aa75277b56828879f13e0c8defa779544888e bip47: git: