I’m exploring a potential solution to discourage UTXO-bloat patterns without touching Script or witness semantics. This rule simply flags “bulk dust” transactions that create many very low-value outputs:
- Threshold: at least 100 outputs with value < 1,000 sats
- Ratio: those “tiny” (sub-1,000 sat) outputs are ≥ 60% of all outputs in the Tx
The goal isn’t to eliminate all arbitrary data uses, but to raise costs for patterns most associated with UTXO growth (cheap, “bulk dust” Txs), while leaving typical payments, channel opens, and one-off inscriptions unaffected.
Patterns this would limit (not necessarily eliminate)
- Fake pubkeys hiding data (UTXO fan-outs)
- Bitcoin STAMPS / UTXO-art using many dust UTXOs
- BRC-20 batch mints that fan out tiny outputs
- Some batched Ordinal inscriptions that spread data/state across many small UTXOs
- Dust bombing (tracking) / UTXO squatting / resource-exhaustion attempts
- Mass micro-airdrops of sub-1k-sat outputs (collateral effect)
Non-goals / Not covered
- Single/few-output inscriptions with large witness data (these wouldn’t trigger the “bulk dust” heuristic)
- Any scheme that simply uses ≥1,000 sat per output (economically more expensive but still valid)
Why a ratio + count?
Requiring both (tiny_count ≥ 100) and (tiny_count / total_outputs ≥ 0.6) reduces false positives (e.g., big custodial batch payouts with a mix of values). It focuses on transactions that are mostly made of dust-like outputs.
Ask
- Are there credible, non-spam use-cases that need ≥100 sub-1k-sat outputs with ≥60% tiny ratio in one tx?
- Are there policy pitfalls I’m missing (e.g., fee market dynamics, odd coinbase behaviors, privacy tools)?
- Any prior art or measurements you can point to about the prevalence of such transactions?
(with syntax highlighting here: https://pastebin.com/tYsvDh2R)
RELAY POLICY FILTER sketch —
// Place in /policy/policy.cpp, and call from within IsStandardTx() before returning:
// if (IsBulkDust(tx, reason))
// return false; // reject as nonstandard
bool IsBulkDust(const CTransaction& tx, std::string& reason)
{
static constexpr CAmount MIN_OUTPUT_VALUE_SATS = 1000; // < 1000 sats counts as "tiny"
static constexpr int MAX_TINY_OUTPUTS = 100; // >= 100 tiny outputs triggers ratio check
static constexpr double TINY_RATIO_THRESHOLD = 0.6; // >= 60% of all outputs tiny = reject
int tiny = 0;
const int total = tx.vout.size();
// Sanity check — avoid division by zero
if (total == 0)
return false;
// Count any spendable output under 1000 sats as "tiny"
for (const auto& out : tx.vout) {
if (out.nValue < MIN_OUTPUT_VALUE_SATS)
++tiny;
}
// Threshold + ratio check
if (tiny >= MAX_TINY_OUTPUTS && (static_cast(tiny) / total) >= TINY_RATIO_THRESHOLD)
{
reason = strprintf("too-many-tiny-outputs(%d of %d, %.2f%%)", tiny, total, 100.0 * tiny / total);
return true; // flag as bulk dust
}
return false;
}
CONSENSUS (soft-fork, hybrid activation) sketch —
// Helpers in /consensus/tx_check.cpp; activation/enforcement in /validation.cpp
// Also define deployment in: /consensus/params.h, /chainparams.cpp, /versionbits.*
// -----------------------------------------------------------------------
// --- In /consensus/tx_check.cpp (helper only; no params needed) ---
// -----------------------------------------------------------------------
static constexpr CAmount MIN_OUTPUT_VALUE_SATS = 1000; // < 1000 sats counts as "tiny"
static constexpr int MAX_TINY_OUTPUTS = 100; // >= 100 tiny outputs triggers ratio check
static constexpr double TINY_RATIO_THRESHOLD = 0.6; // >= 60% of all outputs tiny = reject
bool IsBulkDust(const CTransaction& tx) // expose via tx_check.h if needed
{
int tiny = 0;
const int total = tx.vout.size();
// Sanity check — avoid division by zero
if (total == 0)
return false;
// Count any spendable output under 1000 sats as "tiny"
for (const auto& out : tx.vout) {
if (out.nValue < MIN_OUTPUT_VALUE_SATS)
++tiny;
}
// Threshold + ratio check
if (tiny >= MAX_TINY_OUTPUTS && ((static_cast(tiny) / total) >= TINY_RATIO_THRESHOLD))
return true;
return false;
}
// -----------------------------------------------------------------------
// --- In /validation.cpp (enforcement with hybrid activation) ---
// -----------------------------------------------------------------------
#include
#include
// ... inside the appropriate validation path (e.g., after basic tx checks),
// with access to chainparams/params and a tip pointer:
const Consensus::Params& params = chainparams.GetConsensus();
const bool bulk_dust_active =
DeploymentActiveAtTip(params, Consensus::DEPLOYMENT_BULK_DUST_LIMIT) ||
(chainActive.Tip() && chainActive.Tip()->nHeight >= params.BulkDustActivationHeight);
if (bulk_dust_active) {
if (IsBulkDust(tx)) {
return state.Invalid(TxValidationResult::TX_CONSENSUS, "too-many-tiny-outputs");
}
}
// -----------------------------------------------------------------------
// --- In /consensus/params.h ---
// -----------------------------------------------------------------------
enum DeploymentPos {
// ...
DEPLOYMENT_BULK_DUST_LIMIT,
MAX_VERSION_BITS_DEPLOYMENTS
};
struct Params {
// ...
int BulkDustActivationHeight; // height flag-day fallback
};
// -----------------------------------------------------------------------
// --- In /chainparams.cpp (per-network values; examples only) ---
// -----------------------------------------------------------------------
consensus.vDeployments[Consensus::DEPLOYMENT_BULK_DUST_LIMIT].bit = 12;
consensus.vDeployments[Consensus::DEPLOYMENT_BULK_DUST_LIMIT].nStartTime = 1767225600; // 2026-01-01 UTC
consensus.vDeployments[Consensus::DEPLOYMENT_BULK_DUST_LIMIT].nTimeout = 1838160000; // 2028-04-01 UTC
consensus.vDeployments[Consensus::DEPLOYMENT_BULK_DUST_LIMIT].min_activation_height = 969696;
consensus.BulkDustActivationHeight = 1021021; // flag-day fallback









