From 83d30a765ffc4b61739b8c3c693de9018021af18 Mon Sep 17 00:00:00 2001 From: Navid Rahimi Date: Tue, 4 Aug 2026 21:06:19 +0330 Subject: [PATCH] Fix Firo OP_RETURN fee selection --- .../electrumx_interface.dart | 119 ++++++++++-------- test/wallets/firo_op_return_fee_test.dart | 52 ++++++++ 2 files changed, 120 insertions(+), 51 deletions(-) create mode 100644 test/wallets/firo_op_return_fee_test.dart diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 100aa9f9f..e0588d33d 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -40,6 +40,57 @@ import 'rbf_interface.dart'; import 'sign_verify_interface.dart'; import 'view_only_option_interface.dart'; +@visibleForTesting +coinlib.Output buildFiroOpReturnOutput(String opReturnData) { + try { + final opReturnBytes = opReturnData.toUint8ListFromHex; + + if (opReturnBytes.length > 80) { + throw Exception( + "OP_RETURN data exceeds 80 byte limit: ${opReturnBytes.length} bytes", + ); + } + + final pushData = opReturnBytes.length <= 75 + ? Uint8List.fromList([opReturnBytes.length, ...opReturnBytes]) + : Uint8List.fromList([0x4c, opReturnBytes.length, ...opReturnBytes]); + + return coinlib.Output.fromScriptBytes( + BigInt.zero, + Uint8List.fromList([0x6a, ...pushData]), + ); + } catch (e, s) { + Logging.instance.e( + "Failed to add OP_RETURN output", + error: e, + stackTrace: s, + ); + throw Exception("Invalid OP_RETURN data: $e"); + } +} + +@visibleForTesting +coinlib.CoinSelection selectOptimalElectrumxCoins({ + required List candidates, + required coinlib.Output recipientOutput, + required coinlib.Program changeProgram, + required BigInt feePerKb, + required BigInt minFee, + required BigInt minChange, + String? firoOpReturnData, +}) => coinlib.CoinSelection.optimal( + candidates: candidates, + recipients: [ + recipientOutput, + if (firoOpReturnData != null && firoOpReturnData.isNotEmpty) + buildFiroOpReturnOutput(firoOpReturnData), + ], + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, +); + mixin ElectrumXInterface on Bip39HDWallet implements ViewOnlyOptionInterface, SignVerifyInterface { @@ -744,13 +795,14 @@ mixin ElectrumXInterface final coinlib.Program changeProgram = clChangeAddress.program; - final coinlib.CoinSelection selection = coinlib.CoinSelection.optimal( + final coinlib.CoinSelection selection = selectOptimalElectrumxCoins( candidates: candidates, - recipients: [recipientOutput], + recipientOutput: recipientOutput, changeProgram: changeProgram, feePerKb: feePerKb, minFee: minFee, minChange: cryptoCurrency.dustLimit.raw, + firoOpReturnData: cryptoCurrency is Firo ? txData.opReturnData : null, ); if (selection.tooLarge) { @@ -1066,57 +1118,22 @@ mixin ElectrumXInterface if (cryptoCurrency is Firo && txData.opReturnData != null && txData.opReturnData!.isNotEmpty) { - try { - final opReturnBytes = txData.opReturnData!.toUint8ListFromHex; - - // Validate OP_RETURN size (Bitcoin/Firo limit is 80 bytes) - if (opReturnBytes.length > 80) { - throw Exception( - "OP_RETURN data exceeds 80 byte limit: ${opReturnBytes.length} bytes", - ); - } - - // Encode push data: OP_PUSHDATA1 (0x4c) for 76-80 bytes, direct length otherwise - final pushData = opReturnBytes.length <= 75 - ? Uint8List.fromList([opReturnBytes.length, ...opReturnBytes]) - : Uint8List.fromList([ - 0x4c, - opReturnBytes.length, - ...opReturnBytes, - ]); - - final opReturnScript = Uint8List.fromList([ - 0x6a, // OP_RETURN opcode - ...pushData, - ]); - - final opReturnOutput = coinlib.Output.fromScriptBytes( - BigInt.zero, // OP_RETURN outputs have 0 value - opReturnScript, - ); + final opReturnOutput = buildFiroOpReturnOutput(txData.opReturnData!); + clTx = clTx.addOutput(opReturnOutput); - clTx = clTx.addOutput(opReturnOutput); - - Logging.instance.i( - "Added OP_RETURN output with ${opReturnBytes.length} bytes of data", - ); + Logging.instance.i( + "Added OP_RETURN output with " + "${txData.opReturnData!.length ~/ 2} bytes of data", + ); - tempOutputs.add( - OutputV2.isarCantDoRequiredInDefaultConstructor( - scriptPubKeyHex: opReturnScript.toHex, - valueStringSats: "0", - addresses: [], - walletOwns: false, - ), - ); - } catch (e, s) { - Logging.instance.e( - "Failed to add OP_RETURN output", - error: e, - stackTrace: s, - ); - throw Exception("Invalid OP_RETURN data: $e"); - } + tempOutputs.add( + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: opReturnOutput.scriptPubKey.toHex, + valueStringSats: "0", + addresses: [], + walletOwns: false, + ), + ); } if (isMweb) { if (hasNonWitnessInput) { diff --git a/test/wallets/firo_op_return_fee_test.dart b/test/wallets/firo_op_return_fee_test.dart new file mode 100644 index 000000000..c00aa6b09 --- /dev/null +++ b/test/wallets/firo_op_return_fee_test.dart @@ -0,0 +1,52 @@ +import 'dart:typed_data'; + +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; + +void main() { + setUpAll(coinlib.loadCoinlib); + + test('Firo OP_RETURN is included in the optimal selection fee', () { + final publicKey = coinlib.ECPublicKey.fromHex( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + ); + final program = coinlib.P2PKH.fromPublicKey(publicKey); + final candidate = coinlib.InputCandidate( + input: coinlib.P2PKHInput( + prevOut: coinlib.OutPoint(Uint8List(32), 0), + publicKey: publicKey, + ), + value: BigInt.from(200000), + ); + final recipientOutput = coinlib.Output.fromProgram( + BigInt.from(100000), + program, + ); + + coinlib.CoinSelection select({String? opReturnData}) => + selectOptimalElectrumxCoins( + candidates: [candidate], + recipientOutput: recipientOutput, + changeProgram: program, + feePerKb: BigInt.from(1000), + minFee: BigInt.zero, + minChange: BigInt.from(546), + firoOpReturnData: opReturnData, + ); + + final withoutMetadata = select(); + final withMetadata = select(opReturnData: List.filled(75, 'ab').join()); + final metadataOutput = withMetadata.recipients.last; + + expect(withoutMetadata.recipients, [recipientOutput]); + expect(metadataOutput.value, BigInt.zero); + expect(metadataOutput.scriptPubKey.take(2), [0x6a, 75]); + expect(metadataOutput.size, 86); + expect( + withMetadata.signedSize, + withoutMetadata.signedSize + metadataOutput.size, + ); + expect(withMetadata.fee, BigInt.from(withMetadata.signedSize)); + }); +}