Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 68 additions & 51 deletions lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<coinlib.InputCandidate> 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<T extends ElectrumXCurrencyInterface>
on Bip39HDWallet<T>
implements ViewOnlyOptionInterface<T>, SignVerifyInterface {
Expand Down Expand Up @@ -744,13 +795,14 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>

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) {
Expand Down Expand Up @@ -1066,57 +1118,22 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
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) {
Expand Down
52 changes: 52 additions & 0 deletions test/wallets/firo_op_return_fee_test.dart
Original file line number Diff line number Diff line change
@@ -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));
});
}
Loading