diff --git a/doc/api-documentation.md b/doc/api-documentation.md index 45883c4949..4dd6ed38d4 100644 --- a/doc/api-documentation.md +++ b/doc/api-documentation.md @@ -786,7 +786,10 @@ Returns estimated network hashes per second. Returns data needed to construct a block for mining. **Arguments:** -1. `template_request` (json object, optional) - BIP 22/23 compliant request +1. `template_request` (json object, optional) - BIP 22/23 compliant request. Firo additionally accepts + `"coinbase_message": "text"` (at most 80 UTF-8 bytes), which is put into the coinbase of the block + the node builds for `pprpcsb` and echoed back as `coinbase_message`. Needs `reward_address`. +2. `reward_address` (string, optional) - Address paid by the coinbase of the block the node builds for `pprpcsb` **Result:** ```json diff --git a/doc/release-notes.md b/doc/release-notes.md index a721449e43..4ea5a859d8 100644 --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -37,6 +37,10 @@ Notable changes RPC --- +- `getblocktemplate`: the template request accepts `coinbase_message`, a text of + at most 80 UTF-8 bytes that is put into the coinbase of the block the node + builds for `pprpcsb`. The result echoes it as `coinbase_message`. + - `getsparknametxdetails`: For confirmed Spark name transactions, `validUntil` reports the expiry height recorded in the containing block (`sparkNameValidityHeight`) rather than the name manager's current state. diff --git a/qa/pull-tester/rpc-tests.py b/qa/pull-tester/rpc-tests.py index cf3b09beae..86ac0efd4a 100755 --- a/qa/pull-tester/rpc-tests.py +++ b/qa/pull-tester/rpc-tests.py @@ -173,6 +173,7 @@ # Firo-specific tests 'transactions_verification_after_restart.py', + 'getblocktemplate_coinbase.py', # Evo Znodes 'dip3-deterministicmns.py', diff --git a/qa/rpc-tests/getblocktemplate_coinbase.py b/qa/rpc-tests/getblocktemplate_coinbase.py new file mode 100755 index 0000000000..446828c958 --- /dev/null +++ b/qa/rpc-tests/getblocktemplate_coinbase.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Firo Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test coinbase messages for solo mining through getblocktemplate and pprpcsb. + +getblocktemplate({"coinbase_message": text}, reward_address) puts text into the +coinbase of the block the node builds for pprpcsb and echoes it in the result. +""" + +import time + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal, assert_raises_jsonrpc, connect_nodes_bi, start_nodes, sync_blocks + +RPC_TYPE_ERROR = -3 +RPC_INVALID_PARAMETER = -8 +RPC_INVALID_PARAMS = -32602 + + +class GetBlockTemplateCoinbaseMessageTest(BitcoinTestFramework): + + def __init__(self): + super().__init__() + self.num_nodes = 2 + self.setup_clean_chain = False + + def setup_network(self): + # ProgPoW has to be active on regtest for getblocktemplate to hand out pprpcsb jobs + args = ['-ppswitchtime=%d' % (int(time.time()) - 10)] + self.nodes = start_nodes(self.num_nodes, self.options.tmpdir, [args] * self.num_nodes) + connect_nodes_bi(self.nodes, 0, 1) + self.is_network_split = False + self.sync_all() + + def run_test(self): + node = self.nodes[0] + node.generate(1) # getblocktemplate refuses to work on a stale tip + sync_blocks(self.nodes) + address = node.getnewaddress() + + plain = node.getblocktemplate({}, address) + assert 'coinbase_message' not in plain + + # The message is acknowledged and, being part of the coinbase, changes the job + hello = node.getblocktemplate({'coinbase_message': 'hello'}, address) + assert_equal(hello['coinbase_message'], 'hello') + assert hello['pprpcheader'] != plain['pprpcheader'] + + # The same request keeps its job while it is fresh; a different message gets a job of its own + assert_equal(node.getblocktemplate({'coinbase_message': 'hello'}, address)['pprpcheader'], hello['pprpcheader']) + world = node.getblocktemplate({'coinbase_message': 'world'}, address) + assert world['pprpcheader'] not in (plain['pprpcheader'], hello['pprpcheader']) + + # An empty message gives back the job built from the original coinbase + assert_equal(node.getblocktemplate({'coinbase_message': ''}, address)['pprpcheader'], plain['pprpcheader']) + + # Every job handed out can still be submitted: a wrong solution is a bad solution, not an unknown job + for header in (plain['pprpcheader'], hello['pprpcheader'], world['pprpcheader']): + assert_raises_jsonrpc(RPC_INVALID_PARAMS, 'Bad solution', node.pprpcsb, header, '00' * 32, '0x1') + + # 80 UTF-8 bytes fit, more do not, and the value must be a string + longest = 'a' * 80 + assert_equal(node.getblocktemplate({'coinbase_message': longest}, address)['coinbase_message'], longest) + assert_raises_jsonrpc(RPC_INVALID_PARAMETER, 'too long', node.getblocktemplate, {'coinbase_message': longest + 'a'}, address) + assert_raises_jsonrpc(RPC_INVALID_PARAMETER, 'too long', node.getblocktemplate, {'coinbase_message': 'é' * 41}, address) + assert_raises_jsonrpc(RPC_TYPE_ERROR, 'must be a string', node.getblocktemplate, {'coinbase_message': 1}, address) + + +if __name__ == '__main__': + GetBlockTemplateCoinbaseMessageTest().main() diff --git a/src/rpc/mining.cpp b/src/rpc/mining.cpp index 1823c508ea..a742ddf024 100644 --- a/src/rpc/mining.cpp +++ b/src/rpc/mining.cpp @@ -47,6 +47,9 @@ extern CTxPoolAggregate txpools; */ std::map mapPPBlockTemplates; +/** Maximum length in bytes of a miner-supplied coinbase message (getblocktemplate "coinbase_message") */ +static const size_t MAX_COINBASE_MESSAGE_SIZE = 80; + /** * Return average network hashes per second based on the last 'lookup' blocks, * or from the last difficulty change if 'lookup' is nonpositive. @@ -435,7 +438,8 @@ UniValue getblocktemplate(const JSONRPCRequest& request) " \"rules\":[ (array, optional) A list of strings\n" " \"support\" (string) client side supported softfork deployment\n" " ,...\n" - " ]\n" + " ],\n" + " \"coinbase_message\":\"text\" (string, optional) text (at most 80 UTF-8 bytes) to put in the coinbase of the block built for pprpcsb; needs reward_address\n" " }\n" "2. reward_address (string, optional) address for reward in coinbase (meaningful only if block solution is later submitter with pprpcsb)\n" "\n" @@ -493,7 +497,8 @@ UniValue getblocktemplate(const JSONRPCRequest& request) " },\n" " \"znode_payments_started\" : true|false, (boolean) true, if znode payments started\n" " \"znode_payments_enforced\" : true|false, (boolean) true, if znode payments are enforced\n" - " \"coinbase_payload\" : \"xxxxxxxx\" (string) coinbase transaction payload data encoded in hexadecimal\n" + " \"coinbase_payload\" : \"xxxxxxxx\", (string) coinbase transaction payload data encoded in hexadecimal\n" + " \"coinbase_message\" : \"text\" (string) the coinbase message that was applied (only present when requested)\n" "}\n" "\nExamples:\n" @@ -507,6 +512,8 @@ UniValue getblocktemplate(const JSONRPCRequest& request) UniValue lpval = NullUniValue; std::set setClientRules; int64_t nMaxVersionPreVB = -1; + std::string strCoinbaseMessage; + bool fCoinbaseMessageSet = false; if (request.params.size() > 0) { const auto oparam = request.params[0].get_obj(); @@ -564,6 +571,16 @@ UniValue getblocktemplate(const JSONRPCRequest& request) nMaxVersionPreVB = uvMaxVersion.get_int64(); } } + + const auto msgval = find_value(oparam, "coinbase_message"); + if (msgval.isStr()) { + strCoinbaseMessage = msgval.get_str(); + fCoinbaseMessageSet = true; + if (strCoinbaseMessage.size() > MAX_COINBASE_MESSAGE_SIZE) + throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("coinbase_message is too long (%u bytes, maximum is %u)", strCoinbaseMessage.size(), MAX_COINBASE_MESSAGE_SIZE)); + } else if (!msgval.isNull()) { + throw JSONRPCError(RPC_TYPE_ERROR, "coinbase_message must be a string"); + } } if (strMode != "template") @@ -664,7 +681,9 @@ UniValue getblocktemplate(const JSONRPCRequest& request) // Need to update only after we know CreateNewBlock succeeded pindexPrev = pindexPrevNew; } - CBlock* pblock = &pblocktemplate->block; // pointer for convenience + // Work on a copy of the shared template: the coinbase is customised per request below + CBlock block = pblocktemplate->block; + CBlock* pblock = █ // pointer for convenience const Consensus::Params& consensusParams = Params().GetConsensus(); // Update nTime @@ -686,6 +705,16 @@ UniValue getblocktemplate(const JSONRPCRequest& request) fRewardAddressSet = true; } + // Append the coinbase message to the coinbase input script, after whatever CreateNewBlock put there + if (!strCoinbaseMessage.empty()) { + CMutableTransaction coinbaseTx = *pblock->vtx[0]; + coinbaseTx.vin[0].scriptSig << std::vector(strCoinbaseMessage.begin(), strCoinbaseMessage.end()); + // consensus limits the coinbase input script to 100 bytes (see CheckTransaction) + if (coinbaseTx.vin[0].scriptSig.size() > 100) + throw JSONRPCError(RPC_INVALID_PARAMETER, "coinbase_message does not fit in the coinbase script"); + pblock->vtx[0] = MakeTransactionRef(CTransaction(coinbaseTx)); + } + // TODO: support segwit // NOTE: If at some point we support pre-segwit miners post-segwit-activation, this needs to take segwit support into consideration // const bool fPreSegWit = (THRESHOLD_ACTIVE != VersionBitsState(pindexPrev, consensusParams, Consensus::DEPLOYMENT_SEGWIT, versionbitscache)); @@ -859,21 +888,28 @@ UniValue getblocktemplate(const JSONRPCRequest& request) result.push_back(Pair("default_witness_commitment", HexStr(pblocktemplate->vchCoinbaseCommitment.begin(), pblocktemplate->vchCoinbaseCommitment.end()))); } + if (fCoinbaseMessageSet) + result.pushKV("coinbase_message", strCoinbaseMessage); + if (pblock->IsProgPow()) { - static std::string lastHeader{}; - if (mapPPBlockTemplates.count(lastHeader) && ((pblock->nTime - 30) < mapPPBlockTemplates.at(lastHeader).nTime)) - { - result.pushKV("pprpcheader", lastHeader); - result.pushKV("pprpcepoch", ethash::get_epoch_number(pblock->nHeight)); - return result; + // Reuse a fresh job that was built for the same coinbase (reward address and message). + // Jobs of other callers stay in the cache so that they can still be submitted with pprpcsb. + std::string header; + for (const auto& entry : mapPPBlockTemplates) { + if (entry.second.vtx[0]->GetHash() == pblock->vtx[0]->GetHash() && (pblock->nTime - 30) < entry.second.nTime) { + header = entry.first; + break; + } } - pblock->hashMerkleRoot = BlockMerkleRoot(*pblock); - lastHeader = pblock->GetProgPowHeaderHash().GetHex(); - result.pushKV("pprpcheader", lastHeader); + if (header.empty()) { + pblock->hashMerkleRoot = BlockMerkleRoot(*pblock); + header = pblock->GetProgPowHeaderHash().GetHex(); + if (fRewardAddressSet) + // don't bother to save block unless reward address is set + mapPPBlockTemplates[header] = *pblock; + } + result.pushKV("pprpcheader", header); result.pushKV("pprpcepoch", ethash::get_epoch_number(pblock->nHeight)); - if (fRewardAddressSet) - // don't bother to save block unless reward address is set - mapPPBlockTemplates[lastHeader] = *pblock; } return result; @@ -931,13 +967,16 @@ UniValue pprpcsb(const JSONRPCRequest& request) } // Check provided header_hash is in cache - if (!mapPPBlockTemplates.count(header_hex)) + std::shared_ptr blockptr = std::make_shared(); { - throw JSONRPCError(RPC_INVALID_PARAMS, "Job not found"); + LOCK(cs_main); // the cache is maintained by getblocktemplate under cs_main + const auto it = mapPPBlockTemplates.find(header_hex); + if (it == mapPPBlockTemplates.end()) + { + throw JSONRPCError(RPC_INVALID_PARAMS, "Job not found"); + } + *blockptr = it->second; } - - std::shared_ptr blockptr = std::make_shared(); - *blockptr = mapPPBlockTemplates.at(header_hex); blockptr->nNonce64 = nonce; // Check provided solution is formally valid