Repository: bitcoin/bitcoin (language: cpp)

Issue:
rpc: prevent non-zero value OP_RETURN outputs in sendrawtransaction
While some protocols require value attached to OP_RETURN outputs, this otherwise seems like a mistake.

Setting the value to 0 should really be done by the wallet/script creating the transaction (like createrawtransaction does for `data` outputs). However, as an additional check, similar to the high-fee check, sendrawtransaction could also check for non-zero value OP_RETURN outputs.

This should be configurable by a new RPC argument.

See also https://transactionfee.info/charts/output-opreturn-amount/

#### Useful skills:

* Bitcoin Core RPC code
* Transaction output types
* Bitcoin Core functional tests

#### Want to work on this issue?

For guidance on contributing, please read [CONTRIBUTING.md](https://github.com/bitcoin/bitcoin/blob/master/CONTRIBUTING.md) before opening your pull request.



Discussion / hints:
Hi! I would like to work on this. Please assign 
Considering all of the applications using nonzero outputs in op_return, wouldn't it make more sense to just have an option to prune this before saving it to disk?  Would it not be better to deal with one optional flag on the node instead of complicating countless apps that are already using the previously assumed behavior?  Nothing is gained through this change that cannot be accomplished with existing pruning code for op_return, unless I am misunderstanding something.
> Considering all of the applications using nonzero outputs in op_return

It's unclear to me why any application needs to use non-zero op_returns. It is literally just burning money. While there are some applications where burning money is the point, I don't think we should really be encouraging/enabling those. Just because someone uses a particular feature does not mean it is necessarily good or worth keeping. Furthermore, I would not be surprised if many of those applications are burning money accidentally. A few developers I have spoken to were surprised that 0 value op_returns were allowed because they were not aware of the exception to the dust limit for op_returns. Application developers may have the same misconception.

> wouldn't it make more sense to just have an option to prune this before saving it to disk?

Disk space is not the concern. Whether the amount is 0 or something else doesn't matter, it still takes up the same amount of space. We're already pruning op_returns from the UTXO set. Rather the point is to add a protection for users. The purpose is to prevent users from accidentally burning money. In general, we should erect barriers to users throwing away their money as it is often done by accident.
> It's unclear to me why any application needs to use non-zero op_returns. It is literally just burning money. While there are some applications where burning money is the point, I don't think we should really be encouraging/enabling those.

Why is burning an issue? Projects that do proof of burn and similar things won't be able to use `sendrawtransaction` and figure out other ways to broadcast transactions.
Please read the description. It says "This should be configurable by a new RPC argument." So everyone can still burn as much money as they want after they toggle the value of the new RPC optional argument.
Sorry I misunderstood the issue and linked pull request. I thought all non zero OP_RETURN would get rejected.

Fix patch:
diff --git a/doc/release-notes-25943.md b/doc/release-notes-25943.md
new file mode 100644
index 0000000000000..81b0a48b5d3b5
--- /dev/null
+++ b/doc/release-notes-25943.md
@@ -0,0 +1,4 @@
+New RPC Argument
+--------
+- `sendrawtransaction` has a new, optional argument, `maxburnamount` with a default value of `0`. Any transaction containing an unspendable output with a value greater than `maxburnamount` will not be submitted. At present, the outputs deemed unspendable are those with scripts that begin with an `OP_RETURN` code (known as 'datacarriers'), scripts that exceed the maximum script size, and scripts that contain invalid opcodes.
+
diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp
index 5fe914f0a1498..eb91a151b52bf 100644
--- a/src/rpc/client.cpp
+++ b/src/rpc/client.cpp
@@ -114,6 +114,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
     { "signrawtransactionwithkey", 2, "prevtxs" },
     { "signrawtransactionwithwallet", 1, "prevtxs" },
     { "sendrawtransaction", 1, "maxfeerate" },
+    { "sendrawtransaction", 2, "maxburnamount" },
     { "testmempoolaccept", 0, "rawtxs" },
     { "testmempoolaccept", 1, "maxfeerate" },
     { "submitpackage", 0, "package" },
diff --git a/src/rpc/mempool.cpp b/src/rpc/mempool.cpp
index 44f7435a26e63..0e202a963dfdc 100644
--- a/src/rpc/mempool.cpp
+++ b/src/rpc/mempool.cpp
@@ -18,6 +18,7 @@
 #include <rpc/server.h>
 #include <rpc/server_util.h>
 #include <rpc/util.h>
+#include <script/standard.h>
 #include <txmempool.h>
 #include <univalue.h>
 #include <util/moneystr.h>
@@ -44,7 +45,11 @@ static RPCHelpMan sendrawtransaction()
             {"hexstring", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The hex string of the raw transaction"},
             {"maxfeerate", RPCArg::Type::AMOUNT, RPCArg::Default{FormatMoney(DEFAULT_MAX_RAW_TX_FEE_RATE.GetFeePerK())},
              "Reject transactions whose fee rate is higher than the specified value, expressed in " + CURRENCY_UNIT +
-                 "/kvB.\nSet to 0 to accept any fee rate.\n"},
+                 "/kvB.\nSet to 0 to accept any fee rate."},
+            {"maxburnamount", RPCArg::Type::AMOUNT, RPCArg::Default{FormatMoney(0)},
+             "Reject transactions with provably unspendable outputs (e.g. 'datacarrier' outputs that use the OP_RETURN opcode) greater than the specified value, expressed in " + CURRENCY_UNIT + ".\n"
+             "If burning funds through unspendable outputs is desired, increase this value.\n"
+             "This check is based on heuristics and does not guarantee spendability of outputs.\n"},
         },
         RPCResult{
             RPCResult::Type::STR_HEX, "", "The transaction hash in hex"
@@ -61,10 +66,19 @@ static RPCHelpMan sendrawtransaction()
                 },
         [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue
         {
+            const CAmount max_burn_amount = request.params[2].isNull() ? 0 : AmountFromValue(request.params[2]);
+
             CMutableTransaction mtx;
             if (!DecodeHexTx(mtx, request.params[0].get_str())) {
                 throw JSONRPCError(RPC_DESERIALIZATION_ERROR, "TX decode failed. Make sure the tx has at least one input.");
             }
+
+            for (const auto& out : mtx.vout) {
+                if((out.scriptPubKey.IsUnspendable() || !out.scriptPubKey.HasValidOps()) && out.nValue > max_burn_amount) {
+                    throw JSONRPCTransactionError(TransactionError::MAX_BURN_EXCEEDED);
+                }
+            }
+
             CTransactionRef tx(MakeTransactionRef(std::move(mtx)));
 
             const CFeeRate max_raw_tx_fee_rate = request.params[1].isNull() ?
diff --git a/src/util/error.cpp b/src/util/error.cpp
index 193265c8429f1..309877d06754a 100644
--- a/src/util/error.cpp
+++ b/src/util/error.cpp
@@ -33,6 +33,8 @@ bilingual_str TransactionErrorString(const TransactionError err)
             return Untranslated("Specified sighash value does not match value stored in PSBT");
         case TransactionError::MAX_FEE_EXCEEDED:
             return Untranslated("Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)");
+        case TransactionError::MAX_BURN_EXCEEDED:
+            return Untranslated("Unspendable output exceeds maximum configured by user (maxburnamount)");
         case TransactionError::EXTERNAL_SIGNER_NOT_FOUND:
             return Untranslated("External signer not found");
         case TransactionError::EXTERNAL_SIGNER_FAILED:
diff --git a/src/util/error.h b/src/util/error.h
index 649200c98ef13..a52a8f47de47b 100644
--- a/src/util/error.h
+++ b/src/util/error.h
@@ -30,6 +30,7 @@ enum class TransactionError {
     PSBT_MISMATCH,
     SIGHASH_MISMATCH,
     MAX_FEE_EXCEEDED,
+    MAX_BURN_EXCEEDED,
     EXTERNAL_SIGNER_NOT_FOUND,
     EXTERNAL_SIGNER_FAILED,
     INVALID_PACKAGE,


Test patch:
diff --git a/test/functional/feature_coinstatsindex.py b/test/functional/feature_coinstatsindex.py
index eff4d9b149f32..4f8541a5d7ded 100755
--- a/test/functional/feature_coinstatsindex.py
+++ b/test/functional/feature_coinstatsindex.py
@@ -156,9 +156,10 @@ def _test_coin_stats_index(self):
 
         # Generate and send another tx with an OP_RETURN output (which is unspendable)
         tx2 = self.wallet.create_self_transfer(utxo_to_spend=tx1_out_21)['tx']
-        tx2.vout = [CTxOut(int(Decimal('20.99') * COIN), CScript([OP_RETURN] + [OP_FALSE] * 30))]
+        tx2_val = '20.99'
+        tx2.vout = [CTxOut(int(Decimal(tx2_val) * COIN), CScript([OP_RETURN] + [OP_FALSE] * 30))]
         tx2_hex = tx2.serialize().hex()
-        self.nodes[0].sendrawtransaction(tx2_hex)
+        self.nodes[0].sendrawtransaction(tx2_hex, 0, tx2_val)
 
         # Include both txs in a block
         self.generate(self.nodes[0], 1)
diff --git a/test/functional/rpc_rawtransaction.py b/test/functional/rpc_rawtransaction.py
index cdec4b2a85dee..23959356200f3 100755
--- a/test/functional/rpc_rawtransaction.py
+++ b/test/functional/rpc_rawtransaction.py
@@ -18,9 +18,17 @@
 
 from test_framework.messages import (
     MAX_BIP125_RBF_SEQUENCE,
+    COIN,
     CTransaction,
+    CTxOut,
     tx_from_hex,
 )
+from test_framework.script import (
+    CScript,
+    OP_FALSE,
+    OP_INVALIDOPCODE,
+    OP_RETURN,
+)
 from test_framework.test_framework import BitcoinTestFramework
 from test_framework.util import (
     assert_equal,
@@ -331,6 +339,57 @@ def sendrawtransaction_tests(self):
         rawtx = self.nodes[2].createrawtransaction(inputs, outputs)
         assert_raises_rpc_error(-25, "bad-txns-inputs-missingorspent", self.nodes[2].sendrawtransaction, rawtx)
 
+        self.log.info("Test sendrawtransaction exceeding, falling short of, and equaling maxburnamount")
+        max_burn_exceeded = "Unspendable output exceeds maximum configured by user (maxburnamount)"
+
+
+        # Test that spendable transaction with default maxburnamount (0) gets sent
+        tx = self.wallet.create_self_transfer()['tx']
+        tx_hex = tx.serialize().hex()
+        self.nodes[2].sendrawtransaction(hexstring=tx_hex)
+
+        # Test that datacarrier transaction with default maxburnamount (0) does not get sent
+        tx = self.wallet.create_self_transfer()['tx']
+        tx_val = 0.001
+        tx.vout = [CTxOut(int(Decimal(tx_val) * COIN), CScript([OP_RETURN] + [OP_FALSE] * 30))]
+        tx_hex = tx.serialize().hex()
+        assert_raises_rpc_error(-25, max_burn_exceeded, self.nodes[2].sendrawtransaction, tx_hex)
+
+        # Test that oversized script gets rejected by sendrawtransaction
+        tx = self.wallet.create_self_transfer()['tx']
+        tx_val = 0.001
+        tx.vout = [CTxOut(int(Decimal(tx_val) * COIN), CScript([OP_FALSE] * 10001))]
+        tx_hex = tx.serialize().hex()
+        assert_raises_rpc_error(-25, max_burn_exceeded, self.nodes[2].sendrawtransaction, tx_hex)
+
+        # Test that script containing invalid opcode gets rejected by sendrawtransaction
+        tx = self.wallet.create_self_transfer()['tx']
+        tx_val = 0.01
+        tx.vout = [CTxOut(int(Decimal(tx_val) * COIN), CScript([OP_INVALIDOPCODE]))]
+        tx_hex = tx.serialize().hex()
+        assert_raises_rpc_error(-25, max_burn_exceeded, self.nodes[2].sendrawtransaction, tx_hex)
+
+        # Test a transaction where our burn exceeds maxburnamount
+        tx = self.wallet.create_self_transfer()['tx']
+        tx_val = 0.001
+        tx.vout = [CTxOut(int(Decimal(tx_val) * COIN), CScript([OP_RETURN] + [OP_FALSE] * 30))]
+        tx_hex = tx.serialize().hex()
+        assert_raises_rpc_error(-25, max_burn_exceeded, self.nodes[2].sendrawtransaction, tx_hex, 0, 0.0009)
+
+        # Test a transaction where our burn falls short of maxburnamount
+        tx = self.wallet.create_self_transfer()['tx']
+        tx_val = 0.001
+        tx.vout = [CTxOut(int(Decimal(tx_val) * COIN), CScript([OP_RETURN] + [OP_FALSE] * 30))]
+        tx_hex = tx.serialize().hex()
+        self.nodes[2].sendrawtransaction(hexstring=tx_hex, maxfeerate='0', maxburnamount='0.0011')
+
+        # Test a transaction where our burn equals maxburnamount
+        tx = self.wallet.create_self_transfer()['tx']
+        tx_val = 0.001
+        tx.vout = [CTxOut(int(Decimal(tx_val) * COIN), CScript([OP_RETURN] + [OP_FALSE] * 30))]
+        tx_hex = tx.serialize().hex()
+        self.nodes[2].sendrawtransaction(hexstring=tx_hex, maxfeerate='0', maxburnamount='0.001')
+
     def sendrawtransaction_testmempoolaccept_tests(self):
         self.log.info("Test sendrawtransaction/testmempoolaccept with maxfeerate")
         fee_exceeds_max = "Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)"