subsidy: implementation for claimpegin, createrawpegin, and RPCs
What changed, and why it matters
This commit adds a new 'pegin subsidy' feature to the Elements sidechain. When users move small amounts of Bitcoin into the sidechain (a 'pegin'), the sidechain now sometimes adds an extra output that pays the network operators (the functionaries) a fee to cover the cost of later moving those coins back to Bitcoin. The change also adds a helper that reads how many signatures a federation script requires, and exposes new information in several RPC commands. There is no explicit security bug in the diff, but the new subsidy logic touches consensus-relevant money flows and relies on external fee data, so it deserves careful review.
Treat this as a high-risk feature change rather than a simple bugfix. Review the subsidy formula, edge cases around dust outputs, the trust model when validatepegin=0, and whether ParseFedPegQuorum correctly handles all valid fedpegscript forms (including non-standard and future dynamic federation scripts). Run targeted tests for small-value pegins, high fee-rate inputs, and federation script variations. Consider requesting a security write-up or audit from the maintainers before deployment.
Security signals we found
New money-flow logic that creates an additional transaction output paying a subsidy
Subsidy calculation depends on externally supplied or mainchain-RPC-derived fee rate
ParseFedPegQuorum assumes fedpegscript starts with threshold and only supports OP_N thresholds up to 16; malformed scripts return false but callers use CHECK_NONFATAL
Manual fee_rate parameter added to claimpegin/createrawpegin when validatepegin=0
RPC output now includes active/inactive flags for pegin minimum and subsidy based on current tip height
Evidence from the diff
The patch introduces PeginSubsidy/PeginMinimum parameters, a ParseFedPegQuorum helper to extract t-of-n from a fedpegscript, and RPC changes for claimpegin/createrawpegin/getpeginaddress/getsidechaininfo. When a pegin is below a subsidy threshold and validatepegin is disabled, the wallet now requires a manual fee_rate, computes a subsidy based on the parent-chain transaction’s feerate and the expected size of a future federation-spending witness, and creates an extra OP_RETURN-like subsidy output. The subsidy amount is derived from the current federation’s first CHECKMULTISIG script and a hardcoded witness-size estimate.
Changed components
src/dynafed.cpp / src/dynafed.hsrc/wallet/rpc/elements.cppsrc/rpc/blockchain.cppsrc/rpc/client.cppsrc/test/dynafed_tests.cppInspect captured patch +219 / −17
diff --git a/src/dynafed.cpp b/src/dynafed.cpp
index d719795..cb288a8 100644
--- a/src/dynafed.cpp
+++ b/src/dynafed.cpp
@@ -123,3 +123,34 @@ DynaFedParamEntry ComputeNextBlockCurrentParameters(const CBlockIndex* pindexPre
}
}
+bool ParseFedPegQuorum(const CScript& fedpegscript, int& t, int& n) {
+ CScript::const_iterator it = fedpegscript.begin();
+ std::vector<unsigned char> vch;
+ opcodetype opcode;
+
+ // parse the required threshold number
+ if (!fedpegscript.GetOp(it, opcode, vch)) return false;
+ t = CScript::DecodeOP_N(opcode);
+ if (t < 1 || t > MAX_PUBKEYS_PER_MULTISIG) return false;
+
+ // support a fedpegscript like OP_TRUE if we're at the end of the script
+ if (it == fedpegscript.end()) return true;
+
+ // count the pubkeys
+ int pubkeys = 0;
+ while (fedpegscript.GetOp(it, opcode, vch)) {
+ if (opcode != 0x21) break;
+ if (vch.size() != 33) return false;
+ pubkeys++;
+ }
+
+ // parse the total number of pubkeys
+ n = CScript::DecodeOP_N(opcode);
+ if (n < 1 || n > MAX_PUBKEYS_PER_MULTISIG || n < t) return false;
+ if (pubkeys != n) return false;
+
+ // the next opcode must be OP_CHECKMULTISIG
+ if (!fedpegscript.GetOp(it, opcode, vch)) return false;
+
+ return opcode == OP_CHECKMULTISIG;
+}
diff --git a/src/dynafed.h b/src/dynafed.h
index ea65163..373fdb2 100644
--- a/src/dynafed.h
+++ b/src/dynafed.h
@@ -15,5 +15,10 @@ DynaFedParamEntry ComputeNextBlockFullCurrentParameters(const CBlockIndex* pinde
* publish signblockscript-related fields */
DynaFedParamEntry ComputeNextBlockCurrentParameters(const CBlockIndex* pindexPrev, const Consensus::Params& consensus);
+/* Get the threshold (t) and maybe the total pubkeys (n) of the first OP_CHECKMULTISIG in the fedpegscript.
+ * Assumes the fedpegscript starts with the threshold, otherwise returns false.
+ * Uses CScript::DecodeOP_N, so only supports up to a threshold of 16, otherwise asserts.
+ * Supports a fedpegscript like OP_TRUE by returning early. */
+bool ParseFedPegQuorum(const CScript& fedpegscript, int& t, int& n);
#endif // BITCOIN_DYNAFED_H
diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp
index b20765a..602cd5d 100644
--- a/src/rpc/blockchain.cpp
+++ b/src/rpc/blockchain.cpp
@@ -40,6 +40,7 @@
#include <txdb.h>
#include <txmempool.h>
#include <undo.h>
+#include <util/moneystr.h>
#include <util/strencodings.h>
#include <util/string.h>
#include <util/translation.h>
@@ -3134,6 +3135,25 @@ static RPCHelpMan getsidechaininfo()
obj.pushKV("parent_chain_signblockscript_hex", HexStr(consensus.parent_chain_signblockscript));
obj.pushKV("parent_pegged_asset", consensus.parent_pegged_asset.GetHex());
}
+
+ PeginMinimum pegin_minimum = Params().GetPeginMinimum();
+ if (pegin_minimum.amount > 0) {
+ obj.pushKV("pegin_min_amount", FormatMoney(pegin_minimum.amount));
+ }
+ if (pegin_minimum.height < std::numeric_limits<int>::max()) {
+ obj.pushKV("pegin_min_height", pegin_minimum.height);
+ obj.pushKV("pegin_min_active", chainman.ActiveTip()->nHeight >= pegin_minimum.height);
+ }
+
+ PeginSubsidy pegin_subsidy = Params().GetPeginSubsidy();
+ if (pegin_subsidy.threshold > 0) {
+ obj.pushKV("pegin_subsidy_threshold", FormatMoney(pegin_subsidy.threshold));
+ }
+ if (pegin_subsidy.height < std::numeric_limits<int>::max()) {
+ obj.pushKV("pegin_subsidy_height", pegin_subsidy.height);
+ obj.pushKV("pegin_subsidy_active", chainman.ActiveTip()->nHeight >= pegin_subsidy.height);
+ }
+
return obj;
},
};
diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp
index 5d8da16..3b65133 100644
--- a/src/rpc/client.cpp
+++ b/src/rpc/client.cpp
@@ -239,6 +239,8 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "calculateasset", 3, "blind_reissuance" },
{ "updatepsbtpegin", 1, "input" },
{ "updatepsbtpegin", 2, "value" },
+ { "claimpegin", 3, "fee_rate" },
+ { "createrawpegin", 3, "fee_rate" },
};
// clang-format on
diff --git a/src/test/dynafed_tests.cpp b/src/test/dynafed_tests.cpp
index 3a186db..f9a8099 100644
--- a/src/test/dynafed_tests.cpp
+++ b/src/test/dynafed_tests.cpp
@@ -2,12 +2,13 @@
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-#include <test/util/setup_common.h>
-#include <string>
#include <boost/test/unit_test.hpp>
+#include <dynafed.h>
#include <primitives/block.h>
#include <script/script.h>
#include <serialize.h>
+#include <string>
+#include <test/util/setup_common.h>
BOOST_FIXTURE_TEST_SUITE(dynafed_tests, BasicTestingSetup)
@@ -41,6 +42,48 @@ BOOST_AUTO_TEST_CASE(dynafed_params_root)
);
}
-BOOST_AUTO_TEST_SUITE_END()
+BOOST_AUTO_TEST_CASE(parse_fedpegscript_multisig)
+{
+ int t = 0;
+ int n = 0;
+
+ auto simplebytes = ParseHex("512103dff4923d778550cc13ce0d887d737553b4b58f4e8e886507fc39f5e447b2186451ae");
+
+ CScript simple{simplebytes.begin(), simplebytes.end()};
+
+ BOOST_CHECK(ParseFedPegQuorum(simple, t, n));
+ BOOST_CHECK_EQUAL(t, 1);
+ BOOST_CHECK_EQUAL(n, 1);
+
+ auto liquidv1bytes = ParseHex("5b21020e0338c96a8870479f2396c373cc7696ba124e8635d41b0ea581112b678172612102675333a4e4b8fb51d9d4e22fa5a8eaced3fdac8a8cbf9be8c030f75712e6af992102896807d54bc55c24981f24a453c60ad3e8993d693732288068a23df3d9f50d4821029e51a5ef5db3137051de8323b001749932f2ff0d34c82e96a2c2461de96ae56c2102a4e1a9638d46923272c266631d94d36bdb03a64ee0e14c7518e49d2f29bc401021031c41fdbcebe17bec8d49816e00ca1b5ac34766b91c9f2ac37d39c63e5e008afb2103079e252e85abffd3c401a69b087e590a9b86f33f574f08129ccbd3521ecf516b2103111cf405b627e22135b3b3733a4a34aa5723fb0f58379a16d32861bf576b0ec2210318f331b3e5d38156da6633b31929c5b220349859cc9ca3d33fb4e68aa08401742103230dae6b4ac93480aeab26d000841298e3b8f6157028e47b0897c1e025165de121035abff4281ff00660f99ab27bb53e6b33689c2cd8dcd364bc3c90ca5aea0d71a62103bd45cddfacf2083b14310ae4a84e25de61e451637346325222747b157446614c2103cc297026b06c71cbfa52089149157b5ff23de027ac5ab781800a578192d175462103d3bde5d63bdb3a6379b461be64dad45eabff42f758543a9645afd42f6d4248282103ed1e8d5109c9ed66f7941bc53cc71137baa76d50d274bda8d5e8ffbd6e61fe9a5fae736402c00fb269522103aab896d53a8e7d6433137bbba940f9c521e085dd07e60994579b64a6d992cf79210291b7d0b1b692f8f524516ed950872e5da10fb1b808b5a526dedc6fed1cf29807210386aa9372fbab374593466bc5451dc59954e90787f08060964d95c87ef34ca5bb53ae68");
+ CScript liquidv1{liquidv1bytes.begin(), liquidv1bytes.end()};
+ t = 0;
+ n = 0;
+ BOOST_CHECK(ParseFedPegQuorum(liquidv1, t, n));
+ BOOST_CHECK_EQUAL(t, 11);
+ BOOST_CHECK_EQUAL(n, 15);
+
+ auto optruebytes = ParseHex("51");
+
+ CScript optrue{optruebytes.begin(), optruebytes.end()};
+
+ t = 0;
+ n = 0;
+ BOOST_CHECK(ParseFedPegQuorum(optrue, t, n));
+ BOOST_CHECK_EQUAL(t, 1);
+ BOOST_CHECK_EQUAL(n, 0);
+
+ auto op3bytes = ParseHex("53");
+
+ CScript op3{op3bytes.begin(), op3bytes.end()};
+
+ t = 0;
+ n = 0;
+ BOOST_CHECK(ParseFedPegQuorum(op3, t, n));
+ BOOST_CHECK_EQUAL(t, 3);
+ BOOST_CHECK_EQUAL(n, 0);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
diff --git a/src/wallet/rpc/elements.cpp b/src/wallet/rpc/elements.cpp
index 798c218..93aab0b 100644
--- a/src/wallet/rpc/elements.cpp
+++ b/src/wallet/rpc/elements.cpp
@@ -6,6 +6,7 @@
#include <block_proof.h>
#include <core_io.h>
#include <deploymentstatus.h>
+#include <dynafed.h>
#include <issuance.h>
#include <key_io.h>
#include <mainchainrpc.h>
@@ -15,10 +16,11 @@
#include <script/generic.hpp>
#include <script/pegins.h>
#include <secp256k1.h>
+#include <util/moneystr.h>
#include <wallet/coincontrol.h>
#include <wallet/fees.h>
-#include <wallet/rpc/util.h>
#include <wallet/receive.h>
+#include <wallet/rpc/util.h>
#include <wallet/spend.h>
#include <wallet/wallet.h>
@@ -220,6 +222,25 @@ RPCHelpMan getpeginaddress()
ret.pushKV("mainchain_address", EncodeParentDestination(mainchain_dest));
ret.pushKV("claim_script", HexStr(dest_script));
+
+ PeginMinimum pegin_minimum = Params().GetPeginMinimum();
+ if (pegin_minimum.amount > 0) {
+ ret.pushKV("pegin_min_amount", FormatMoney(pegin_minimum.amount));
+ }
+ if (pegin_minimum.height < std::numeric_limits<int>::max()) {
+ ret.pushKV("pegin_min_height", pegin_minimum.height);
+ ret.pushKV("pegin_min_active", wallet->chain().getTip()->nHeight >= pegin_minimum.height);
+ }
+
+ PeginSubsidy pegin_subsidy = Params().GetPeginSubsidy();
+ if (pegin_subsidy.threshold > 0) {
+ ret.pushKV("pegin_subsidy_threshold", FormatMoney(pegin_subsidy.threshold));
+ }
+ if (pegin_subsidy.height < std::numeric_limits<int>::max()) {
+ ret.pushKV("pegin_subsidy_height", pegin_subsidy.height);
+ ret.pushKV("pegin_subsidy_active", wallet->chain().getTip()->nHeight >= pegin_subsidy.height);
+ }
+
return ret;
},
};
@@ -787,7 +808,7 @@ RPCHelpMan sendtomainchain()
extern UniValue signrawtransaction(const JSONRPCRequest& request);
extern UniValue sendrawtransaction(const JSONRPCRequest& request);
-template<typename T_tx_ref, typename T_merkle_block>
+template <typename T_tx_ref, typename T_merkle_block>
static UniValue createrawpegin(const JSONRPCRequest& request, T_tx_ref& txBTCRef, T_merkle_block& merkleBlock)
{
std::shared_ptr<CWallet> const wallet = GetWalletForJSONRPCRequest(request);
@@ -824,6 +845,59 @@ static UniValue createrawpegin(const JSONRPCRequest& request, T_tx_ref& txBTCRef
// Construct pegin input
CreatePegInInput(mtx, 0, txBTCRef, merkleBlock, claim_scripts, txData, txOutProofData, wallet->chain().getTip());
+ // Get value for peg-in output
+ CAmount value = 0;
+ if (!GetAmountFromParentChainPegin(value, *txBTCRef, mtx.vin[0].prevout.n)) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Amounts to pegin must be explicit and asset must be %s", Params().GetConsensus().parent_pegged_asset.GetHex()));
+ }
+
+ const PeginMinimum pegin_minimum = Params().GetPeginMinimum();
+ if (pwallet->chain().getTip()->nHeight >= pegin_minimum.height && value < pegin_minimum.amount) {
+ throw JSONRPCError(RPC_WALLET_ERROR, strprintf("Pegin amount (%d) is lower than the minimum pegin amount for this chain (%d).", FormatMoney(value), FormatMoney(pegin_minimum.amount)));
+ }
+
+ const PeginSubsidy pegin_subsidy = Params().GetPeginSubsidy();
+ bool subsidy_required = pwallet->chain().getTip()->nHeight >= pegin_subsidy.height && value < pegin_subsidy.threshold;
+ if (subsidy_required && !gArgs.GetBoolArg("-validatepegin", Params().GetConsensus().has_parent_chain) && request.params[3].isNull()) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "Bitcoin transaction fee rate must be supplied, because validatepegin is off and this peg-in requires a burn subsidy.");
+ }
+
+ CAmount fee = 0;
+ uint32_t parent_vsize = 0;
+ CFeeRate feerate = CFeeRate{0};
+ if (gArgs.GetBoolArg("-validatepegin", false) && subsidy_required) {
+ std::string txid = txBTCRef->GetHash().ToString();
+ std::string blockhash = merkleBlock.header.GetHash().ToString();
+ UniValue params(UniValue::VARR);
+ params.push_back(txid);
+ params.push_back(2);
+ params.push_back(blockhash);
+ UniValue result = CallMainChainRPC("getrawtransaction", params);
+ if (result["error"].isStr()) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, result["error"]["message"].get_str());
+ } else {
+ parent_vsize = result["result"]["vsize"].get_int64();
+ if (result["result"]["fee"].isNum()) {
+ fee = static_cast<CAmount>(std::round(result["result"]["fee"].get_real() * COIN));
+ } else if (result["result"]["fee"].isObject()) {
+ std::string asset = Params().GetConsensus().parent_pegged_asset.GetHex();
+ if (result["result"]["fee"][asset].isNum()) {
+ fee = static_cast<CAmount>(std::round(result["result"]["fee"][asset].get_real() * COIN));
+ } else {
+ throw JSONRPCError(RPC_MISC_ERROR, "No fee result for the parent pegged asset.");
+ }
+ } else {
+ throw JSONRPCError(RPC_MISC_ERROR, "Fee result is not a number or object.");
+ }
+ // when parent feerate is less than 1 sat/vb, use 1 sat/vb for the calculation
+ feerate = std::max(CFeeRate{fee, parent_vsize}, CFeeRate{1000});
+ }
+ } else if (!request.params[3].isNull()) {
+ // manual feerate, specified in sats/vb but CFeeRate takes sats/Kvb
+ CAmount satsperk = static_cast<CAmount>(std::round(request.params[3].get_real() * 1000));
+ feerate = std::max(CFeeRate{satsperk}, CFeeRate{1000});
+ }
+
// Manually construct peg-in transaction, sign it, and send it off.
// Decrement the output value as much as needed given the total vsize to
// pay the fees.
@@ -838,19 +912,17 @@ static UniValue createrawpegin(const JSONRPCRequest& request, T_tx_ref& txBTCRef
throw JSONRPCError(RPC_WALLET_KEYPOOL_RAN_OUT, error.original);
}
- // Get value for output
- CAmount value = 0;
- if (!GetAmountFromParentChainPegin(value, *txBTCRef, mtx.vin[0].prevout.n)) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Amounts to pegin must be explicit and asset must be %s", Params().GetConsensus().parent_pegged_asset.GetHex()));
- }
-
- // one wallet output and one fee output
+ // add a wallet output for the peg-in value
mtx.vout.push_back(CTxOut(Params().GetConsensus().pegged_asset, value, GetScriptForDestination(wpkhash)));
+ if (subsidy_required) {
+ // add an op_return for the peg-in fee subsidy
+ mtx.vout.push_back(CTxOut(Params().GetConsensus().pegged_asset, 0, CScript() << OP_RETURN));
+ }
+ // add a fee output
mtx.vout.push_back(CTxOut(Params().GetConsensus().pegged_asset, 0, CScript()));
- // Estimate fee for transaction, decrement fee output(including witness data)
- unsigned int nBytes = GetVirtualTransactionSize(CTransaction(mtx)) +
- (1+1+72+1+33)/WITNESS_SCALE_FACTOR;
+ // Estimate fee for transaction, decrement fee output (including witness data)
+ unsigned int nBytes = GetVirtualTransactionSize(CTransaction(mtx)) + (1 + 1 + 72 + 1 + 33) / WITNESS_SCALE_FACTOR;
CCoinControl coin_control;
FeeCalculation feeCalc;
CAmount nFeeNeeded = GetMinimumFee(*pwallet, nBytes, coin_control, &feeCalc);
@@ -859,8 +931,35 @@ static UniValue createrawpegin(const JSONRPCRequest& request, T_tx_ref& txBTCRef
throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, "Fee estimation failed. Fallbackfee is disabled. Wait a few blocks or enable -fallbackfee.");
}
- mtx.vout[0].nValue = mtx.vout[0].nValue.GetAmount() - nFeeNeeded;
- mtx.vout[1].nValue = mtx.vout[1].nValue.GetAmount() + nFeeNeeded;
+ if (subsidy_required) {
+ CHECK_NONFATAL(mtx.vout.size() == 3);
+
+ // calculate the subsidy as the amount required to spend the P2WSH output
+ const auto& fedpegscripts = GetValidFedpegScripts(pwallet->chain().getTip(), Params().GetConsensus(), true /* nextblock_validation */);
+ int t = 0;
+ int n = 0;
+ CHECK_NONFATAL(fedpegscripts.size() > 0);
+ CHECK_NONFATAL(ParseFedPegQuorum(fedpegscripts[0].second, t, n));
+
+ // P2WSH input is 41 bytes: txid (32) + vout (4) + scriptsig len (1) + sequence (4)
+ // the witness to spend is `t` signatures + the script size
+ unsigned int weight = WITNESS_SCALE_FACTOR * (32 + 4 + 1 + 4) + (t * 72 + fedpegscripts[0].second.size());
+ unsigned int vbytes = (weight + WITNESS_SCALE_FACTOR - 1) / WITNESS_SCALE_FACTOR;
+
+ CAmount subsidy = feerate.GetFee(vbytes);
+
+ CAmount value = mtx.vout[0].nValue.GetAmount() - nFeeNeeded - subsidy;
+ mtx.vout[0].nValue = value;
+ mtx.vout[1].nValue = subsidy;
+ mtx.vout[2].nValue = nFeeNeeded;
+ if (IsDust(mtx.vout[0], pwallet->chain().relayDustFee())) {
+ throw JSONRPCError(RPC_WALLET_ERROR, "Peg-in transaction would create dust output.");
+ }
+ } else {
+ CHECK_NONFATAL(mtx.vout.size() == 2);
+ mtx.vout[0].nValue = mtx.vout[0].nValue.GetAmount() - nFeeNeeded;
+ mtx.vout[1].nValue = nFeeNeeded;
+ }
UniValue ret(UniValue::VOBJ);
@@ -893,6 +992,7 @@ RPCHelpMan createrawpegin()
{"bitcoin_tx", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The raw bitcoin transaction (in hex) depositing bitcoin to the mainchain_address generated by getpeginaddress"},
{"txoutproof", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "A rawtxoutproof (in hex) generated by the mainchain daemon's `gettxoutproof` containing a proof of only bitcoin_tx"},
{"claim_script", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED_NAMED_ARG, "The witness program generated by getpeginaddress. Only needed if not in wallet."},
+ {"fee_rate", RPCArg::Type::AMOUNT, RPCArg::Optional::OMITTED_NAMED_ARG, "The fee rate of the Bitcoin transaction in sats/vb, only necessary when validatepegin=0."},
},
RPCResult{
RPCResult::Type::OBJ, "", "",
@@ -942,6 +1042,7 @@ RPCHelpMan claimpegin()
{"bitcoin_tx", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The raw bitcoin transaction (in hex) depositing bitcoin to the mainchain_address generated by getpeginaddress"},
{"txoutproof", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "A rawtxoutproof (in hex) generated by the mainchain daemon's `gettxoutproof` containing a proof of only bitcoin_tx"},
{"claim_script", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED_NAMED_ARG, "The witness program generated by getpeginaddress. Only needed if not in wallet."},
+ {"fee_rate", RPCArg::Type::AMOUNT, RPCArg::Optional::OMITTED_NAMED_ARG, "The fee rate of the Bitcoin transaction in sats/vb, only necessary when validatepegin=0."},
},
RPCResult{
RPCResult::Type::STR_HEX, "txid", "txid of the resulting sidechain transaction",
Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.