fix: unblinded re/issuance for non-policy asset greater than 21 million (#1445)
What changed, and why it matters
This commit fixes a bug in the Elements sidechain where creating or reissuing more than 21 million units of a non-bitcoin asset was incorrectly rejected when the issuance was unblinded (publicly visible amounts). The change makes the 21-million limit apply only to the network's main pegged asset (like bitcoin), not to other custom assets, and adds a configurable policy switch so node operators can choose whether to relay/mine such large unblinded issuances. Previously, unblinded issuances over 21 million could fail validation even though they are allowed by consensus rules for non-policy assets.
Review whether the default of true for -acceptunlimitedissuances is appropriate for your network's policy. Ensure that all nodes meant to enforce the stricter policy set -acceptunlimitedissuances=0 consistently, and verify that miners/relays in your federation agree on the policy to avoid mempool divergence. Consider adding release-note documentation about the new policy option.
Security signals we found
Consensus/policy boundary change: 21M amount check narrowed from all explicit issuances to only the pegged asset
New configurable mempool policy: -acceptunlimitedissuances controls relay/mining of large unblinded issuances
Potential for network partition: nodes with different flag settings may accept/reject different mempool transactions
Fixes incorrect rejection of valid unblinded re/issuance for non-policy assets > MAX_MONEY
Evidence from the diff
The patch modifies confidential_validation.cpp so VerifyIssuanceAmount only enforces MoneyRange on the pegged asset for explicit (unblinded) issuances; other assets only require a positive non-zero amount. It introduces a new policy function IsIssuanceInMoneyRange() and a runtime flag -acceptunlimitedissuances (default true). When the flag is false, mempool acceptance rejects unblinded issuances/reissuances exceeding MAX_MONEY with the rejection reason ‘issuance-out-of-range’. The default remains permissive, preserving existing behavior for custom assets while giving operators a way to restrict relay. Tests confirm unblinded issuance/reissuance above 21 million now succeeds by default and is rejected when the flag is disabled.
Changed components
src/confidential_validation.cppsrc/policy/policy.cppsrc/policy/policy.hsrc/validation.cppsrc/validation.hsrc/init.cpptest/functional/wallet_elements_21million.pyInspect captured patch +73 / −3
diff --git a/src/confidential_validation.cpp b/src/confidential_validation.cpp
index 05f65db..a1230c6 100644
--- a/src/confidential_validation.cpp
+++ b/src/confidential_validation.cpp
@@ -1,4 +1,5 @@
+#include <chainparams.h>
#include <confidential_validation.h>
#include <issuance.h>
#include <pegins.h>
@@ -103,14 +104,13 @@ static bool VerifyIssuanceAmount(secp256k1_pedersen_commitment& value_commit, se
// Build value commitment
if (value.IsExplicit()) {
- if (!MoneyRange(value.GetAmount()) || value.GetAmount() == 0) {
+ if ((asset == Params().GetConsensus().pegged_asset && !MoneyRange(value.GetAmount())) || value.GetAmount() <= 0) {
return false;
}
if (!rangeproof.empty()) {
return false;
}
-
ret = secp256k1_pedersen_commit(secp256k1_ctx_verify_amounts, &value_commit, explicit_blinds, value.GetAmount(), &asset_gen);
// The explicit_blinds are all 0, and the amount is not 0. So secp256k1_pedersen_commit does not fail.
assert(ret == 1);
diff --git a/src/init.cpp b/src/init.cpp
index 27addf2..714b517 100644
--- a/src/init.cpp
+++ b/src/init.cpp
@@ -621,6 +621,7 @@ void SetupServerArgs(ArgsManager& argsman)
std::vector<std::string> elements_hidden_args = {"-con_fpowallowmindifficultyblocks", "-con_fpownoretargeting", "-con_nsubsidyhalvinginterval", "-con_bip16exception", "-con_bip34height", "-con_bip65height", "-con_bip66height", "-con_npowtargettimespan", "-con_npowtargetspacing", "-con_nrulechangeactivationthreshold", "-con_nminerconfirmationwindow", "-con_powlimit", "-con_bip34hash", "-con_nminimumchainwork", "-con_defaultassumevalid", "-npruneafterheight", "-fdefaultconsistencychecks", "-fmineblocksondemand", "-fallback_fee_enabled", "-pchmessagestart"};
+ argsman.AddArg("-acceptunlimitedissuances", strprintf("Relay and mine unblinded issuance transactions (default: %u)", DEFAULT_ACCEPT_UNLIMITED_ISSUANCES), ArgsManager::ALLOW_ANY, OptionsCategory::ELEMENTS);
argsman.AddArg("-initialfreecoins", strprintf("The amount of OP_TRUE coins created in the genesis block. Primarily for testing. (default: %d)", 0), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::DEBUG_TEST);
argsman.AddArg("-validatepegin", "Validate peg-in claims. An RPC connection will be attempted to the trusted mainchain daemon using the `mainchain*` settings below. All functionaries must run this enabled. (default: 1 if chain has federated peg)", ArgsManager::ALLOW_ANY, OptionsCategory::ELEMENTS);
argsman.AddArg("-mainchainrpchost=<host>", "The address which the daemon will try to connect to the trusted mainchain daemon to validate peg-ins, if enabled. (default: 127.0.0.1)", ArgsManager::ALLOW_ANY, OptionsCategory::ELEMENTS);
@@ -1076,6 +1077,7 @@ bool AppInitParameterInteraction(const ArgsManager& args)
fIsBareMultisigStd = args.GetBoolArg("-permitbaremultisig", DEFAULT_PERMIT_BAREMULTISIG);
fAcceptDatacarrier = args.GetBoolArg("-datacarrier", DEFAULT_ACCEPT_DATACARRIER);
nMaxDatacarrierBytes = args.GetIntArg("-datacarriersize", nMaxDatacarrierBytes);
+ fAcceptUnlimitedIssuances = args.GetBoolArg("-acceptunlimitedissuances", DEFAULT_ACCEPT_UNLIMITED_ISSUANCES);
// Option to startup with mocktime set (used for regression testing):
SetMockTime(args.GetIntArg("-mocktime", 0)); // SetMockTime(0) is a no-op
diff --git a/src/policy/policy.cpp b/src/policy/policy.cpp
index 9943515..65d7bae 100644
--- a/src/policy/policy.cpp
+++ b/src/policy/policy.cpp
@@ -308,6 +308,20 @@ bool IsWitnessStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs)
return true;
}
+bool IsIssuanceInMoneyRange(const CTransaction& tx)
+{
+ for (size_t i = 0; i < tx.vin.size(); ++i) {
+ const CAssetIssuance& issuance = tx.vin[i].assetIssuance;
+ if (issuance.IsNull()) {
+ continue;
+ }
+ if (issuance.nAmount.IsExplicit() && !MoneyRange(issuance.nAmount.GetAmount())) {
+ return false;
+ }
+ }
+ return true;
+}
+
int64_t GetVirtualTransactionSize(int64_t nWeight, int64_t nSigOpCost, unsigned int bytes_per_sigop)
{
return (std::max(nWeight, nSigOpCost * bytes_per_sigop) + WITNESS_SCALE_FACTOR - 1) / WITNESS_SCALE_FACTOR;
diff --git a/src/policy/policy.h b/src/policy/policy.h
index 0c48bad..14390dc 100644
--- a/src/policy/policy.h
+++ b/src/policy/policy.h
@@ -94,6 +94,9 @@ static constexpr unsigned int STANDARD_LOCKTIME_VERIFY_FLAGS = LOCKTIME_VERIFY_S
// ELEMENTS: keep a copy of the upstream default dust relay fee rate
static const unsigned int DUST_RELAY_TX_FEE_BITCOIN = 3000;
+// ELEMENTS: allow unblinded issuances/reissuances greater than MAX_MONEY
+static const bool DEFAULT_ACCEPT_UNLIMITED_ISSUANCES = true;
+
CAmount GetDustThreshold(const CTxOut& txout, const CFeeRate& dustRelayFee);
bool IsDust(const CTxOut& txout, const CFeeRate& dustRelayFee);
@@ -126,6 +129,11 @@ bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs)
*/
bool IsWitnessStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs);
+/* ELEMENTS
+* Check if unblinded issuance/reissuance is in MoneyRange
+*/
+bool IsIssuanceInMoneyRange(const CTransaction& tx);
+
/** Compute the virtual transaction size (weight reinterpreted as bytes). */
int64_t GetVirtualTransactionSize(int64_t nWeight, int64_t nSigOpCost, unsigned int bytes_per_sigop);
int64_t GetVirtualTransactionSize(const CTransaction& tx, int64_t nSigOpCost, unsigned int bytes_per_sigop);
diff --git a/src/validation.cpp b/src/validation.cpp
index d2576dc..a566ccd 100644
--- a/src/validation.cpp
+++ b/src/validation.cpp
@@ -151,6 +151,7 @@ bool g_parallel_script_checks{false};
bool fRequireStandard = true;
bool fCheckBlockIndex = false;
bool fCheckpointsEnabled = DEFAULT_CHECKPOINTS_ENABLED;
+bool fAcceptUnlimitedIssuances = true;
int64_t nMaxTipAge = DEFAULT_MAX_TIP_AGE;
uint256 hashAssumeValid;
@@ -722,6 +723,10 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)
}
}
+ // Check unblinded issuance is in MoneyRange if configured
+ if (!fAcceptUnlimitedIssuances && !IsIssuanceInMoneyRange(tx))
+ return state.Invalid(TxValidationResult::TX_NOT_STANDARD, "issuance-out-of-range", "Issuance is greater than 21 million and acceptunlimitedissuances is not enabled.");
+
// Do not work on transactions that are too small.
// A transaction with 1 segwit input and 1 P2WPHK output has non-witness size of 82 bytes.
// Transactions smaller than this are not relayed to mitigate CVE-2017-12842 by not relaying
diff --git a/src/validation.h b/src/validation.h
index 78fa8be..f66ea65 100644
--- a/src/validation.h
+++ b/src/validation.h
@@ -130,6 +130,7 @@ extern bool g_parallel_script_checks;
extern bool fRequireStandard;
extern bool fCheckBlockIndex;
extern bool fCheckpointsEnabled;
+extern bool fAcceptUnlimitedIssuances;
/** A fee rate smaller than this is considered zero fee (for relaying, mining and transaction creation) */
extern CFeeRate minRelayTxFee;
/** If the tip is older than this (in seconds), the node is considered to be in initial block download. */
diff --git a/test/functional/wallet_elements_21million.py b/test/functional/wallet_elements_21million.py
index ea6b34d..063832d 100755
--- a/test/functional/wallet_elements_21million.py
+++ b/test/functional/wallet_elements_21million.py
@@ -7,13 +7,21 @@ from test_framework.blocktools import COINBASE_MATURITY
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
+ assert_raises_rpc_error,
)
class WalletTest(BitcoinTestFramework):
def set_test_params(self):
self.setup_clean_chain = True
self.num_nodes = 3
- self.extra_args = [['-blindedaddresses=1']] * self.num_nodes
+ args = [
+ "-blindedaddresses=1"
+ ]
+ self.extra_args = [
+ args,
+ args,
+ args + ["-acceptunlimitedissuances=0"], # node 2 blocks unblinded issuances out of moneyrange
+ ]
def setup_network(self, split=False):
self.setup_nodes()
@@ -42,6 +50,17 @@ class WalletTest(BitcoinTestFramework):
self.generate(self.nodes[0], 1)
assert_equal(self.nodes[0].getbalance()[asset], 200_000_000)
+ self.log.info("Issue more than 21 million of a unblinded non-policy asset")
+ issuance = self.nodes[0].issueasset(300_000_000, 100, False)
+ unblinded_asset = issuance['asset']
+ self.generate(self.nodes[0], 1)
+ assert_equal(self.nodes[0].getbalance()[unblinded_asset], 300_000_000)
+
+ self.log.info("Reissue more than 21 million of a unblinded non-policy asset")
+ self.nodes[0].reissueasset(unblinded_asset, 200_000_000)
+ self.generate(self.nodes[0], 1)
+ assert_equal(self.nodes[0].getbalance()[unblinded_asset], 500_000_000)
+
# send more than 21 million of that asset
addr = self.nodes[1].getnewaddress()
self.nodes[0].sendtoaddress(address=addr, amount=22_000_000, assetlabel=asset)
@@ -90,5 +109,26 @@ class WalletTest(BitcoinTestFramework):
self.nodes[2].loadwallet(self.default_wallet_name)
assert_equal(self.nodes[2].getbalance()[asset], 200_000_000)
+ # send some policy asset to node 2 for fees
+ addr = self.nodes[2].getnewaddress()
+ self.nodes[0].sendtoaddress(address=addr, amount=1)
+ self.generate(self.nodes[0], 1)
+ assert_equal(self.nodes[2].getbalance()['bitcoin'], 1)
+
+ self.log.info("Issue more than 21 million of a non-policy asset on node 2 - rejected from mempool")
+ issuance = self.nodes[2].issueasset(300_000_000, 100, False)
+ asset = issuance['asset']
+ issuance_tx = self.nodes[2].gettransaction(issuance["txid"])
+ assert_raises_rpc_error(-26, "issuance-out-of-range", self.nodes[2].sendrawtransaction, issuance_tx['hex'])
+ self.generate(self.nodes[0], 1)
+ assert(asset not in self.nodes[2].getbalance())
+ # transaction should be accepted on node 0
+ self.nodes[0].sendrawtransaction(issuance_tx["hex"])
+ assert(issuance['txid'] in self.nodes[0].getrawmempool())
+ assert(issuance['txid'] not in self.nodes[2].getrawmempool())
+ self.generate(self.nodes[0], 1)
+ assert(asset not in self.nodes[0].getbalance())
+ assert_equal(self.nodes[2].getbalance()[asset], 300_000_000)
+
if __name__ == '__main__':
WalletTest().main()
Why this scored 59/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.