From 0785bd69a9dda35d406d6efe66752f14c4bd2351 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 9 Oct 2026 23:20:54 +0800 Subject: [PATCH] feat: add signed transaction hooks and FIRO data-output validation --- lib/wallets/wallet/impl/ethereum_wallet.dart | 33 +- .../impl/sub_wallets/eth_token_wallet.dart | 19 +- .../electrumx_interface.dart | 90 ++--- .../firo_op_return.dart | 320 ++++++++++++++++ .../mweb_interface.dart | 13 +- .../spark_interface.dart | 10 + test/wallets/broadcast_callback_test.dart | 349 ++++++++++++++++++ test/wallets/firo_coin_selection_test.dart | 162 ++++++++ test/wallets/firo_op_return_test.dart | 249 +++++++++++++ 9 files changed, 1166 insertions(+), 79 deletions(-) create mode 100644 lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart create mode 100644 test/wallets/broadcast_callback_test.dart create mode 100644 test/wallets/firo_coin_selection_test.dart create mode 100644 test/wallets/firo_op_return_test.dart diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index f5444c742..ee23f20ef 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -683,6 +683,7 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { Future confirmSend({ required TxData txData, TxData Function(TxData txData, String myAddress)? prepareTempTx, + Future Function(String raw)? beforeBroadcast, }) async { final client = getEthClient(); if (_credentials == null) { @@ -690,11 +691,33 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { } try { - final txid = await client.sendTransaction( - _credentials!, - txData.web3dartTransaction!, - chainId: txData.chainId!.toInt(), - ); + final String txid; + if (beforeBroadcast == null) { + txid = await client.sendTransaction( + _credentials!, + txData.web3dartTransaction!, + chainId: txData.chainId!.toInt(), + ); + } else { + var signed = await client.signTransaction( + _credentials!, + txData.web3dartTransaction!, + chainId: txData.chainId!.toInt(), + ); + if (txData.web3dartTransaction!.isEIP1559) { + signed = web3.prependTransactionType(0x02, signed); + } + txid = web3.bytesToHex(web3.keccak256(signed), include0x: true); + await beforeBroadcast(web3.bytesToHex(signed, include0x: true)); + final response = await client + .sendRawTransaction(signed) + .timeout(const Duration(seconds: 30)); + if (response.toLowerCase() != txid) { + throw StateError( + 'Ethereum node returned an unexpected transaction ID.', + ); + } + } final data = (prepareTempTx ?? _prepareTempTx)( txData.copyWith(txid: txid, txHash: txid), diff --git a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart index 1c61f297b..ce59a5909 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -272,17 +272,14 @@ class EthTokenWallet extends Wallet { } @override - Future confirmSend({required TxData txData}) async { - try { - return await ethWallet.confirmSend( - txData: txData, - prepareTempTx: _prepareTempTx, - ); - } catch (e) { - // rethrow to pass error in alert - rethrow; - } - } + Future confirmSend({ + required TxData txData, + Future Function(String raw)? beforeBroadcast, + }) => ethWallet.confirmSend( + txData: txData, + prepareTempTx: _prepareTempTx, + beforeBroadcast: beforeBroadcast, + ); @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index f2d5eb05b..948a51d0c 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -35,6 +35,7 @@ import '../impl/peercoin_wallet.dart'; import '../intermediate/bip39_hd_wallet.dart'; import 'cpfp_interface.dart'; import 'electrum_fee_planner.dart'; +import 'firo_op_return.dart'; import 'mweb_fee_utils.dart'; import 'mweb_interface.dart'; import 'paynym_interface.dart'; @@ -563,7 +564,7 @@ mixin ElectrumXInterface final coinlib.CoinSelection selection = coinlib.CoinSelection.optimal( candidates: candidates, - recipients: [recipientOutput], + recipients: [recipientOutput, ?_opReturnOutput(txData)], changeProgram: changeProgram, feePerKb: feePerKb, minFee: minFee, @@ -707,12 +708,22 @@ mixin ElectrumXInterface } } + coinlib.Output? _opReturnOutput(TxData txData) { + final hex = txData.opReturnData; + if (hex == null || hex.isEmpty) return null; + if (cryptoCurrency is! Firo) { + throw UnsupportedError('OP_RETURN sends are only supported for Firo'); + } + return firoOpReturnOutput(hex); + } + /// Builds and signs a transaction Future buildTransaction({ required TxData txData, required List inputsWithKeys, }) async { Logging.instance.d("Starting buildTransaction ----------"); + final opReturnOutput = _opReturnOutput(txData); // temp tx data to show in gui while waiting for real data from server final List tempInputs = []; @@ -870,62 +881,16 @@ mixin ElectrumXInterface ); } - // Add OP_RETURN output if provided (for Rosen Bridge and other protocols) - // Currently only supported for Firo - 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, - ); - - clTx = clTx.addOutput(opReturnOutput); - - Logging.instance.i( - "Added OP_RETURN output with ${opReturnBytes.length} 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"); - } + if (opReturnOutput != null) { + clTx = clTx.addOutput(opReturnOutput); + tempOutputs.add( + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: opReturnOutput.scriptPubKey.toHex, + valueStringSats: "0", + addresses: [], + walletOwns: false, + ), + ); } if (isMweb) { if (hasNonWitnessInput) { @@ -1863,13 +1828,20 @@ mixin ElectrumXInterface } @override - Future confirmSend({required TxData txData}) async { + Future confirmSend({ + required TxData txData, + Future Function(String raw)? beforeBroadcast, + }) async { try { Logging.instance.d("confirmSend txData: $txData"); - final txHash = await electrumXClient.broadcastTransaction( + if (beforeBroadcast != null) await beforeBroadcast(txData.raw!); + final broadcasting = electrumXClient.broadcastTransaction( rawTx: txData.raw!, ); + final txHash = await (beforeBroadcast == null + ? broadcasting + : broadcasting.timeout(const Duration(seconds: 30))); Logging.instance.d("Sent txHash: $txHash"); txData = txData.copyWith( diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart b/lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart new file mode 100644 index 000000000..7f578ca33 --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart @@ -0,0 +1,320 @@ +import 'dart:typed_data'; + +import 'package:coinlib/coinlib.dart' as coinlib; + +typedef FiroTransactionInput = ({String txid, int vout, BigInt value}); +typedef FiroTransactionOutput = ({String script, BigInt value}); +typedef FiroTransactionPrevout = ({String txid, int vout}); +typedef FiroDecodedTransaction = ({ + String txid, + List prevouts, +}); + +typedef _ParsedFiroTransaction = ({ + coinlib.Transaction transaction, + bool isWitness, + List prevouts, +}); + +final _secp256k1Field = + (BigInt.one << 256) - (BigInt.one << 32) - BigInt.from(977); +final _secp256k1Order = BigInt.parse( + 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', + radix: 16, +); + +/// The same zero-value output must be used for fee selection and signing. +coinlib.Output firoOpReturnOutput(String hex) { + if (hex.startsWith('0x')) hex = hex.substring(2); + if (hex.isEmpty || + hex.length.isOdd || + !RegExp(r'^[0-9a-fA-F]+$').hasMatch(hex)) { + throw const FormatException('Invalid OP_RETURN hex'); + } + if (hex.length > 160) { + throw const FormatException('OP_RETURN data exceeds 80 byte limit'); + } + + final bytes = coinlib.hexToBytes(hex); + return coinlib.Output.fromScriptBytes( + BigInt.zero, + Uint8List.fromList([ + 0x6a, + if (bytes.length > 75) 0x4c, + bytes.length, + ...bytes, + ]), + ); +} + +/// Verify the signed bytes that will be broadcast, not only the TxData fields. +/// [expectedOutputs] contains every payment/change output; [data] is appended. +/// Returns the transaction ID derived from [raw] after successful validation. +String verifyFiroOpReturnTransaction({ + required String raw, + required String data, + required Iterable expectedInputs, + required Iterable expectedOutputs, + required BigInt expectedFee, +}) { + late final _ParsedFiroTransaction parsed; + try { + parsed = _parseFiroTransaction(raw); + } on FormatException { + throw StateError( + 'The signed FIRO transaction does not match this bridge swap.', + ); + } + final tx = parsed.transaction; + final inputs = expectedInputs.toList(); + final outputs = [ + for (final output in expectedOutputs) + coinlib.Output.fromScriptBytes( + output.value, + coinlib.hexToBytes(output.script), + ), + firoOpReturnOutput(data), + ]; + final expectedInputKeys = [ + for (final input in inputs) + coinlib.OutPoint.fromHex(input.txid, input.vout).toHex(), + ]; + final actualInputKeys = [ + for (final input in tx.inputs) input.prevOut.toHex(), + ]; + final expectedOutputKeys = [for (final output in outputs) output.toHex()]; + final actualOutputKeys = [for (final output in tx.outputs) output.toHex()]; + final inputValue = inputs.fold( + BigInt.zero, + (sum, input) => sum + input.value, + ); + final outputValue = tx.outputs.fold( + BigInt.zero, + (sum, output) => sum + output.value, + ); + if (tx.version != 1 || + parsed.isWitness || + tx.inputs.any((input) => !_isStructurallySignedP2pkh(input.scriptSig)) || + !_sameItems(expectedInputKeys, actualInputKeys) || + !_sameItems(expectedOutputKeys, actualOutputKeys) || + expectedFee.isNegative || + inputValue - outputValue != expectedFee) { + throw StateError( + 'The signed FIRO transaction does not match this bridge swap.', + ); + } + return tx.txid; +} + +/// Decodes a FIRO transaction and derives its standard no-witness transaction +/// ID and input outpoints from the raw bytes. +FiroDecodedTransaction decodeFiroTransaction(String raw) { + final parsed = _parseFiroTransaction(raw); + return (txid: parsed.transaction.txid, prevouts: parsed.prevouts); +} + +/// Decode the non-witness FIRO transaction layout used by bridge sends. +coinlib.Transaction firoTransactionFromHex(String raw) { + final parsed = _parseFiroTransaction(raw); + if (parsed.isWitness) { + throw const FormatException('Invalid FIRO bridge transaction.'); + } + return parsed.transaction; +} + +// ponytail: pinned coinlib misreads legacy flags; replace after its decoder fix. +_ParsedFiroTransaction _parseFiroTransaction(String raw) { + try { + final reader = coinlib.BytesReader(coinlib.hexToBytes(raw)); + final packedVersion = reader.readInt32(); + var inputCount = _readCompactSize(reader); + var isWitness = false; + if (inputCount == BigInt.zero) { + final flags = reader.readUInt8(); + if (flags != 1) { + throw const FormatException('Invalid FIRO transaction flags.'); + } + isWitness = true; + inputCount = _readCompactSize(reader); + } + final rawInputs = List.generate( + _checkedCount(reader, inputCount, 41), + (_) => _readRawInput(reader), + ); + final outputs = List.generate( + _checkedCount(reader, _readCompactSize(reader), 9), + (_) => _readOutput(reader), + ); + if (isWitness) { + for (var i = 0; i < rawInputs.length; i++) { + final itemCount = _checkedCount( + reader, + _readCompactSize(reader), + 1, + allowZero: true, + ); + for (var j = 0; j < itemCount; j++) { + _readVarSlice(reader); + } + } + } + final locktime = reader.readUInt32(); + final version = packedVersion & 0xffff; + final type = (packedVersion >> 16) & 0xffff; + final extraPayload = version == 3 && type != 0 + ? _readVarSlice(reader) + : null; + if (!reader.atEnd) { + throw const FormatException('Invalid FIRO transaction.'); + } + + final transaction = coinlib.Transaction( + version: packedVersion, + inputs: rawInputs, + outputs: outputs, + locktime: locktime, + vExtraData: extraPayload, + ); + return ( + transaction: transaction, + isWitness: isWitness, + prevouts: List.unmodifiable([ + for (final input in rawInputs) + ( + txid: coinlib.bytesToHex( + Uint8List.fromList(input.prevOut.hash.reversed.toList()), + ), + vout: input.prevOut.n, + ), + ]), + ); + } on FormatException { + rethrow; + } on Exception { + throw const FormatException('Invalid FIRO transaction.'); + } +} + +BigInt _readCompactSize(coinlib.BytesReader reader) { + final first = reader.readUInt8(); + if (first < 0xfd) return BigInt.from(first); + final value = switch (first) { + 0xfd => BigInt.from(reader.readUInt16()), + 0xfe => BigInt.from(reader.readUInt32()), + _ => reader.readUInt64(), + }; + if ((first == 0xfd && value < BigInt.from(0xfd)) || + (first == 0xfe && value <= BigInt.from(0xffff)) || + (first == 0xff && value <= BigInt.from(0xffffffff))) { + throw const FormatException('Non-canonical FIRO compact size.'); + } + return value; +} + +Uint8List _readVarSlice(coinlib.BytesReader reader) => reader.readSlice( + _checkedCount(reader, _readCompactSize(reader), 1, allowZero: true), +); + +coinlib.RawInput _readRawInput(coinlib.BytesReader reader) => coinlib.RawInput( + prevOut: coinlib.OutPoint.fromReader(reader), + scriptSig: _readVarSlice(reader), + sequence: reader.readUInt32(), +); + +coinlib.Output _readOutput(coinlib.BytesReader reader) => + coinlib.Output.fromScriptBytes(reader.readUInt64(), _readVarSlice(reader)); + +int _checkedCount( + coinlib.BytesReader reader, + BigInt value, + int minimumSize, { + bool allowZero = false, +}) { + final remaining = reader.bytes.lengthInBytes - reader.offset; + if ((!allowZero && value == BigInt.zero) || + value > BigInt.from(remaining ~/ minimumSize)) { + throw const FormatException('Invalid FIRO transaction item count.'); + } + return value.toInt(); +} + +bool _sameItems(List first, List second) { + if (first.length != second.length) return false; + first.sort(); + second.sort(); + for (var i = 0; i < first.length; i++) { + if (first[i] != second[i]) return false; + } + return true; +} + +// Checks BIP66/P2PKH shape without claiming to verify a signature without the +// previous output script. +bool _isStructurallySignedP2pkh(Uint8List script) { + try { + final reader = coinlib.BytesReader(script); + final signature = reader.readVarSlice(); + final publicKey = reader.readVarSlice(); + return reader.atEnd && + _isCanonicalSignature(signature) && + _isSecp256k1Point(publicKey); + } on Exception { + return false; + } +} + +bool _isCanonicalSignature(Uint8List signature) { + if (signature.length < 9 || + signature.length > 73 || + signature[0] != 0x30 || + signature[1] != signature.length - 3 || + signature[2] != 0x02 || + signature.last != 1) { + return false; + } + final rLength = signature[3]; + final sType = 4 + rLength; + if (!_isCanonicalScalar(signature, 4, rLength) || + sType + 2 >= signature.length - 1 || + signature[sType] != 0x02) { + return false; + } + final sLength = signature[sType + 1]; + final sStart = sType + 2; + return sStart + sLength == signature.length - 1 && + _isCanonicalScalar(signature, sStart, sLength); +} + +bool _isCanonicalScalar(Uint8List bytes, int start, int length) { + if (length == 0 || + start + length >= bytes.length || + (bytes[start] & 0x80) != 0 || + (length > 1 && bytes[start] == 0 && (bytes[start + 1] & 0x80) == 0)) { + return false; + } + final value = BigInt.parse( + coinlib.bytesToHex(bytes.sublist(start, start + length)), + radix: 16, + ); + return value > BigInt.zero && value < _secp256k1Order; +} + +bool _isSecp256k1Point(Uint8List key) { + final compressed = key.length == 33 && (key.first == 2 || key.first == 3); + final uncompressed = key.length == 65 && key.first == 4; + if (!compressed && !uncompressed) return false; + final x = BigInt.parse(coinlib.bytesToHex(key.sublist(1, 33)), radix: 16); + if (x >= _secp256k1Field) return false; + final ySquared = + (x.modPow(BigInt.from(3), _secp256k1Field) + BigInt.from(7)) % + _secp256k1Field; + if (compressed) { + final y = ySquared.modPow( + (_secp256k1Field + BigInt.one) >> 2, + _secp256k1Field, + ); + return y * y % _secp256k1Field == ySquared; + } + final y = BigInt.parse(coinlib.bytesToHex(key.sublist(33)), radix: 16); + return y < _secp256k1Field && y * y % _secp256k1Field == ySquared; +} diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart index 8729d02ac..4dff06522 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart @@ -728,12 +728,17 @@ mixin MwebInterface // =========================================================================== @override - Future confirmSend({required TxData txData}) async { + Future confirmSend({ + required TxData txData, + Future Function(String raw)? beforeBroadcast, + }) { if (txData.type.isMweb()) { - return await _confirmSendMweb(txData: txData); - } else { - return await super.confirmSend(txData: txData); + if (beforeBroadcast != null) { + throw UnsupportedError('Pre-broadcast MWEB callbacks are unsupported.'); + } + return _confirmSendMweb(txData: txData); } + return super.confirmSend(txData: txData, beforeBroadcast: beforeBroadcast); } @override diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 671b029ab..80642d76b 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -663,6 +663,11 @@ mixin SparkInterface required TxData txData, bool requireChaumV2 = false, }) async { + if (txData.opReturnData != null) { + throw ArgumentError( + 'OP_RETURN sends require the Firo transparent balance', + ); + } if (isViewOnly) { throw Exception("Spending is not supported for view only wallets"); } @@ -2749,6 +2754,11 @@ mixin SparkInterface /// /// See https://docs.google.com/document/d/1RG52GoYTZDvKlZz_3G4sQu-PpT6JWSZGHLNswWcrE3o Future prepareSparkMintTransaction({required TxData txData}) async { + if (txData.opReturnData != null) { + throw ArgumentError( + 'OP_RETURN sends require the Firo transparent balance', + ); + } if (isViewOnly) { throw Exception("Minting is not supported for view only wallets"); } diff --git a/test/wallets/broadcast_callback_test.dart b/test/wallets/broadcast_callback_test.dart new file mode 100644 index 000000000..724601113 --- /dev/null +++ b/test/wallets/broadcast_callback_test.dart @@ -0,0 +1,349 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:wallet/wallet.dart' as eth; +import 'package:web3dart/web3dart.dart' as web3; + +void main() { + test( + 'Ethereum awaits the journal before broadcasting its signed bytes', + () async { + final entered = Completer(); + final release = Completer(); + var journaled = false; + var requests = 0; + late String raw; + late String localTxid; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient((request) async { + requests++; + expect(journaled, isTrue); + final rpc = jsonDecode(request.body) as Map; + expect(rpc['method'], 'eth_sendRawTransaction'); + expect((rpc['params'] as List).single, raw); + return http.Response( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': localTxid, + }), + 200, + ); + }), + ); + addTearDown(client.dispose); + + final send = _EthereumWallet(client).confirmSend( + txData: _ethereumTxData(), + prepareTempTx: (txData, _) => txData, + beforeBroadcast: (signed) async { + raw = signed; + expect(raw, startsWith('0x02')); + localTxid = web3.bytesToHex( + web3.keccak256(raw.toUint8ListFromHex), + include0x: true, + ); + entered.complete(); + await release.future; + journaled = true; + }, + ); + + await entered.future; + expect(requests, 0); + release.complete(); + final sent = await send; + expect(requests, 1); + expect(sent.txid, localTxid); + expect(sent.txHash, localTxid); + }, + ); + + test( + 'Rosen Ethereum rejects a node transaction ID that differs from its own', + () async { + var journaled = false; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient((request) async { + expect(journaled, isTrue); + final rpc = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': '0x${'00' * 32}', + }), + 200, + ); + }), + ); + addTearDown(client.dispose); + + await expectLater( + _EthereumWallet(client).confirmSend( + txData: _ethereumTxData(), + prepareTempTx: (txData, _) => txData, + beforeBroadcast: (_) async => journaled = true, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Ethereum node returned an unexpected transaction ID.', + ), + ), + ); + }, + ); + + test('Ordinary Ethereum sends preserve the existing node response', () async { + final nodeTxid = '0x${'AB' * 32}'; + var requests = 0; + var tempTxPrepared = false; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient((request) async { + requests++; + final rpc = jsonDecode(request.body) as Map; + expect(rpc['method'], 'eth_sendRawTransaction'); + expect((rpc['params'] as List).single, startsWith('0x02')); + return http.Response( + jsonEncode({'jsonrpc': '2.0', 'id': rpc['id'], 'result': nodeTxid}), + 200, + ); + }), + ); + addTearDown(client.dispose); + + final sent = await _EthereumWallet(client).confirmSend( + txData: _ethereumTxData().copyWith(note: 'existing send note'), + prepareTempTx: (txData, address) { + expect(txData.txid, nodeTxid); + expect(txData.txHash, nodeTxid); + expect(address, '0x9858effd232b4033e47d90003d41ec34ecaeda94'); + tempTxPrepared = true; + return txData; + }, + ); + expect(requests, 1); + expect(tempTxPrepared, isTrue); + expect(sent.txid, nodeTxid); + expect(sent.txHash, nodeTxid); + expect(sent.note, 'existing send note'); + }); + + test('Electrum awaits the journal before broadcasting', () async { + final entered = Completer(); + final release = Completer(); + var journaled = false; + final client = _ElectrumClient(() => expect(journaled, isTrue)); + final wallet = _ElectrumWallet() + ..mainDB = MainDB.instance + ..electrumXClient = client; + + final send = wallet.confirmSend( + txData: TxData(raw: '010203', usedUTXOs: []), + beforeBroadcast: (raw) async { + expect(raw, '010203'); + entered.complete(); + await release.future; + journaled = true; + }, + ); + + await entered.future; + expect(client.calls, 0); + release.complete(); + final sent = await send; + expect(client.calls, 1); + expect(sent.txid, 'electrum-txid'); + }); + + for (final ethereum in [true, false]) { + for (final journal in [true, false]) { + testWidgets('${ethereum ? 'Ethereum' : 'Electrum'} ' + '${journal ? 'bridge' : 'ordinary'} broadcast timeout scope', ( + tester, + ) async { + final journalEntered = Completer(); + final journalRelease = Completer(); + final response = Completer(); + var journaled = false; + var requests = 0; + var txid = 'unreached'; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient((request) async { + requests++; + expectSync(journaled, journal); + final rpc = jsonDecode(request.body) as Map; + expectSync(rpc['method'], 'eth_sendRawTransaction'); + final raw = (rpc['params'] as List).single as String; + txid = web3.bytesToHex( + web3.keccak256(raw.toUint8ListFromHex), + include0x: true, + ); + return http.Response( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': await response.future, + }), + 200, + ); + }), + ); + addTearDown(client.dispose); + final ethereumWallet = _EthereumWallet(client); + final electrumWallet = _ElectrumWallet() + ..mainDB = MainDB.instance + ..electrumXClient = _ElectrumClient(() { + requests++; + expectSync(journaled, journal); + txid = 'electrum-txid'; + }, response: response); + Future beforeBroadcast(String raw) async { + journalEntered.complete(); + await journalRelease.future; + journaled = true; + } + + final sending = ethereum + ? ethereumWallet.confirmSend( + txData: _ethereumTxData(), + prepareTempTx: (txData, _) => txData, + beforeBroadcast: journal ? beforeBroadcast : null, + ) + : electrumWallet.confirmSend( + txData: TxData(raw: '010203', usedUTXOs: []), + beforeBroadcast: journal ? beforeBroadcast : null, + ); + Object? result; + final completed = sending.then( + (_) => result = true, + onError: (Object error) => result = error, + ); + try { + await tester.pump(); + if (journal) { + expect(journalEntered.isCompleted, isTrue); + await tester.pump(const Duration(seconds: 31)); + expect(result, isNull); + expect(requests, 0); + journalRelease.complete(); + await tester.pump(); + } + expect(requests, 1); + await tester.pump(const Duration(seconds: 29)); + expect(result, isNull); + await tester.pump(const Duration(seconds: 1)); + expect(response.isCompleted, isFalse); + expect(result, journal ? isA() : isNull); + } finally { + if (!journalRelease.isCompleted) journalRelease.complete(); + response.complete(txid); + await tester.pump(); + await completed; + } + final cacheUpdates = ethereum + ? ethereumWallet.cacheUpdates + : electrumWallet.cacheUpdates; + expect(cacheUpdates, journal ? 0 : 1); + expect(result, journal ? isA() : isTrue); + expect(requests, 1); + }); + } + } +} + +TxData _ethereumTxData() => TxData( + chainId: BigInt.one, + web3dartTransaction: web3.Transaction( + to: eth.EthereumAddress.fromHex( + '0x00112233445566778899aabbccddeeff00112233', + ), + value: eth.EtherAmount.zero(), + nonce: 7, + maxGas: 21000, + maxFeePerGas: eth.EtherAmount.inWei(BigInt.from(30)), + maxPriorityFeePerGas: eth.EtherAmount.inWei(BigInt.from(2)), + ), +); + +class _EthereumWallet extends EthereumWallet { + _EthereumWallet(this.client) : super(CryptoCurrencyNetwork.main); + + final web3.Web3Client client; + int cacheUpdates = 0; + + @override + web3.Web3Client getEthClient() => client; + + @override + Future getMnemonic() async => + 'abandon abandon abandon abandon abandon abandon abandon abandon ' + 'abandon abandon abandon about'; + + @override + Future getMnemonicPassphrase() async => ''; + + @override + Future getCurrentReceivingAddress() async => Address( + walletId: 'test', + value: '0x9858effd232b4033e47d90003d41ec34ecaeda94', + publicKey: [], + derivationIndex: 0, + derivationPath: null, + type: cryptoCurrency.defaultAddressType, + subType: AddressSubType.receiving, + ); + + @override + Future updateSentCachedTxData({required TxData txData}) async { + cacheUpdates++; + return txData; + } +} + +class _ElectrumWallet extends FiroWallet { + _ElectrumWallet() : super(CryptoCurrencyNetwork.main); + + int cacheUpdates = 0; + + @override + Future updateSentCachedTxData({required TxData txData}) async { + cacheUpdates++; + return txData; + } +} + +class _ElectrumClient extends Fake implements ElectrumXClient { + _ElectrumClient(this.beforeSend, {this.response}); + + final void Function() beforeSend; + final Completer? response; + int calls = 0; + + @override + Future broadcastTransaction({ + required String rawTx, + String? requestID, + }) async { + beforeSend(); + calls++; + return response == null ? 'electrum-txid' : await response!.future; + } +} diff --git a/test/wallets/firo_coin_selection_test.dart b/test/wallets/firo_coin_selection_test.dart new file mode 100644 index 000000000..9ad25a6a5 --- /dev/null +++ b/test/wallets/firo_coin_selection_test.dart @@ -0,0 +1,162 @@ +import 'dart:typed_data'; + +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/input.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; + +void main() { + test('optimal FIRO selection includes OP_RETURN before signing', () async { + final plain = _Wallet(); + final plainTx = await plain.select(10191); + expect(plainTx.fee!.raw, BigInt.from(191)); + expect(plain.builds, isNotEmpty); + + final bridge = _Wallet(); + await expectLater(bridge.select(10191, data: _data), throwsException); + expect(bridge.builds, isEmpty); + }); + + test('funded FIRO selection pays the serialized data output fee', () async { + final plain = await _Wallet().select(20000); + final bridgeWallet = _Wallet(); + final bridge = await bridgeWallet.select(20000, data: _data); + expect(bridge.usedUTXOs, hasLength(1)); + expect( + bridge.fee!.raw - plain.fee!.raw, + BigInt.from(firoOpReturnOutput(_data).size), + ); + expect(bridge.opReturnData, _data); + expect(bridge.fee!.raw, BigInt.from(bridge.vSize!)); + expect(bridgeWallet.builds.every((tx) => tx.opReturnData == _data), isTrue); + }); +} + +const _address = 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y'; +final _data = 'aa' * 80; + +class _Wallet extends FiroWallet { + _Wallet() : super(CryptoCurrencyNetwork.main); + + final builds = []; + + Future select(int inputValue, {String? data}) => coinSelection( + txData: TxData( + recipients: [ + TxRecipient( + address: _address, + amount: Amount(rawValue: BigInt.from(10000), fractionDigits: 8), + isChange: false, + addressType: AddressType.p2pkh, + ), + ], + feeRateAmount: BigInt.from(1000), + opReturnData: data, + ), + coinControl: false, + isSendAll: false, + isSendAllCoinControlUtxos: false, + utxos: [ + StandardInput( + UTXO( + walletId: 'test', + txid: '01' * 32, + vout: 0, + value: inputValue, + name: '', + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: 'confirmed', + blockHeight: 90, + blockTime: 1, + address: _address, + ), + ), + ], + ); + + @override + Future get chainHeight async => 100; + + @override + Future> addSigningKeys(List utxosToUse) async => + utxosToUse; + + @override + coinlib.Input standardInputToCoinlibInput( + StandardInput input, { + int sequence = 0xffffffff, + }) => _SizedInput(input.utxo.txid, input.utxo.vout); + + @override + Future getCurrentChangeAddress() async => Address( + walletId: 'test', + value: _address, + publicKey: [], + derivationIndex: 0, + derivationPath: null, + type: AddressType.p2pkh, + subType: AddressSubType.change, + ); + + @override + Future checkChangeAddressForTransactions() async {} + + @override + Future> helperRecipientsConvert( + List addrs, + List satValues, + ) async => [ + for (var i = 0; i < addrs.length; i++) + TxRecipient( + address: addrs[i], + amount: Amount(rawValue: satValues[i], fractionDigits: 8), + isChange: i > 0, + addressType: AddressType.p2pkh, + ), + ]; + + @override + Future buildTransaction({ + required TxData txData, + required List inputsWithKeys, + }) async { + builds.add(txData); + final transaction = coinlib.Transaction( + inputs: inputsWithKeys + .cast() + .map(standardInputToCoinlibInput) + .toList(), + outputs: [ + for (final recipient in txData.recipients!) + coinlib.Output.fromAddress( + recipient.amount.raw, + coinlib.Address.fromString( + recipient.address, + cryptoCurrency.networkParams, + ), + ), + if (txData.opReturnData != null) + firoOpReturnOutput(txData.opReturnData!), + ], + ); + return txData.copyWith(vSize: transaction.size); + } +} + +class _SizedInput extends coinlib.RawInput { + _SizedInput(String txid, int vout) + : super( + prevOut: coinlib.OutPoint.fromHex(txid, vout), + scriptSig: Uint8List(106), + ); + + @override + int get signedSize => size; +} diff --git a/test/wallets/firo_op_return_test.dart b/test/wallets/firo_op_return_test.dart new file mode 100644 index 000000000..9de2a87dc --- /dev/null +++ b/test/wallets/firo_op_return_test.dart @@ -0,0 +1,249 @@ +import 'dart:typed_data'; + +import 'package:coinlib/coinlib.dart' as coinlib; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; + +class _SizedInput extends coinlib.RawInput { + _SizedInput() + : super( + prevOut: coinlib.OutPoint(Uint8List(32), 0), + scriptSig: Uint8List(106), + ); + + @override + int get signedSize => size; +} + +void main() { + test('FIRO data output preserves payload and handles PUSHDATA1 boundary', () { + for (final length in [1, 75, 76, 80]) { + final hex = List.filled(length, 'aa').join(); + final output = firoOpReturnOutput(hex); + final prefix = length <= 75 ? [0x6a, length] : [0x6a, 0x4c, length]; + + expect(output.value, BigInt.zero); + expect(output.scriptPubKey, [...prefix, ...List.filled(length, 0xaa)]); + expect(output.size, 9 + prefix.length + length); + expect(firoOpReturnOutput('0x$hex').scriptPubKey, output.scriptPubKey); + } + for (final hex in ['00', '01', '10', '81', 'ff']) { + expect(firoOpReturnOutput(hex).scriptPubKey, [ + 0x6a, + 1, + int.parse(hex, radix: 16), + ]); + } + }); + + test('FIRO data output rejects malformed or oversized payloads', () { + for (final hex in [ + '', + '0', + 'zz', + '0x', + 'aa bb', + 'aa\n', + List.filled(81, 'aa').join(), + ]) { + expect(() => firoOpReturnOutput(hex), throwsFormatException); + } + }); + + test('coin selection funds the serialized data output before signing', () { + final program = coinlib.P2PKH.fromHash(Uint8List(20)); + final payment = coinlib.Output.fromProgram(BigInt.from(10000), program); + final data = firoOpReturnOutput(List.filled(80, 'aa').join()); + + coinlib.CoinSelection select(int inputValue, {required bool withData}) => + coinlib.CoinSelection( + selected: [ + coinlib.InputCandidate( + input: _SizedInput(), + value: BigInt.from(inputValue), + ), + ], + recipients: [payment, if (withData) data], + changeProgram: program, + feePerKb: BigInt.from(1000), + minFee: BigInt.zero, + minChange: BigInt.from(546), + ); + + final plain = select(20000, withData: false); + final bridge = select(20000, withData: true); + expect(bridge.ready, isTrue); + expect(bridge.fee - plain.fee, BigInt.from(data.size)); + expect(plain.changeValue - bridge.changeValue, BigInt.from(data.size)); + expect(bridge.transaction.outputs.where((o) => o.value == BigInt.zero), [ + data, + ]); + expect(bridge.transaction.size, bridge.signedSize); + + // A UTXO that covers the payment alone must not pass bridge fee selection. + final exactPlainValue = 10000 + plain.signedSize - payment.size; + expect(select(exactPlainValue, withData: false).ready, isTrue); + expect(select(exactPlainValue, withData: true).ready, isFalse); + }); + + test('decodes complete v3 special transactions and derives their txid', () { + const inputTxid = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + const raw = + '0300090001' + '1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100' + '0700000000ffffffff' + '01010000000000000000' + '00000000' + '03aabbcc'; + const txid = + '41630f29d16f8e4c246cba40d58dac6c69b3821ec319c5140e58fde5b2c356bc'; + + final decoded = decodeFiroTransaction(raw); + expect(decoded.txid, txid); + expect(decoded.prevouts.single, (txid: inputTxid, vout: 7)); + expect( + decodeFiroTransaction(raw.replaceFirst('aabbcc', 'aabbcd')).txid, + isNot(txid), + ); + expect( + () => decodeFiroTransaction(raw.substring(0, raw.length - 8)), + throwsFormatException, + ); + expect(() => decodeFiroTransaction('${raw}00'), throwsFormatException); + }); + + test('structurally signed FIRO bytes match the prepared envelope', () { + const metadata = + '03000000000000007b00000000000001c81400112233445566778899aabbccdd' + 'eeff00112233'; + const inputTxid = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + const serializedInputHash = + '1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100'; + const otherTxid = + '202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f'; + const otherSerializedInputHash = + '3f3e3d3c3b3a393837363534333231302f2e2d2c2b2a29282726252423222120'; + const paymentScript = '76a914111111111111111111111111111111111111111188ac'; + const changeScript = '76a914222222222222222222222222222222222222222288ac'; + // Fixed low-R/low-S SIGHASH_ALL signature for private key 1 and its P2PKH + // previous output; no platform-native signing is needed at test time. + const derSignature = + '304402206687c87c5f80c4e2a4e63fed02b0de65bcd38b77255b1de3f375f5d7' + 'a1b124c9' + '02202d5997a82c65d254e08ecc2ef7ba15995df3adfefa509b6ce0fdd65642a4062f'; + const publicKey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const invalidPublicKey = + '02ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + const paymentOutput = + '00e1f505000000001976a914111111111111111111111111111111111111111188ac'; + const changeOutput = + '80f0fa02000000001976a914222222222222222222222222222222222222222288ac'; + const dataOutput = '0000000000000000286a26$metadata'; + const transactionPrefix = + '0100000001${serializedInputHash}000000006a47${derSignature}0121' + '${publicKey}feffffff'; + const signedRaw = + '${transactionPrefix}03$paymentOutput$changeOutput$dataOutput' + '00000000'; + const witnessRaw = + '01000000000101$serializedInputHash' + '000000006a47$derSignature' + '0121${publicKey}feffffff' + '03$paymentOutput$changeOutput$dataOutput' + '0000000000'; + const signedTxid = + '3bd009801405a65f11f0e1c635025d115e93b175433a99958ac3cd5cd16ef64c'; + final inputValue = BigInt.from(150001000); + final amount = BigInt.from(100000000); + final change = BigInt.from(50000000); + final fee = BigInt.from(1000); + final inputs = [ + (txid: inputTxid, vout: 0, value: inputValue), + ]; + final outputs = [ + (script: paymentScript, value: amount), + (script: changeScript, value: change), + ]; + String verify({ + String? raw, + String? opReturn, + Iterable? expectedInputs, + Iterable? expectedOutputs, + BigInt? expectedFee, + }) => verifyFiroOpReturnTransaction( + raw: raw ?? signedRaw, + data: opReturn ?? metadata, + expectedInputs: expectedInputs ?? inputs, + expectedOutputs: expectedOutputs ?? outputs, + expectedFee: expectedFee ?? fee, + ); + + expect(verify(), signedTxid); + expect(decodeFiroTransaction(witnessRaw).txid, signedTxid); + expect( + verify( + expectedInputs: inputs.reversed, + expectedOutputs: outputs.reversed, + ), + signedTxid, + ); + + for (final raw in [ + '${transactionPrefix}02$paymentOutput$dataOutput' + '00000000', + '${transactionPrefix}04$paymentOutput$changeOutput$dataOutput$dataOutput' + '00000000', + signedRaw.replaceFirst( + paymentOutput, + paymentOutput.replaceFirst('00e1f505', 'ffe0f505'), + ), + signedRaw.replaceFirst(dataOutput, '0100000000000000286a26$metadata'), + signedRaw.replaceFirst(metadata, '${metadata.substring(0, 74)}ff'), + signedRaw.replaceFirst(serializedInputHash, otherSerializedInputHash), + signedRaw.replaceFirst('3044', '3144'), + signedRaw.replaceFirst('30440220', '30440320'), + signedRaw.replaceFirst('022066', '0220e6'), + signedRaw.replaceFirst(publicKey, invalidPublicKey), + '${signedRaw}00', + '0100000001${serializedInputHash}0000000000feffffff' + '03$paymentOutput$changeOutput$dataOutput' + '00000000', + '0100000001${serializedInputHash}0000000001d3feffffff' + '03$paymentOutput$changeOutput$dataOutput' + '00000000', + witnessRaw, + ]) { + expect(() => verify(raw: raw), throwsStateError); + } + + for (final expectedInputs in [ + [], + [(txid: inputTxid, vout: 1, value: inputValue)], + [(txid: otherTxid, vout: 0, value: inputValue)], + [(txid: inputTxid, vout: 0, value: inputValue + BigInt.one)], + [...inputs, ...inputs], + ]) { + expect(() => verify(expectedInputs: expectedInputs), throwsStateError); + } + for (final expectedOutputs in [ + [], + [(script: paymentScript, value: amount)], + [(script: paymentScript, value: amount - BigInt.one), outputs[1]], + [ + (script: paymentScript.replaceFirst('11', '12'), value: amount), + outputs[1], + ], + [...outputs, outputs.first], + ]) { + expect(() => verify(expectedOutputs: expectedOutputs), throwsStateError); + } + expect(() => verify(expectedFee: fee + BigInt.one), throwsStateError); + expect( + () => verify(opReturn: '${metadata.substring(0, 74)}ff'), + throwsStateError, + ); + }); +}