From 72af187defc9784856fca3f7af40301ad4a24a9c Mon Sep 17 00:00:00 2001 From: Julian Date: Thu, 29 Oct 2026 13:35:56 -0600 Subject: [PATCH] verify legacy inputs Co-authored-by: sneurlax --- .../cached_electrumx_client.dart | 35 +- .../tx_v2/transaction_v2_details_view.dart | 174 +++--- .../crypto_currency/coins/particl.dart | 3 + .../crypto_currency/coins/peercoin.dart | 3 + .../electrumx_currency_interface.dart | 11 + lib/wallets/wallet/impl/litecoin_wallet.dart | 24 +- lib/wallets/wallet/impl/namecoin_wallet.dart | 30 +- .../supporting/owned_input_signatures.dart | 386 ++++++++++++ .../electrumx_interface.dart | 172 ++++- .../paynym_interface.dart | 24 +- .../rbf_interface.dart | 297 ++++----- .../replacement_fee.dart | 66 ++ test/wallets/owned_input_signatures_test.dart | 590 ++++++++++++++++++ .../wallets/raw_transaction_layouts_test.dart | 180 ++++++ test/wallets/replacement_fee_test.dart | 113 ++++ 15 files changed, 1868 insertions(+), 240 deletions(-) create mode 100644 lib/wallets/wallet/supporting/owned_input_signatures.dart create mode 100644 lib/wallets/wallet/wallet_mixin_interfaces/replacement_fee.dart create mode 100644 test/wallets/owned_input_signatures_test.dart create mode 100644 test/wallets/raw_transaction_layouts_test.dart create mode 100644 test/wallets/replacement_fee_test.dart diff --git a/lib/electrumx_rpc/cached_electrumx_client.dart b/lib/electrumx_rpc/cached_electrumx_client.dart index e1b2235015..0908e9e934 100644 --- a/lib/electrumx_rpc/cached_electrumx_client.dart +++ b/lib/electrumx_rpc/cached_electrumx_client.dart @@ -20,19 +20,42 @@ class CachedElectrumXClient { static const minCacheConfirms = 30; + static const _rawTxKeyPrefix = "raw:"; + CachedElectrumXClient({required this.electrumXClient}); factory CachedElectrumXClient.from({ required ElectrumXClient electrumXClient, }) => CachedElectrumXClient(electrumXClient: electrumXClient); - String base64ToHex(String source) => base64Decode( - LineSplitter.split(source).join(), - ).map((e) => e.toRadixString(16).padLeft(2, '0')).join(); + String base64ToHex(String source) => + base64Decode(LineSplitter.split(source).join()) + .map((e) => e.toRadixString(16).padLeft(2, '0')) + .join(); + + String base64ToReverseHex(String source) => + base64Decode(LineSplitter.split(source).join()).reversed + .map((e) => e.toRadixString(16).padLeft(2, '0')) + .join(); - String base64ToReverseHex(String source) => base64Decode( - LineSplitter.split(source).join(), - ).reversed.map((e) => e.toRadixString(16).padLeft(2, '0')).join(); + /// The raw hex stored for [txHash] by [putAuthenticatedRawTransaction]. + Future getAuthenticatedRawTransaction({ + required String txHash, + required CryptoCurrency cryptoCurrency, + }) async { + final box = await DB.instance.getTxCacheBox(currency: cryptoCurrency); + return box.get("$_rawTxKeyPrefix$txHash") as String?; + } + + /// Stores [rawTxHex], which must already be checked to hash to [txHash]. + Future putAuthenticatedRawTransaction({ + required String txHash, + required String rawTxHex, + required CryptoCurrency cryptoCurrency, + }) async { + final box = await DB.instance.getTxCacheBox(currency: cryptoCurrency); + await box.put("$_rawTxKeyPrefix$txHash", rawTxHex); + } /// Call electrumx getTransaction on a per coin basis, storing the result in local db if not already there. /// diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart index e10200c8d1..c6eb3706ff 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart @@ -117,20 +117,42 @@ class _TransactionV2DetailsViewState } _boostButtonLock = true; try { - if (Util.isDesktop) { - if (_transaction.vSize == null) { - final updatedTx = await showLoading( - whileFuture: wallet.updateVSize(_transaction), - context: context, - message: "Fetching transaction vSize...", - ); - - // TODO handle errors if null - _transaction = updatedTx!; + // Re-check the transaction against the current server before pricing a + // bump off its size. One the server cannot prove we signed is not + // boosted, and the refresh drops it from history. + Exception? failure; + final updatedTx = await showLoading( + whileFuture: wallet.updateVSize(_transaction), + context: context, + message: "Verifying transaction...", + onException: (e) => failure = e, + ); + if (!mounted) { + return; + } + if (updatedTx == null) { + final error = failure; + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: error == null + ? "Could not verify transaction" + : "Failed to verify transaction", + message: error == null + ? "The current server did not return a transaction signed by " + "this wallet, so it cannot be boosted." + : error.toString(), + maxWidth: Util.isDesktop ? 400 : null, + ), + ); + if (error == null) { + unawaited(wallet.refresh()); } + return; + } + _transaction = updatedTx; - // TODO pass the size in to the rbf screen - + if (Util.isDesktop) { if (mounted) { await showDialog( context: context, @@ -511,9 +533,9 @@ class _TransactionV2DetailsViewState appBar: isDesktop ? null : AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -559,9 +581,9 @@ class _TransactionV2DetailsViewState builder: (child) { return RoundedWhiteContainer( borderColor: isDesktop - ? Theme.of( - context, - ).extension()!.backgroundAppBar + ? Theme.of(context) + .extension()! + .backgroundAppBar : null, padding: const EdgeInsets.all(0), child: child, @@ -816,36 +838,30 @@ class _TransactionV2DetailsViewState fetchContactNameFor( e, ), - builder: - ( - builderContext, - AsyncSnapshot< - String - > - snapshot, - ) { - final String - addressOrContactName; - if (snapshot.connectionState == - ConnectionState.done && - snapshot - .hasData) { - addressOrContactName = - snapshot.data!; - } else { - addressOrContactName = - e; - } - - return OutputCard( - address: - addressOrContactName, - amount: - data[i].amount, - coin: - coin, - ); - }, + builder: (builderContext, AsyncSnapshot snapshot) { + final String + addressOrContactName; + if (snapshot.connectionState == + ConnectionState + .done && + snapshot + .hasData) { + addressOrContactName = + snapshot + .data!; + } else { + addressOrContactName = + e; + } + + return OutputCard( + address: + addressOrContactName, + amount: data[i] + .amount, + coin: coin, + ); + }, ); }), ], @@ -1658,9 +1674,9 @@ class OutputCard extends ConsumerWidget { ref.watch(pAmountFormatter(coin)).format(amount), style: Util.isDesktop ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ) : STextStyles.itemSubtitle12(context), ), @@ -1696,9 +1712,9 @@ class IconCopyButton extends StatelessWidget { height: 26, width: 26, child: RawMaterialButton( - fillColor: Theme.of( - context, - ).extension()!.buttonBackSecondary, + fillColor: Theme.of(context) + .extension()! + .buttonBackSecondary, elevation: 0, hoverElevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), @@ -1738,9 +1754,9 @@ class IconPencilButton extends StatelessWidget { height: 26, width: 26, child: RawMaterialButton( - fillColor: Theme.of( - context, - ).extension()!.buttonBackSecondary, + fillColor: Theme.of(context) + .extension()! + .buttonBackSecondary, elevation: 0, hoverElevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), @@ -1889,16 +1905,16 @@ class _TxidDetailItemState extends ConsumerState<_TxidDetailItem> { child: Text( "Cancel", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), rightButton: TextButton( - style: Theme.of( - context, - ).extension()!.getPrimaryEnabledButtonStyle(context), + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), onPressed: () { Navigator.of(context).pop(true); }, @@ -2026,13 +2042,12 @@ class _TxidDetailItemState extends ConsumerState<_TxidDetailItem> { SelectableText( widget.txid.replaceFirst("mweb_outputId_", ""), style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ) : STextStyles.itemSubtitle12(context), ), ], @@ -2073,13 +2088,12 @@ class _TxidDetailItemState extends ConsumerState<_TxidDetailItem> { SelectableText( widget.txid, style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ) : STextStyles.itemSubtitle12(context), ), if (widget.coin is! Epiccash && @@ -2176,9 +2190,9 @@ class _TxDetailsAmountHeader extends ConsumerWidget { child: Container( decoration: isDesktop ? BoxDecoration( - color: Theme.of( - context, - ).extension()!.backgroundAppBar, + color: Theme.of(context) + .extension()! + .backgroundAppBar, borderRadius: BorderRadius.vertical( top: Radius.circular(Constants.size.circularBorderRadius), ), diff --git a/lib/wallets/crypto_currency/coins/particl.dart b/lib/wallets/crypto_currency/coins/particl.dart index a631e94e4f..8d79863928 100644 --- a/lib/wallets/crypto_currency/coins/particl.dart +++ b/lib/wallets/crypto_currency/coins/particl.dart @@ -235,6 +235,9 @@ class Particl extends Bip39HDCurrency with ElectrumXCurrencyInterface { @override int get transactionVersion => 160; + @override + bool get hasBitcoinTransactionLayout => false; + @override BigInt get defaultFeeRate => BigInt.from(20000); } diff --git a/lib/wallets/crypto_currency/coins/peercoin.dart b/lib/wallets/crypto_currency/coins/peercoin.dart index 1c2f826d88..b8a9477354 100644 --- a/lib/wallets/crypto_currency/coins/peercoin.dart +++ b/lib/wallets/crypto_currency/coins/peercoin.dart @@ -263,6 +263,9 @@ class Peercoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { @override int get transactionVersion => 3; + @override + bool get legacyTransactionTimestamp => true; + @override BigInt get defaultFeeRate => BigInt.from(10000); } diff --git a/lib/wallets/crypto_currency/interfaces/electrumx_currency_interface.dart b/lib/wallets/crypto_currency/interfaces/electrumx_currency_interface.dart index 70929af413..b4822812fc 100644 --- a/lib/wallets/crypto_currency/interfaces/electrumx_currency_interface.dart +++ b/lib/wallets/crypto_currency/interfaces/electrumx_currency_interface.dart @@ -11,6 +11,17 @@ mixin ElectrumXCurrencyInterface on Bip39HDCurrency { /// The default fee rate in satoshis per kilobyte. BigInt get defaultFeeRate; + /// Whether raw transactions follow Bitcoin's layout, which the parser in + /// owned_input_signatures.dart reads, allowing for Peercoin's timestamp and + /// the Dash and Firo extra payload. Particl's layout differs, so its coins + /// are not authenticated against the parent transaction; its signature hash + /// commits to the amount anyway. + bool get hasBitcoinTransactionLayout => true; + + /// Whether transactions below version 3 carry a timestamp after the + /// version field, as Peercoin's do. + bool get legacyTransactionTimestamp => false; + @override AddressType? getAddressType(String address) { try { diff --git a/lib/wallets/wallet/impl/litecoin_wallet.dart b/lib/wallets/wallet/impl/litecoin_wallet.dart index 32cfe8fe6b..08d0aeeb4f 100644 --- a/lib/wallets/wallet/impl/litecoin_wallet.dart +++ b/lib/wallets/wallet/impl/litecoin_wallet.dart @@ -15,6 +15,7 @@ import '../../crypto_currency/crypto_currency.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; import '../intermediate/bip39_hd_wallet.dart'; import '../intermediate/external_wallet.dart'; +import '../supporting/owned_input_signatures.dart'; import '../wallet_mixin_interfaces/coin_control_interface.dart'; import '../wallet_mixin_interfaces/electrumx_interface.dart'; import '../wallet_mixin_interfaces/extended_keys_interface.dart'; @@ -252,6 +253,22 @@ class LitecoinWallet } } + // A pending spend of our coins is kept, and sized, only from raw bytes + // the server proves this wallet signed. Anything else is dropped. + final height = txData["height"] as int?; + RawTransactionSize? verifiedSize; + if (wasSentFromThisWallet && (height == null || height <= 0)) { + verifiedSize = await verifyPendingSpend( + txid: txData["txid"] as String, + inputs: inputs, + outputs: outputs, + ); + if (verifiedSize == null) { + await deleteUnverifiedTransaction(txData["txid"] as String); + continue; + } + } + final totalOut = outputs .map((e) => e.value) .fold(BigInt.zero, (value, element) => value + element); @@ -328,7 +345,12 @@ class LitecoinWallet } String? otherData; - if (txData["size"] is int || txData["vsize"] is int) { + if (verifiedSize != null) { + otherData = jsonEncode({ + TxV2OdKeys.size: verifiedSize.size, + TxV2OdKeys.vSize: verifiedSize.vSize, + }); + } else if (txData["size"] is int || txData["vsize"] is int) { otherData = jsonEncode({ TxV2OdKeys.size: txData["size"] as int?, TxV2OdKeys.vSize: txData["vsize"] as int?, diff --git a/lib/wallets/wallet/impl/namecoin_wallet.dart b/lib/wallets/wallet/impl/namecoin_wallet.dart index 10ea40c5a7..95a8168cb2 100644 --- a/lib/wallets/wallet/impl/namecoin_wallet.dart +++ b/lib/wallets/wallet/impl/namecoin_wallet.dart @@ -20,6 +20,7 @@ import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; import '../../models/name_op_state.dart'; import '../../models/tx_data.dart'; import '../intermediate/bip39_hd_wallet.dart'; +import '../supporting/owned_input_signatures.dart'; import '../wallet_mixin_interfaces/coin_control_interface.dart'; import '../wallet_mixin_interfaces/cpfp_interface.dart'; import '../wallet_mixin_interfaces/electrumx_interface.dart'; @@ -174,11 +175,38 @@ class NamecoinWallet } } + int value = jsonUTXO["value"] as int; + // Name outputs are blocked above and carry a name script, so only plain + // coins are checked against their parent. + if (!shouldBlock) { + try { + final authenticated = await authenticatedP2pkhOutput( + txid: txn["txid"] as String, + vout: vout, + address: utxoOwnerAddress, + confirmations: txn["confirmations"] as int?, + ); + if (authenticated != null) { + if (authenticated.value.toInt() != value) { + Logging.instance.w( + "Server misreported coin ${txn["txid"]}:$vout as $value; using " + "${authenticated.value}", + ); + } + value = authenticated.value.toInt(); + } + } on UnauthenticatedTransaction catch (e) { + Logging.instance.w("Could not verify coin ${txn["txid"]}:$vout: $e"); + shouldBlock = true; + blockReason = ElectrumXInterface.unverifiedCoinReason; + } + } + final utxo = UTXO( walletId: walletId, txid: txn["txid"] as String, vout: vout, - value: jsonUTXO["value"] as int, + value: value, name: label ?? "", isBlocked: shouldBlock, blockedReason: blockReason, diff --git a/lib/wallets/wallet/supporting/owned_input_signatures.dart b/lib/wallets/wallet/supporting/owned_input_signatures.dart new file mode 100644 index 0000000000..7ddcbd1b4d --- /dev/null +++ b/lib/wallets/wallet/supporting/owned_input_signatures.dart @@ -0,0 +1,386 @@ +import 'dart:typed_data'; + +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; + +import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; + +final class UnauthenticatedTransaction implements Exception { + final String message; + + const UnauthenticatedTransaction(this.message); + + @override + String toString() => "UnauthenticatedTransaction: $message"; +} + +/// Sizes computed from raw bytes, excluding MWEB data. +typedef RawTransactionSize = ({int size, int vSize}); + +/// Parses [rawTxHex], which must hash to [txid]. Witness data is dropped. +/// [legacyTimestamp] is for coins whose transactions below version 3 carry a +/// timestamp, as Peercoin's do. +coinlib.Transaction parseAuthenticatedTransaction({ + required String txid, + required String rawTxHex, + bool legacyTimestamp = false, +}) => _parseAuthenticated(txid, rawTxHex, legacyTimestamp: legacyTimestamp).tx; + +/// Whether [address] is p2pkh (bip44), whose legacy signature hash does not +/// commit to the amount being spent. +bool isP2pkhAddress(String address, coinlib.Network network) { + try { + return coinlib.Address.fromString(address, network).program + is coinlib.P2PKH; + } catch (_) { + return false; + } +} + +/// Verifies [rawTxHex] as [txid], then checks the parent output of each +/// wallet owned p2pkh input, as only the legacy signature hash leaves the +/// amount unverified. Parents are fetched with [fetchRawTransaction]. Only +/// their outputs are read, which the txid commits to, so they may come from a +/// cache. The spend itself may not: its witnesses are checked but are not +/// committed by the txid, so a cached copy could hold a server's lie forever. +Future fetchAndVerifyOwnedInputSignatures({ + required String txid, + required String rawTxHex, + required List inputs, + required List outputs, + required coinlib.Network network, + required Future Function(String txid) fetchRawTransaction, + bool allowMweb = false, + bool legacyTimestamp = false, +}) async { + final verified = verifyOwnedInputSignatures( + txid: txid, + rawTxHex: rawTxHex, + inputs: inputs, + outputs: outputs, + network: network, + allowMweb: allowMweb, + ); + + final parents = {}; + for (final (i, input) in inputs.indexed) { + if (!isP2pkhAddress(input.addresses.single, network)) continue; + + final outpoint = input.outpoint!; + final parentTxid = outpoint.txid.toLowerCase(); + final parent = parents[parentTxid] ??= parseAuthenticatedTransaction( + txid: parentTxid, + rawTxHex: await fetchRawTransaction(parentTxid), + legacyTimestamp: legacyTimestamp, + ); + + if (outpoint.vout >= parent.outputs.length) { + throw UnauthenticatedTransaction("Previous output $i missing"); + } + final prevOut = parent.outputs[outpoint.vout]; + if (input.value != prevOut.value || + !scriptPaysTo(input.addresses.single, prevOut.scriptPubKey, network)) { + throw UnauthenticatedTransaction("Previous output $i mismatch"); + } + } + + return verified; +} + +/// Parses [rawTxHex], which must hash to [txid]. +_RawTransaction _parseAuthenticated( + String txid, + String rawTxHex, { + bool legacyTimestamp = false, +}) { + final _RawTransaction raw; + try { + raw = _parse(rawTxHex, legacyTimestamp: legacyTimestamp); + } catch (_) { + throw const UnauthenticatedTransaction("Malformed transaction"); + } + if (raw.txid != txid.toLowerCase()) { + throw const UnauthenticatedTransaction("Transaction mismatch"); + } + return raw; +} + +/// Checks that [rawTxHex] is transaction [txid], that [inputs] and [outputs] +/// describe it, and that every input is marked [InputV2.walletOwns] and signed +/// with SIGHASH_ALL by the key of the address it spends from. A spend of this +/// wallet's has no inputs of anyone else's, so every witness is checked and +/// the size is authenticated. Throws [UnauthenticatedTransaction] otherwise. +/// +/// Outputs with addresses are matched by address and value. With [allowMweb], +/// MWEB data is allowed and outputs recorded as "mweb" are skipped. +RawTransactionSize verifyOwnedInputSignatures({ + required String txid, + required String rawTxHex, + required List inputs, + required List outputs, + required coinlib.Network network, + bool allowMweb = false, +}) { + final raw = _parseAuthenticated(txid, rawTxHex); + + final tx = raw.tx; + final canonicalOutputs = outputs + .where((e) => !(raw.hasMweb && e.scriptPubKeyHex == "mweb")) + .toList(); + if ((raw.hasMweb && !allowMweb) || + inputs.length != tx.inputs.length || + canonicalOutputs.length != tx.outputs.length) { + throw const UnauthenticatedTransaction("Transaction mismatch"); + } + + for (final (i, input) in inputs.indexed) { + final prevOut = tx.inputs[i].prevOut; + final prevTxid = coinlib.bytesToHex( + Uint8List.fromList(prevOut.hash.reversed.toList()), + ); + if (input.outpoint?.vout != prevOut.n || + input.outpoint?.txid.toLowerCase() != prevTxid) { + throw UnauthenticatedTransaction("Input $i mismatch"); + } + } + + for (final (i, output) in canonicalOutputs.indexed) { + final script = tx.outputs[i].scriptPubKey; + final scriptMatches = output.addresses.isEmpty + ? output.scriptPubKeyHex.toLowerCase() == coinlib.bytesToHex(script) + : output.addresses.every((e) => scriptPaysTo(e, script, network)); + if (!scriptMatches || output.value != tx.outputs[i].value) { + throw UnauthenticatedTransaction("Output $i mismatch"); + } + } + + for (final (i, input) in inputs.indexed) { + if (!input.walletOwns || input.addresses.length != 1) { + throw UnauthenticatedTransaction("Input $i is not this wallet's"); + } + + bool valid; + try { + valid = _verifyOwnedInput(raw, i, inputs, network); + } catch (_) { + valid = false; + } + if (!valid) { + throw UnauthenticatedTransaction("Invalid signature on input $i"); + } + } + + return (size: raw.size, vSize: raw.vSize); +} + +/// The transaction without witness or MWEB data, its txid, its witnesses, and +/// its size excluding MWEB data. +typedef _RawTransaction = ({ + coinlib.Transaction tx, + String txid, + List> witnesses, + bool hasMweb, + int size, + int vSize, +}); + +_RawTransaction _parse(String hex, {bool legacyTimestamp = false}) { + final bytes = coinlib.hexToBytes(hex); + final reader = coinlib.BytesReader(bytes); + + // Lengths are checked against the remaining data before allocating. + int readCount() { + final n = reader.readVarInt(); + if (n > BigInt.from(bytes.length - reader.offset)) { + throw const FormatException("Invalid count"); + } + return n.toInt(); + } + + final version = reader.readInt32(); + if (legacyTimestamp && version < 3) { + reader.readUInt32(); + } + final headerEnd = reader.offset; + + int flag = 0; + if (bytes.length > reader.offset + 1 && bytes[reader.offset] == 0) { + reader.offset++; + flag = reader.readUInt8(); + if (flag == 0 || flag & ~0x09 != 0) { + throw const FormatException("Unknown flag"); + } + } + final hasWitness = flag & 0x01 != 0; + final hasMweb = flag & 0x08 != 0; + + final bodyStart = reader.offset; + final inputs = List.generate( + readCount(), + (_) => coinlib.RawInput.fromReader(reader), + ); + final outputs = List.generate( + readCount(), + (_) => coinlib.Output.fromReader(reader), + ); + final bodyEnd = reader.offset; + final witnesses = [ + for (final _ in inputs) + hasWitness + ? List.generate(readCount(), (_) => reader.readVarSlice()) + : [], + ]; + + // MWEB data sits between the witnesses and the locktime. + var mwebLength = 0; + if (hasMweb) { + mwebLength = bytes.length - reader.offset - 4; + reader.readSlice(mwebLength); + } + + final locktimeStart = reader.offset; + final locktime = reader.readUInt32(); + + // Dash and Firo special transactions, version 3 with the type in the upper + // 16 bits, end with an extra payload. + Uint8List? extraPayload; + if (!reader.atEnd && (version & 0xffff) >= 3 && (version >>> 16) != 0) { + extraPayload = reader.readSlice(readCount()); + } + if (!reader.atEnd || inputs.isEmpty) { + throw const FormatException("Malformed transaction"); + } + + // The txid commits to everything but the witnesses and MWEB data. + final stripped = Uint8List.fromList([ + ...bytes.sublist(0, headerEnd), + ...bytes.sublist(bodyStart, bodyEnd), + ...bytes.sublist(locktimeStart), + ]); + final txid = coinlib.bytesToHex( + Uint8List.fromList(coinlib.sha256DoubleHash(stripped).reversed.toList()), + ); + + final tx = coinlib.Transaction( + version: version, + inputs: inputs, + outputs: outputs, + locktime: locktime, + vExtraData: extraPayload, + ); + final size = bytes.length - mwebLength; + return ( + tx: tx, + txid: txid, + witnesses: witnesses, + hasMweb: hasMweb, + size: size, + vSize: ((stripped.length * 3 + size) / 4).ceil(), + ); +} + +bool _verifyOwnedInput( + _RawTransaction raw, + int inputN, + List inputs, + coinlib.Network network, +) { + final program = coinlib.Address.fromString( + inputs[inputN].addresses.single, + network, + ).program; + final txIn = raw.tx.inputs[inputN] as coinlib.RawInput; + final witness = raw.witnesses[inputN]; + + if (program is coinlib.P2TR) { + final insig = coinlib.TaprootKeyInput.match(txIn, witness)?.insig; + if (insig == null || !_signsAll(insig.hashType)) return false; + + // Taproot signatures commit to every spent output. + final prevOuts = [ + for (final input in inputs) + coinlib.Output.fromAddress( + input.value, + coinlib.Address.fromString(input.addresses.single, network), + ), + ]; + final hash = coinlib.TaprootSignatureHasher( + coinlib.TaprootKeySignDetails( + tx: raw.tx, + inputN: inputN, + prevOuts: prevOuts, + hashType: insig.hashType, + ), + ).hash; + return insig.signature.verify(program.tweakedKey, hash); + } + + // p2pkh carries the signature and key in the scriptSig, p2wpkh and + // p2sh-p2wpkh in the witness. + final isLegacy = program is coinlib.P2PKH; + final coinlib.PKHInput? signed; + if (isLegacy) { + signed = coinlib.P2PKHInput.match(txIn); + } else { + signed = coinlib.P2WPKHInput.match( + coinlib.RawInput(prevOut: txIn.prevOut, scriptSig: Uint8List(0)), + witness, + ); + } + final insig = signed?.insig; + if (signed == null || insig == null || !_signsAll(insig.hashType)) { + return false; + } + + final scriptCode = coinlib.P2PKH.fromPublicKey(signed.publicKey).script; + final p2wpkh = coinlib.P2WPKH.fromPublicKey(signed.publicKey).script; + final (spentScript, scriptSig) = switch (program) { + coinlib.P2PKH() => (scriptCode, txIn.scriptSig), + coinlib.P2WPKH() => (p2wpkh, Uint8List(0)), + coinlib.P2SH() => ( + coinlib.P2SH.fromRedeemScript(p2wpkh).script, + coinlib.Script([coinlib.ScriptPushData(p2wpkh.compiled)]).compiled, + ), + _ => throw UnsupportedError("Unsupported input $inputN"), + }; + if (!coinlib.bytesEqual(spentScript.compiled, program.script.compiled) || + !coinlib.bytesEqual(scriptSig, txIn.scriptSig) || + (isLegacy && witness.isNotEmpty)) { + return false; + } + + final hash = isLegacy + ? coinlib.LegacySignatureHasher( + coinlib.LegacySignDetailsWithScript( + tx: raw.tx, + inputN: inputN, + scriptCode: scriptCode, + hashType: insig.hashType, + ), + ).hash + : coinlib.WitnessSignatureHasher( + coinlib.LegacyWitnessSignDetailsWithScript( + tx: raw.tx, + inputN: inputN, + value: inputs[inputN].value, + scriptCode: scriptCode, + hashType: insig.hashType, + ), + ).hash; + return insig.signature.verify(signed.publicKey, hash); +} + +bool _signsAll(coinlib.SigHashType hashType) => + hashType.all && hashType.allInputs; + +/// Whether [script] is exactly the output script of [address]. +bool scriptPaysTo(String address, Uint8List script, coinlib.Network network) { + try { + return coinlib.bytesEqual( + coinlib.Address.fromString(address, network).program.script.compiled, + script, + ); + } catch (_) { + return false; + } +} diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index f2d5eb05b9..15ecc7db61 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -33,6 +33,7 @@ import '../impl/bitcoin_wallet.dart'; import '../impl/firo_wallet.dart'; import '../impl/peercoin_wallet.dart'; import '../intermediate/bip39_hd_wallet.dart'; +import '../supporting/owned_input_signatures.dart'; import 'cpfp_interface.dart'; import 'electrum_fee_planner.dart'; import 'mweb_fee_utils.dart'; @@ -1379,6 +1380,141 @@ mixin ElectrumXInterface } } + /// The size of pending spend [txid], from the raw bytes the server returns + /// for it, once those bytes are proven to be a transaction this wallet + /// signed with the stored [inputs] and [outputs], and for p2pkh inputs + /// the amounts match the parent transactions. Null if not. Network + /// errors propagate. Transaction records are not touched. The spend is + /// always fetched from the server, never from the cache. + Future verifyPendingSpend({ + required String txid, + required List inputs, + required List outputs, + }) async { + try { + return await fetchAndVerifyOwnedInputSignatures( + txid: txid, + rawTxHex: await _fetchRawTransactionHexFromServer(txid), + inputs: inputs, + outputs: outputs, + network: cryptoCurrency.networkParams, + allowMweb: this is MwebInterface, + legacyTimestamp: cryptoCurrency.legacyTransactionTimestamp, + fetchRawTransaction: _fetchRawTransactionHex, + ); + } on UnauthenticatedTransaction catch (e) { + Logging.instance.w("Could not authenticate pending spend $txid: $e"); + return null; + } + } + + /// Drops any stored copy of [txid]. A pending spend the server cannot prove + /// this wallet signed may be one it made up, so it is not kept. It is picked + /// up again on a later refresh if the bytes then verify. + Future deleteUnverifiedTransaction(String txid) => mainDB.isar.writeTxn( + () => mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txid, walletId) + .deleteAll(), + ); + + /// The raw hex of [txid] from the server, never from the cache. + Future _fetchRawTransactionHexFromServer(String txid) async { + final response = await electrumXClient.getTransaction( + txHash: txid, + verbose: false, + ); + final rawTx = response["rawtx"]; + return rawTx is String ? rawTx : ""; + } + + /// The raw hex of [txid], from the authenticated cache when present. + Future _fetchRawTransactionHex(String txid) async { + final cached = await electrumXCachedClient.getAuthenticatedRawTransaction( + txHash: txid, + cryptoCurrency: cryptoCurrency, + ); + return cached ?? await _fetchRawTransactionHexFromServer(txid); + } + + /// Fetches [txid] and checks that the bytes hash to it. They are cached + /// only once [confirmations] is past the threshold the decoded transaction + /// cache uses: a reorg cannot change bytes the txid commits to, but an + /// unconfirmed transaction can be replaced, so it is not kept. + Future fetchAuthenticatedTransaction( + String txid, { + int? confirmations, + }) async { + final cached = await electrumXCachedClient.getAuthenticatedRawTransaction( + txHash: txid, + cryptoCurrency: cryptoCurrency, + ); + final rawTxHex = cached ?? await _fetchRawTransactionHexFromServer(txid); + final tx = parseAuthenticatedTransaction( + txid: txid, + rawTxHex: rawTxHex, + legacyTimestamp: cryptoCurrency.legacyTransactionTimestamp, + ); + if (cached == null && + confirmations != null && + confirmations > CachedElectrumXClient.minCacheConfirms) { + await electrumXCachedClient.putAuthenticatedRawTransaction( + txHash: txid, + rawTxHex: rawTxHex, + cryptoCurrency: cryptoCurrency, + ); + } + return tx; + } + + /// The output funding p2pkh coin [txid]:[vout], read from the authenticated + /// parent transaction. The legacy signature hash does not commit to the + /// amount, so a server could otherwise lie about a coin's value and have + /// the wallet overpay the fee. Null when [address] is not p2pkh or the + /// parser cannot read this coin's transactions. [address] is as the server + /// reports it, so it is normalised first (cashaddr for Bitcoin Cash and + /// eCash). [confirmations] decides whether the parent is cached. + /// + /// Throws [UnauthenticatedTransaction] when the parent cannot be verified + /// or its output does not pay [address]. Network errors propagate. + Future authenticatedP2pkhOutput({ + required String txid, + required int vout, + required String? address, + required int? confirmations, + }) async { + if (!cryptoCurrency.hasBitcoinTransactionLayout || address == null) { + return null; + } + final normalized = normalizeAddress(address); + if (!isP2pkhAddress(normalized, cryptoCurrency.networkParams)) { + return null; + } + final parent = await fetchAuthenticatedTransaction( + txid, + confirmations: confirmations, + ); + if (vout >= parent.outputs.length) { + throw UnauthenticatedTransaction("Output $vout of $txid missing"); + } + final output = parent.outputs[vout]; + if (!scriptPaysTo( + normalized, + output.scriptPubKey, + cryptoCurrency.networkParams, + )) { + throw UnauthenticatedTransaction( + "Output $vout of $txid does not pay $address", + ); + } + return output; + } + + /// The reason to block a coin whose parent could not be verified. The coin + /// is kept with the server's values, visible but never spent, so one bad + /// parent does not stop the rest of the coins from refreshing. + static const unverifiedCoinReason = "Could not verify this coin's value"; + Future parseUTXO({required Map jsonUTXO}) async { final txn = await electrumXCachedClient.getTransaction( txHash: jsonUTXO["tx_hash"] as String, @@ -1408,6 +1544,36 @@ mixin ElectrumXInterface } } + int value = jsonUTXO["value"] as int; + final utxoTxid = txn["txid"] as String; + String? unverifiedReason; + try { + final authenticated = await authenticatedP2pkhOutput( + txid: utxoTxid, + vout: vout, + address: utxoOwnerAddress, + confirmations: txn["confirmations"] as int?, + ); + if (authenticated != null) { + final authenticatedValue = authenticated.value.toInt(); + final authenticatedScript = coinlib.bytesToHex( + authenticated.scriptPubKey, + ); + if (authenticatedValue != value || + authenticatedScript != scriptPubKey?.toLowerCase()) { + Logging.instance.w( + "Server misreported coin $utxoTxid:$vout as $value with script " + "$scriptPubKey; using $authenticatedValue and $authenticatedScript", + ); + } + value = authenticatedValue; + scriptPubKey = authenticatedScript; + } + } on UnauthenticatedTransaction catch (e) { + Logging.instance.w("Could not verify coin $utxoTxid:$vout: $e"); + unverifiedReason = ElectrumXInterface.unverifiedCoinReason; + } + final checkBlockResult = await checkBlockUTXO( jsonUTXO, scriptPubKey, @@ -1419,10 +1585,10 @@ mixin ElectrumXInterface walletId: walletId, txid: txn["txid"] as String, vout: vout, - value: jsonUTXO["value"] as int, + value: value, name: checkBlockResult.utxoLabel ?? "", - isBlocked: checkBlockResult.blocked, - blockedReason: checkBlockResult.blockedReason, + isBlocked: unverifiedReason != null || checkBlockResult.blocked, + blockedReason: unverifiedReason ?? checkBlockResult.blockedReason, isCoinbase: txn["is_coinbase"] as bool? ?? txn["is-coinbase"] as bool? ?? diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index a38df01671..050433e887 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -30,6 +30,7 @@ import '../../crypto_currency/crypto_currency.dart'; import '../../crypto_currency/interfaces/paynym_currency_interface.dart'; import '../../models/tx_data.dart'; import '../intermediate/bip39_hd_wallet.dart'; +import '../supporting/owned_input_signatures.dart'; import 'electrumx_interface.dart'; const String kPCodeKeyPrefix = "pCode_key_"; @@ -1863,6 +1864,22 @@ mixin PaynymInterface outputs.add(output); } + // A pending spend of our coins is kept, and sized, only from raw bytes + // the server proves this wallet signed. Anything else is dropped. + final height = txData["height"] as int?; + RawTransactionSize? verifiedSize; + if (wasSentFromThisWallet && (height == null || height <= 0)) { + verifiedSize = await verifyPendingSpend( + txid: txData["txid"] as String, + inputs: inputs, + outputs: outputs, + ); + if (verifiedSize == null) { + await deleteUnverifiedTransaction(txData["txid"] as String); + continue; + } + } + final totalOut = outputs .map((e) => e.value) .fold(BigInt.zero, (value, element) => value + element); @@ -1912,7 +1929,12 @@ mixin PaynymInterface } String? otherData; - if (txData["size"] is int || txData["vsize"] is int) { + if (verifiedSize != null) { + otherData = jsonEncode({ + TxV2OdKeys.size: verifiedSize.size, + TxV2OdKeys.vSize: verifiedSize.vSize, + }); + } else if (txData["size"] is int || txData["vsize"] is int) { otherData = jsonEncode({ TxV2OdKeys.size: txData["size"] as int?, TxV2OdKeys.vSize: txData["vsize"] as int?, diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart index f21293f956..bf8e241e6d 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart @@ -12,32 +12,27 @@ import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; import 'electrumx_interface.dart'; - -typedef TxSize = ({int real, int virtual}); +import 'replacement_fee.dart'; mixin RbfInterface on ElectrumXInterface { bool get flagOptInRBF => info.otherData[WalletInfoKeys.enableOptInRbf] as bool? ?? false; - Future getVSize(String txid) async { - final tx = await electrumXCachedClient.getTransaction( - txHash: txid, - cryptoCurrency: cryptoCurrency, + /// [transactionV2] stored with its size taken from raw bytes the current + /// server proves this wallet signed, or null if it cannot be authenticated. + Future updateVSize(TransactionV2 transactionV2) async { + final size = await verifyPendingSpend( + txid: transactionV2.txid, + inputs: transactionV2.inputs, + outputs: transactionV2.outputs, ); - - try { - return (real: tx["size"] as int, virtual: tx["vsize"] as int); - } catch (_) { + if (size == null) { return null; } - } - - Future updateVSize(TransactionV2 transactionV2) async { - final size = await getVSize(transactionV2.txid); final otherData = jsonDecode(transactionV2.otherData ?? "{}"); - otherData[TxV2OdKeys.vSize] = size!.virtual; - otherData[TxV2OdKeys.size] = size.real; + otherData[TxV2OdKeys.vSize] = size.vSize; + otherData[TxV2OdKeys.size] = size.size; final updatedTx = transactionV2.copyWith(otherData: jsonEncode(otherData)); await mainDB.updateOrPutTransactionV2s([updatedTx]); return updatedTx; @@ -47,13 +42,23 @@ mixin RbfInterface required TransactionV2 oldTransaction, required int newRate, }) async { - final note = - await mainDB.isar.transactionNotes - .where() - .walletIdEqualTo(walletId) - .filter() - .txidEqualTo(oldTransaction.txid) - .findFirst(); + // Price the bump off a size taken from bytes the server proves we signed, + // never off anything it merely claimed. + final oldSize = await verifyPendingSpend( + txid: oldTransaction.txid, + inputs: oldTransaction.inputs, + outputs: oldTransaction.outputs, + ); + if (oldSize == null) { + throw Exception("Could not verify this transaction's signatures"); + } + + final note = await mainDB.isar.transactionNotes + .where() + .walletIdEqualTo(walletId) + .filter() + .txidEqualTo(oldTransaction.txid) + .findFirst(); final Set utxos = {}; for (final input in oldTransaction.inputs) { @@ -100,32 +105,29 @@ mixin RbfInterface ); } - final oldFee = - oldTransaction - .getFee(fractionDigits: cryptoCurrency.fractionDigits) - .raw; + final oldFee = oldTransaction + .getFee(fractionDigits: cryptoCurrency.fractionDigits) + .raw; final inSum = utxos.map((e) => e.value).fold(BigInt.zero, (p, e) => p + e); final noChange = recipients.map((e) => e.isChange).fold(false, (p, e) => p || e) == false; - final otherAvailableUtxos = - await mainDB - .getUTXOs(walletId) - .filter() - .isBlockedEqualTo(false) - .and() - .group((q) => q.usedIsNull().or().usedEqualTo(false)) - .findAll(); + final otherAvailableUtxos = await mainDB + .getUTXOs(walletId) + .filter() + .isBlockedEqualTo(false) + .and() + .group((q) => q.usedIsNull().or().usedEqualTo(false)) + .findAll(); final height = await chainHeight; otherAvailableUtxos.removeWhere( - (e) => - !e.isConfirmed( - height, - cryptoCurrency.minConfirms, - cryptoCurrency.minCoinbaseConfirms, - ), + (e) => !e.isConfirmed( + height, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + ), ); TxData txData = TxData( @@ -153,121 +155,48 @@ mixin RbfInterface ], ); Logging.instance.d("RBF on assumed send all"); - return await prepareSend(txData: txData); + final result = await prepareSend(txData: txData); + final vSize = result.vSize; + if (vSize != null && result.fee != null) { + requireReplacementFee( + result.fee!.raw, + vSize: vSize, + oldFee: oldFee, + newRate: newRate, + ); + } + return result; } else if (txData.recipients!.where((e) => e.isChange).length == 1) { - final newFee = BigInt.from(oldTransaction.vSize! * newRate); - final feeDifferenceRequired = newFee - oldFee; - if (feeDifferenceRequired < BigInt.zero) { - throw Exception("Negative new fee in RBF found"); - } else if (feeDifferenceRequired == BigInt.zero) { - throw Exception("New fee in RBF has not changed at all"); + if (BigInt.from(oldSize.vSize * newRate) <= oldFee) { + throw Exception("New fee rate must be greater than the current rate"); } final indexOfChangeOutput = txData.recipients!.indexWhere( (e) => e.isChange, ); + final change = txData.recipients![indexOfChangeOutput]; + final otherRecipients = [...txData.recipients!] + ..removeAt(indexOfChangeOutput); - final removed = txData.recipients!.removeAt(indexOfChangeOutput); - - BigInt newChangeAmount = removed.amount.raw - feeDifferenceRequired; - - if (newChangeAmount >= BigInt.zero) { - if (newChangeAmount >= cryptoCurrency.dustLimit.raw) { - // yay we have enough - // update recipients - txData.recipients!.insert( - indexOfChangeOutput, - removed.copyWith( - amount: Amount( - rawValue: newChangeAmount, - fractionDigits: cryptoCurrency.fractionDigits, - ), - ), - ); - Logging.instance.d( - "RBF with same utxo set with increased fee and reduced change", - ); - } else { - // new change amount is less than dust limit. - // TODO: check if worth adding another utxo? - // depending on several factors, it may be cheaper to just add] - // the dust to the fee... - // we'll do that for now... aka remove the change output entirely - // which now that I think about it, will reduce the size of the tx... - // oh well... - - // do nothing here as we already removed the change output above - Logging.instance.d( - "RBF with same utxo set with increased fee and no change", + // Size the built replacement and raise the fee until it pays [newRate] + // on that size and clears the old fee by the incremental relay fee. + return settleReplacementFee( + oldVSize: oldSize.vSize, + oldFee: oldFee, + newRate: newRate, + build: (newFee) async { + final built = await _buildReplacement( + txData: txData, + otherRecipients: otherRecipients, + change: change, + indexOfChangeOutput: indexOfChangeOutput, + oldFee: oldFee, + newFee: newFee, + otherAvailableUtxos: otherAvailableUtxos, ); - } - return await buildTransaction( - txData: txData.copyWith( - usedUTXOs: txData.utxos!.toList(), - fee: Amount( - rawValue: newFee, - fractionDigits: cryptoCurrency.fractionDigits, - ), - ), - inputsWithKeys: await addSigningKeys(txData.utxos!.toList()), - ); - - // if change amount is negative - } else { - // we need more utxos - if (otherAvailableUtxos.isEmpty) { - throw Exception("Insufficient funds to pay for increased fee"); - } - - final List extraUtxos = []; - for (int i = 0; i < otherAvailableUtxos.length; i++) { - final utxoToAdd = otherAvailableUtxos[i]; - newChangeAmount += BigInt.from(utxoToAdd.value); - extraUtxos.add(utxoToAdd); - - if (newChangeAmount >= cryptoCurrency.dustLimit.raw) { - break; - } - } - - if (newChangeAmount < cryptoCurrency.dustLimit.raw) { - throw Exception("Insufficient funds to pay for increased fee"); - } - txData.recipients!.insert( - indexOfChangeOutput, - removed.copyWith( - amount: Amount( - rawValue: newChangeAmount, - fractionDigits: cryptoCurrency.fractionDigits, - ), - ), - ); - - final newUtxoSet = { - ...txData.utxos!.whereType(), - ...extraUtxos.map((e) => StandardInput(e)), - }; - - // TODO: remove assert - assert(newUtxoSet.length == txData.utxos!.length + extraUtxos.length); - - Logging.instance.d( - "RBF with ${extraUtxos.length} extra utxo(s)" - " added to pay for the new fee", - ); - - return await buildTransaction( - txData: txData.copyWith( - utxos: newUtxoSet, - usedUTXOs: newUtxoSet.toList(), - fee: Amount( - rawValue: newFee, - fractionDigits: cryptoCurrency.fractionDigits, - ), - ), - inputsWithKeys: await addSigningKeys(newUtxoSet.toList()), - ); - } + return (fee: built.fee!.raw, vSize: built.vSize!, built: built); + }, + ); } else { // TODO handle building a tx here in this case throw Exception( @@ -276,4 +205,76 @@ mixin RbfInterface ); } } + + /// Builds the replacement paying [newFee] out of the change output, dropping + /// it below dust or adding coins as needed. The result's fee is as paid. + Future _buildReplacement({ + required TxData txData, + required List otherRecipients, + required TxRecipient change, + required int indexOfChangeOutput, + required BigInt oldFee, + required BigInt newFee, + required List otherAvailableUtxos, + }) async { + final recipients = [...otherRecipients]; + var utxoSet = {...txData.utxos!.whereType()}; + var newChangeAmount = change.amount.raw - (newFee - oldFee); + + if (newChangeAmount < BigInt.zero) { + // we need more utxos + final List extraUtxos = []; + for (final utxo in otherAvailableUtxos) { + newChangeAmount += BigInt.from(utxo.value); + extraUtxos.add(utxo); + if (newChangeAmount >= cryptoCurrency.dustLimit.raw) { + break; + } + } + if (newChangeAmount < cryptoCurrency.dustLimit.raw) { + throw Exception("Insufficient funds to pay for increased fee"); + } + utxoSet = {...utxoSet, ...extraUtxos.map((e) => StandardInput(e))}; + Logging.instance.d( + "RBF with ${extraUtxos.length} extra utxo(s)" + " added to pay for the new fee", + ); + } + + if (newChangeAmount >= cryptoCurrency.dustLimit.raw) { + recipients.insert( + indexOfChangeOutput, + change.copyWith( + amount: Amount( + rawValue: newChangeAmount, + fractionDigits: cryptoCurrency.fractionDigits, + ), + ), + ); + Logging.instance.d("RBF with increased fee and reduced change"); + } else { + // change below dust goes to the fee + Logging.instance.d("RBF with increased fee and no change"); + } + + final inSum = utxoSet + .map((e) => e.value) + .fold(BigInt.zero, (p, e) => p + e); + final outSum = recipients + .map((e) => e.amount.raw) + .fold(BigInt.zero, (p, e) => p + e); + final inputs = utxoSet.toList(); + return await buildTransaction( + txData: txData.copyWith( + recipients: recipients, + utxos: utxoSet, + usedUTXOs: inputs, + fee: Amount( + rawValue: inSum - outSum, + fractionDigits: cryptoCurrency.fractionDigits, + ), + ), + inputsWithKeys: await addSigningKeys(inputs), + ); + } } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/replacement_fee.dart b/lib/wallets/wallet/wallet_mixin_interfaces/replacement_fee.dart new file mode 100644 index 0000000000..16788b92ff --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/replacement_fee.dart @@ -0,0 +1,66 @@ +/// BIP 125 rule 4: a replacement pays at least the original fee plus the +/// incremental relay fee for its own size, in sats per vbyte. +const incrementalRelayFeeRate = 1; + +/// The fee a replacement of [vSize] must pay: [newRate] on that size, but at +/// least [oldFee] plus the relay increment. +BigInt requiredReplacementFee({ + required int vSize, + required BigInt oldFee, + required int newRate, +}) { + final size = BigInt.from(vSize); + final atNewRate = size * BigInt.from(newRate); + final relayFloor = oldFee + size * BigInt.from(incrementalRelayFeeRate); + return atNewRate > relayFloor ? atNewRate : relayFloor; +} + +/// Throws unless [fee] reaches [requiredReplacementFee]. +void requireReplacementFee( + BigInt fee, { + required int vSize, + required BigInt oldFee, + required int newRate, +}) { + final required = requiredReplacementFee( + vSize: vSize, + oldFee: oldFee, + newRate: newRate, + ); + if (fee < required) { + throw Exception( + "New fee of $fee does not reach the $required needed to replace", + ); + } +} + +/// A built replacement: the fee it pays, its size, and the build itself. +typedef BuiltReplacement = ({BigInt fee, int vSize, T built}); + +/// Builds a replacement with [build], starting from [newRate] on [oldVSize], +/// and raises the fee until a build pays [requiredReplacementFee] for its own +/// size. Gives up after [attempts] builds. +Future settleReplacementFee({ + required int oldVSize, + required BigInt oldFee, + required int newRate, + required Future> Function(BigInt newFee) build, + int attempts = 3, +}) async { + var newFee = BigInt.from(oldVSize * newRate); + for (var attempt = 1; ; attempt++) { + final result = await build(newFee); + final requiredFee = requiredReplacementFee( + vSize: result.vSize, + oldFee: oldFee, + newRate: newRate, + ); + if (result.fee >= requiredFee) { + return result.built; + } + if (attempt == attempts) { + throw Exception("Could not settle on a fee for the replacement"); + } + newFee = requiredFee; + } +} diff --git a/test/wallets/owned_input_signatures_test.dart b/test/wallets/owned_input_signatures_test.dart new file mode 100644 index 0000000000..8c481abce4 --- /dev/null +++ b/test/wallets/owned_input_signatures_test.dart @@ -0,0 +1,590 @@ +import 'dart:typed_data'; + +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/input_v2.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/output_v2.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:stackwallet/wallets/wallet/supporting/owned_input_signatures.dart'; + +final network = coinlib.Network.mainnet; + +InputV2 inputV2( + coinlib.Input input, + String? address, + int value, { + bool walletOwns = true, +}) => InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: OutpointV2.isarCantDoRequiredInDefaultConstructor( + txid: coinlib.bytesToHex( + Uint8List.fromList(input.prevOut.hash.reversed.toList()), + ), + vout: input.prevOut.n, + ), + addresses: [if (address != null) address], + valueStringSats: "$value", + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: walletOwns, +); + +OutputV2 outputV2(coinlib.Output output, [String? address]) => + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: coinlib.bytesToHex(output.scriptPubKey), + valueStringSats: "${output.value}", + addresses: [if (address != null) address], + walletOwns: false, + ); + +void main() { + setUpAll(coinlib.loadCoinlib); + + final throwsUnauthenticated = throwsA(isA()); + + // A transaction spending the wallet's p2pkh, p2wpkh and p2tr coins. + late final coinlib.Transaction Function({coinlib.SigHashType hashType}) sign; + late final coinlib.Transaction signed; + late final List inputs; + late final List outputs; + late final String attacker; + + setUpAll(() { + coinlib.ECPrivateKey key(int n) => + coinlib.ECPrivateKey(Uint8List(32)..[31] = n); + final taproot = coinlib.Taproot(internalKey: key(3).pubkey); + + final p2pkh = coinlib.P2PKHAddress.fromPublicKey( + key(1).pubkey, + version: network.p2pkhPrefix, + ); + final p2wpkh = coinlib.P2WPKHAddress.fromPublicKey( + key(2).pubkey, + hrp: network.bech32Hrp, + ); + final p2tr = coinlib.P2TRAddress.fromTaproot( + taproot, + hrp: network.bech32Hrp, + ); + final recipient = coinlib.P2WPKHAddress.fromPublicKey( + key(4).pubkey, + hrp: network.bech32Hrp, + ); + attacker = coinlib.P2WPKHAddress.fromPublicKey( + key(5).pubkey, + hrp: network.bech32Hrp, + ).toString(); + + final prevOuts = [ + coinlib.Output.fromAddress(BigInt.from(100000), p2pkh), + coinlib.Output.fromAddress(BigInt.from(200000), p2wpkh), + coinlib.Output.fromAddress(BigInt.from(300000), p2tr), + ]; + final txOutputs = [ + coinlib.Output.fromAddress(BigInt.from(500000), recipient), + coinlib.Output.fromAddress(BigInt.from(90000), p2wpkh), + ]; + + sign = ({hashType = const coinlib.SigHashType.all()}) => + coinlib.Transaction( + version: 2, + inputs: [ + coinlib.P2PKHInput( + prevOut: coinlib.OutPoint(Uint8List(32)..[0] = 1, 0), + publicKey: key(1).pubkey, + ), + coinlib.P2WPKHInput( + prevOut: coinlib.OutPoint(Uint8List(32)..[0] = 2, 1), + publicKey: key(2).pubkey, + ), + coinlib.TaprootKeyInput( + prevOut: coinlib.OutPoint(Uint8List(32)..[0] = 3, 2), + ), + ], + outputs: txOutputs, + ) + .signLegacy(inputN: 0, key: key(1)) + .signLegacyWitness( + inputN: 1, + key: key(2), + value: prevOuts[1].value, + hashType: hashType, + ) + .signTaproot( + inputN: 2, + key: taproot.tweakPrivateKey(key(3)), + prevOuts: prevOuts, + ); + + signed = sign(); + inputs = [ + for (final (i, address) in [p2pkh, p2wpkh, p2tr].indexed) + inputV2( + signed.inputs[i], + address.toString(), + prevOuts[i].value.toInt(), + ), + ]; + outputs = [ + outputV2(txOutputs[0], recipient.toString()), + outputV2(txOutputs[1], p2wpkh.toString()), + ]; + }); + + RawTransactionSize verify({ + String? txid, + String? rawTxHex, + List? withInputs, + List? withOutputs, + bool allowMweb = false, + }) => verifyOwnedInputSignatures( + txid: txid ?? signed.txid, + rawTxHex: rawTxHex ?? signed.toHex(), + inputs: withInputs ?? inputs, + outputs: withOutputs ?? outputs, + network: network, + allowMweb: allowMweb, + ); + + test("accepts inputs signed by the wallet", () { + expect(verify, returnsNormally); + // The wallet's own record of a sent transaction has no output scripts. + expect( + () => verify( + withOutputs: [ + for (final e in outputs) e.copyWith(scriptPubKeyHex: "000000"), + ], + ), + returnsNormally, + ); + }); + + test("rejects our signatures with different outputs", () { + final forged = coinlib.Transaction( + version: signed.version, + inputs: signed.inputs, + outputs: [ + coinlib.Output.fromAddress( + outputs[0].value, + coinlib.Address.fromString(attacker, network), + ), + signed.outputs[1], + ], + ); + expect( + () => verify( + txid: forged.txid, + rawTxHex: forged.toHex(), + withOutputs: [outputV2(forged.outputs[0], attacker), outputs[1]], + ), + throwsUnauthenticated, + ); + }); + + test("rejects details that do not match the raw transaction", () { + final cases = { + "txid": () => verify(txid: inputs[0].outpoint!.txid), + "output address": () => verify( + withOutputs: [ + outputs[0].copyWith(addresses: [attacker]), + outputs[1], + ], + ), + "output value": () => verify( + withOutputs: [ + outputs[0].copyWith(valueStringSats: "1"), + outputs[1], + ], + ), + "output count": () => verify(withOutputs: [outputs[0]]), + "outpoint": () => verify(withInputs: [inputs[1], inputs[0], inputs[2]]), + "segwit input value": () => verify( + withInputs: [ + inputs[0], + inputs[1].copyWith(valueStringSats: "1"), + inputs[2], + ], + ), + "spent address": () => verify( + withInputs: [ + inputs[0], + inputs[1].copyWith(addresses: inputs[0].addresses), + inputs[2], + ], + ), + }; + for (final MapEntry(:key, :value) in cases.entries) { + expect(value, throwsUnauthenticated, reason: key); + } + }); + + test("rejects signatures that do not commit to every output", () { + for (final hashType in [ + const coinlib.SigHashType.none(), + const coinlib.SigHashType.single(), + const coinlib.SigHashType.all( + inputs: coinlib.InputSigHashOption.anyOneCanPay, + ), + ]) { + final tx = sign(hashType: hashType); + expect( + () => verify(txid: tx.txid, rawTxHex: tx.toHex()), + throwsUnauthenticated, + reason: "$hashType", + ); + } + }); + + test("allows MWEB data only when asked", () { + final hex = signed.toHex(); + final withMweb = + "${hex.substring(0, 8)}0009${hex.substring(12, hex.length - 8)}" + "010203${hex.substring(hex.length - 8)}"; + final withMwebOutput = [ + ...outputs, + outputV2(signed.outputs[0]).copyWith(scriptPubKeyHex: "mweb"), + ]; + + expect( + () => verify( + rawTxHex: withMweb, + withOutputs: withMwebOutput, + allowMweb: true, + ), + returnsNormally, + ); + expect( + () => verify(rawTxHex: withMweb, withOutputs: withMwebOutput), + throwsUnauthenticated, + ); + expect( + () => verify(withOutputs: withMwebOutput, allowMweb: true), + throwsUnauthenticated, + ); + }); + + test("rejects malformed raw transactions", () { + final hex = signed.toHex(); + for (final bad in [ + "", + "zz", + hex.substring(0, hex.length - 2), + "${hex}00", + "02000000ffffffffffffffffff", + ]) { + expect(() => verify(rawTxHex: bad), throwsUnauthenticated, reason: bad); + } + }); + + test("rejects the BIP 143 p2wpkh example, whose first input is not ours", () { + const hex = + "01000000000102fff7f7881a8099afa6940d42d1e7f6362bec38171ea3edf433541d" + "b4e4ad969f00000000494830450221008b9d1dc26ba6a9cb62127b02742fa9d754cd" + "3bebf337f7a55d114c8e5cdd30be022040529b194ba3f9281a99f2b1c0a19c0489bc" + "22ede944ccf4ecbab4cc618ef3ed01eeffffffef51e1b804cc89d182d279655c3aa8" + "9e815b1b309fe287d9b2b55d57b90ec68a0100000000ffffffff02202cb206000000" + "001976a9148280b37df378db99f66f85c95a783a76ac7a6d5988ac9093510d000000" + "001976a9143bde42dbee7e4dbe6a21b2d50ce2f0167faa815988ac000247304402" + "203609e17b84f6a7d30c80bfa610b5b4542f32a8a0d5447a12fb1366d7f01cc44a02" + "20573a954c4518331561406f90300e8f3358f51928d43c212a8caed02de67eebee01" + "21025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357" + "11000000"; + final tx = coinlib.Transaction.fromHex(hex); + final owner = coinlib.P2WPKHAddress.fromPublicKey( + coinlib.ECPublicKey.fromHex( + "025476c2e83188368da1ff3e292e7aca" + "fcdb3566bb0ad253f62fc70f07aeee6357", + ), + hrp: network.bech32Hrp, + ).toString(); + + // The first input is p2pk and not ours. A spend of this wallet's has no + // such input, so the transaction is rejected whatever the second input + // claims. + void check(int value) => verify( + txid: tx.txid, + rawTxHex: hex, + withInputs: [ + inputV2(tx.inputs[0], null, 625000000, walletOwns: false), + inputV2(tx.inputs[1], owner, value), + ], + withOutputs: [for (final e in tx.outputs) outputV2(e)], + ); + + expect(() => check(600000000), throwsUnauthenticated); + expect(() => check(600000001), throwsUnauthenticated); + }); + + test("accepts the BIP 143 p2sh-p2wpkh example", () { + const hex = + "01000000000101db6b1b20aa0fd7b23880be2ecbd4a98130974cf4748fb66092ac4d" + "3ceb1a5477010000001716001479091972186c449eb1ded22b78e40d009bdf0089fe" + "ffffff02b8b4eb0b000000001976a914a457b684d7f0d539a46a45bbc043f35b59d0" + "d96388ac0008af2f000000001976a914fd270b1ee6abcaea97fea7ad0402e8bd8ad6" + "d77c88ac02473044022047ac8e878352d3ebbde1c94ce3a10d057c24175747116f82" + "88e5d794d12d482f0220217f36a485cae903c713331d877c1f64677e3622ad401072" + "6870540656fe9dcb012103ad1d8e89212f0b92c74d23bb710c00662ad1470198ac48" + "c43f7d6f93a2a2687392040000"; + final tx = coinlib.Transaction.fromHex(hex); + final owner = coinlib.P2SHAddress.fromRedeemScript( + coinlib.P2WPKH + .fromPublicKey( + coinlib.ECPublicKey.fromHex( + "03ad1d8e89212f0b92c74d23bb710c00" + "662ad1470198ac48c43f7d6f93a2a26873", + ), + ) + .script, + version: network.p2shPrefix, + ).toString(); + + expect( + () => verify( + txid: tx.txid, + rawTxHex: hex, + withInputs: [inputV2(tx.inputs[0], owner, 1000000000)], + withOutputs: [for (final e in tx.outputs) outputV2(e)], + ), + returnsNormally, + ); + }); + test("returns the size and vsize of the raw transaction", () { + final hex = signed.toHex(); + expect(verify(), (size: hex.length ~/ 2, vSize: signed.vSize())); + expect(signed.vSize(), lessThan(hex.length ~/ 2), reason: "witness tx"); + expect(() => verify(txid: signed.txid.toUpperCase()), returnsNormally); + }); + + test("does not count MWEB data towards the size", () { + final hex = signed.toHex(); + final withMweb = + "${hex.substring(0, 8)}0009${hex.substring(12, hex.length - 8)}" + "010203${hex.substring(hex.length - 8)}"; + final withMwebOutput = [ + ...outputs, + outputV2(signed.outputs[0]).copyWith(scriptPubKeyHex: "mweb"), + ]; + expect( + verify(rawTxHex: withMweb, withOutputs: withMwebOutput, allowMweb: true), + (size: hex.length ~/ 2, vSize: signed.vSize()), + ); + }); + + group("parent binding", () { + // A wallet coin from a parent transaction, spent to a recipient. + ({ + coinlib.Transaction transaction, + coinlib.Transaction parent, + List inputs, + List outputs, + }) + fixture({required bool legacy}) { + final walletKey = coinlib.ECPrivateKey(Uint8List(32)..[31] = 11); + final recipient = coinlib.P2WPKHAddress.fromPublicKey( + coinlib.ECPrivateKey(Uint8List(32)..[31] = 12).pubkey, + hrp: network.bech32Hrp, + ); + final coinlib.Address walletAddress = legacy + ? coinlib.P2PKHAddress.fromPublicKey( + walletKey.pubkey, + version: network.p2pkhPrefix, + ) + : coinlib.P2WPKHAddress.fromPublicKey( + walletKey.pubkey, + hrp: network.bech32Hrp, + ); + final parent = coinlib.Transaction( + inputs: [ + coinlib.RawInput( + prevOut: coinlib.OutPoint(Uint8List(32), 0xffffffff), + scriptSig: Uint8List.fromList([1]), + ), + ], + outputs: [ + coinlib.Output.fromAddress(BigInt.from(100000), walletAddress), + ], + ); + final prevOut = coinlib.OutPoint.fromHex(parent.txid, 0); + final unsigned = coinlib.Transaction( + version: 2, + inputs: [ + if (legacy) + coinlib.P2PKHInput( + prevOut: prevOut, + publicKey: walletKey.pubkey, + sequence: 0xfffffffd, + ) + else + coinlib.P2WPKHInput( + prevOut: prevOut, + publicKey: walletKey.pubkey, + sequence: 0xfffffffd, + ), + ], + outputs: [coinlib.Output.fromAddress(BigInt.from(99000), recipient)], + ); + final transaction = legacy + ? unsigned.signLegacy(inputN: 0, key: walletKey) + : unsigned.signLegacyWitness( + inputN: 0, + key: walletKey, + value: parent.outputs.single.value, + ); + return ( + transaction: transaction, + parent: parent, + inputs: [ + inputV2(transaction.inputs.single, walletAddress.toString(), 100000), + ], + outputs: [outputV2(transaction.outputs.single, recipient.toString())], + ); + } + + // Keys can only be created once coinlib is loaded. + late final ({ + coinlib.Transaction transaction, + coinlib.Transaction parent, + List inputs, + List outputs, + }) legacy, + segwit; + setUpAll(() { + legacy = fixture(legacy: true); + segwit = fixture(legacy: false); + }); + + Future run( + ({ + coinlib.Transaction transaction, + coinlib.Transaction parent, + List inputs, + List outputs, + }) + f, { + List? calls, + List? withInputs, + String? parentHex, + }) => fetchAndVerifyOwnedInputSignatures( + txid: f.transaction.txid, + rawTxHex: f.transaction.toHex(), + inputs: withInputs ?? f.inputs, + outputs: f.outputs, + network: network, + // Only parents are fetched; the spend is given, never fetched. + fetchRawTransaction: (txid) async { + calls?.add(txid); + if (txid == f.parent.txid) return parentHex ?? f.parent.toHex(); + throw StateError("Unexpected fetch of $txid"); + }, + ); + + test("fetches the parent of legacy inputs only", () async { + final legacyCalls = []; + expect(await run(legacy, calls: legacyCalls), ( + size: legacy.transaction.toHex().length ~/ 2, + vSize: legacy.transaction.vSize(), + )); + expect(legacyCalls, [legacy.parent.txid]); + + final segwitCalls = []; + await run(segwit, calls: segwitCalls); + expect(segwitCalls, isEmpty); + }); + + test("rejects a misreported legacy input value", () async { + final misreported = [ + inputV2( + legacy.transaction.inputs.single, + legacy.inputs.single.addresses.single, + 100001, + ), + ]; + + // The legacy signature does not commit to the amount, so only the + // parent check can catch this. + expect( + () => verifyOwnedInputSignatures( + txid: legacy.transaction.txid, + rawTxHex: legacy.transaction.toHex(), + inputs: misreported, + outputs: legacy.outputs, + network: network, + ), + returnsNormally, + ); + await expectLater( + run(legacy, withInputs: misreported), + throwsUnauthenticated, + ); + }); + + test("rejects a parent that does not hash to the outpoint", () async { + await expectLater( + run(legacy, parentHex: legacy.transaction.toHex()), + throwsUnauthenticated, + ); + }); + + test("rejects malformed parent data", () async { + for (final bad in ["", "zz", legacy.parent.toHex().substring(2)]) { + await expectLater( + run(legacy, parentHex: bad), + throwsUnauthenticated, + reason: bad, + ); + } + }); + + test("does not hide network errors as forgeries", () async { + await expectLater( + fetchAndVerifyOwnedInputSignatures( + txid: legacy.transaction.txid, + rawTxHex: legacy.transaction.toHex(), + inputs: legacy.inputs, + outputs: legacy.outputs, + network: network, + fetchRawTransaction: (txid) async => throw StateError("offline"), + ), + throwsStateError, + ); + }); + + test("parseAuthenticatedTransaction binds bytes to the txid", () { + final hex = legacy.parent.toHex(); + final parsed = parseAuthenticatedTransaction( + txid: legacy.parent.txid, + rawTxHex: hex, + ); + expect(parsed.outputs.single.value, BigInt.from(100000)); + expect( + () => parseAuthenticatedTransaction( + txid: legacy.transaction.txid, + rawTxHex: hex, + ), + throwsUnauthenticated, + ); + expect( + () => parseAuthenticatedTransaction( + txid: legacy.parent.txid, + rawTxHex: "${hex}00", + ), + throwsUnauthenticated, + ); + }); + + test("recognises p2pkh addresses", () { + expect( + isP2pkhAddress(legacy.inputs.single.addresses.single, network), + isTrue, + ); + expect( + isP2pkhAddress(segwit.inputs.single.addresses.single, network), + isFalse, + ); + expect(isP2pkhAddress("not an address", network), isFalse); + }); + }); +} diff --git a/test/wallets/raw_transaction_layouts_test.dart b/test/wallets/raw_transaction_layouts_test.dart new file mode 100644 index 0000000000..ea9a8b3420 --- /dev/null +++ b/test/wallets/raw_transaction_layouts_test.dart @@ -0,0 +1,180 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/supporting/owned_input_signatures.dart'; + +// Real transactions as served by each coin's ElectrumX server. The parent +// binding reads parents on every ElectrumX coin, and a rejected parent fails +// the whole UTXO refresh, so each layout the parser meets is covered here. + +void main() { + test("Dash special transactions end with an extra payload", () { + final tx = parseAuthenticatedTransaction( + txid: _dashCoinbaseTxid, + rawTxHex: _dashCoinbaseHex, + ); + expect(tx.outputs.map((e) => e.value.toInt()), [51326352, 153979055]); + }); + + test("Firo Spark spends end with an extra payload", () { + final tx = parseAuthenticatedTransaction( + txid: _firoSparkSpendTxid, + rawTxHex: _firoSparkSpendHex, + ); + expect(tx.outputs.map((e) => e.value.toInt()), [36215612963, 0]); + }); + + test("Peercoin transactions before version 3 carry a timestamp", () { + expect( + () => parseAuthenticatedTransaction( + txid: _peercoinV1Txid, + rawTxHex: _peercoinV1Hex, + ), + throwsA(isA()), + ); + final tx = parseAuthenticatedTransaction( + txid: _peercoinV1Txid, + rawTxHex: _peercoinV1Hex, + legacyTimestamp: true, + ); + expect(tx.outputs.map((e) => e.value.toInt()), [31193356, 36000000]); + }); + + test("Peercoin version 3 witness transactions have no timestamp", () { + final tx = parseAuthenticatedTransaction( + txid: _peercoinV3Txid, + rawTxHex: _peercoinV3Hex, + legacyTimestamp: true, + ); + expect(tx.inputs.length, 1); + expect(tx.outputs.length, 2); + }); +} + +// Coinbase of Dash block 2100000: version 3, type 5. +const _dashCoinbaseTxid = + "2222cad2ccf1536b45869a8cf0143e24f5974b310f08ded0bf28ef81d6be2775"; +const _dashCoinbaseHex = + "0300050001000000000000000000000000000000000000000000000000000000000000" + "0000ffffffff5303200b200462e7886608fabe6d6d3e86ce92b71416b5eb2ab17b555e" + "19c980450c130a277b5856417338cda759d5010000000000000003059494df2c0000ae" + "01122f4d696e696e672d44757463682f2d3130310000000002902d0f03000000001976" + "a914c762a134542453faef37eb2b9ef3dceeb462926688acaf882d09000000001976a9" + "141df7723118059da7deb5c86e9ae67f2a567a3a5488ac00000000af0300200b20005c" + "56432a67d2cdbe3ea3dfd071f1a7304d9c6553881ead634d19f13a38d9f3a4f6b5a306" + "3c289ff632f98ebf2281f8459ff3d1f76a5245dc1d2210c12255515a01a3473ac1fcce" + "3d516d2b6ddc5b44a8b4df92750f5e84c678549a2b516a84e51bd2128230640da1f259" + "d45e1c5ad6bce014342c67ba0bb19c9a8a1ce1f0fdeaa61e1b5f40c355d00aed0108b4" + "3fa62fa4fe0d11077e95b7bbc6d8ece3951ffe2b0000000000000000"; + +// Spark spend to a transparent address in Firo block 1401904: version 3, +// type 11. +const _firoSparkSpendTxid = + "aedacdb04acdf4eddb24b6254fdbf2f8849419f8e44d25475709631dbcd91cd6"; +const _firoSparkSpendHex = + "03000b0001000000000000000000000000000000000000000000000000000000000000" + "0000ffffffff01d3ffffffff0223669e6e080000001976a9149a212bd04526cadedb46" + "baa340e3aadc70a233dd88ac0000000000000000f5d20138e97f2f285bfd89dccd6062" + "53ed6e8c6d0fa2e25229bb1246f69c9bd7bf2b8d0000385ebaf31710878e6f04c39b97" + "6b51ab4e215841c2fad345307ee87c0a788dcd0000943e6b565a631a0e6c7c4fe3d26a" + "8309c635e987be7f715ff474ee6c72abaebf01005ad589b528eb5f8ae721acb609c02b" + "c3328f8d2010e01696eeb619a6f831722d61cf380ace847b41b5a70b7e6df7d929eeec" + "d2fe47ce9a57d61aee2fdf67354589d3c2db4eda418020df164ea227c6bd1c80d9d6d1" + "6d8997299cf710d9bb39572457a81ccbd96611987b83f72081afb641b21127b90fb8e3" + "b3811689e9a6e9e0ae2a46d534f429d3a5f64706a52f641500fd5e0a0201010b000000" + "00000000010b000000000000007d1c77a39113159232066fe8cb67c4268703dd157bc6" + "1e62f60040eedf74497d400c0000000000000000000000000000000000000000000000" + "000000000000000000000000000000bbf19355692a5a3a6dcbeb01b0597eeb75e47eab" + "3dbb18a42f2e97fc564cb37f0000cfb2bd72449ae28fec380a9547a07d2efa780bc49c" + "5bfc7eb7e2562a80ce51180000f84cde1d72a0df34e3fa83643edb7e3bce45feb2201e" + "346668e94600ce1c0e2d010055dcc7345343943d44937020f60da23db08611646b925f" + "c61913e7041d3f448a0000342c3227d20f90cf3b837fb6453a89cad7671838e07d24f9" + "ce3b950a93b5792a0100e03ee59c5b28d8d8c8bc5cc7a3d19c9b0f4c76b925bc142580" + "fe73f4aef1646800007070e1eb5915db710c40af51f65f374dcd9148fef78ca595616b" + "cb74b071fa0d0100da431b287ad628e29e818532ce2f980ea763b948717d4fb300fdfe" + "79bb54aa5e01001af3a85f7bf8ec98a7b219dfd40ff007641027748c0f27ac2bfdde4b" + "4668e0190100215716d9270dda4085e7da6ca93f738b9374e8e8845433a329401f15c7" + "5de84e01005e3f33568a6c1deb0df90815c0b357062c8faf510e18bce0389de8e0f4fc" + "a02f01001bc245de6d74127feba19d938d3c1446d6d2f84a684509c899a7b45ae689bd" + "11010060c76b9b021495af9eda54a9449108be93d35028c2ba1d11666b9adf836b83c2" + "01009e7c2e86f3a564a4f10de2952ebdcacd83c193e01e8e8f9e8f70e62c0dc8cbd701" + "00ca63a87db1e25e3988ced6b85bff9e490f563046c4a79d768d911f186e7693540100" + "a7532519df8f40215bbf1ad96b1c7c701b520cae848b6412698374b47947fa845421fc" + "7ee61fbc1988cfccaa18628a8841e8670cdac35f270e6e45e905ad8c888f65fd91cbd5" + "ae30a944bd52e622f99d61bb6ae2e4f45bbd4e1f4a4c30937369405659b702b242cd7a" + "7864f0aab58097a5ded7166060e5310f8c0389158b06f811ef3bac440713d414707718" + "2c5fe0890d29ec770cdb0f3f9c6ce5c88293f103a9b265a2c9d3b073adde79d3356476" + "9674bd81d197adf466c480a37d9c46457f937cdeec792af275473524eb7f7784e41dd1" + "2369cdf2071183f5e371e2430ee1943c81f8c7586d0cb7f76be4fe70bae39596d065dc" + "5c232ca49050e59b8d163572aab9c0f6b453f83e6fe71c0d67a027952e826d078e8668" + "c32952032c7d270d838399be8de1b56e16b78243b54206d581cc7ca1bf34096aef9470" + "5af7bc7e8fea8eb7353e12a7a4e533c76f78b548bfa5143a887e542f75f1f8edce0937" + "b9ca582afd3e40b8a9442460f17e167bd15f00ab661a01b2ad983e9bbf5a32a31d781e" + "e0a5451a5ad8861a7dac1dce6919939131d813be998c214f30d8561ba44c115ccf15b4" + "09c1cf5184b8d4e8360bd91097981fbf9b41790e11c19c93b8274d56259887c51939e8" + "ea256ddc94c03a727f370e3380370ed5a71a64b57eb8a3ef4f695b0b97ab7b3e91f8c9" + "80c7130d403bfc9e73e9d3f7ea0fb46b09bb7d09accabc09f06afed268d6dcc866f89a" + "0729969684611af9b14a71bbb0c7fd790605cd47e27a9045f55dc38817ec7541ca3893" + "1999aa0d425ec8d17f25e50413b87d6accfdb02b032c047b197e14f531ad5c2f703e16" + "87bfefca6c9396502da3c33816e05289208ed19df4e0f39bbea860f2e206f4c36493d7" + "9a4b9a26748b51ba14bfe9132e1669c0556caa98b6bef370de90d6ee0fc2a1097694ba" + "5b92a0e02846862dc4cc00b6e971808256998c0a7e1abe40495bc37f002b2c4219bdfd" + "eeb1fe44251252dbcc3408ad2136337b96f9976c6a4b190ad22286004347cc4bbf9a80" + "726e3aa2c892fa0d9459db2b0921fda606e1ffe5b2bd44d096af88ca972e3cd238b2fb" + "530275eed6e1571f4d576ec5f1a61104d0480e38cca6059ead5f6bd819ef231c1efdfe" + "42d69c5843c3b19a4c125493de88546c698f813afd4016e0f98c0733cbb5a7645b3aae" + "1718c4affa285452984419316c94c3e35c4cf32d812a12adf2e0409b50f5c14919c4ed" + "88c3a0364a872f1ec6e8e6e5c108278d6a4c12bf6cb8313d3fb8dcdb8d7edcc4e28ecd" + "cbaeff43f8d242215805d29bb9648d5d377e2309a9886ef1801894f5bc3138a86c0392" + "0f586e351edb2391aa05804dfe5603d74605741d7947108c1643e402c09d296e4a8fd2" + "c29c829488669b59ba22c9a2a77be7790d86f3cdad141d1e53bd115a0d4c51a2bb8226" + "9a8c25911f5cdd466bfd95e954b428ee83f73ee022c5307e68cfe251fbf7f36e015a20" + "627929bae508cd74938b9f6a9f1e70ec68238baccc2233b2e5c1c48d036f263dfdbd11" + "d920f190e691482f1b0bc388a14d7da4966df9cde453704d78fddf6d222d9ba35506b5" + "3ca3cb8a56c2c67ebf575ec0b8bb3b0f3c38d3cc9dbf3551e2b157ebf1bc6655a7ebc2" + "7a1ad37819791a2d9034a66d91886824b4c3dfabbfdcc72ed2707032abf6949d30662f" + "d8ce92e0143a6a5c8fa3b05038cc0430ab96c2df90704b12a1985e52cf36c260e06f71" + "20cc24266ef44de4020dec76382302e97dfe7ff01eb9aa0000202795c6388afd32a57c" + "5c0172f20bea98bd330fc72467a2f8a84799be2949f60100abcea1634adf3ef45393ed" + "56779a4949aac219d27cedfbfa20f1e00dd74a7930e3c9e5a5e4e156a54281d9e8e446" + "8477f239601a854aa8de6d9a4f83f1d311b58601907f190c7bf18ca5d4d275d8c5888a" + "b07d4f323a7a61f862952a77baa3a9a007ae659261ef8ff3d0f3d81fdfdaff4a8e14a9" + "7794a238133c53a577c55ad10100d722ec7139c6489f1403a756800187964f9fe8fac7" + "753a1fdc87a9f8c882275c99654def28a7e983c2ae4500e5a92200c4c9cb2d55dbca88" + "bf7c62b9458f27230100a2cff7f9569aeee3a0b046e299124fc058cb1238e68cf9be13" + "3cae62bc7b5bad0000c35bc46add0ca13b98205c2b1c48b444803c751ad00780040fd0" + "0ff0c211fbdd00007a107497e33702696d18480c34a6bc5088d12b64a5e46faac0be34" + "6c864311118a126d122b9875b662d5f3515529718d3a5ba1d81821ea651c92eca99190" + "bdcd3012c11df4aa01664bdce42c840e1f16c71a4061c9dd07b9ad427b80cc386c2444" + "170cf191259dcfbf7ef5d3969d613bb7cf5c7ac4ad5fb3185abf58a8a823450000dac7" + "c5fcfb5b2254ae30f7d749ea263dd6e284af7c0fb997b5cb4fc0224dbcd900000e348d" + "48c148012d78d0afa52b258d5243a770b6c1fde3802f968bd4b2aca2c8010029c2def7" + "3204113ca33143ebd3e6343f2a684ce941e0cb2ae6b786ac682692370000676eb47528" + "9e319b88716719e8043effb21f79fc930dc2558a6ab3d06b1ea83a0000073dd50b6b08" + "7f728fd9d9bf42c6a57170ad2a48335f9c89f5ec88be6fc5c9e40100ebfa7a7a237951" + "49b0fae25729c3847ad3a8e30558ef7a769d0d069bfdfb3e420100295060129e92af31" + "3862db7e0353d62f914075fff4c36aec48c0d48b607f296f0100cfe153f8a861baedf4" + "abf8321e1f9b6dc716d85371ac1e75447f18154afa7b3c0100d3e41d1bbaabb1c0d537" + "707b11b810833bc63b0504de524c4558359cd3f796660100459e83a35eff06e2719337" + "16f8c954811e832e2f68057a6b88f63872c15d91e700004e364f1e00cea4f7191741c4" + "ac351b5f5f3d840612e4d7666645d2c8a079ee520100"; + +// A p2pkh payment in Peercoin block 100000: version 1, with timestamp. +const _peercoinV1Txid = + "d1efe5036b49c37805ea74ea6ea33757edc79c318c047828bc3920c26e0895f4"; +const _peercoinV1Hex = + "01000000365b1853011efa5444974d8bf2d97bb90dfb1cbf1fea03364dac5c4ba17849" + "8321cd638609000000006b48304502201b9ab06ee0718b029d8ba319e9a8c3720b78e0" + "ad5174faee65baaa4f68319da5022100b119cfa40927c4a8e79046d9c251d1776ea580" + "d2f3d2fb4788128311b42a472a012102593578804a55171adb327ed411af97f4d4d1da" + "e2449579f8b9ebd17ae4bee542ffffffff020cf9db01000000001976a9145d3c63ca13" + "24c935ab78b822d7d4d3a3c26d89d288ac00512502000000001976a9142efc510c6bc2" + "55f0cedb64c8cf1b9b49a5d5eb9f88ac00000000"; + +// Coinbase of Peercoin block 898300: version 3, segwit, no timestamp. +const _peercoinV3Txid = + "97fc6392233a5c7c3e63b9a3ab9bb6cd4e14aa05841863a5e1da270dd6c4e1d0"; +const _peercoinV3Hex = + "0300000000010100000000000000000000000000000000000000000000000000000000" + "00000000ffffffff0603fcb40d0101ffffffff02000000000000000000000000000000" + "0000266a24aa21a9eda03cbad0dba8a0670b882cc61bdb958b7de91af6d24858b0d6fc" + "d518a41816e80120000000000000000000000000000000000000000000000000000000" + "000000000000000000"; diff --git a/test/wallets/replacement_fee_test.dart b/test/wallets/replacement_fee_test.dart new file mode 100644 index 0000000000..1cc44c0d52 --- /dev/null +++ b/test/wallets/replacement_fee_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/replacement_fee.dart'; + +void main() { + BigInt required(int vSize, int oldFee, int newRate) => requiredReplacementFee( + vSize: vSize, + oldFee: BigInt.from(oldFee), + newRate: newRate, + ); + + test("a replacement pays the new rate, or the old fee plus the relay " + "increment, whichever is more", () { + // Old fee 10 sat/vB: a bump to 10 sat/vB must still add 1 sat/vB. + expect(required(200, 2000, 10), BigInt.from(2200)); + expect(required(200, 2000, 15), BigInt.from(3000)); + expect(required(200, 1800, 10), BigInt.from(2000)); + }); + + test("a fee short of the requirement is refused", () { + void require(int fee) => requireReplacementFee( + BigInt.from(fee), + vSize: 200, + oldFee: BigInt.from(2000), + newRate: 10, + ); + expect(() => require(2199), throwsException); + expect(() => require(2200), returnsNormally); + }); + + // Builds pay the fee asked, less [shortBy], at the next size in [vSizes] + // (the last size repeats). Returns the accepted build and the fees asked. + Future<({String built, List fees})> settle({ + required int oldVSize, + required int oldFee, + required int newRate, + required List vSizes, + int shortBy = 0, + }) async { + final fees = []; + final built = await settleReplacementFee( + oldVSize: oldVSize, + oldFee: BigInt.from(oldFee), + newRate: newRate, + build: (newFee) async { + fees.add(newFee); + final i = fees.length - 1; + return ( + fee: newFee - BigInt.from(shortBy), + vSize: vSizes[i < vSizes.length ? i : vSizes.length - 1], + built: "build ${fees.length}", + ); + }, + ); + return (built: built, fees: fees); + } + + test("settles on the first build when it already pays enough", () async { + final r = await settle( + oldVSize: 200, + oldFee: 1000, + newRate: 10, + vSizes: [200], + ); + expect(r.fees, [BigInt.from(2000)]); + expect(r.built, "build 1"); + }); + + test( + "raises to the relay floor when the new rate alone falls short", + () async { + // Old rate 10 sat/vB bumped to 10: the first build pays the old fee, so + // the second adds the relay increment. + final r = await settle( + oldVSize: 200, + oldFee: 2000, + newRate: 10, + vSizes: [200], + ); + expect(r.fees, [BigInt.from(2000), BigInt.from(2200)]); + expect(r.built, "build 2"); + }, + ); + + test("rebuilds at the new size when the replacement grows", () async { + // An added input makes the replacement 300 vB; it must pay the rate on + // that size. + final r = await settle( + oldVSize: 200, + oldFee: 1000, + newRate: 10, + vSizes: [300], + ); + expect(r.fees, [BigInt.from(2000), BigInt.from(3000)]); + expect(r.built, "build 2"); + }); + + test("gives up after three builds that keep falling short", () async { + final fees = []; + await expectLater( + settleReplacementFee( + oldVSize: 200, + oldFee: BigInt.from(1000), + newRate: 10, + build: (newFee) async { + fees.add(newFee); + return (fee: newFee - BigInt.one, vSize: 200, built: fees.length); + }, + ), + throwsException, + ); + expect(fees.length, 3); + }); +}