diff --git a/qa/rpc-tests/llmq-is-spark.py b/qa/rpc-tests/llmq-is-spark.py index d400d03fc5..07804dd17c 100755 --- a/qa/rpc-tests/llmq-is-spark.py +++ b/qa/rpc-tests/llmq-is-spark.py @@ -31,8 +31,7 @@ def run_test(self): self.nodes[0].generate(501 - self.nodes[0].getblockcount()) sparkaddress = self.nodes[0].getnewsparkaddress()[0] - # Mint a few larger single coins so spendspark can cover amount+fee from - # one coin (multi-input Spark spends are disabled). + # Mint coins large enough that spendspark covers amount and fee. for i in range(0, 3): mintTxids = self.nodes[0].mintspark({sparkaddress: {"amount": 5, "memo":"Test memo"}}) diff --git a/qa/rpc-tests/spark_mintspend.py b/qa/rpc-tests/spark_mintspend.py index 654dfa3c7f..9fa49aa4de 100755 --- a/qa/rpc-tests/spark_mintspend.py +++ b/qa/rpc-tests/spark_mintspend.py @@ -22,9 +22,8 @@ def run_test(self): sparkAddress = self.nodes[0].getsparkdefaultaddress()[0] mint_trans = list() - # Mint one large coin that can alone cover later spend amount + fee - # (multi-input Spark spends are disabled), plus a second mint for the - # confirmation / balance accounting checks below. + # Mint one coin large enough to cover a later spend and its fee, + # plus a second mint for the confirmation and balance checks below. mint_trans.append(self.nodes[0].mintspark({sparkAddress: {"amount": 5, "memo": "Test memo"}})) mint_trans.append(self.nodes[0].mintspark({sparkAddress: {"amount": 2, "memo":"Test memo"}})) diff --git a/qa/rpc-tests/spark_spend_gettransaction.py b/qa/rpc-tests/spark_spend_gettransaction.py index ff15c4afbf..8df1c17959 100755 --- a/qa/rpc-tests/spark_spend_gettransaction.py +++ b/qa/rpc-tests/spark_spend_gettransaction.py @@ -28,8 +28,7 @@ def run_test(self): valid_address = self.nodes[0].getnewaddress() - # Mint a few large single coins so each spendspark can fund amount+fee - # from one coin (multi-input Spark spends are disabled). + # Mint coins large enough that each spendspark covers amount and fee. for _ in range(2): self.nodes[0].mintspark({sparkAddress: {"amount": 5, "memo": "Test memo"}}) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index ddfe8abc6c..77c276011f 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -1273,8 +1273,8 @@ class CRegTestParams : public CChainParams { consensus.nLelantusStartBlock = 1; consensus.nLelantusFixesStartBlock = 1; consensus.nSparkStartBlock = 100; - consensus.nSparkSingleInputStartBlock = 500; - consensus.nSparkChaumV2StartBlock = 700; + consensus.nSparkSingleInputStartBlock = 1; + consensus.nSparkChaumV2StartBlock = 1; consensus.nExchangeAddressStartBlock = 1000; consensus.nLelantusGracefulPeriod = 600; consensus.nSigmaEndBlock = 1; diff --git a/src/qt/bitcoinstrings.cpp b/src/qt/bitcoinstrings.cpp index c53757b9d1..b379393969 100644 --- a/src/qt/bitcoinstrings.cpp +++ b/src/qt/bitcoinstrings.cpp @@ -215,28 +215,13 @@ QT_TRANSLATE_NOOP("firo-core", "" "Sets the serialization of raw transaction or block hex returned in non-" "verbose mode, non-segwit(0) or segwit(1) (default: %d)"), QT_TRANSLATE_NOOP("firo-core", "" -"Spark Coin Control temporarily supports selecting at most one coin. Clear " -"the selection to let the wallet split the payment automatically."), -QT_TRANSLATE_NOOP("firo-core", "" "Spark batch verification failed. The invalid spend transactions are listed " "in debug.log. Restart the node: batching is disabled and a reindex is " "started automatically so chainstate is rebuilt and Spark proofs are checked " "block by block."), QT_TRANSLATE_NOOP("firo-core", "" -"Spark multi-input spends are temporarily disabled. No single available Spark " -"coin can fund this transaction."), -QT_TRANSLATE_NOOP("firo-core", "" -"Spark spend batch failed after committing %u of %u transactions: %s. Do not " -"retry the whole payment. Already sent: %s"), -QT_TRANSLATE_NOOP("firo-core", "" -"Subtracting the fee from the amount is temporarily unavailable when a Spark " -"spend must be split across multiple transactions."), -QT_TRANSLATE_NOOP("firo-core", "" "Support filtering of blocks and transaction with bloom filters (default: %u)"), QT_TRANSLATE_NOOP("firo-core", "" -"The available Spark coins cannot cover the amount and the required " -"transaction fees."), -QT_TRANSLATE_NOOP("firo-core", "" "The block database contains a block which appears to be from the future. " "This may be due to your computer's date and time being set incorrectly. Only " "rebuild the block database if you are sure that your computer's date and " @@ -347,7 +332,6 @@ QT_TRANSLATE_NOOP("firo-core", "(default: %u)"), QT_TRANSLATE_NOOP("firo-core", "-maxmempool must be at least %d MB"), QT_TRANSLATE_NOOP("firo-core", "-wallet parameter must only specify a filename (not a path)"), QT_TRANSLATE_NOOP("firo-core", " can be:"), -QT_TRANSLATE_NOOP("firo-core", "A Spark payment may use at most %u transactions."), QT_TRANSLATE_NOOP("firo-core", "Accept command line and JSON-RPC commands"), QT_TRANSLATE_NOOP("firo-core", "Accept public REST requests (default: %u)"), QT_TRANSLATE_NOOP("firo-core", "Add Pay To n-of-m Multi-sig output to TX. n = REQUIRED, m = PUBKEYS"), @@ -510,7 +494,6 @@ QT_TRANSLATE_NOOP("firo-core", "Signing transaction failed"), QT_TRANSLATE_NOOP("firo-core", "Spark V2 spends are limited to %1% inputs"), QT_TRANSLATE_NOOP("firo-core", "Spark address doesn't belong to the wallet"), QT_TRANSLATE_NOOP("firo-core", "Spark coin selection changed during transaction construction; retry"), -QT_TRANSLATE_NOOP("firo-core", "Spark fee estimate did not match the wallet (planned %s, wallet %s)."), QT_TRANSLATE_NOOP("firo-core", "Spark name transaction size is out of range"), QT_TRANSLATE_NOOP("firo-core", "Spark shielded output limit exceeded."), QT_TRANSLATE_NOOP("firo-core", "Spark spend amount is out of range"), @@ -555,7 +538,6 @@ QT_TRANSLATE_NOOP("firo-core", "Transaction not allowed in mempool"), QT_TRANSLATE_NOOP("firo-core", "Transaction too large for fee policy"), QT_TRANSLATE_NOOP("firo-core", "Unable to bind to %s on this computer (bind returned error %s)"), QT_TRANSLATE_NOOP("firo-core", "Unable to bind to %s on this computer. %s is probably already running."), -QT_TRANSLATE_NOOP("firo-core", "Unable to create a single-input Spark transaction."), QT_TRANSLATE_NOOP("firo-core", "Unable to create a valid Spark name transaction"), QT_TRANSLATE_NOOP("firo-core", "Unable to create spend transaction."), QT_TRANSLATE_NOOP("firo-core", "Unable to estimate the final Spark name transaction fee"), diff --git a/src/qt/coincontroldialog.cpp b/src/qt/coincontroldialog.cpp index 12175181dd..facea91d70 100644 --- a/src/qt/coincontroldialog.cpp +++ b/src/qt/coincontroldialog.cpp @@ -16,7 +16,6 @@ #include "walletmodel.h" #include "wallet/coincontrol.h" -#include "wallet/sparkbatchplanner.h" #include "init.h" #include "policy/policy.h" #include "validation.h" // For mempool @@ -64,25 +63,18 @@ unsigned int CoinControlDialog::estimateSparkTxBytes( : left * right; }; - uint64_t estimatedSize; - if (selectedInputs == 1) { - estimatedSize = spark::EstimateSingleInputSparkSize( - privateOutputs, - transparentOutputs); - } else { - estimatedSize = 924; - estimatedSize = saturatingAdd( - estimatedSize, - saturatingMultiply(1803, selectedInputs)); - estimatedSize = saturatingAdd( - estimatedSize, - saturatingMultiply( - 322, - saturatingAdd(privateOutputs, 1))); - estimatedSize = saturatingAdd( - estimatedSize, - saturatingMultiply(34, transparentOutputs)); - } + uint64_t estimatedSize = 924; + estimatedSize = saturatingAdd( + estimatedSize, + saturatingMultiply(1803, selectedInputs)); + estimatedSize = saturatingAdd( + estimatedSize, + saturatingMultiply( + 322, + saturatingAdd(privateOutputs, 1))); + estimatedSize = saturatingAdd( + estimatedSize, + saturatingMultiply(34, transparentOutputs)); if (versionedSpend) { estimatedSize = saturatingAdd(estimatedSize, 32); if (selectedInputs > 1) { diff --git a/src/qt/locale/bitcoin_en.ts b/src/qt/locale/bitcoin_en.ts index 05490bf249..6a126c878a 100644 --- a/src/qt/locale/bitcoin_en.ts +++ b/src/qt/locale/bitcoin_en.ts @@ -4607,12 +4607,7 @@ Also you can choose wallet birth date for more faster and optimised wallet scan. - - Private transaction staging produced an unexpected transaction count - - - - + Intermediate address was not found in the transaction @@ -4754,16 +4749,6 @@ Also you can choose wallet birth date for more faster and optimised wallet scan. The displayed information may be out of date. Your wallet automatically synchronizes with the Firo network after a connection is established, but this process has not completed yet. - - - Sending private funds to an exchange address is temporarily unavailable. Move the funds to a transparent address first, then send from there. - - - - - This payment does not fit in one Spark coin and will be sent as %1 separate transactions. Each pays its own fee, they can be linked to each other, and if one of them is rejected the recipients will have been paid only in part. - - SendCoinsEntry @@ -6272,41 +6257,11 @@ Raw data: %1 The entered address does not belong to this wallet. - - - Spark Coin Control temporarily supports selecting at most one coin. Clear the selection to let the wallet split the payment automatically. - - - - - Subtracting the fee from the amount is temporarily unavailable for Spark spends. - - Spend to transparent address limit exceeded. - - - A Spark payment may use at most %1 transactions. - - - - - The available Spark coins cannot cover the amount and the required transaction fees. - - - - - Unable to create a single-input Spark transaction. - - - - - Spark fee estimate did not match the wallet (planned %1, wallet %2). - - Unable to create a versioned Spark transaction. @@ -6317,21 +6272,6 @@ Raw data: %1 Chain height changed during Spark name construction; retry - - - Spark name registration temporarily uses a single Spark coin. Please select at most one. - - - - - Unable to select a Spark coin. - - - - - Spark name registration temporarily requires one Spark coin large enough to cover the registration and transaction fees. - - Unable to create the expected Spark transaction format. @@ -6344,16 +6284,6 @@ Raw data: %1 Refusing to commit an incompatible Spark transaction format. - - - Refusing to commit an incomplete Spark transaction. - - - - - This payment was split across %1 transactions and %2 of them were already sent, so the recipients have been paid only in part. Do not retry the whole payment. Already sent: %3 - - Loading address book... @@ -6650,36 +6580,11 @@ Raw data: %1 Set the number of script verification threads (%u to %d, 0 = auto, <0 = leave that many cores free, default: %d) - - - Spark Coin Control temporarily supports selecting at most one coin. Clear the selection to let the wallet split the payment automatically. - - Spark batch verification failed. The invalid spend transactions are listed in debug.log. Restart the node: batching is disabled and a reindex is started automatically so chainstate is rebuilt and Spark proofs are checked block by block. - - - Spark multi-input spends are temporarily disabled. No single available Spark coin can fund this transaction. - - - - - Spark spend batch failed after committing %u of %u transactions: %s. Do not retry the whole payment. Already sent: %s - - - - - Subtracting the fee from the amount is temporarily unavailable when a Spark spend must be split across multiple transactions. - - - - - The available Spark coins cannot cover the amount and the required transaction fees. - - The block database contains a block which appears to be from the future. This may be due to your computer's date and time being set incorrectly. Only rebuild the block database if you are sure that your computer's date and time are correct @@ -6815,11 +6720,6 @@ Raw data: %1 <category> can be: - - - A Spark payment may use at most %u transactions. - - Add Pay To n-of-m Multi-sig output to TX. n = REQUIRED, m = PUBKEYS @@ -7236,11 +7136,6 @@ Raw data: %1 Unable to bind to %s on this computer. %s is probably already running. - - - Unable to create a single-input Spark transaction. - - Unable to create a valid Spark name transaction @@ -7741,11 +7636,6 @@ Raw data: %1 Spark coin selection changed during transaction construction; retry - - - Spark fee estimate did not match the wallet (planned %s, wallet %s). - - Spark name transaction size is out of range diff --git a/src/qt/sendcoinsdialog.cpp b/src/qt/sendcoinsdialog.cpp index 3d62e6889a..cf4f375964 100644 --- a/src/qt/sendcoinsdialog.cpp +++ b/src/qt/sendcoinsdialog.cpp @@ -506,6 +506,20 @@ void SendCoinsDialog::on_sendButton_clicked() return; } + if (fAnonymousMode) { + for (const SendCoinsRecipient& recipient : recipients) { + if (model->validateExchangeAddress(recipient.address) && + recipient.fSubtractFeeFromAmount) { + QMessageBox::critical( + this, + tr("Error"), + tr("Fee subtraction from the amount is not supported when sending private " + "funds to an exchange address.")); + return; + } + } + } + fNewRecipientAllowed = false; if(!ctx) { @@ -853,18 +867,6 @@ void SendCoinsDialog::on_sendButton_clicked() } } - // Before versioned construction is active, a payment that needs several - // coins is sent as several transactions. Make the fee and partial-commit - // behavior explicit before confirmation. - if (isSparkSpend && sparkSpendTransactions.size() > 1) { - questionString.append("
"); - questionString.append(tr("This payment does not fit in one Spark coin and will be sent as " - "%1 separate transactions. Each pays its own fee, they can be " - "linked to each other, and if one of them is rejected the " - "recipients will have been paid only in part.") - .arg(sparkSpendTransactions.size())); - } - // add total amount in all subdivision units questionString.append("
"); if ((fAnonymousMode == false) && (recipients.size() == sparkAddressCount) && spark::IsSparkAllowed()) @@ -907,7 +909,7 @@ void SendCoinsDialog::on_sendButton_clicked() if (isSparkSpend) { sendStatus = runWalletOperation([&] { - return model->spendSparkCoins(sparkSpendTransactions); + return model->spendSparkCoins(sparkSpendTransactions.front()); }); } else if ((fAnonymousMode == false) && (sparkAddressCount == recipients.size()) && spark::IsSparkAllowed()) { sendStatus = runWalletOperation([&] { @@ -942,15 +944,6 @@ void SendCoinsDialog::on_sendButton_clicked() // Launch the second stage of the transaction if needed if (fGoThroughTransparentAddress) { - if (sparkSpendTransactions.size() != 1) { - sendStatus.status = WalletModel::TransactionCreationFailed; - sendStatus.reasonCommitFailed = - tr("Private transaction staging produced an unexpected transaction count"); - processSendCoinsReturn(sendStatus); - fNewRecipientAllowed = true; - return; - } - WalletModelTransaction& firstStage = sparkSpendTransactions.front(); // prepare the coin control so the transaction will use (by default) only the transparent address // created in the first stage @@ -1076,13 +1069,6 @@ void SendCoinsDialog::updateBlocks(int count, const QDateTime& blockDate, double sendFromPrivate->setEnabled(false); } - for (int i = 0; i < ui->entries->count(); ++i) { - SendCoinsEntry* sendEntry = qobject_cast( - ui->entries->itemAt(i)->widget()); - if (sendEntry) { - sendEntry->setfAnonymousMode(fAnonymousMode); - } - } coinControlUpdateLabels(); } diff --git a/src/qt/sendcoinsentry.cpp b/src/qt/sendcoinsentry.cpp index a65ed3546c..b109c14cd1 100644 --- a/src/qt/sendcoinsentry.cpp +++ b/src/qt/sendcoinsentry.cpp @@ -294,6 +294,7 @@ void SendCoinsEntry::on_payTo_textChanged(const QString &address) ui->messageTextLabel->setVisible(isSparkAddress); updateSparkNameResolution(); + updateSubtractFeeFromAmountControls(); } void SendCoinsEntry::updateSparkNameResolution() @@ -346,23 +347,34 @@ void SendCoinsEntry::clearRosenBridgeData() Q_EMIT rosenBridgeChanged(); } +void SendCoinsEntry::updateSubtractFeeFromAmountControls() +{ + RosenBridge::Metadata metadata; + const bool rosenMetadataPresent = + !recipient.opReturnData.empty() && RosenBridge::Parse(recipient.opReturnData, &metadata); + bool subtractFeeAllowed = !rosenMetadataPresent; + if (subtractFeeAllowed && model && fAnonymousMode && + model->validateExchangeAddress(ui->payTo->text())) { + subtractFeeAllowed = false; + } + + ui->checkboxSubtractFeeFromAmount->setEnabled(subtractFeeAllowed); + if (!subtractFeeAllowed) { + ui->checkboxSubtractFeeFromAmount->setChecked(false); + } +} + void SendCoinsEntry::updateRosenBridgeDisplay() { RosenBridge::Metadata metadata; const bool valid = !recipient.opReturnData.empty() && RosenBridge::Parse(recipient.opReturnData, &metadata); - const bool subtractFeeAllowed = - (!fAnonymousMode || (model && model->versionedSparkSpendsAllowed())) && !valid; - ui->rosenBridgeLabel->setVisible(valid); ui->rosenBridgeDetails->setVisible(valid); ui->rosenBridgeRow->setVisible(valid); ui->payAmount->setReadOnly(valid); - ui->checkboxSubtractFeeFromAmount->setEnabled(subtractFeeAllowed); - if (!subtractFeeAllowed) { - ui->checkboxSubtractFeeFromAmount->setChecked(false); - } + updateSubtractFeeFromAmountControls(); if (!valid) { ui->rosenBridgeDetails->clear(); @@ -487,6 +499,11 @@ bool SendCoinsEntry::validate() } } + if (retval && fAnonymousMode && model->validateExchangeAddress(ui->payTo->text()) && + ui->checkboxSubtractFeeFromAmount->isChecked()) { + retval = false; + } + isPcodeEntry = bip47::CPaymentCode::validate(ui->payTo->text().toStdString()); bool isSparkAddress = model->validateSparkAddress(ui->payTo->text()); @@ -590,7 +607,9 @@ void SendCoinsEntry::setAddress(const QString &address) void SendCoinsEntry::setSubtractFeeFromAmount(bool enable) { - ui->checkboxSubtractFeeFromAmount->setCheckState(enable && recipient.opReturnData.empty() ? Qt::Checked : Qt::Unchecked); + const bool canSubtract = recipient.opReturnData.empty() && + !(model && fAnonymousMode && model->validateExchangeAddress(ui->payTo->text())); + ui->checkboxSubtractFeeFromAmount->setCheckState(enable && canSubtract ? Qt::Checked : Qt::Unchecked); } bool SendCoinsEntry::isClear() diff --git a/src/qt/sendcoinsentry.h b/src/qt/sendcoinsentry.h index 156e832c59..6867b4aa59 100644 --- a/src/qt/sendcoinsentry.h +++ b/src/qt/sendcoinsentry.h @@ -82,7 +82,8 @@ private Q_SLOTS: void clearRosenBridgeData(); void updateRosenBridgeDisplay(); void updateSparkNameResolution(); - + void updateSubtractFeeFromAmountControls(); + }; #endif // BITCOIN_QT_SENDCOINSENTRY_H diff --git a/src/qt/test/test_sendcoinsentry.cpp b/src/qt/test/test_sendcoinsentry.cpp index 0993ffb9ba..8533605eb5 100644 --- a/src/qt/test/test_sendcoinsentry.cpp +++ b/src/qt/test/test_sendcoinsentry.cpp @@ -6,11 +6,9 @@ #include "libspark/params.h" #include "optionsmodel.h" #include "platformstyle.h" -#include "sendcoinsdialog.h" #include "ui_interface.h" #include "wallet/wallet.h" -#include #include #include @@ -85,39 +83,6 @@ void TestSendCoinsEntry::testMemoByteLimit() } } -void TestSendCoinsEntry::testPrivateModeUpdatesExistingEntries() -{ - const std::unique_ptr platformStyle(PlatformStyle::instantiate("other")); - QVERIFY(platformStyle); - - SendCoinsDialog dialog(platformStyle.get()); - SendCoinsEntry* firstEntry = dialog.entry; - SendCoinsEntry* secondEntry = dialog.addEntry(); - QCheckBox* firstSubtractFee = - firstEntry->findChild("checkboxSubtractFeeFromAmount"); - QCheckBox* secondSubtractFee = - secondEntry->findChild("checkboxSubtractFeeFromAmount"); - QVERIFY(firstSubtractFee); - QVERIFY(secondSubtractFee); - - QVERIFY(QMetaObject::invokeMethod( - &dialog, "setAnonymizeMode", Qt::DirectConnection, Q_ARG(bool, false))); - firstSubtractFee->setChecked(true); - secondSubtractFee->setChecked(true); - - QVERIFY(QMetaObject::invokeMethod( - &dialog, "setAnonymizeMode", Qt::DirectConnection, Q_ARG(bool, true))); - QVERIFY(!firstSubtractFee->isChecked()); - QVERIFY(!secondSubtractFee->isChecked()); - QVERIFY(!firstSubtractFee->isEnabled()); - QVERIFY(!secondSubtractFee->isEnabled()); - - QVERIFY(QMetaObject::invokeMethod( - &dialog, "setAnonymizeMode", Qt::DirectConnection, Q_ARG(bool, false))); - QVERIFY(firstSubtractFee->isEnabled()); - QVERIFY(secondSubtractFee->isEnabled()); -} - void TestSendCoinsEntry::testSparkCoinControlSizeEstimate() { QCOMPARE(CoinControlDialog::estimateSparkTxBytes(1, 1, 0), 3'371U); diff --git a/src/qt/test/test_sendcoinsentry.h b/src/qt/test/test_sendcoinsentry.h index 2d38b6d02c..611d3988b0 100644 --- a/src/qt/test/test_sendcoinsentry.h +++ b/src/qt/test/test_sendcoinsentry.h @@ -13,7 +13,6 @@ private Q_SLOTS: void testGenerateWarningText(); void testTransactionCreationErrorDetails(); void testMemoByteLimit(); - void testPrivateModeUpdatesExistingEntries(); void testSparkCoinControlSizeEstimate(); void testBlockHeightCacheIgnoresHeaders(); }; diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index 47cfb8197e..8873f2c0e7 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -24,8 +24,6 @@ #include "net.h" // for g_connman #include "sync.h" #include "util.h" // for GetBoolArg -#include "wallet/sparkbatchplanner.h" -#include "wallet/sparkspendbatch.h" #include "wallet/wallet.h" #include "wallet/walletdb.h" // for BackupWallet #include "wallet/walletexcept.h" @@ -41,45 +39,10 @@ #include #include -#include #include #include -namespace { -constexpr size_t MAX_SINGLE_INPUT_SPARK_TRANSACTIONS = 50; - -bool CompareSparkCoins(const CSparkMintMeta& a, const CSparkMintMeta& b) -{ - if (a.v != b.v) return a.v > b.v; - if (a.nHeight != b.nHeight) return a.nHeight < b.nHeight; - if (a.txid != b.txid) return a.txid < b.txid; - return a.GetNonceHash() < b.GetNonceHash(); -} - -bool HasMultipleSelectedCoins(const CCoinControl* coinControl) -{ - if (!coinControl || !coinControl->HasSelected()) return false; - - std::vector selected; - coinControl->ListSelected(selected); - return selected.size() > 1; -} - -// With one input the transaction size does not depend on the fee, so the -// planner can use the same per-send fee policy as construction. -CAmount EstimateSingleInputSparkFee( - size_t privateOutputs, - size_t transparentOutputs, - const CCoinControl* coinControl) -{ - const unsigned int estimatedSize = - spark::EstimateSingleInputSparkSize(privateOutputs, transparentOutputs); - - return CWallet::GetMinimumFee(estimatedSize, coinControl, mempool); -} -} - WalletModel::WalletModel(const PlatformStyle *platformStyle, CWallet *_wallet, OptionsModel *_optionsModel, QObject *parent) : QObject(parent), wallet(_wallet), optionsModel(_optionsModel), _client_model(0), addressTableModel(0), pcodeAddressTableModel(0), sparkModel(0), @@ -1396,254 +1359,6 @@ WalletModel::SendCoinsReturn WalletModel::prepareMintSparkTransaction(std::vecto return SendCoinsReturn(OK); } -WalletModel::SendCoinsReturn WalletModel::prepareSpendSparkTransactionsSingleInput( - std::vector& transactions, - const QList& recipients, - const CCoinControl* coinControl) -{ - transactions.clear(); - if (recipients.empty()) { - return OK; - } - - // A selected set is a single coin-control instruction. Splitting it into - // separate transactions would silently change its "use all inputs" - // semantics, so this path accepts at most one selected Spark coin. - if (HasMultipleSelectedCoins(coinControl)) { - return SendCoinsReturn( - TransactionCreationFailed, - tr("Spark Coin Control temporarily supports selecting at most one coin. Clear the selection to let the wallet split the payment automatically.")); - } - - struct RecipientPlan { - SendCoinsRecipient recipient; - CScript scriptPubKey; - spark::OutputCoinData privateOutput; - bool isPrivate; - }; - - struct AvailableCoin { - CAmount value; - COutPoint outpoint; - }; - - CAmount total = 0; - QSet addresses; - std::vector recipientPlans; - std::vector plannerRecipients; - recipientPlans.reserve(recipients.size()); - plannerRecipients.reserve(recipients.size()); - const spark::Params* params = spark::Params::get_default(); - - for (const SendCoinsRecipient& recipient : recipients) { - if (recipient.amount <= 0 || !MoneyRange(recipient.amount)) { - return InvalidAmount; - } - if (recipient.fSubtractFeeFromAmount) { - return SendCoinsReturn( - TransactionCreationFailed, - tr("Subtracting the fee from the amount is temporarily unavailable for Spark spends.")); - } - if (addresses.contains(recipient.address)) { - return DuplicateAddress; - } - addresses.insert(recipient.address); - - if (total > MAX_MONEY - recipient.amount) { - return InvalidAmount; - } - total += recipient.amount; - - RecipientPlan plan; - plan.recipient = recipient; - plan.isPrivate = false; - - if (validateAddress(recipient.address)) { - plan.scriptPubKey = GetScriptForDestination(CBitcoinAddress(recipient.address.toStdString()).Get()); - } else if (validateSparkAddress(recipient.address)) { - try { - plan.privateOutput.address = spark::Address(params); - plan.privateOutput.address.decode(recipient.address.toStdString()); - } catch (const std::exception&) { - return InvalidAddress; - } - plan.privateOutput.memo = recipient.message.toStdString(); - plan.isPrivate = true; - } else { - return InvalidAddress; - } - - const CAmount minimumOutputAmount = plan.isPrivate - ? 1 - : sparkspendbatch::TransparentMinimumOutputAmount(plan.scriptPubKey); - plannerRecipients.push_back({recipient.amount, plan.isPrivate, minimumOutputAmount}); - recipientPlans.push_back(std::move(plan)); - } - - CAmount balance; - std::tie(balance, std::ignore) = getSparkBalance(); - if (total > balance) { - return AmountExceedsBalance; - } - - std::vector availableCoins; - spark::BatchPlanLimits limits; - int expectedNextBlockHeight; - { - // Snapshot the chain-dependent selection data. Proof generation below - // performs its own per-transaction locking and revalidates each selected - // outpoint, so block processing is not stalled for the entire batch. - LOCK2(cs_main, wallet->cs_wallet); - - std::list coinMetadata = wallet->GetAvailableSparkCoins(coinControl); - coinMetadata.sort(CompareSparkCoins); - availableCoins.reserve(coinMetadata.size()); - for (const CSparkMintMeta& coin : coinMetadata) { - if (coin.v > static_cast(MAX_MONEY)) { - continue; - } - - COutPoint outpoint; - if (spark::GetOutPoint(outpoint, coin.coin)) { - availableCoins.push_back({static_cast(coin.v), outpoint}); - } - } - - const auto& consensus = Params().GetConsensus(); - expectedNextBlockHeight = chainActive.Height() + 1; - limits.maxTransactions = MAX_SINGLE_INPUT_SPARK_TRANSACTIONS; - limits.maxPrivateOutputs = consensus.nMaxSparkOutLimitPerTx > 1 - ? consensus.nMaxSparkOutLimitPerTx - 2 - : 0; - limits.maxTransparentAmount = - consensus.GetMaxValueSparkSpendPerTransaction( - expectedNextBlockHeight); - limits.maxFee = maxTxFee; - limits.maxMoney = MAX_MONEY; - limits.maxWeight = MAX_NEW_TX_WEIGHT; - limits.weightScaleFactor = WITNESS_SCALE_FACTOR; - } - - std::vector coinValues; - coinValues.reserve(availableCoins.size()); - for (const AvailableCoin& coin : availableCoins) { - coinValues.push_back(coin.value); - } - - spark::BatchPlanResult plan; - - try { - plan = spark::PlanSingleInputSpend( - coinValues, - plannerRecipients, - limits, - [coinControl](size_t privateOutputs, size_t transparentOutputs) { - return EstimateSingleInputSparkFee( - privateOutputs, transparentOutputs, coinControl); - }, - spark::EstimateSingleInputSparkSize); - } catch (const std::exception& e) { - return SendCoinsReturn(TransactionCreationFailed, QString::fromStdString(e.what())); - } - - switch (plan.status) { - case spark::BatchPlanStatus::OK: - break; - case spark::BatchPlanStatus::INVALID_AMOUNT: - return InvalidAmount; - case spark::BatchPlanStatus::TRANSPARENT_LIMIT: - return SendCoinsReturn( - TransactionCreationFailed, - tr("Spend to transparent address limit exceeded.")); - case spark::BatchPlanStatus::FEE_TOO_HIGH: - return AbsurdFee; - case spark::BatchPlanStatus::TOO_MANY_TRANSACTIONS: - return SendCoinsReturn( - TransactionCreationFailed, - tr("A Spark payment may use at most %1 transactions.") - .arg(MAX_SINGLE_INPUT_SPARK_TRANSACTIONS)); - case spark::BatchPlanStatus::INSUFFICIENT_FUNDS: - return SendCoinsReturn( - TransactionCreationFailed, - tr("The available Spark coins cannot cover the amount and the required transaction fees.")); - } - - transactions.reserve(plan.batches.size()); - for (const spark::SingleInputBatch& batch : plan.batches) { - QList guiRecipients; - std::vector transparentRecipients; - std::vector> privateRecipients; - guiRecipients.reserve(batch.fragments.size()); - transparentRecipients.reserve(batch.transparentOutputs); - privateRecipients.reserve(batch.privateOutputs); - - for (const spark::BatchFragment& fragment : batch.fragments) { - const RecipientPlan& recipient = recipientPlans.at(fragment.recipientIndex); - SendCoinsRecipient guiRecipient = recipient.recipient; - guiRecipient.amount = fragment.amount; - guiRecipient.fSubtractFeeFromAmount = false; - guiRecipients.append(std::move(guiRecipient)); - - if (recipient.isPrivate) { - spark::OutputCoinData output = recipient.privateOutput; - output.v = fragment.amount; - privateRecipients.emplace_back(std::move(output), false); - } else { - transparentRecipients.push_back({recipient.scriptPubKey, fragment.amount, false}); - } - } - - CCoinControl singleCoinControl = coinControl ? *coinControl : CCoinControl(); - singleCoinControl.UnSelectAll(); - singleCoinControl.fAllowOtherInputs = false; - singleCoinControl.fRequireAllInputs = true; - singleCoinControl.Select(availableCoins.at(batch.coinIndex).outpoint); - - CAmount fee = 0; - CWalletTx walletTransaction; - try { - walletTransaction = wallet->CreateSparkSpendTransaction( - transparentRecipients, - privateRecipients, - fee, - &singleCoinControl, - expectedNextBlockHeight); - } catch (const std::exception& e) { - transactions.clear(); - return SendCoinsReturn(TransactionCreationFailed, QString::fromStdString(e.what())); - } - - if (!walletTransaction.tx || spark::GetSpendInputs(*walletTransaction.tx) != 1) { - transactions.clear(); - return SendCoinsReturn( - TransactionCreationFailed, - tr("Unable to create a single-input Spark transaction.")); - } - - // The plan was costed with EstimateSingleInputSparkFee; if the wallet - // disagrees, the two size models have drifted and the split is no longer - // guaranteed to be funded. Refuse rather than send something unplanned. - if (fee != batch.fee) { - transactions.clear(); - const int unit = optionsModel - ? optionsModel->getDisplayUnit() - : BitcoinUnits::BTC; - return SendCoinsReturn( - TransactionCreationFailed, - tr("Spark fee estimate did not match the wallet (planned %1, wallet %2).") - .arg(BitcoinUnits::formatWithUnit(unit, batch.fee)) - .arg(BitcoinUnits::formatWithUnit(unit, fee))); - } - - transactions.emplace_back(guiRecipients); - WalletModelTransaction& transaction = transactions.back(); - *transaction.getTransaction() = std::move(walletTransaction); - transaction.setTransactionFee(fee); - } - - return OK; -} - WalletModel::SendCoinsReturn WalletModel::prepareSpendSparkTransactions( std::vector& transactions, const QList& recipients, @@ -1655,12 +1370,6 @@ WalletModel::SendCoinsReturn WalletModel::prepareSpendSparkTransactions( expectedNextBlockHeight = chainActive.Height() + 1; } - if (expectedNextBlockHeight < - Params().GetConsensus().nSparkChaumV2StartBlock) { - return prepareSpendSparkTransactionsSingleInput( - transactions, recipients, coinControl); - } - transactions.clear(); if (recipients.empty()) { return OK; @@ -1788,12 +1497,6 @@ bool WalletModel::sparkNamesAllowed() const return chainHeight + 1 >= Params().GetConsensus().nSparkNamesStartBlock; } -bool WalletModel::versionedSparkSpendsAllowed() const -{ - return _client_model && _client_model->getNumBlocks() + 1 >= - Params().GetConsensus().nSparkChaumV2StartBlock; -} - bool WalletModel::GetSparkNameByAddress(const QString& sparkAddress, QString& name) { std::string name_ = name.toStdString(); @@ -1868,36 +1571,6 @@ WalletModel::SendCoinsReturn WalletModel::prepareSparkNameTransaction(WalletMode const bool versionedSpend = expectedNextBlockHeight >= Params().GetConsensus().nSparkChaumV2StartBlock; - CCoinControl singleCoinControl; - const CCoinControl* constructionControl = coinControl; - if (!versionedSpend) { - if (HasMultipleSelectedCoins(coinControl)) { - return SendCoinsReturn( - TransactionCreationFailed, - tr("Spark name registration temporarily uses a single Spark coin. Please select at most one.")); - } - - LOCK2(cs_main, wallet->cs_wallet); - - std::list availableCoins = wallet->GetAvailableSparkCoins(coinControl); - availableCoins.sort(CompareSparkCoins); - if (availableCoins.empty()) { - return AmountExceedsBalance; - } - - COutPoint outpoint; - if (!spark::GetOutPoint(outpoint, availableCoins.front().coin)) { - return SendCoinsReturn(TransactionCreationFailed, tr("Unable to select a Spark coin.")); - } - - singleCoinControl = coinControl ? *coinControl : CCoinControl(); - singleCoinControl.UnSelectAll(); - singleCoinControl.fAllowOtherInputs = false; - singleCoinControl.fRequireAllInputs = true; - singleCoinControl.Select(outpoint); - constructionControl = &singleCoinControl; - } - CAmount nFeeRequired = 0; CWalletTx *newTx = transaction.getTransaction(); try { @@ -1905,16 +1578,11 @@ WalletModel::SendCoinsReturn WalletModel::prepareSparkNameTransaction(WalletMode sparkNameData, sparkNameFee, nFeeRequired, - constructionControl, + coinControl, expectedNextBlockHeight); } catch (InsufficientFunds const&) { transaction.setTransactionFee(nFeeRequired); - if (!versionedSpend) { - return SendCoinsReturn( - TransactionCreationFailed, - tr("Spark name registration temporarily requires one Spark coin large enough to cover the registration and transaction fees.")); - } return AmountExceedsBalance; } catch (const std::exception& e) { @@ -1926,7 +1594,6 @@ WalletModel::SendCoinsReturn WalletModel::prepareSparkNameTransaction(WalletMode return AbsurdFee; } if (!newTx->tx || - (!versionedSpend && spark::GetSpendInputs(*newTx->tx) != 1) || (versionedSpend && !newTx->tx->IsSparkSpendV2())) { return SendCoinsReturn( TransactionCreationFailed, @@ -2091,134 +1758,3 @@ WalletModel::SendCoinsReturn WalletModel::spendSparkCoins(WalletModelTransaction return SendCoinsReturn(OK); } - -WalletModel::SendCoinsReturn WalletModel::spendSparkCoins(std::vector& transactions) -{ - for (WalletModelTransaction& transaction : transactions) { - const CWalletTx* walletTransaction = transaction.getTransaction(); - if (!walletTransaction->tx) { - return SendCoinsReturn( - TransactionCommitFailed, - tr("Refusing to commit an incomplete Spark transaction.")); - } - - for (const SendCoinsRecipient& recipient : transaction.getRecipients()) { - if (!validateAddress(recipient.address) && !validateSparkAddress(recipient.address)) { - return InvalidAddress; - } - } - } - - { - // Reject the whole batch before any commit so a later format mismatch - // cannot leave earlier transactions already sent. - LOCK2(cs_main, wallet->cs_wallet); - const int nextBlockHeight = chainActive.Height() + 1; - for (WalletModelTransaction& transaction : transactions) { - const CWalletTx* walletTransaction = transaction.getTransaction(); - if (!spark::IsSparkSpendFormatAllowed( - *walletTransaction->tx, nextBlockHeight)) { - return SendCoinsReturn( - TransactionCommitFailed, - tr("Refusing to commit an incompatible Spark transaction format.")); - } - } - } - - SendCoinsReturn result = OK; - bool committedAny = false; - // Every transaction that made it out, so a partial failure can tell the user - // exactly what was sent instead of leaving them to reconstruct it. - QStringList committed; - - for (WalletModelTransaction& transaction : transactions) { - QByteArray transactionArray; - QList notifiedRecipients; - - { - // Hold locks only for this transaction's commit and address-book - // update. CommitTransaction may accept to the mempool when checking, - // and coinsSent slots can take wallet/chain locks, so do not keep - // cs_main/cs_wallet across the whole batch or while emitting. - LOCK2(cs_main, wallet->cs_wallet); - - CWalletTx* walletTransaction = transaction.getTransaction(); - if (!spark::IsSparkSpendFormatAllowed( - *walletTransaction->tx, chainActive.Height() + 1)) { - result = SendCoinsReturn( - TransactionCommitFailed, - tr("Refusing to commit an incompatible Spark transaction format."), - committedAny); - break; - } - for (const SendCoinsRecipient& recipient : transaction.getRecipients()) { - if (!recipient.message.isEmpty()) { - walletTransaction->vOrderForm.emplace_back("Message", recipient.message.toStdString()); - } - } - - CValidationState state; - CReserveKey reserveKey(wallet); - // fCheckTransaction: mempool rejection fails this commit (and the - // batch). Relay still respects -walletbroadcast=0. - if (!wallet->CommitTransaction( - *walletTransaction, - reserveKey, - g_connman.get(), - state, - true)) { - QString reason = QString::fromStdString(state.GetRejectReason()); - if (committedAny) { - reason.append(tr(" This payment was split across %1 transactions and %2 of them " - "were already sent, so the recipients have been paid only in " - "part. Do not retry the whole payment. Already sent: %3") - .arg(transactions.size()) - .arg(committed.size()) - .arg(committed.join(", "))); - } - result = SendCoinsReturn(TransactionCommitFailed, reason, committedAny); - break; - } - committedAny = true; - committed.append(QString::fromStdString(walletTransaction->GetHash().ToString())); - - CDataStream stream(SER_NETWORK, PROTOCOL_VERSION); - stream << *walletTransaction->tx; - transactionArray.append(&stream[0], stream.size()); - - for (const SendCoinsRecipient& recipient : transaction.getRecipients()) { - const std::string address = recipient.address.toStdString(); - const std::string label = recipient.label.toStdString(); - - if (validateAddress(recipient.address)) { - const CTxDestination destination = CBitcoinAddress(address).Get(); - const auto item = wallet->mapAddressBook.find(destination); - if (item == wallet->mapAddressBook.end()) { - wallet->SetAddressBook(destination, label, "send"); - } else if (item->second.name != label) { - wallet->SetAddressBook(destination, label, ""); - } - } else { - const auto item = wallet->mapSparkAddressBook.find(address); - if (item == wallet->mapSparkAddressBook.end()) { - wallet->SetSparkAddressBook(address, label, "send"); - } else if (item->second.name != label) { - wallet->SetSparkAddressBook(address, label, ""); - } - } - } - - notifiedRecipients = transaction.getRecipients(); - } - - for (const SendCoinsRecipient& recipient : notifiedRecipients) { - Q_EMIT coinsSent(wallet, recipient, transactionArray); - } - } - - if (committedAny) { - QMetaObject::invokeMethod(this, "checkBalanceChanged", Qt::QueuedConnection); - } - - return result; -} diff --git a/src/qt/walletmodel.h b/src/qt/walletmodel.h index af1fd1a17d..2af0ee7acf 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -206,19 +206,12 @@ class WalletModel : public QObject std::list &reserveKeys, const CCoinControl *coinControl); - SendCoinsReturn prepareSpendSparkTransactionsSingleInput( - std::vector &transactions, - const QList &recipients, - const CCoinControl *coinControl); - /** - * Prepare Spark spend(s) for broadcast. Before Chaum V2 activation the - * spend may be split into multiple single-input transactions; afterward - * one V2 spend is built. - * @param[in,out] transactions Cleared, then filled with the prepared batch + * Prepare one Spark spend for broadcast. + * @param[in,out] transactions Cleared, then filled with the prepared spend * on success. On failure it is empty, except AmountWithFeeExceedsBalance * may hold a fee-only hint. - * @return Status of preparation; OK only if the batch is ready. + * @return Status of preparation; OK only if the spend is ready. */ SendCoinsReturn prepareSpendSparkTransactions( std::vector &transactions, @@ -228,18 +221,8 @@ class WalletModel : public QObject SendCoinsReturn spendSparkCoins( WalletModelTransaction &transaction); - SendCoinsReturn spendSparkCoins( - std::vector &transactions); - bool sparkNamesAllowed() const; - /** - * True when the next block (chainActive.Height()+1) is at or past - * Spark Chaum V2 activation. - * @return true if versioned Spark spends are allowed at the next block. - */ - bool versionedSparkSpendsAllowed() const; - bool GetSparkNameByAddress(const QString& sparkAddress, QString& name); bool validateSparkNameData(const QString &name, const QString &sparkAddress, const QString &additionalData, QString &strError); diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index aa9bc042f6..40fb84be8f 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -192,8 +192,6 @@ static const CRPCConvertParam vRPCConvertParams[] = { "mintspark", 2, {} }, { "spendspark", 0, {} }, { "spendspark", 1, {} }, - { "spendsparksplit", 0, {} }, - { "spendsparksplit", 1, {} }, // Spark names { "registersparkname", 2, {} }, diff --git a/src/spark/sparkwallet.cpp b/src/spark/sparkwallet.cpp index c27b2afec9..79e8d8b1d0 100644 --- a/src/spark/sparkwallet.cpp +++ b/src/spark/sparkwallet.cpp @@ -1790,13 +1790,6 @@ CWalletTx CSparkWallet::CreateSparkSpendTransaction( } selectedCoins = std::move(estimated.second); - // V1 construction retains the single-input selection rule. - if (!useChaumV2 && selectedCoins.size() != 1) { - throw InsufficientFunds(_( - "Spark multi-input spends are temporarily disabled. " - "No single available Spark coin can fund this transaction.")); - } - bool remainderSubtracted = false; fee = estimated.first; const CAmount feePerRecipient = recipientsToSubtractFee > 0 ? fee / recipientsToSubtractFee : 0; diff --git a/src/test/spark_state_test.cpp b/src/test/spark_state_test.cpp index 2ac40a1cb5..df83baae0d 100644 --- a/src/test/spark_state_test.cpp +++ b/src/test/spark_state_test.cpp @@ -820,28 +820,26 @@ BOOST_AUTO_TEST_CASE(add_remove_block) GenerateBlocks(501); auto index1 = GenerateBlock({}); - auto block1 = GetCBlock(index1); - PopulateSparkTxInfo(block1, {}, {}); - sparkState->AddBlock(index1); BOOST_CHECK_EQUAL(0, sparkState->GetMints().size()); BOOST_CHECK_EQUAL(0, sparkState->GetSpends().size()); - // some mints - std::vector txs; - auto mint1 = GenerateMints({1 * COIN}, txs)[0]; - auto mint2 = GenerateMints({2 * COIN}, txs)[0]; + // AddBlock replays mints stored on the block index; no wallet transaction is needed. + const spark::Coin mint1 = CreateCoin(spark::COIN_TYPE_MINT, 1 * COIN); + const spark::Coin mint2 = CreateCoin(spark::COIN_TYPE_MINT, 2 * COIN); auto index2 = GenerateBlock({}); - auto block2 = GetCBlock(index2); - PopulateSparkTxInfo(block2, {pwalletMain->sparkWallet->getCoinFromMeta(mint1), pwalletMain->sparkWallet->getCoinFromMeta(mint2)}, {}); - - sparkState->AddMintsToStateAndBlockIndex(index2, &block2); + index2->ensurePrivacyData().sparkMintedCoins[1] = {mint1, mint2}; sparkState->AddBlock(index2); BOOST_CHECK_EQUAL(2, sparkState->GetMints().size()); BOOST_CHECK_EQUAL(0, sparkState->GetSpends().size()); + spark::CSparkState::SparkCoinGroupInfo group; + BOOST_REQUIRE(sparkState->GetCoinGroupInfo(1, group)); + BOOST_CHECK_EQUAL(2, group.nCoins); + BOOST_CHECK(group.firstBlock == index2); + BOOST_CHECK(group.lastBlock == index2); // some serials GroupElement lTag1, lTag2; @@ -849,9 +847,8 @@ BOOST_AUTO_TEST_CASE(add_remove_block) lTag2.randomize(); auto index3 = GenerateBlock({}); - auto block3 = GetCBlock(index3); - PopulateSparkTxInfo(block3, {}, {{lTag1, 1}, {lTag2, 1}}); - index3->ensurePrivacyData().spentLTags = block3.sparkTxInfo->spentLTags; + index3->ensurePrivacyData().spentLTags.emplace(lTag1, 1); + index3->ensurePrivacyData().spentLTags.emplace(lTag2, 1); sparkState->AddBlock(index3); @@ -859,32 +856,37 @@ BOOST_AUTO_TEST_CASE(add_remove_block) BOOST_CHECK_EQUAL(2, sparkState->GetSpends().size()); // both mint and lTag - auto mint3 = GenerateMints({3 * COIN}, txs)[0]; + const spark::Coin mint3 = CreateCoin(spark::COIN_TYPE_MINT, 3 * COIN); GroupElement lTag3; lTag3.randomize(); auto index4 = GenerateBlock({}); - auto block4 = GetCBlock(index4); - PopulateSparkTxInfo(block4, {pwalletMain->sparkWallet->getCoinFromMeta(mint3)}, {{lTag3, 1}}); - sparkState->AddMintsToStateAndBlockIndex(index4, &block4); - index4->ensurePrivacyData().spentLTags = block4.sparkTxInfo->spentLTags; + auto& privacyData = index4->ensurePrivacyData(); + privacyData.sparkMintedCoins[1].push_back(mint3); + privacyData.spentLTags.emplace(lTag3, 1); sparkState->AddBlock(index4); BOOST_CHECK_EQUAL(3, sparkState->GetMints().size()); BOOST_CHECK_EQUAL(3, sparkState->GetSpends().size()); + BOOST_REQUIRE(sparkState->GetCoinGroupInfo(1, group)); + BOOST_CHECK_EQUAL(3, group.nCoins); + BOOST_CHECK(group.lastBlock == index4); // remove last block sparkState->RemoveBlock(index4); BOOST_CHECK_EQUAL(2, sparkState->GetMints().size()); BOOST_CHECK_EQUAL(2, sparkState->GetSpends().size()); + BOOST_REQUIRE(sparkState->GetCoinGroupInfo(1, group)); + BOOST_CHECK_EQUAL(2, group.nCoins); + BOOST_CHECK(group.lastBlock == index2); // verify mints and spends on blocks - BOOST_CHECK(sparkState->HasCoin(pwalletMain->sparkWallet->getCoinFromMeta(mint1))); - BOOST_CHECK(sparkState->HasCoin(pwalletMain->sparkWallet->getCoinFromMeta(mint2))); - BOOST_CHECK(!sparkState->HasCoin(pwalletMain->sparkWallet->getCoinFromMeta(mint3))); + BOOST_CHECK(sparkState->HasCoin(mint1)); + BOOST_CHECK(sparkState->HasCoin(mint2)); + BOOST_CHECK(!sparkState->HasCoin(mint3)); BOOST_CHECK(sparkState->IsUsedLTag(lTag1)); BOOST_CHECK(sparkState->IsUsedLTag(lTag2)); diff --git a/src/test/spark_tests.cpp b/src/test/spark_tests.cpp index e8191ae898..ae2380fec7 100644 --- a/src/test/spark_tests.cpp +++ b/src/test/spark_tests.cpp @@ -225,7 +225,7 @@ class SparkTests : public SparkTestingSetup // Restore Spark activation heights by assignment. Calling // UpdateRegtestSparkSingleInputHeight(INT_MAX) throws when Chaum V2 is still -// the regtest default (700), and a throw from a test destructor terminates. +// the regtest default (1), and a throw from a test destructor terminates. struct RestoreSparkActivationHeights { Consensus::Params& consensus; int singleInput; @@ -1331,207 +1331,438 @@ BOOST_AUTO_TEST_CASE(spark_duplicate_mint_policy_and_block_activation) sparkState->Reset(); } -BOOST_AUTO_TEST_CASE(spark_single_input_mempool_policy) +BOOST_AUTO_TEST_CASE(spark_multi_input_v1_and_aliased_group_ids_are_rejected) { - GenerateBlocks(200); + BatchProofContainer* batch = BatchProofContainer::get_instance(); + struct ResetBatch { + BatchProofContainer* batch; + ~ResetBatch() + { + batch->init(); + } + } resetBatch{batch}; + + GenerateBlocks(500); std::vector mintTransactions; const auto createdMints = - GenerateMints({5 * COIN, 5 * COIN, 10 * COIN}, mintTransactions); + GenerateMints({5 * COIN, 5 * COIN}, mintTransactions); mempool.clear(); GenerateBlock(mintTransactions); GenerateBlocks(10); std::vector selectedMints; for (const auto& mint : createdMints) { - if (mint.v == 5 * COIN) { - selectedMints.push_back( - pwalletMain->sparkWallet->getMintMeta(mint.k)); - } + selectedMints.push_back( + pwalletMain->sparkWallet->getMintMeta(mint.k)); } BOOST_REQUIRE_EQUAL(selectedMints.size(), 2U); + const CTransaction multiInputSpend( GenerateCustomSparkSpend(selectedMints, 9 * COIN)); - const SpendTransaction parsed = ParseSparkSpend(multiInputSpend); - BOOST_REQUIRE_EQUAL(parsed.getUsedLTags().size(), 2U); + BOOST_REQUIRE(multiInputSpend.IsSparkSpendV1()); + BOOST_REQUIRE_EQUAL( + ParseSparkSpend(multiInputSpend).getUsedLTags().size(), 2U); + + // Group 1 + 2^32 names group 1 under the historical 32-bit interpretation. + constexpr uint64_t groupIdAliasOffset = uint64_t{1} << 32; + const CTransaction aliasedSpend(GenerateCustomSparkSpend( + {selectedMints.front()}, 4 * COIN, groupIdAliasOffset)); + BOOST_REQUIRE( + ParseSparkSpend(aliasedSpend).getCoinGroupIds().front() > + static_cast(std::numeric_limits::max())); mempool.clear(); - // Until consensus activation, historical/block validation retains the - // deployed verifier so existing blocks remain reindexable. - CValidationState blockState; - CSparkTxInfo blockInfo; - BOOST_REQUIRE(CheckSparkTransaction( - multiInputSpend, - blockState, - multiInputSpend.GetHash(), - false, - chainActive.Height(), - false, - true, - &blockInfo)); + const int nextHeight = chainActive.Height() + 1; + BOOST_CHECK(!IsSparkSpendFormatAllowed(multiInputSpend, nextHeight)); - GenerateBlocks(185); + const std::pair cases[] = { + {&multiInputSpend, "CheckSparkSpendTransaction: multi-input Spark spends are disabled"}, + {&aliasedSpend, "CheckSparkSpendTransaction: invalid coin group id"}, + }; + for (const auto& [tx, reason] : cases) { + CValidationState mempoolState; + BOOST_CHECK(!CheckSparkTransaction( + *tx, + mempoolState, + tx->GetHash(), + false, + INT_MAX, + false, + true, + nullptr)); + int dos = -1; + BOOST_REQUIRE(mempoolState.IsInvalid(dos)); + BOOST_CHECK_EQUAL(dos, 0); + BOOST_CHECK_EQUAL(mempoolState.GetRejectReason(), reason); - // Upgraded nodes stop relaying multi-input spends immediately, - // without assigning peer misbehavior points. - CValidationState mempoolState; - BOOST_CHECK(!CheckSparkTransaction( - multiInputSpend, - mempoolState, - multiInputSpend.GetHash(), - false, - INT_MAX, - false, - true, - nullptr)); - int mempoolDoS = -1; - BOOST_REQUIRE(mempoolState.IsInvalid(mempoolDoS)); - BOOST_CHECK_EQUAL(mempoolDoS, 0); + for (const auto mode : {BatchProofContainer::Mode::Disabled, BatchProofContainer::Mode::Deferred}) { + batch->init(mode); + CValidationState blockState; + CSparkTxInfo blockInfo; + BOOST_CHECK(!CheckSparkTransaction( + *tx, + blockState, + tx->GetHash(), + false, + nextHeight, + false, + true, + &blockInfo)); + BOOST_REQUIRE(blockState.IsInvalid(dos)); + BOOST_CHECK_EQUAL(dos, 100); + BOOST_CHECK_EQUAL(blockState.GetRejectReason(), reason); + } + batch->init(); + + BOOST_CHECK(!GenerateBlock({CMutableTransaction(*tx)})); + } mempool.clear(); sparkState->Reset(); } -BOOST_AUTO_TEST_CASE(spark_single_input_consensus_activation) +BOOST_AUTO_TEST_CASE(spark_historical_v1_rules_accept_pre_activation_blocks) { - RestoreSparkActivationHeights resetActivationHeights; + // Regtest activates both heights at block 1. Hold them above this chain so + // a pre-activation block still has to accept the V1 forms mainnet buried: + // several inputs, a 32-bit group id wrap, and an unknown cover-set block. + RestoreSparkActivationHeights restoreHeights; + BatchProofContainer* batch = BatchProofContainer::get_instance(); + struct ResetBatch { + BatchProofContainer* batch; + ~ResetBatch() + { + batch->init(); + } + } resetBatch{batch}; - GenerateBlocks(500); + // Version 3 transactions are rejected before DIP3. Regtest crosses it at 500. + const int dip3Height = ::Params().GetConsensus().DIP0003Height; + if (chainActive.Height() < dip3Height) { + GenerateBlocks(dip3Height - chainActive.Height()); + } std::vector mintTransactions; - const auto createdMints = - GenerateMints({5 * COIN, 5 * COIN, 10 * COIN}, mintTransactions); + const auto createdMints = GenerateMints( + {5 * COIN, 5 * COIN, 5 * COIN}, mintTransactions); + BOOST_REQUIRE_EQUAL(mintTransactions.size(), 3U); mempool.clear(); - GenerateBlock(mintTransactions); + BOOST_REQUIRE(GenerateBlock(mintTransactions)); GenerateBlocks(10); - std::vector selectedMints; + std::vector groupOne; for (const auto& mint : createdMints) { - if (mint.v == 5 * COIN) { - selectedMints.push_back( - pwalletMain->sparkWallet->getMintMeta(mint.k)); - } + const CSparkMintMeta meta = + pwalletMain->sparkWallet->getMintMeta(mint.k); + BOOST_REQUIRE_EQUAL(meta.nId, 1); + groupOne.push_back(meta); } - BOOST_REQUIRE_EQUAL(selectedMints.size(), 2U); - const CTransaction multiInputSpend( - GenerateCustomSparkSpend(selectedMints, 9 * COIN)); - const SpendTransaction parsed = ParseSparkSpend(multiInputSpend); - BOOST_REQUIRE_EQUAL(parsed.getUsedLTags().size(), 2U); - mempool.clear(); + BOOST_REQUIRE_EQUAL(groupOne.size(), 3U); - const int activationHeight = chainActive.Height() + 1; - UpdateRegtestSparkSingleInputHeight(activationHeight); + // Build the proofs while H2 is still ahead, so they commit to the unbound + // cover-set hash that pre-H2 validation recomputes. + const int historicalHeight = chainActive.Height(); + const int deferredHeight = historicalHeight + 5; + UpdateRegtestSparkActivationHeights(&deferredHeight, &deferredHeight); - const CTransaction singleInputSpend( - GenerateSparkSpend({4 * COIN}, {}, nullptr)); - BOOST_REQUIRE_EQUAL( - ParseSparkSpend(singleInputSpend).getUsedLTags().size(), 1U); - mempool.clear(); + const CTransaction multiInput(GenerateCustomSparkSpend( + {groupOne[0], groupOne[1]}, 9 * COIN)); + BOOST_REQUIRE_EQUAL(ParseSparkSpend(multiInput).getUsedLTags().size(), 2U); - CValidationState historicalState; - CSparkTxInfo historicalInfo; - BOOST_REQUIRE(CheckSparkTransaction( - multiInputSpend, - historicalState, - multiInputSpend.GetHash(), - false, - activationHeight - 1, - false, - true, - &historicalInfo)); + constexpr uint64_t groupIdAliasOffset = uint64_t{1} << 32; + const CTransaction aliased(GenerateCustomSparkSpend( + {groupOne[2]}, 4 * COIN, groupIdAliasOffset)); + BOOST_REQUIRE( + ParseSparkSpend(aliased).getCoinGroupIds().front() > + static_cast(std::numeric_limits::max())); - CValidationState activeState; - CSparkTxInfo activeInfo; - BOOST_CHECK(!CheckSparkTransaction( - multiInputSpend, - activeState, - multiInputSpend.GetHash(), - false, - activationHeight, - false, - true, - &activeInfo)); - int activeDoS = 0; - BOOST_REQUIRE(activeState.IsInvalid(activeDoS)); - BOOST_CHECK_EQUAL(activeDoS, 100); + const CTransaction canonicalSingle(GenerateCustomSparkSpend( + {groupOne[2]}, 4 * COIN)); + SpendTransaction unknownParsed = ParseSparkSpend(canonicalSingle); + auto unknownReferences = unknownParsed.getBlockHashes(); + BOOST_REQUIRE_EQUAL(unknownReferences.size(), 1U); + unknownReferences.begin()->second = uint256S("01"); + unknownParsed.setBlockHashes(unknownReferences); + CDataStream unknownPayload(SER_NETWORK, PROTOCOL_VERSION); + unknownPayload << unknownParsed; + CMutableTransaction unknownMutable(canonicalSingle); + unknownMutable.vExtraPayload.assign( + unknownPayload.begin(), unknownPayload.end()); + const CTransaction unknownReference(unknownMutable); + + const std::pair historicalCases[] = { + {&multiInput, "CheckSparkSpendTransaction: multi-input Spark spends are disabled"}, + {&aliased, "CheckSparkSpendTransaction: invalid coin group id"}, + {&unknownReference, "CheckSparkSpendTransaction: unknown cover-set reference"}, + }; - // Consensus keeps the single-input rule effective if an in-memory test - // configuration bypasses the startup ordering check. - Consensus::Params& mutableConsensus = - const_cast(::Params().GetConsensus()); - const int originalV2Height = mutableConsensus.nSparkChaumV2StartBlock; - mutableConsensus.nSparkSingleInputStartBlock = activationHeight + 1; - mutableConsensus.nSparkChaumV2StartBlock = activationHeight; - CValidationState defensiveState; - CSparkTxInfo defensiveInfo; - BOOST_CHECK(!CheckSparkTransaction( - multiInputSpend, - defensiveState, - multiInputSpend.GetHash(), - false, - activationHeight, - false, - true, - &defensiveInfo)); - BOOST_REQUIRE(defensiveState.IsInvalid(activeDoS)); - BOOST_CHECK_EQUAL(activeDoS, 100); - mutableConsensus.nSparkSingleInputStartBlock = activationHeight; - mutableConsensus.nSparkChaumV2StartBlock = originalV2Height; + for (const auto& [tx, reason] : historicalCases) { + batch->init(); + CValidationState blockState; + CSparkTxInfo blockInfo; + const bool historicalOk = CheckSparkTransaction( + *tx, + blockState, + tx->GetHash(), + false, + historicalHeight, + false, + true, + &blockInfo); + BOOST_REQUIRE_MESSAGE(historicalOk, blockState.GetRejectReason()); + + CValidationState mempoolState; + BOOST_CHECK(!CheckSparkTransaction( + *tx, + mempoolState, + tx->GetHash(), + false, + INT_MAX, + false, + true, + nullptr)); + int dos = -1; + BOOST_REQUIRE(mempoolState.IsInvalid(dos)); + BOOST_CHECK_EQUAL(dos, 0); + BOOST_CHECK_EQUAL(mempoolState.GetRejectReason(), reason); + } - CValidationState historicalSingleState; - CSparkTxInfo historicalSingleInfo; - BOOST_REQUIRE(CheckSparkTransaction( - singleInputSpend, - historicalSingleState, - singleInputSpend.GetHash(), - false, - activationHeight - 1, - false, - true, - &historicalSingleInfo)); + for (const CTransaction* tx : {&multiInput, &aliased}) { + batch->init(BatchProofContainer::Mode::Deferred); + CValidationState batchState; + CSparkTxInfo batchInfo; + BOOST_REQUIRE(CheckSparkTransaction( + *tx, + batchState, + tx->GetHash(), + false, + historicalHeight, + false, + true, + &batchInfo)); + batch->finalize(); + BOOST_CHECK(batch->verify_pending()); + batch->remove(ParseSparkSpend(*tx)); + batch->init(); + } - CValidationState activeSingleState; - CSparkTxInfo activeSingleInfo; - BOOST_CHECK(CheckSparkTransaction( - singleInputSpend, - activeSingleState, - singleInputSpend.GetHash(), - false, - activationHeight, - false, - true, - &activeSingleInfo)); + mempool.clear(); + CBlockIndex* historicalIndex = + GenerateBlock({CMutableTransaction(multiInput)}); + BOOST_REQUIRE(historicalIndex); + BOOST_REQUIRE_LT(historicalIndex->nHeight, deferredHeight); + const CBlock historicalBlock = GetCBlock(historicalIndex); + const int connectedHeight = chainActive.Height(); + + BOOST_REQUIRE(DisconnectBlocks(1)); + BOOST_REQUIRE_EQUAL(chainActive.Height(), connectedHeight - 1); + { + CValidationState reconnectState; + BOOST_REQUIRE(ActivateBestChain( + reconnectState, + ::Params(), + std::make_shared(historicalBlock))); + } + BOOST_REQUIRE_EQUAL(chainActive.Height(), connectedHeight); + BOOST_REQUIRE(chainActive.Tip()->GetBlockHash() == historicalBlock.GetHash()); + + UpdateRegtestSparkActivationHeights(&connectedHeight, &connectedHeight); + for (const auto& [tx, reason] : historicalCases) { + batch->init(); + CValidationState activeState; + CSparkTxInfo activeInfo; + BOOST_CHECK(!CheckSparkTransaction( + *tx, + activeState, + tx->GetHash(), + false, + connectedHeight, + false, + true, + &activeInfo)); + int dos = 0; + BOOST_REQUIRE(activeState.IsInvalid(dos)); + BOOST_CHECK_EQUAL(dos, 100); + BOOST_CHECK_EQUAL(activeState.GetRejectReason(), reason); + } mempool.clear(); sparkState->Reset(); } -BOOST_AUTO_TEST_CASE(spark_single_input_wallet_requires_one_coin_immediately) +BOOST_AUTO_TEST_CASE(spark_single_input_interval_before_chaum_v2_block_validation) { - GenerateBlocks(500); + // Mainnet keeps H1 < H2 (1355970 through 1370999). Block validation in that + // window must enforce single-input V1 spends while still accepting the + // pre-H2 32-bit group-id interpretation; H2 canonical reference rules must + // not apply yet. + RestoreSparkActivationHeights restoreHeights; + BatchProofContainer* batch = BatchProofContainer::get_instance(); + struct ResetBatch { + BatchProofContainer* batch; + ~ResetBatch() + { + batch->init(); + } + } resetBatch{batch}; + + const int dip3Height = ::Params().GetConsensus().DIP0003Height; + if (chainActive.Height() < dip3Height) { + GenerateBlocks(dip3Height - chainActive.Height()); + } std::vector mintTransactions; - GenerateMints({5 * COIN, 5 * COIN}, mintTransactions); + const auto createdMints = GenerateMints( + {5 * COIN, 5 * COIN, 5 * COIN}, mintTransactions); + BOOST_REQUIRE_EQUAL(mintTransactions.size(), 3U); mempool.clear(); - GenerateBlock(mintTransactions); + BOOST_REQUIRE(GenerateBlock(mintTransactions)); GenerateBlocks(10); - const auto hasSingleCoinMessage = [](const InsufficientFunds& error) { - return std::string(error.what()).find( - "No single available Spark coin can fund this transaction") != - std::string::npos; + std::vector groupOne; + for (const auto& mint : createdMints) { + groupOne.push_back(pwalletMain->sparkWallet->getMintMeta(mint.k)); + } + BOOST_REQUIRE_EQUAL(groupOne.size(), 3U); + + const int baseHeight = chainActive.Height(); + const int h1Height = baseHeight + 2; + const int h2Height = baseHeight + 10; + const int intervalHeight = baseHeight + 5; + BOOST_REQUIRE_LT(h1Height, h2Height); + BOOST_REQUIRE_GE(intervalHeight, h1Height); + BOOST_REQUIRE_LT(intervalHeight, h2Height); + UpdateRegtestSparkActivationHeights(&h1Height, &h2Height); + + const CTransaction multiInput(GenerateCustomSparkSpend( + {groupOne[0], groupOne[1]}, 9 * COIN)); + BOOST_REQUIRE_EQUAL(ParseSparkSpend(multiInput).getUsedLTags().size(), 2U); + + constexpr uint64_t groupIdAliasOffset = uint64_t{1} << 32; + const CTransaction aliased(GenerateCustomSparkSpend( + {groupOne[2]}, 4 * COIN, groupIdAliasOffset)); + const CTransaction aliasedForPostH2Check(GenerateCustomSparkSpend( + {groupOne[1]}, 4 * COIN, groupIdAliasOffset)); + BOOST_REQUIRE( + ParseSparkSpend(aliased).getCoinGroupIds().front() > + static_cast(std::numeric_limits::max())); + + const CTransaction canonicalSingle(GenerateCustomSparkSpend( + {groupOne[0]}, 4 * COIN)); + SpendTransaction unknownParsed = ParseSparkSpend(canonicalSingle); + auto unknownReferences = unknownParsed.getBlockHashes(); + BOOST_REQUIRE_EQUAL(unknownReferences.size(), 1U); + unknownReferences.begin()->second = uint256S("01"); + unknownParsed.setBlockHashes(unknownReferences); + CDataStream unknownPayload(SER_NETWORK, PROTOCOL_VERSION); + unknownPayload << unknownParsed; + CMutableTransaction unknownMutable(canonicalSingle); + unknownMutable.vExtraPayload.assign( + unknownPayload.begin(), unknownPayload.end()); + const CTransaction unknownReference(unknownMutable); + + const int preH1Height = baseHeight + 1; + { + batch->init(); + CValidationState preH1State; + CSparkTxInfo preH1Info; + BOOST_REQUIRE(CheckSparkTransaction( + multiInput, + preH1State, + multiInput.GetHash(), + false, + preH1Height, + false, + true, + &preH1Info)); + } + + const std::pair rejectInInterval[] = { + {&multiInput, + "CheckSparkSpendTransaction: multi-input Spark spends are disabled"}, + {&unknownReference, + "CheckSparkSpendTransaction: unknown cover-set reference"}, }; - try { - GenerateSparkSpend({9 * COIN}, {}, nullptr); - BOOST_FAIL("Expected InsufficientFunds for multi-coin V1 spend"); - } catch (const InsufficientFunds& error) { - BOOST_CHECK(hasSingleCoinMessage(error)); + for (const auto& [tx, reason] : rejectInInterval) { + batch->init(); + CValidationState blockState; + CSparkTxInfo blockInfo; + BOOST_CHECK(!CheckSparkTransaction( + *tx, + blockState, + tx->GetHash(), + false, + intervalHeight, + false, + true, + &blockInfo)); + int dos = -1; + BOOST_REQUIRE(blockState.IsInvalid(dos)); + BOOST_CHECK_EQUAL(dos, 100); + BOOST_CHECK_EQUAL(blockState.GetRejectReason(), reason); } - const CTransaction singleInputSpend( - GenerateSparkSpend({4 * COIN}, {}, nullptr)); + { + batch->init(); + CValidationState aliasedState; + CSparkTxInfo aliasedInfo; + BOOST_REQUIRE(CheckSparkTransaction( + aliased, + aliasedState, + aliased.GetHash(), + false, + intervalHeight, + false, + true, + &aliasedInfo)); + + batch->init(BatchProofContainer::Mode::Deferred); + CValidationState batchState; + CSparkTxInfo batchInfo; + BOOST_REQUIRE(CheckSparkTransaction( + aliased, + batchState, + aliased.GetHash(), + false, + intervalHeight, + false, + true, + &batchInfo)); + batch->finalize(); + BOOST_CHECK(batch->verify_pending()); + batch->remove(ParseSparkSpend(aliased)); + batch->init(); + } + + const int advance = (intervalHeight - 1) - chainActive.Height(); + if (advance > 0) { + GenerateBlocks(advance); + } + BOOST_REQUIRE_EQUAL(chainActive.Height(), intervalHeight - 1); + + BOOST_CHECK(!GenerateBlock({CMutableTransaction(multiInput)})); + BOOST_REQUIRE(GenerateBlock({CMutableTransaction(aliased)})); + BOOST_REQUIRE_EQUAL(chainActive.Height(), intervalHeight); + + batch->init(); + CValidationState postH2State; + CSparkTxInfo postH2Info; + BOOST_CHECK(!CheckSparkTransaction( + aliasedForPostH2Check, + postH2State, + aliasedForPostH2Check.GetHash(), + false, + h2Height, + false, + true, + &postH2Info)); + int postH2Dos = -1; + BOOST_REQUIRE(postH2State.IsInvalid(postH2Dos)); + BOOST_CHECK_EQUAL(postH2Dos, 100); BOOST_CHECK_EQUAL( - ParseSparkSpend(singleInputSpend).getUsedLTags().size(), 1U); + postH2State.GetRejectReason(), + "CheckSparkSpendTransaction: invalid coin group id"); mempool.clear(); sparkState->Reset(); @@ -2014,8 +2245,6 @@ BOOST_AUTO_TEST_CASE(spark_v2_activation_and_wallet_selection) BOOST_AUTO_TEST_CASE(first_block_cover_set_hash_after_chaum_v2) { - RestoreSparkActivationHeights restoreHeights; - GenerateBlocks(500); std::vector mintTransactions; GenerateMints({5 * COIN, 5 * COIN}, mintTransactions); @@ -2040,14 +2269,6 @@ BOOST_AUTO_TEST_CASE(first_block_cover_set_hash_after_chaum_v2) const int maxHeight = chainActive.Height() - (ZC_MINT_CONFIRMATIONS - 1); - BOOST_REQUIRE_GE(sparkState->GetCoinSetForSpend( - &chainActive, maxHeight, groupId, blockHash, coins, setHash), 2); - BOOST_CHECK(setHash.empty()); - - const int activationHeight = chainActive.Height() + 1; - UpdateRegtestSparkActivationHeights( - &activationHeight, &activationHeight); - BOOST_REQUIRE_GE(sparkState->GetCoinSetForSpend( &chainActive, maxHeight, groupId, blockHash, coins, setHash), 2); BOOST_CHECK(setHash == firstBlockHash); @@ -2985,184 +3206,6 @@ BOOST_AUTO_TEST_CASE(spark_spend_commit_persists_outputs_before_mempool) sparkState->Reset(); } -BOOST_AUTO_TEST_CASE(spark_single_input_historical_batch_verification) -{ - BatchProofContainer* batch = BatchProofContainer::get_instance(); - struct ResetBatchAndActivation { - BatchProofContainer* batch; - RestoreSparkActivationHeights heights; - explicit ResetBatchAndActivation(BatchProofContainer* batchIn) - : batch(batchIn) - { - } - ~ResetBatchAndActivation() - { - batch->init(); - } - } reset{batch}; - - // Stay below regtest nSparkSingleInputStartBlock (500) so historical - // multi-input spends remain consensus-valid for the first check. - GenerateBlocks(200); - - std::vector mintTransactions; - const auto createdMints = - GenerateMints({5 * COIN, 5 * COIN}, mintTransactions); - mempool.clear(); - GenerateBlock(mintTransactions); - GenerateBlocks(10); - - std::vector selectedMints; - for (const auto& mint : createdMints) { - selectedMints.push_back( - pwalletMain->sparkWallet->getMintMeta(mint.k)); - } - BOOST_REQUIRE_EQUAL(selectedMints.size(), 2U); - const CTransaction multiInputSpend( - GenerateCustomSparkSpend(selectedMints, 9 * COIN)); - SpendTransaction parsedMultiInput = ParseSparkSpend(multiInputSpend); - constexpr uint64_t groupIdAliasOffset = uint64_t{1} << 32; - const CTransaction aliasedSpend(GenerateCustomSparkSpend( - {selectedMints.front()}, 4 * COIN, groupIdAliasOffset)); - SpendTransaction parsedAlias = ParseSparkSpend(aliasedSpend); - BOOST_REQUIRE_EQUAL(parsedAlias.getCoinGroupIds().size(), 1U); - BOOST_REQUIRE_EQUAL(parsedAlias.getBlockHashes().size(), 1U); - BOOST_CHECK_EQUAL( - parsedAlias.getBlockHashes().begin()->first, - parsedAlias.getCoinGroupIds().front()); - BOOST_CHECK( - parsedAlias.getCoinGroupIds().front() > - static_cast(std::numeric_limits::max())); - - batch->init(BatchProofContainer::Mode::Deferred); - CValidationState historicalState; - CSparkTxInfo historicalInfo; - BOOST_REQUIRE(CheckSparkTransaction( - multiInputSpend, - historicalState, - multiInputSpend.GetHash(), - false, - chainActive.Height(), - false, - true, - &historicalInfo)); - batch->finalize(); - BOOST_CHECK(batch->verify_pending()); - batch->remove(parsedMultiInput); - - - // Pre-activation blocks retain the deployed 32-bit group ID - // interpretation, including when their proofs are collected in a batch. - CValidationState legacyAliasState; - CSparkTxInfo legacyAliasInfo; - BOOST_REQUIRE(CheckSparkTransaction( - aliasedSpend, - legacyAliasState, - aliasedSpend.GetHash(), - false, - chainActive.Height(), - false, - true, - &legacyAliasInfo)); - - batch->init(BatchProofContainer::Mode::Deferred); - CValidationState legacyBatchState; - CSparkTxInfo legacyBatchInfo; - BOOST_REQUIRE(CheckSparkTransaction( - aliasedSpend, - legacyBatchState, - aliasedSpend.GetHash(), - false, - chainActive.Height(), - false, - true, - &legacyBatchInfo)); - batch->finalize(); - BOOST_CHECK(batch->verify_pending()); - batch->remove(parsedAlias); - - // Exercise the post-single-input batch as well; it has a separate - // collection and verification path. - UpdateRegtestSparkSingleInputHeight(chainActive.Height()); - batch->init(BatchProofContainer::Mode::Deferred); - CValidationState currentBatchState; - CSparkTxInfo currentBatchInfo; - BOOST_REQUIRE(CheckSparkTransaction( - aliasedSpend, - currentBatchState, - aliasedSpend.GetHash(), - false, - chainActive.Height(), - false, - true, - ¤tBatchInfo)); - batch->finalize(); - BOOST_CHECK(batch->verify_pending()); - batch->remove(parsedAlias); - - // Upgraded mempools reject aliases before consensus activation. - CValidationState mempoolAliasState; - BOOST_CHECK(!CheckSparkTransaction( - aliasedSpend, - mempoolAliasState, - aliasedSpend.GetHash(), - false, - INT_MAX, - false, - true, - nullptr)); - int mempoolAliasDoS = -1; - BOOST_REQUIRE(mempoolAliasState.IsInvalid(mempoolAliasDoS)); - BOOST_CHECK_EQUAL(mempoolAliasDoS, 0); - - UpdateRegtestSparkChaumV2Height(chainActive.Height()); - CValidationState activeAliasState; - CSparkTxInfo activeAliasInfo; - BOOST_CHECK(!CheckSparkTransaction( - aliasedSpend, - activeAliasState, - aliasedSpend.GetHash(), - false, - chainActive.Height(), - false, - true, - &activeAliasInfo)); - int activeAliasDoS = 0; - BOOST_REQUIRE(activeAliasState.IsInvalid(activeAliasDoS)); - BOOST_CHECK_EQUAL(activeAliasDoS, 100); - - batch->init(BatchProofContainer::Mode::Deferred); - CValidationState activeBatchAliasState; - CSparkTxInfo activeBatchAliasInfo; - BOOST_CHECK(!CheckSparkTransaction( - aliasedSpend, - activeBatchAliasState, - aliasedSpend.GetHash(), - false, - chainActive.Height(), - false, - true, - &activeBatchAliasInfo)); - batch->finalize(); - BOOST_CHECK(batch->verify_pending()); - - batch->init(BatchProofContainer::Mode::Deferred); - CValidationState activeState; - CSparkTxInfo activeInfo; - BOOST_CHECK(!CheckSparkTransaction( - multiInputSpend, - activeState, - multiInputSpend.GetHash(), - false, - chainActive.Height(), - false, - true, - &activeInfo)); - int activeDoS = 0; - BOOST_REQUIRE(activeState.IsInvalid(activeDoS)); - BOOST_CHECK_EQUAL(activeDoS, 100); -} - BOOST_AUTO_TEST_CASE(recent_spark_blocks_preserve_batch_and_cache_semantics) { RestoreSparkActivationHeights resetActivationHeights; @@ -3582,98 +3625,6 @@ BOOST_AUTO_TEST_CASE(verifydb_rejects_cross_block_spark_double_spend) sparkState->Reset(); } -BOOST_AUTO_TEST_CASE(spark_single_input_block_boundary_and_reorg) -{ - RestoreSparkActivationHeights resetActivationHeights; - - GenerateBlocks(500); - std::vector mintTransactions; - const auto createdMints = GenerateMints( - {5 * COIN, 5 * COIN, 5 * COIN, 5 * COIN, 10 * COIN}, - mintTransactions); - mempool.clear(); - GenerateBlock(mintTransactions); - GenerateBlocks(10); - - std::vector fiveCoinMints; - for (const auto& mint : createdMints) { - if (mint.v == 5 * COIN) { - fiveCoinMints.push_back( - pwalletMain->sparkWallet->getMintMeta(mint.k)); - } - } - BOOST_REQUIRE_EQUAL(fiveCoinMints.size(), 4U); - - const CTransaction historicalMultiInput = - GenerateCustomSparkSpend( - {fiveCoinMints[0], fiveCoinMints[1]}, 9 * COIN); - const CTransaction activeMultiInput = - GenerateCustomSparkSpend( - {fiveCoinMints[2], fiveCoinMints[3]}, 9 * COIN); - const CTransaction activeSingleInput( - GenerateSparkSpend({4 * COIN}, {}, nullptr)); - mempool.clear(); - - const int baseHeight = chainActive.Height(); - const int activationHeight = baseHeight + 2; - UpdateRegtestSparkSingleInputHeight(activationHeight); - - BOOST_CHECK(IsSparkSpendFormatAllowed( - historicalMultiInput, activationHeight - 1)); - BOOST_CHECK(!IsSparkSpendFormatAllowed( - historicalMultiInput, activationHeight)); - BOOST_CHECK(IsSparkSpendFormatAllowed( - activeSingleInput, activationHeight)); - - TestMemPoolEntryHelper mempoolEntry; - mempool.addUnchecked( - historicalMultiInput.GetHash(), - mempoolEntry.Height(baseHeight).FromTx(historicalMultiInput)); - BOOST_REQUIRE(mempool.exists(historicalMultiInput.GetHash())); - { - LOCK(cs_main); - mempool.removeForReorg( - pcoinsTip, activationHeight - 1, LOCKTIME_VERIFY_SEQUENCE); - } - BOOST_CHECK(mempool.exists(historicalMultiInput.GetHash())); - { - LOCK(cs_main); - mempool.removeForReorg( - pcoinsTip, activationHeight, LOCKTIME_VERIFY_SEQUENCE); - } - BOOST_CHECK(!mempool.exists(historicalMultiInput.GetHash())); - - CBlockIndex* historicalIndex = - GenerateBlock({CMutableTransaction(historicalMultiInput)}); - BOOST_REQUIRE(historicalIndex); - BOOST_REQUIRE_EQUAL(chainActive.Height(), activationHeight - 1); - const CBlock historicalBlock = GetCBlock(historicalIndex); - - BOOST_CHECK(!GenerateBlock({CMutableTransaction(activeMultiInput)})); - BOOST_CHECK_EQUAL(chainActive.Height(), activationHeight - 1); - - CBlockIndex* activationIndex = - GenerateBlock({CMutableTransaction(activeSingleInput)}); - BOOST_REQUIRE(activationIndex); - BOOST_REQUIRE_EQUAL(chainActive.Height(), activationHeight); - const CBlock activationBlock = GetCBlock(activationIndex); - - BOOST_REQUIRE(DisconnectBlocks(2)); - BOOST_REQUIRE_EQUAL(chainActive.Height(), baseHeight); - - const auto reconnect = [](const CBlock& block) { - CValidationState state; - const auto shared = std::make_shared(block); - BOOST_REQUIRE(ActivateBestChain(state, ::Params(), shared)); - }; - - // Supplying the first disconnected block lets ActivateBestChain reconnect - // the known two-block branch across the boundary in one activation step. - reconnect(historicalBlock); - BOOST_CHECK_EQUAL(chainActive.Height(), activationHeight); - BOOST_CHECK(chainActive.Tip()->GetBlockHash() == activationBlock.GetHash()); -} - BOOST_AUTO_TEST_CASE(spark_proof_cache_is_invalidated_on_cover_set_reorg) { RestoreBroadcastSetting restoreBroadcast; @@ -3981,99 +3932,40 @@ BOOST_AUTO_TEST_CASE(spark_unknown_cover_set_reference_is_not_mempool_admissible BOOST_REQUIRE(outOfRangeState.IsInvalid(dos)); BOOST_CHECK_EQUAL(dos, 0); - const int activationHeight = chainActive.Height() + 1; - const int exactReferencesHeight = activationHeight + 1; - UpdateRegtestSparkSingleInputHeight(activationHeight); - UpdateRegtestSparkChaumV2Height(exactReferencesHeight); - BatchProofContainer* batch = BatchProofContainer::get_instance(); - batch->init(); - CValidationState legacyExtraReferenceState; - CSparkTxInfo legacyExtraReferenceInfo; - BOOST_REQUIRE(CheckSparkTransaction( - extraReference, - legacyExtraReferenceState, - extraReference.GetHash(), - false, - activationHeight, - false, - true, - &legacyExtraReferenceInfo)); - - batch->init(BatchProofContainer::Mode::Deferred); - CValidationState legacyBatchedExtraReferenceState; - CSparkTxInfo legacyBatchedExtraReferenceInfo; - BOOST_REQUIRE(CheckSparkTransaction( - extraReference, - legacyBatchedExtraReferenceState, - extraReference.GetHash(), - false, - activationHeight, - false, - true, - &legacyBatchedExtraReferenceInfo)); - - CValidationState batchedExtraReferenceState; - CSparkTxInfo batchedExtraReferenceInfo; - BOOST_CHECK(!CheckSparkTransaction( - extraReference, - batchedExtraReferenceState, - extraReference.GetHash(), - false, - exactReferencesHeight, - false, - true, - &batchedExtraReferenceInfo)); - BOOST_REQUIRE(batchedExtraReferenceState.IsInvalid(dos)); - BOOST_CHECK_EQUAL(dos, 100); - BOOST_CHECK_EQUAL( - batchedExtraReferenceState.GetRejectCode(), REJECT_INVALID); - BOOST_CHECK_EQUAL( - batchedExtraReferenceState.GetRejectReason(), - "CheckSparkSpendTransaction: invalid cover set references"); - - CValidationState batchedOutOfRangeState; - CSparkTxInfo batchedOutOfRangeInfo; - BOOST_CHECK(!CheckSparkTransaction( - CTransaction(outOfRangeGroup), - batchedOutOfRangeState, - outOfRangeGroup.GetHash(), - false, - activationHeight, - false, - true, - &batchedOutOfRangeInfo)); - BOOST_REQUIRE(batchedOutOfRangeState.IsInvalid(dos)); - BOOST_CHECK_EQUAL(dos, 100); - batch->finalize(); - BOOST_CHECK(batch->verify_pending()); - - CValidationState historicalState; - CSparkTxInfo historicalInfo; - BOOST_CHECK(CheckSparkTransaction( - CTransaction(unknownReference), - historicalState, - unknownReference.GetHash(), - false, - activationHeight - 1, - false, - true, - &historicalInfo)); - - CValidationState activeState; - CSparkTxInfo activeInfo; - BOOST_CHECK(!CheckSparkTransaction( - CTransaction(unknownReference), - activeState, - unknownReference.GetHash(), - false, - activationHeight, - false, - true, - &activeInfo)); - BOOST_REQUIRE(activeState.IsInvalid(dos)); - BOOST_CHECK_EQUAL(dos, 100); - + struct ResetBatch { + BatchProofContainer* batch; + ~ResetBatch() + { + batch->init(); + } + } resetBatch{batch}; + const int nextHeight = chainActive.Height() + 1; + const std::pair blockCases[] = { + {extraReference, "CheckSparkSpendTransaction: invalid cover set references"}, + {CTransaction(unknownReference), "CheckSparkSpendTransaction: unknown cover-set reference"}, + {CTransaction(outOfRangeGroup), "CheckSparkSpendTransaction: invalid coin group id"}, + }; + for (const auto mode : {BatchProofContainer::Mode::Disabled, BatchProofContainer::Mode::Deferred}) { + batch->init(mode); + for (const auto& [tx, reason] : blockCases) { + CValidationState blockState; + CSparkTxInfo blockInfo; + BOOST_CHECK(!CheckSparkTransaction( + tx, + blockState, + tx.GetHash(), + false, + nextHeight, + false, + true, + &blockInfo)); + BOOST_REQUIRE(blockState.IsInvalid(dos)); + BOOST_CHECK_EQUAL(dos, 100); + BOOST_CHECK_EQUAL(blockState.GetRejectCode(), REJECT_INVALID); + BOOST_CHECK_EQUAL(blockState.GetRejectReason(), reason); + } + } mempool.clear(); sparkState->Reset(); } @@ -4085,11 +3977,11 @@ BOOST_AUTO_TEST_CASE(spark_activation_order_is_validated) const_cast(::Params().GetConsensus()); BOOST_CHECK_THROW( - UpdateRegtestSparkChaumV2Height(100), std::runtime_error); + UpdateRegtestSparkChaumV2Height(0), std::runtime_error); BOOST_CHECK_EQUAL(consensus.nSparkChaumV2StartBlock, restore.v2); - UpdateRegtestSparkSingleInputHeight(100); - UpdateRegtestSparkChaumV2Height(100); + const int alignedHeight = 100; + UpdateRegtestSparkActivationHeights(&alignedHeight, &alignedHeight); BOOST_CHECK_NO_THROW(ValidateSparkActivationHeights(consensus)); // Raising both past the previous V2 height must succeed when applied diff --git a/src/test/sparkname_tests.cpp b/src/test/sparkname_tests.cpp index eecc02ec18..e00c58ffae 100644 --- a/src/test/sparkname_tests.cpp +++ b/src/test/sparkname_tests.cpp @@ -747,12 +747,6 @@ BOOST_AUTO_TEST_CASE(hfblocknumber) { Initialize(1000); // stay below HF block number for a time being - // This lifecycle test creates several large name payments. Exercise it - // with V2 enabled so the single-input rule does not make its legacy - // multi-input wallet setup fail before the Spark Names assertions run. - mutableConsensus.nSparkSingleInputStartBlock = 1; - mutableConsensus.nSparkChaumV2StartBlock = 1; - // Push V2.1 activation past the end of the graceful period so fee-tag // requirements don't interfere with the address-transition test below. mutableConsensus.nSparkNamesV21StartBlock = INT_MAX; @@ -1212,6 +1206,11 @@ BOOST_AUTO_TEST_CASE(v2_mempool_spark_name_parse_failure_does_not_score_peer) nameData.sparkAddress = GenerateSparkAddress(); nameData.sparkNameValidityBlocks = nBlockPerYear; + // Regtest activates Chaum V2 at height 1. Hold it back so this spend is V1. + const int deferredHeight = chainActive.Height() + 20; + mutableConsensus.nSparkSingleInputStartBlock = deferredHeight; + mutableConsensus.nSparkChaumV2StartBlock = deferredHeight; + // Construct a valid pre-activation V1 Spark Name spend, then remove its // optional metadata suffix while retaining the tagged name-fee output. This form is // accepted below activation but becomes noncanonical at the boundary. @@ -1361,6 +1360,11 @@ BOOST_AUTO_TEST_CASE(v2_mempool_nested_name_proof_failure_does_not_score_peer) nameData.sparkAddress = GenerateSparkAddress(); nameData.sparkNameValidityBlocks = nBlockPerYear; + // Regtest activates Chaum V2 at height 1. Hold it back so this spend is V1. + const int deferredHeight = chainActive.Height() + 20; + mutableConsensus.nSparkSingleInputStartBlock = deferredHeight; + mutableConsensus.nSparkChaumV2StartBlock = deferredHeight; + // A V1 ownership-proof vector may contain an ignored suffix. Keep that // historically valid encoding non-punitive when next-block policy first // requires the canonical V2 representation. diff --git a/src/wallet/CMakeLists.txt b/src/wallet/CMakeLists.txt index 56d01e45d2..aa210e5b2a 100644 --- a/src/wallet/CMakeLists.txt +++ b/src/wallet/CMakeLists.txt @@ -23,8 +23,6 @@ add_library(firo_wallet STATIC EXCLUDE_FROM_ALL ${CMAKE_CURRENT_SOURCE_DIR}/db.cpp ${CMAKE_CURRENT_SOURCE_DIR}/rpcdump.cpp ${CMAKE_CURRENT_SOURCE_DIR}/rpcwallet.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/sparkbatchplanner.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/sparkspendbatch.cpp ${CMAKE_CURRENT_SOURCE_DIR}/walletexcept.cpp ${CMAKE_CURRENT_SOURCE_DIR}/wallet.cpp ${CMAKE_CURRENT_SOURCE_DIR}/walletdb.cpp diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp index 8b6ebaa312..be9f545360 100644 --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -27,7 +27,6 @@ #include "bip47/paymentchannel.h" #include "bip47/account.h" #include "wallet/coincontrol.h" -#include "wallet/sparkspendbatch.h" #include "rpcdump.h" #include @@ -4540,9 +4539,6 @@ UniValue spendspark(const JSONRPCRequest& request) CWalletTx wtx; try { wtx = pwallet->SpendAndStoreSpark(recipients, privateRecipients, fee); - } catch (const SparkFundsFragmented& e) { - throw JSONRPCError(RPC_WALLET_ERROR, - std::string(e.what()) + " Use spendsparksplit instead."); } catch (const std::exception &) { throw JSONRPCError(RPC_WALLET_ERROR, "Spark spend creation failed."); } @@ -4550,174 +4546,6 @@ UniValue spendspark(const JSONRPCRequest& request) return wtx.GetHash().GetHex(); } -UniValue spendsparksplit(const JSONRPCRequest& request) -{ - CWallet * const pwallet = GetWalletForJSONRPCRequest(request); - if (!EnsureWalletIsAvailable(pwallet, request.fHelp)) { - return NullUniValue; - } - - if (request.fHelp || request.params.size() != 1) - throw std::runtime_error( - "spendsparksplit {\"address\":{amount,subtractfee...}, \"address\":{amount,memo,subtractfee...}}\n" - + HelpRequiringPassphrase(pwallet) + "\n" - "Temporary helper that splits a Spark payment across multiple single-input\n" - "transactions when no single Spark coin can fund the payment alone.\n" - "\nArguments:\n" - "1. recipients (string, required) A json object with addresses and amounts\n" - "{\n" - " \"address\":amount (numeric or string), memo (string,only for private, not required), subtractfee (bool) The Spark address is the key, the numeric amount (can be string) in " + CURRENCY_UNIT + " is the value\n" - " ,...\n" - " }\n" - "\nResult:\n" - "[ (json array) Transaction ids for the send. Always an array,\n" - " \"txid\", even when only one transaction is created.\n" - " ...\n" - "]\n" - "\nExamples:\n" - "\nSend an amount to transparent address:\n" - + HelpExampleCli("spendsparksplit", "\"{\\\"TR1FW48J6ozpRu25U8giSDdTrdXXUYau7U\\\":{\\\"amount\\\":0.01, \\\"subtractFee\\\": false}}\"") + - "\nAs a json rpc call\n" - + HelpExampleRpc("spendsparksplit", "\"{\"TR1FW48J6ozpRu25U8giSDdTrdXXUYau7U\":{\"amount\":0.01, \"subtractFee\": false}}\"") - ); - - EnsureWalletIsUnlocked(pwallet); - EnsureSparkWalletIsAvailable(); - - if (!spark::IsSparkAllowed()) { - throw JSONRPCError(RPC_WALLET_ERROR, "Spark is not activated yet"); - } - - std::vector recipients; - std::vector> privateRecipients; - - UniValue sendTo; - if (request.params[0].isStr()) { - if (!sendTo.read(request.params[0].get_str())) { - throw JSONRPCError(RPC_PARSE_ERROR, "Invalid JSON string"); - } - if (!sendTo.isObject()) { - throw JSONRPCError(RPC_INVALID_PARAMETER, "JSON parameter must be an object"); - } - } else { - sendTo = request.params[0].get_obj(); - } - - std::vector keys = sendTo.getKeys(); - const spark::Params* params = spark::Params::get_default(); - std::set setAddress; - unsigned char network = spark::GetNetworkType(); - - BOOST_FOREACH(const std::string& name_, keys) - { - spark::Address sAddress(params); - bool isSparkAddress; - std::string sparkAddressStr; - - if (!name_.empty() && name_[0] == '@') { - LOCK(cs_main); - - CSparkNameManager *sparkNameManager = CSparkNameManager::GetInstance(); - if (!sparkNameManager->GetSparkAddress(name_.substr(1), sparkAddressStr)) - throw JSONRPCError(RPC_INVALID_PARAMETER, std::string("Spark name not found: ")+name_); - } else { - sparkAddressStr = name_; - } - - try { - unsigned char coinNetwork = sAddress.decode(sparkAddressStr); - isSparkAddress = true; - if (coinNetwork != network) - throw JSONRPCError(RPC_INVALID_PARAMETER, std::string("Invalid address, wrong network type: ")+name_); - } catch (const std::exception &) { - isSparkAddress = false; - } - - if (isSparkAddress) { - UniValue amountAndMemo = sendTo[name_].get_obj(); - CAmount nAmount(0); - if (amountAndMemo.exists("amount")) - nAmount = AmountFromValue(amountAndMemo["amount"]); - else - throw JSONRPCError(RPC_INVALID_PARAMETER, std::string("Invalid parameters, no amount: ")+name_); - - if (nAmount <= 0) - throw JSONRPCError(RPC_TYPE_ERROR, "Invalid amount for send"); - - std::string memo = ""; - if (amountAndMemo.exists("memo")) - memo = amountAndMemo["memo"].get_str(); - - bool subtractFee = false; - if (amountAndMemo.exists("subtractFee")) - subtractFee = amountAndMemo["subtractFee"].get_bool(); - else - throw JSONRPCError(RPC_INVALID_PARAMETER, std::string("Invalid parameters, no subtractFee: ")+name_); - - ValidateFeeSubtractionAmount(nAmount, subtractFee, name_); - - spark::OutputCoinData data; - data.address = sAddress; - data.memo = memo; - data.v = nAmount; - privateRecipients.push_back(std::make_pair(data, subtractFee)); - continue; - } - - CBitcoinAddress address(name_); - if (address.IsValid()) { - if (setAddress.count(address)) - throw JSONRPCError(RPC_INVALID_PARAMETER, - std::string("Invalid parameter, duplicated address: ") + name_); - setAddress.insert(address); - - CScript scriptPubKey = GetScriptForDestination(address.Get()); - - UniValue amountObj = sendTo[name_].get_obj(); - CAmount nAmount(0); - if (amountObj.exists("amount")) - nAmount = AmountFromValue(amountObj["amount"]); - else - throw JSONRPCError(RPC_INVALID_PARAMETER, std::string("Invalid parameters, no amount: ") + name_); - if (nAmount <= 0) - throw JSONRPCError(RPC_TYPE_ERROR, "Invalid amount for send"); - - bool fSubtractFeeFromAmount = false; - if (amountObj.exists("subtractFee")) - fSubtractFeeFromAmount = amountObj["subtractFee"].get_bool(); - else - throw JSONRPCError(RPC_INVALID_PARAMETER, std::string("Invalid parameters, no subtractFee: ") + name_); - - ValidateFeeSubtractionAmount(nAmount, fSubtractFeeFromAmount, name_); - - CRecipient recipient = {scriptPubKey, nAmount, fSubtractFeeFromAmount, {}, {}}; - recipients.push_back(recipient); - continue; - } - - throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, std::string("Invalid Firo address: ") + name_); - } - - CAmount fee; - try { - std::vector wtxs = pwallet->SpendAndStoreSparkSingleInput( - recipients, privateRecipients, fee); - UniValue result(UniValue::VARR); - for (const CWalletTx& wtx : wtxs) { - result.push_back(wtx.GetHash().GetHex()); - } - return result; - } catch (const sparkspendbatch::SparkSpendBatchPartialFailure& e) { - throw JSONRPCError(RPC_WALLET_ERROR, e.what()); - } catch (const SparkFundsFragmented& e) { - throw JSONRPCError(RPC_WALLET_ERROR, e.what()); - } catch (const InsufficientFunds& e) { - throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, e.what()); - } catch (const std::exception& e) { - throw JSONRPCError(RPC_WALLET_ERROR, e.what()); - } -} - UniValue sendspark(const JSONRPCRequest& request) { CWallet * const pwallet = GetWalletForJSONRPCRequest(request); @@ -5916,7 +5744,6 @@ static const CRPCCommand commands[] = { "wallet", "mintspark", &mintspark, true, {} }, { "wallet", "automintspark", &automintspark, false, {} }, { "wallet", "spendspark", &spendspark, false, {} }, - { "wallet", "spendsparksplit", &spendsparksplit, false, {} }, { "wallet", "sendspark", &sendspark, false, {} }, { "wallet", "sendsparkmany", &sendsparkmany, false, {"fromaccount","amounts","comment","subtractfeefrom"} }, { "wallet", "identifysparkcoins", &identifysparkcoins, false, {} }, diff --git a/src/wallet/sparkbatchplanner.cpp b/src/wallet/sparkbatchplanner.cpp deleted file mode 100644 index 664e96bc89..0000000000 --- a/src/wallet/sparkbatchplanner.cpp +++ /dev/null @@ -1,177 +0,0 @@ -// Copyright (c) 2026 The Firo developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or https://opensource.org/license/mit/. - -#include "wallet/sparkbatchplanner.h" - -#include -#include -#include - -namespace spark { - -unsigned int EstimateSingleInputSparkSize( - size_t privateOutputs, - size_t transparentOutputs) -{ - // Keep this in step with CSparkWallet::SelectSparkCoins: 924 bytes of - // fixed transaction data, 1803 for one Grootle proof and its auxiliary - // data, 322 per private output plus one change output, and 34 per - // transparent output. - const uint64_t estimatedSize = 924ULL + 1803ULL - + 322ULL * (static_cast(privateOutputs) + 1) - + 34ULL * static_cast(transparentOutputs); - if (estimatedSize > std::numeric_limits::max()) { - throw std::runtime_error("Spark transaction size is out of range"); - } - - return static_cast(estimatedSize); -} - -BatchPlanResult PlanSingleInputSpend( - const std::vector& sortedCoinValues, - const std::vector& recipients, - const BatchPlanLimits& limits, - const BatchFeeEstimator& estimateFee, - const BatchSizeEstimator& estimateSize) -{ - BatchPlanResult result; - std::vector remaining; - remaining.reserve(recipients.size()); - - CAmount transparentTotal = 0; - for (const BatchRecipient& recipient : recipients) { - if (recipient.amount <= 0 || recipient.amount > limits.maxMoney || - recipient.minimumOutputAmount <= 0) { - result.status = BatchPlanStatus::INVALID_AMOUNT; - return result; - } - remaining.push_back(recipient.amount); - if (!recipient.isPrivate) { - if (recipient.minimumOutputAmount > limits.maxTransparentAmount) { - result.status = BatchPlanStatus::TRANSPARENT_LIMIT; - return result; - } - if (transparentTotal > limits.maxMoney - recipient.amount) { - result.status = BatchPlanStatus::INVALID_AMOUNT; - return result; - } - transparentTotal += recipient.amount; - } - } - - size_t recipientIndex = 0; - for (size_t coinIndex = 0; coinIndex < sortedCoinValues.size(); ++coinIndex) { - if (recipientIndex == recipients.size()) { - break; - } - - const CAmount coinValue = sortedCoinValues[coinIndex]; - if (coinValue <= 0 || coinValue > limits.maxMoney) { - continue; - } - - SingleInputBatch batch; - batch.coinIndex = coinIndex; - - while (recipientIndex < recipients.size()) { - const BatchRecipient& recipient = recipients[recipientIndex]; - const size_t privateOutputs = batch.privateOutputs + (recipient.isPrivate ? 1 : 0); - const size_t transparentOutputs = batch.transparentOutputs + (recipient.isPrivate ? 0 : 1); - - if (privateOutputs > limits.maxPrivateOutputs) { - break; - } - - const uint64_t estimatedSize = estimateSize(privateOutputs, transparentOutputs); - if (limits.weightScaleFactor == 0 || estimatedSize * limits.weightScaleFactor >= limits.maxWeight) { - break; - } - - const CAmount fee = estimateFee(privateOutputs, transparentOutputs); - if (fee > limits.maxFee) { - result.status = BatchPlanStatus::FEE_TOO_HIGH; - result.batches.clear(); - return result; - } - - // With subtractFeeFromAmount the entered amount is taken from the - // coin and the fee is carved out of the output later - // (SelectSparkCoins does not add the fee to the required value). - // Without it the coin must cover amount + fee. - CAmount available; - if (recipient.subtractFeeFromAmount) { - if (fee >= coinValue || batch.amount >= coinValue) { - break; - } - available = coinValue - batch.amount; - } else { - if (batch.amount >= coinValue || fee >= coinValue - batch.amount) { - break; - } - available = coinValue - batch.amount - fee; - } - if (!recipient.isPrivate) { - available = std::min( - available, - limits.maxTransparentAmount - batch.transparentAmount); - } - if (available <= 0) { - break; - } - - CAmount fragment = std::min(remaining[recipientIndex], available); - if (fragment < recipient.minimumOutputAmount) { - break; - } - // Fee is deducted from this output; it must remain funded after that. - if (recipient.subtractFeeFromAmount && fragment <= fee && - remaining[recipientIndex] == fragment && batch.fragments.empty()) { - break; - } - - const CAmount remainder = remaining[recipientIndex] - fragment; - if (remainder > 0 && remainder < recipient.minimumOutputAmount) { - const CAmount adjustment = recipient.minimumOutputAmount - remainder; - if (fragment - adjustment < recipient.minimumOutputAmount) { - break; - } - fragment -= adjustment; - } - batch.fragments.push_back({recipientIndex, fragment}); - batch.amount += fragment; - batch.fee = fee; - if (recipient.isPrivate) { - ++batch.privateOutputs; - } else { - ++batch.transparentOutputs; - batch.transparentAmount += fragment; - } - - remaining[recipientIndex] -= fragment; - if (remaining[recipientIndex] == 0) { - ++recipientIndex; - } - } - - if (batch.fragments.empty()) { - continue; - } - - result.batches.push_back(std::move(batch)); - if (result.batches.size() >= limits.maxTransactions && recipientIndex < recipients.size()) { - result.status = BatchPlanStatus::TOO_MANY_TRANSACTIONS; - result.batches.clear(); - return result; - } - } - - if (recipientIndex != recipients.size()) { - result.status = BatchPlanStatus::INSUFFICIENT_FUNDS; - result.batches.clear(); - } - - return result; -} - -} // namespace spark diff --git a/src/wallet/sparkbatchplanner.h b/src/wallet/sparkbatchplanner.h deleted file mode 100644 index 1b1b4e43a2..0000000000 --- a/src/wallet/sparkbatchplanner.h +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2026 The Firo developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or https://opensource.org/license/mit/. - -#ifndef FIRO_WALLET_SPARKBATCHPLANNER_H -#define FIRO_WALLET_SPARKBATCHPLANNER_H - -#include "amount.h" - -#include -#include -#include -#include - -namespace spark { - -struct BatchRecipient -{ - CAmount amount{0}; - bool isPrivate{false}; - CAmount minimumOutputAmount{1}; - /** When true, amount is the gross value taken from the coin and the fee is - * deducted from this recipient's output (same as CRecipient::fSubtractFeeFromAmount). */ - bool subtractFeeFromAmount{false}; -}; - -struct BatchFragment -{ - size_t recipientIndex{0}; - CAmount amount{0}; -}; - -struct SingleInputBatch -{ - size_t coinIndex{0}; - std::vector fragments; - size_t privateOutputs{0}; - size_t transparentOutputs{0}; - CAmount transparentAmount{0}; - CAmount amount{0}; - CAmount fee{0}; -}; - -struct BatchPlanLimits -{ - size_t maxTransactions{0}; - size_t maxPrivateOutputs{0}; - CAmount maxTransparentAmount{0}; - CAmount maxFee{0}; - CAmount maxMoney{0}; - uint64_t maxWeight{0}; - uint64_t weightScaleFactor{0}; -}; - -enum class BatchPlanStatus -{ - OK, - INVALID_AMOUNT, - TRANSPARENT_LIMIT, - FEE_TOO_HIGH, - TOO_MANY_TRANSACTIONS, - INSUFFICIENT_FUNDS, -}; - -struct BatchPlanResult -{ - BatchPlanStatus status{BatchPlanStatus::OK}; - std::vector batches; -}; - -using BatchFeeEstimator = std::function; -using BatchSizeEstimator = std::function; - -unsigned int EstimateSingleInputSparkSize( - size_t privateOutputs, - size_t transparentOutputs); - -BatchPlanResult PlanSingleInputSpend( - const std::vector& sortedCoinValues, - const std::vector& recipients, - const BatchPlanLimits& limits, - const BatchFeeEstimator& estimateFee, - const BatchSizeEstimator& estimateSize); - -} // namespace spark - -#endif // FIRO_WALLET_SPARKBATCHPLANNER_H diff --git a/src/wallet/sparkspendbatch.cpp b/src/wallet/sparkspendbatch.cpp deleted file mode 100644 index 1ecaec5593..0000000000 --- a/src/wallet/sparkspendbatch.cpp +++ /dev/null @@ -1,402 +0,0 @@ -// Copyright (c) 2026 The Firo developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or https://opensource.org/license/mit/. - -#include "wallet/sparkspendbatch.h" - -#include "chainparams.h" -#include "init.h" -#include "net.h" -#include "policy/policy.h" -#include "spark/state.h" -#include "txmempool.h" -#include "util.h" -#include "utilmoneystr.h" -#include "validation.h" -#include "wallet/sparkbatchplanner.h" -#include "wallet/walletexcept.h" - -#include -#include -#include - -namespace sparkspendbatch { -namespace { - -struct RecipientEntry -{ - bool isPrivate{false}; - CScript scriptPubKey; - spark::OutputCoinData privateOutput; - bool subtractFee{false}; -}; - -struct AvailableCoin -{ - CAmount value{0}; - COutPoint outpoint; -}; - -struct PreparedBatch -{ - CWalletTx wtx; - CAmount fee{0}; -}; - -std::string FormatPartialFailureMessage( - const std::string& reason, - const std::vector& committedTxids, - size_t plannedTransactions) -{ - std::string txidList; - for (size_t i = 0; i < committedTxids.size(); ++i) { - if (i > 0) { - txidList += ", "; - } - txidList += committedTxids[i].ToString(); - } - - return strprintf( - _("Spark spend batch failed after committing %u of %u transactions: %s. " - "Do not retry the whole payment. Already sent: %s"), - committedTxids.size(), - plannedTransactions, - reason, - txidList); -} - -bool HasSubtractFee( - const std::vector& recipients, - const std::vector>& privateRecipients) -{ - for (const CRecipient& recipient : recipients) { - if (recipient.fSubtractFeeFromAmount) { - return true; - } - } - for (const auto& recipient : privateRecipients) { - if (recipient.second) { - return true; - } - } - return false; -} - -void ThrowForPlanStatus(spark::BatchPlanStatus status) -{ - switch (status) { - case spark::BatchPlanStatus::OK: - return; - case spark::BatchPlanStatus::INVALID_AMOUNT: - throw std::runtime_error(_("Invalid Spark spend amount.")); - case spark::BatchPlanStatus::TRANSPARENT_LIMIT: - throw std::runtime_error(_("Spend to transparent address limit exceeded.")); - case spark::BatchPlanStatus::FEE_TOO_HIGH: - throw std::runtime_error(_("Spark transaction fee is too high.")); - case spark::BatchPlanStatus::TOO_MANY_TRANSACTIONS: - throw std::runtime_error(strprintf( - _("A Spark payment may use at most %u transactions."), - MAX_SINGLE_INPUT_SPARK_TRANSACTIONS)); - case spark::BatchPlanStatus::INSUFFICIENT_FUNDS: - throw InsufficientFunds(_( - "The available Spark coins cannot cover the amount and the required transaction fees.")); - } -} - -void CommitSparkSpend(CWallet& wallet, CWalletTx& wtx) -{ - CValidationState state; - CReserveKey reserveKey(&wallet); - // Require mempool acceptance so a rejection fails this commit (and the - // batch). Relay still requires -walletbroadcast=1. - if (!wallet.CommitTransaction( - wtx, - reserveKey, - g_connman.get(), - state, - true)) { - throw std::runtime_error( - state.GetRejectReason().empty() - ? _("Transaction commit failed.") - : state.GetRejectReason()); - } -} - -} // namespace - -SparkSpendBatchPartialFailure::SparkSpendBatchPartialFailure( - std::string reason, - std::vector committedTxids, - CAmount committedFee, - size_t plannedTransactions) - : std::runtime_error(FormatPartialFailureMessage( - reason, committedTxids, plannedTransactions)), - m_committedTxids(std::move(committedTxids)), - m_committedFee(committedFee), - m_plannedTransactions(plannedTransactions) -{ -} - -bool CompareSparkCoins(const CSparkMintMeta& a, const CSparkMintMeta& b) -{ - if (a.v != b.v) return a.v > b.v; - if (a.nHeight != b.nHeight) return a.nHeight < b.nHeight; - if (a.txid != b.txid) return a.txid < b.txid; - return a.GetNonceHash() < b.GetNonceHash(); -} - -bool HasMultipleSelectedCoins(const CCoinControl* coinControl) -{ - if (!coinControl || !coinControl->HasSelected()) { - return false; - } - - std::vector selected; - coinControl->ListSelected(selected); - return selected.size() > 1; -} - -CAmount EstimateSingleInputSparkFee(size_t privateOutputs, size_t transparentOutputs) -{ - const unsigned int estimatedSize = - spark::EstimateSingleInputSparkSize(privateOutputs, transparentOutputs); - - return std::max( - payTxFee.GetFeePerK(), - CWallet::GetMinimumFee(estimatedSize, nTxConfirmTarget, mempool)); -} - -CAmount TransparentMinimumOutputAmount(const CScript& scriptPubKey) -{ - // CreateSparkSpendTransaction rejects transparent outputs with - // IsDust(minRelayTxFee). Keep planner fragments at or above that threshold - // so a later batch cannot fail dust checks after earlier ones are planned. - const CAmount dust = CTxOut(0, scriptPubKey).GetDustThreshold(::minRelayTxFee); - return dust > 0 ? dust : 1; -} - -spark::BatchPlanLimits BuildBatchPlanLimits() -{ - const auto& consensus = Params().GetConsensus(); - spark::BatchPlanLimits limits; - limits.maxTransactions = MAX_SINGLE_INPUT_SPARK_TRANSACTIONS; - limits.maxPrivateOutputs = consensus.nMaxSparkOutLimitPerTx > 1 - ? consensus.nMaxSparkOutLimitPerTx - 2 - : 0; - limits.maxTransparentAmount = - consensus.GetMaxValueSparkSpendPerTransaction(chainActive.Height()); - limits.maxFee = maxTxFee; - limits.maxMoney = MAX_MONEY; - limits.maxWeight = MAX_NEW_TX_WEIGHT; - limits.weightScaleFactor = WITNESS_SCALE_FACTOR; - return limits; -} - -std::vector SpendAndStoreSingleInputBatches( - CWallet& wallet, - const std::vector& recipients, - const std::vector>& privateRecipients, - CAmount& totalFee, - const CCoinControl* coinControl) -{ - if (recipients.empty() && privateRecipients.empty()) { - throw std::runtime_error(_("No Spark spend recipients were provided.")); - } - - if (HasMultipleSelectedCoins(coinControl)) { - throw std::runtime_error(_( - "Spark Coin Control temporarily supports selecting at most one coin. " - "Clear the selection to let the wallet split the payment automatically.")); - } - - CAmount totalAmount = 0; - for (const CRecipient& recipient : recipients) { - if (recipient.nAmount <= 0 || !MoneyRange(recipient.nAmount)) { - throw std::runtime_error(_("Invalid Spark spend amount.")); - } - if (totalAmount > MAX_MONEY - recipient.nAmount) { - throw std::runtime_error(_("Invalid Spark spend amount.")); - } - totalAmount += recipient.nAmount; - } - for (const auto& recipient : privateRecipients) { - const CAmount amount = static_cast(recipient.first.v); - if (amount <= 0 || !MoneyRange(amount)) { - throw std::runtime_error(_("Invalid Spark spend amount.")); - } - if (totalAmount > MAX_MONEY - amount) { - throw std::runtime_error(_("Invalid Spark spend amount.")); - } - totalAmount += amount; - } - - const CAmount sparkBalance = wallet.GetSparkBalance().first; - if (totalAmount > sparkBalance) { - throw InsufficientFunds(); - } - - std::vector recipientEntries; - recipientEntries.reserve(recipients.size() + privateRecipients.size()); - std::vector plannerRecipients; - plannerRecipients.reserve(recipientEntries.capacity()); - - for (const CRecipient& recipient : recipients) { - RecipientEntry entry; - entry.isPrivate = false; - entry.scriptPubKey = recipient.scriptPubKey; - entry.subtractFee = recipient.fSubtractFeeFromAmount; - recipientEntries.push_back(entry); - plannerRecipients.push_back({ - recipient.nAmount, - false, - TransparentMinimumOutputAmount(recipient.scriptPubKey), - recipient.fSubtractFeeFromAmount}); - } - for (const auto& recipient : privateRecipients) { - const CAmount amount = static_cast(recipient.first.v); - RecipientEntry entry; - entry.isPrivate = true; - entry.privateOutput = recipient.first; - entry.subtractFee = recipient.second; - recipientEntries.push_back(entry); - plannerRecipients.push_back({amount, true, 1, recipient.second}); - } - - std::vector availableCoins; - spark::BatchPlanLimits limits; - { - LOCK2(cs_main, wallet.cs_wallet); - - std::list coinMetadata = wallet.GetAvailableSparkCoins(coinControl); - coinMetadata.sort(CompareSparkCoins); - availableCoins.reserve(coinMetadata.size()); - for (const CSparkMintMeta& coin : coinMetadata) { - if (coin.v > static_cast(MAX_MONEY)) { - continue; - } - - COutPoint outpoint; - if (spark::GetOutPoint(outpoint, coin.coin)) { - availableCoins.push_back({static_cast(coin.v), outpoint}); - } - } - - limits = BuildBatchPlanLimits(); - } - - std::vector coinValues; - coinValues.reserve(availableCoins.size()); - for (const AvailableCoin& coin : availableCoins) { - coinValues.push_back(coin.value); - } - - spark::BatchPlanResult plan; - try { - plan = spark::PlanSingleInputSpend( - coinValues, - plannerRecipients, - limits, - EstimateSingleInputSparkFee, - spark::EstimateSingleInputSparkSize); - } catch (const std::exception& e) { - throw std::runtime_error(e.what()); - } - - ThrowForPlanStatus(plan.status); - - if (plan.batches.size() > 1 && HasSubtractFee(recipients, privateRecipients)) { - throw std::runtime_error(_( - "Subtracting the fee from the amount is temporarily unavailable when a Spark spend must be split across multiple transactions.")); - } - - // Create and validate every transaction before committing any of them so - // create-time / fee-mismatch failures cannot leave a partial payment on - // the network. Commit-time failures can still leave earlier txs broadcast; - // those are reported via SparkSpendBatchPartialFailure. - std::vector prepared; - prepared.reserve(plan.batches.size()); - - for (const spark::SingleInputBatch& batch : plan.batches) { - if (batch.coinIndex >= availableCoins.size()) { - throw std::runtime_error(_("Unable to select Spark coins for spend.")); - } - - CCoinControl singleCoinControl = coinControl ? *coinControl : CCoinControl(); - singleCoinControl.UnSelectAll(); - singleCoinControl.fAllowOtherInputs = false; - singleCoinControl.fRequireAllInputs = true; - singleCoinControl.Select(availableCoins[batch.coinIndex].outpoint); - - std::vector batchRecipients; - std::vector> batchPrivateRecipients; - if (plan.batches.size() == 1) { - batchRecipients = recipients; - batchPrivateRecipients = privateRecipients; - } else { - batchRecipients.reserve(batch.transparentOutputs); - batchPrivateRecipients.reserve(batch.privateOutputs); - for (const spark::BatchFragment& fragment : batch.fragments) { - const RecipientEntry& entry = recipientEntries[fragment.recipientIndex]; - if (entry.isPrivate) { - spark::OutputCoinData output = entry.privateOutput; - output.v = fragment.amount; - batchPrivateRecipients.emplace_back(std::move(output), false); - } else { - batchRecipients.push_back({entry.scriptPubKey, fragment.amount, false}); - } - } - } - - PreparedBatch preparedBatch; - preparedBatch.wtx = wallet.CreateSparkSpendTransaction( - batchRecipients, - batchPrivateRecipients, - preparedBatch.fee, - &singleCoinControl); - - if (!preparedBatch.wtx.tx || spark::GetSpendInputs(*preparedBatch.wtx.tx) != 1) { - throw std::runtime_error(_( - "Unable to create a single-input Spark transaction.")); - } - - if (preparedBatch.fee != batch.fee) { - throw std::runtime_error(strprintf( - _("Spark fee estimate did not match the wallet (planned %s, wallet %s)."), - FormatMoney(batch.fee), - FormatMoney(preparedBatch.fee))); - } - - prepared.push_back(std::move(preparedBatch)); - } - - std::vector committedTransactions; - committedTransactions.reserve(prepared.size()); - totalFee = 0; - - for (PreparedBatch& preparedBatch : prepared) { - try { - CommitSparkSpend(wallet, preparedBatch.wtx); - } catch (const std::exception& e) { - if (!committedTransactions.empty()) { - std::vector committedTxids; - committedTxids.reserve(committedTransactions.size()); - for (const CWalletTx& wtx : committedTransactions) { - committedTxids.push_back(wtx.GetHash()); - } - throw SparkSpendBatchPartialFailure( - e.what(), - std::move(committedTxids), - totalFee, - prepared.size()); - } - throw; - } - - totalFee += preparedBatch.fee; - committedTransactions.push_back(std::move(preparedBatch.wtx)); - } - - return committedTransactions; -} - -} // namespace sparkspendbatch diff --git a/src/wallet/sparkspendbatch.h b/src/wallet/sparkspendbatch.h deleted file mode 100644 index 5452609182..0000000000 --- a/src/wallet/sparkspendbatch.h +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2026 The Firo developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or https://opensource.org/license/mit/. - -#ifndef FIRO_WALLET_SPARKSPENDBATCH_H -#define FIRO_WALLET_SPARKSPENDBATCH_H - -#include "amount.h" -#include "spark/primitives.h" -#include "uint256.h" -#include "wallet/coincontrol.h" -#include "wallet/sparkbatchplanner.h" -#include "wallet/wallet.h" - -#include -#include -#include - -namespace sparkspendbatch { - -constexpr size_t MAX_SINGLE_INPUT_SPARK_TRANSACTIONS = 50; - -/** - * Thrown when one or more single-input Spark spends were already broadcast - * before a later batch failed. Callers must surface the committed txids and - * must not retry the whole payment. - */ -class SparkSpendBatchPartialFailure : public std::runtime_error -{ -public: - SparkSpendBatchPartialFailure( - std::string reason, - std::vector committedTxids, - CAmount committedFee, - size_t plannedTransactions); - - const std::vector& GetCommittedTxids() const { return m_committedTxids; } - CAmount GetCommittedFee() const { return m_committedFee; } - size_t GetPlannedTransactions() const { return m_plannedTransactions; } - -private: - std::vector m_committedTxids; - CAmount m_committedFee; - size_t m_plannedTransactions; -}; - -bool CompareSparkCoins(const CSparkMintMeta& a, const CSparkMintMeta& b); - -bool HasMultipleSelectedCoins(const CCoinControl* coinControl); - -CAmount EstimateSingleInputSparkFee(size_t privateOutputs, size_t transparentOutputs); - -/** Minimum non-dust transparent output amount for scriptPubKey. - * Matches CreateSparkSpendTransaction's IsDust(minRelayTxFee) check. */ -CAmount TransparentMinimumOutputAmount(const CScript& scriptPubKey); - -/** Requires cs_main to be held. */ -spark::BatchPlanLimits BuildBatchPlanLimits(); - -std::vector SpendAndStoreSingleInputBatches( - CWallet& wallet, - const std::vector& recipients, - const std::vector>& privateRecipients, - CAmount& totalFee, - const CCoinControl* coinControl = nullptr); - -} // namespace sparkspendbatch - -#endif // FIRO_WALLET_SPARKSPENDBATCH_H diff --git a/src/wallet/test/CMakeLists.txt b/src/wallet/test/CMakeLists.txt index e138e67341..3f1344abc1 100644 --- a/src/wallet/test/CMakeLists.txt +++ b/src/wallet/test/CMakeLists.txt @@ -9,7 +9,6 @@ target_sources(test_firo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/crypto_tests.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mnemonic_tests.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/spark_batch_planner_tests.cpp ${CMAKE_CURRENT_SOURCE_DIR}/spark_wallet_tests.cpp ${CMAKE_CURRENT_SOURCE_DIR}/wallet_test_fixture.cpp ${CMAKE_CURRENT_SOURCE_DIR}/wallet_tests.cpp diff --git a/src/wallet/test/spark_batch_planner_tests.cpp b/src/wallet/test/spark_batch_planner_tests.cpp deleted file mode 100644 index 468a27e60b..0000000000 --- a/src/wallet/test/spark_batch_planner_tests.cpp +++ /dev/null @@ -1,281 +0,0 @@ -// Copyright (c) 2026 The Firo developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or https://opensource.org/license/mit/. - -#include "wallet/sparkbatchplanner.h" - -#include - -namespace { - -spark::BatchPlanLimits DefaultLimits() -{ - spark::BatchPlanLimits limits; - limits.maxTransactions = 50; - limits.maxPrivateOutputs = 14; - limits.maxTransparentAmount = 10'000; - limits.maxFee = 1'000; - limits.maxMoney = MAX_MONEY; - limits.maxWeight = 1'000'000; - limits.weightScaleFactor = 4; - return limits; -} - -} // namespace - -BOOST_AUTO_TEST_SUITE(spark_batch_planner_tests) - -BOOST_AUTO_TEST_CASE(single_input_size_matches_wallet_model) -{ - BOOST_CHECK_EQUAL(spark::EstimateSingleInputSparkSize(0, 0), 3'049U); - BOOST_CHECK_EQUAL(spark::EstimateSingleInputSparkSize(2, 3), 3'795U); -} - -BOOST_AUTO_TEST_CASE(single_coin_funds_single_recipient) -{ - const spark::BatchPlanLimits limits = DefaultLimits(); - - const std::vector coins{1'000}; - const std::vector recipients{{500, false, 100}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::OK); - BOOST_REQUIRE_EQUAL(result.batches.size(), 1U); - BOOST_CHECK_EQUAL(result.batches.front().coinIndex, 0U); - BOOST_CHECK_EQUAL(result.batches.front().fee, 10); - BOOST_REQUIRE_EQUAL(result.batches.front().fragments.size(), 1U); - BOOST_CHECK_EQUAL(result.batches.front().fragments.front().recipientIndex, 0U); - BOOST_CHECK_EQUAL(result.batches.front().fragments.front().amount, 500); -} - -BOOST_AUTO_TEST_CASE(single_coin_funds_mixed_recipients_and_keeps_surplus) -{ - const spark::BatchPlanLimits limits = DefaultLimits(); - - const std::vector coins{2'000}; - const std::vector recipients{ - {400, true, 1}, {500, false, 1}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t privateOutputs, size_t transparentOutputs) { - return static_cast(10 * (privateOutputs + transparentOutputs)); - }, - spark::EstimateSingleInputSparkSize); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::OK); - BOOST_REQUIRE_EQUAL(result.batches.size(), 1U); - const spark::SingleInputBatch& batch = result.batches.front(); - BOOST_CHECK_EQUAL(batch.coinIndex, 0U); - BOOST_CHECK_EQUAL(batch.amount, 900); - BOOST_CHECK_EQUAL(batch.fee, 20); - BOOST_CHECK_EQUAL(batch.privateOutputs, 1U); - BOOST_CHECK_EQUAL(batch.transparentOutputs, 1U); - BOOST_REQUIRE_EQUAL(batch.fragments.size(), 2U); -} - -BOOST_AUTO_TEST_CASE(insufficient_single_coin_returns_no_partial_plan) -{ - const spark::BatchPlanLimits limits = DefaultLimits(); - - const std::vector coins{1'000}; - const std::vector recipients{{1'001, false, 1}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::INSUFFICIENT_FUNDS); - BOOST_CHECK(result.batches.empty()); -} - -BOOST_AUTO_TEST_CASE(transparent_remainder_is_not_dust) -{ - const spark::BatchPlanLimits limits = DefaultLimits(); - - const std::vector coins{1'000, 1'000}; - const std::vector recipients{{991, false, 100}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::OK); - BOOST_REQUIRE_EQUAL(result.batches.size(), 2U); - CAmount total = 0; - for (const auto& batch : result.batches) { - BOOST_REQUIRE_EQUAL(batch.fragments.size(), 1U); - BOOST_CHECK_GE(batch.fragments.front().amount, 100); - total += batch.fragments.front().amount; - } - BOOST_CHECK_EQUAL(total, 991); -} - -BOOST_AUTO_TEST_CASE(private_remainder_respects_configured_minimum) -{ - const spark::BatchPlanLimits limits = DefaultLimits(); - const std::vector coins{1'000, 1'000}; - const std::vector recipients{{991, true, 100}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::OK); - BOOST_REQUIRE_EQUAL(result.batches.size(), 2U); - for (const auto& batch : result.batches) { - BOOST_REQUIRE_EQUAL(batch.fragments.size(), 1U); - BOOST_CHECK_GE(batch.fragments.front().amount, 100); - } -} - -BOOST_AUTO_TEST_CASE(transparent_limit_is_applied_per_transaction) -{ - spark::BatchPlanLimits limits = DefaultLimits(); - limits.maxTransparentAmount = 700; - - const std::vector coins{1'000, 1'000}; - const std::vector recipients{{1'200, false, 100}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::OK); - BOOST_REQUIRE_EQUAL(result.batches.size(), 2U); - CAmount total = 0; - for (const auto& batch : result.batches) { - BOOST_CHECK_LE(batch.transparentAmount, limits.maxTransparentAmount); - total += batch.transparentAmount; - } - BOOST_CHECK_EQUAL(total, 1'200); -} - -BOOST_AUTO_TEST_CASE(private_output_limit_starts_a_new_transaction) -{ - spark::BatchPlanLimits limits = DefaultLimits(); - limits.maxPrivateOutputs = 2; - - const std::vector coins{1'000, 1'000}; - const std::vector recipients{ - {100, true, 1}, {100, true, 1}, {100, true, 1}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::OK); - BOOST_REQUIRE_EQUAL(result.batches.size(), 2U); - BOOST_CHECK_EQUAL(result.batches[0].privateOutputs, 2U); - BOOST_CHECK_EQUAL(result.batches[1].privateOutputs, 1U); -} - -BOOST_AUTO_TEST_CASE(final_fee_is_repriced_for_every_output) -{ - const spark::BatchPlanLimits limits = DefaultLimits(); - const std::vector coins{1'000}; - const std::vector recipients{ - {400, false, 1}, {400, false, 1}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t privateOutputs, size_t transparentOutputs) { - return static_cast(10 * (privateOutputs + transparentOutputs)); - }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::OK); - BOOST_REQUIRE_EQUAL(result.batches.size(), 1U); - BOOST_CHECK_EQUAL(result.batches.front().amount, 800); - BOOST_CHECK_EQUAL(result.batches.front().fee, 20); -} - -BOOST_AUTO_TEST_CASE(transaction_limit_fails_without_a_partial_plan) -{ - spark::BatchPlanLimits limits = DefaultLimits(); - limits.maxTransactions = 2; - - const std::vector coins{100, 100, 100}; - const std::vector recipients{{250, false, 1}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::TOO_MANY_TRANSACTIONS); - BOOST_CHECK(result.batches.empty()); -} - -BOOST_AUTO_TEST_CASE(exact_balance_succeeds_when_fee_subtracted_from_amount) -{ - const spark::BatchPlanLimits limits = DefaultLimits(); - const std::vector coins{1'000}; - // amount == coin value; fee is carved from the output, not reserved against the coin. - const std::vector recipients{{1'000, true, 1, true}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::OK); - BOOST_REQUIRE_EQUAL(result.batches.size(), 1U); - BOOST_CHECK_EQUAL(result.batches.front().amount, 1'000); - BOOST_CHECK_EQUAL(result.batches.front().fee, 10); - BOOST_CHECK_EQUAL(result.batches.front().fragments.size(), 1U); - BOOST_CHECK_EQUAL(result.batches.front().fragments.front().amount, 1'000); -} - -BOOST_AUTO_TEST_CASE(exact_balance_fails_when_fee_not_subtracted_from_amount) -{ - const spark::BatchPlanLimits limits = DefaultLimits(); - const std::vector coins{1'000}; - const std::vector recipients{{1'000, true, 1, false}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::INSUFFICIENT_FUNDS); - BOOST_CHECK(result.batches.empty()); -} - -BOOST_AUTO_TEST_CASE(subtract_fee_rejects_when_fee_consumes_output) -{ - const spark::BatchPlanLimits limits = DefaultLimits(); - const std::vector coins{1'000}; - const std::vector recipients{{10, false, 1, true}}; - const auto result = spark::PlanSingleInputSpend( - coins, - recipients, - limits, - [](size_t, size_t) { return CAmount{10}; }, - [](size_t, size_t) { return 100U; }); - - BOOST_CHECK(result.status == spark::BatchPlanStatus::INSUFFICIENT_FUNDS); - BOOST_CHECK(result.batches.empty()); -} - -BOOST_AUTO_TEST_SUITE_END() diff --git a/src/wallet/test/spark_wallet_tests.cpp b/src/wallet/test/spark_wallet_tests.cpp index b566ad7542..5b93b78af8 100644 --- a/src/wallet/test/spark_wallet_tests.cpp +++ b/src/wallet/test/spark_wallet_tests.cpp @@ -115,9 +115,15 @@ BOOST_AUTO_TEST_CASE(standalone_smint_has_no_wallet_provenance) CValidationState validationState; spark::CSparkTxInfo info; + // Regtest activates Chaum V2 at height 1, before Spark itself. Validate at + // the first height where both rules apply, which is H2 on networks that + // activate Spark first. + const Consensus::Params& consensus = ::Params().GetConsensus(); + const int validationHeight = std::max( + consensus.nSparkStartBlock, consensus.nSparkChaumV2StartBlock); BOOST_REQUIRE(CheckTransaction( mixedTransaction, validationState, true, mixedTransaction.GetHash(), - false, ::Params().GetConsensus().nSparkChaumV2StartBlock, + false, validationHeight, false, true, &info)); BOOST_REQUIRE_EQUAL(info.mints.size(), 1U); diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 447bc2022f..02cc9fbadc 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -15,7 +15,6 @@ #include "checkpoints.h" #include "chain.h" #include "wallet/coincontrol.h" -#include "wallet/sparkspendbatch.h" #include "consensus/consensus.h" #include "consensus/validation.h" #include "key.h" @@ -4247,16 +4246,6 @@ CWalletTx CWallet::SpendAndStoreSpark( return result; } -std::vector CWallet::SpendAndStoreSparkSingleInput( - const std::vector& recipients, - const std::vector>& privateRecipients, - CAmount& totalFee, - const CCoinControl* coinControl) -{ - return sparkspendbatch::SpendAndStoreSingleInputBatches( - *this, recipients, privateRecipients, totalFee, coinControl); -} - void CWallet::ListAccountCreditDebit(const std::string& strAccount, std::list& entries) { CWalletDB walletdb(strWalletFile); return walletdb.ListAccountCreditDebit(strAccount, entries); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index b655825cc9..a98250a7f6 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -1080,12 +1080,6 @@ class CWallet : public CCryptoKeyStore, public CValidationInterface CAmount &fee, const CCoinControl *coinControl = NULL); - std::vector SpendAndStoreSparkSingleInput( - const std::vector& recipients, - const std::vector>& privateRecipients, - CAmount& totalFee, - const CCoinControl* coinControl = NULL); - /** * Persist a constructed transaction. If fCheckTransaction is true, require * mempool acceptance before AddToWallet so a rejection fails the commit. diff --git a/src/wallet/walletexcept.cpp b/src/wallet/walletexcept.cpp index e26eaa7ea3..f8d664b36d 100644 --- a/src/wallet/walletexcept.cpp +++ b/src/wallet/walletexcept.cpp @@ -35,19 +35,3 @@ InsufficientFunds::InsufficientFunds(CAmount requiredFee) : WalletError(_("Insufficient funds")), nRequiredFee(requiredFee) { } - -// SparkFundsFragmented - -SparkFundsFragmented::SparkFundsFragmented() : WalletError(_( - "Spark multi-input spends are temporarily disabled. " - "No single available Spark coin can fund this transaction.")) -{ -} - -SparkFundsFragmented::SparkFundsFragmented(const char *what) : WalletError(what) -{ -} - -SparkFundsFragmented::SparkFundsFragmented(const std::string& what) : WalletError(what) -{ -} diff --git a/src/wallet/walletexcept.h b/src/wallet/walletexcept.h index efa93924c7..0ccaee9389 100644 --- a/src/wallet/walletexcept.h +++ b/src/wallet/walletexcept.h @@ -32,13 +32,4 @@ class InsufficientFunds : public WalletError CAmount nRequiredFee{0}; }; -/** Balance is enough in aggregate, but no single Spark coin can fund the spend. */ -class SparkFundsFragmented : public WalletError -{ -public: - SparkFundsFragmented(); - explicit SparkFundsFragmented(const char *what); - explicit SparkFundsFragmented(const std::string& what); -}; - #endif // FIRO_WALLET_WALLETEXCEPT_H