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 9f4b4df5f..c809d1942 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: @@ -349,9 +349,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: @@ -1578,18 +1578,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: @@ -1602,10 +1602,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: @@ -2276,26 +2276,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: @@ -2486,10 +2486,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 535d7f559..989b859fe 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: