Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ephemeral Dust #30239

Open
wants to merge 9 commits into
base: master
Choose a base branch
from

Conversation

instagibbs
Copy link
Member

@instagibbs instagibbs commented Jun 6, 2024

A replacement for #29001

Now that we have 1P1C relay, TRUC transactions and sibling eviction, it makes sense to retarget this feature more narrowly by not introducing a new output type, and simple focusing on the feature of allowing temporary dust in the mempool.

Users of this can immediately use dust outputs as:

  1. Single keyed anchor (can be shared by multiple parties)
  2. Single unkeyed anchor, ala P2A

Which is useful when the parent transaction cannot have fees for technical or accounting reasons.

What I'm calling "keyed" anchors would be used anytime you don't want a third party to be able to run off with the utxo. As a motivating example, in Ark there is the concept of a "forfeit transaction" which spends a "connector output". The connector output would ideally be 0-value, but you would not want that utxo spend by anyone, because this would cause financial loss for the coordinator of the service: https://arkdev.info/docs/learn/concepts#forfeit-transaction

Note that this specific use-case likely doesn't work as it involves a tree of dust, but the connector idea in general demonstrates how it could be used.

Another related example is connector outputs in BitVM2: https://bitvm.org/bitvm2.html .

Note that non-TRUC usage will be impractical unless the minrelay requirement on individual transactions are dropped in general, which should happen post-cluster mempool.

Lightning Network intends to use this feature post-29.0 if available: lightning/bolts#1171 (comment)

It's also useful for Ark, ln-symmetry, spacechains, Timeout Trees, and other constructs with large presigned trees or other large-N party smart contracts.

@DrahtBot
Copy link
Contributor

DrahtBot commented Jun 6, 2024

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/30239.

Reviews

See the guideline for information on the review process.

Type Reviewers
ACK theStack, sdaftuar
Concept NACK petertodd
Concept ACK glozow, t-bast, murchandamus, stickies-v, hodlinator, tdb3

If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #31153 (bench: Remove various extraneous benchmarks by dergoegge)
  • #29247 (CAT in Tapscript (BIP-347) by 0xBEEFCAF3)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

@DrahtBot
Copy link
Contributor

DrahtBot commented Jun 6, 2024

🚧 At least one of the CI tasks failed. Make sure to run all tests locally, according to the
documentation.

Possibly this is due to a silent merge conflict (the changes in this pull request being
incompatible with the current code in the target branch). If so, make sure to rebase on the latest
commit of the target branch.

Leave a comment here, if you need help tracking down a confusing failure.

Debug: https://github.com/bitcoin/bitcoin/runs/25906194302

@instagibbs
Copy link
Member Author

going to put this into draft until #29496 is merged, since dust checks are completely off when -acceptnonstdtxn=1. which is required for TRUC currently.

@theStack
Copy link
Contributor

theStack commented Jun 9, 2024

Is it an option to be more restrictive and only allow zero-value outputs as ephemeral anchors, for not having to deal with the concept of dust at all? Or, asked differently: what would be the motivation/benefit for users to ever create an anchor output with nValue > 0? (Note that I haven't looked too deep into the concept of ephemeral anchors and the predecessor PR #29001, so very likely I'm missing something obvious here, but I guess the answer could be interesting for other potential reviewers too.)

@instagibbs
Copy link
Member Author

instagibbs commented Jun 10, 2024

@theStack the primary motivation is to cover cases where non-0 value is attached to handle cases where a smart contract may want to "throw away" a few sats to fees, but otherwise cannot because of the 0-fee requirement of this PR for transactions with ephemeral anchors. If the ephemeral anchor-having transaction had non-0-fee, that would allow endogenous incentives to get it mined on its own, leaving the dust in the utxo set. As an example from the LN spec, [trimmed outputs(https://github.com/lightning/bolts/blob/master/03-transactions.md#trimmed-outputs) are directly added to the commitment transaction fee. Instead, spec writers could have the value flow to the ephemeral anchor, which is then spent to fees by the child transaction. Example spec here: https://github.com/instagibbs/bolts/commits/zero_fee_commitment

It's a fairly narrow motivation, and honestly I don't love the timmed output to fees scheme, but also doesn't make the code anymore complicated, so I think it's worth consideration.

@DrahtBot
Copy link
Contributor

🚧 At least one of the CI tasks failed. Make sure to run all tests locally, according to the
documentation.

Possibly this is due to a silent merge conflict (the changes in this pull request being
incompatible with the current code in the target branch). If so, make sure to rebase on the latest
commit of the target branch.

Leave a comment here, if you need help tracking down a confusing failure.

Debug: https://github.com/bitcoin/bitcoin/runs/25953908933

@bitcoin bitcoin deleted a comment from KristijanSajenko Jun 16, 2024
@instagibbs
Copy link
Member Author

rebased due to s/nVersion/version/ change for transactions causing a silent merge conflict

@instagibbs
Copy link
Member Author

rebased on master due to conflict

Copy link
Contributor

@theStack theStack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK

Mostly looked and reasoned about the core commit 92d7ad2 so far. From what I understand the "only one dust output" limit would not be strictly necessary and is far less important than the other two rules (must be 0-fee, child must spend all dust from the parent) in order to disisincentivize creating dust UTXOs, or am I missing something?

What I left below are largely nits and follow-up ideas, feel free to ignore. Still want to look deeper into unit tests and the fuzzer.

src/policy/ephemeral_policy.cpp Outdated Show resolved Hide resolved
doc/release-notes-30239.md Outdated Show resolved Hide resolved
test/functional/mempool_dust.py Outdated Show resolved Hide resolved
src/policy/ephemeral_policy.cpp Outdated Show resolved Hide resolved
src/policy/ephemeral_policy.cpp Outdated Show resolved Hide resolved
src/rpc/mining.cpp Outdated Show resolved Hide resolved
@@ -21,6 +21,20 @@

ORPHAN_TX_EXPIRE_TIME = 1200

def assert_mempool_contents(test_framework, node, expected=None, sync=True):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

follow-up idea: could take use of that helper in mempool_package_rbf.py (might be a nice "good first issue")

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring to Future Work

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: My naive LSP jumped to the PackageRBFTest-version when going to the definition of assert_mempool_contents from EphemeralDustTest. Would prefer if the commit introducing this one was also removed the identically named function from PackageRBFTest.

Comment on lines +71 to +74
dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3)
self.add_output_to_create_multi_result(dusty_tx)

sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

follow-up idea: could put this in a helper for creating an ephemeral package (dusty_tx + sweep_tx), as that's a repeated pattern in many sub-tests; might make sense to parametrize with tx version, number of dust outputs and optional additional sponsors

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deferring to Future Work

test/functional/mempool_ephemeral_dust.py Show resolved Hide resolved
test/functional/data/invalid_txs.py Outdated Show resolved Hide resolved
This test would catch regressions where ephemeral
dust checks are being erroneously applied on outputs
that are not actually dust.
Also known as Ephemeral Dust.

We try to ensure that dust is spent in blocks by requiring:
  - ephemeral dust tx is 0-fee
  - ephemeral dust tx only has one dust output
  - If the ephemeral dust transaction has a child,
    the dust is spent by by that child.

0-fee requirement means there is no incentive to mine
a transaction which doesn't have a child bringing its
own fees for the transaction package.
@sdaftuar
Copy link
Member

sdaftuar commented Nov 2, 2024

ACK 51c2394

Copy link
Contributor

@theStack theStack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 131bed1

* TxC, spends TxA's dust
*
* All the dust is spent if TxA+TxB+TxC is accepted, but the mining template may just pick
* up TxA+TxB rather than the three "legal configurations:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: missing closing double quote

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will fixup if I end up touching things

@@ -141,7 +141,7 @@ std::optional<std::string> CheckPackageMempoolAcceptResult(const Package& txns,
return std::nullopt;
}

std::vector<uint32_t> GetDustIndexes(const CTransactionRef tx_ref, CFeeRate dust_relay_rate)
std::vector<uint32_t> GetDustIndexes(const CTransactionRef& tx_ref, CFeeRate dust_relay_rate)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should ideally be already part of the commit that introduces this function for minimal diff (here and in header)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a mistake, I'll fix if I touch the PR

@sdaftuar
Copy link
Member

sdaftuar commented Nov 5, 2024

ACK 131bed1

Copy link

@rkrux rkrux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successful make and functional tests at 131bed1.

I've gone through only the src/policy/, src/rpc/, src/validation.cpp yet. My comments are mostly around code structure. I plan to review the functional tests and src/tests/* very soon.

#include <policy/ephemeral_policy.h>
#include <policy/policy.h>

bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This function is independent of the ephemeral nature and is generic enough to lie outside this file, near where IsDust() is.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leaving as-is

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In d5c564a

related: the code could be simplified a bit (imo) by changing HasDust to GetDust, which would require to move it to policy.h

git diff on d5c564a
diff --git a/src/policy/ephemeral_policy.cpp b/src/policy/ephemeral_policy.cpp
index 6854822e35..6066d9b3ac 100644
--- a/src/policy/ephemeral_policy.cpp
+++ b/src/policy/ephemeral_policy.cpp
@@ -5,15 +5,10 @@
 #include <policy/ephemeral_policy.h>
 #include <policy/policy.h>
 
-bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate)
-{
-    return std::any_of(tx->vout.cbegin(), tx->vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); });
-}
-
 bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state)
 {
     // We never want to give incentives to mine this transaction alone
-    if ((base_fee != 0 || mod_fee != 0) && HasDust(tx, dust_relay_rate)) {
+    if ((base_fee != 0 || mod_fee != 0) && !GetDust(*tx, dust_relay_rate).empty()) {
         return state.Invalid(TxValidationResult::TX_NOT_STANDARD, "dust", "tx with dust output must be 0-fee");
     }
 
@@ -52,11 +47,8 @@ std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_r
 
             // Check for dust on parents
             if (parent_ref) {
-                for (uint32_t out_index = 0; out_index < parent_ref->vout.size(); out_index++) {
-                    const auto& tx_output = parent_ref->vout[out_index];
-                    if (IsDust(tx_output, dust_relay_rate)) {
-                        unspent_parent_dust.insert(COutPoint(parent_txid, out_index));
-                    }
+                for (const auto& out_index : GetDust(*parent_ref, dust_relay_rate)) {
+                    unspent_parent_dust.insert(COutPoint{parent_txid, out_index});
                 }
             }
 
diff --git a/src/policy/ephemeral_policy.h b/src/policy/ephemeral_policy.h
index 26140f9a02..98f40d38ff 100644
--- a/src/policy/ephemeral_policy.h
+++ b/src/policy/ephemeral_policy.h
@@ -34,9 +34,6 @@
  * are the only way to bring fees.
  */
 
-/** Returns true if transaction contains dust */
-bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate);
-
 /* All the following checks are only called if standardness rules are being applied. */
 
 /** Must be called for each transaction once transaction fees are known.
diff --git a/src/policy/policy.cpp b/src/policy/policy.cpp
index 21c35af5cc..ed33692823 100644
--- a/src/policy/policy.cpp
+++ b/src/policy/policy.cpp
@@ -67,6 +67,15 @@ bool IsDust(const CTxOut& txout, const CFeeRate& dustRelayFeeIn)
     return (txout.nValue < GetDustThreshold(txout, dustRelayFeeIn));
 }
 
+std::vector<uint32_t> GetDust(const CTransaction& tx, CFeeRate dust_relay_rate)
+{
+    std::vector<uint32_t> dust_outputs;
+    for (uint32_t i{0}; i < tx.vout.size(); ++i) {
+        if (IsDust(tx.vout[i], dust_relay_rate)) dust_outputs.push_back(i);
+    }
+    return dust_outputs;
+}
+
 bool IsStandard(const CScript& scriptPubKey, const std::optional<unsigned>& max_datacarrier_bytes, TxoutType& whichType)
 {
     std::vector<std::vector<unsigned char> > vSolutions;
@@ -129,7 +138,6 @@ bool IsStandardTx(const CTransaction& tx, const std::optional<unsigned>& max_dat
     }
 
     unsigned int nDataOut = 0;
-    unsigned int num_dust_outputs{0};
     TxoutType whichType;
     for (const CTxOut& txout : tx.vout) {
         if (!::IsStandard(txout.scriptPubKey, max_datacarrier_bytes, whichType)) {
@@ -142,13 +150,11 @@ bool IsStandardTx(const CTransaction& tx, const std::optional<unsigned>& max_dat
         else if ((whichType == TxoutType::MULTISIG) && (!permit_bare_multisig)) {
             reason = "bare-multisig";
             return false;
-        } else if (IsDust(txout, dust_relay_fee)) {
-            num_dust_outputs++;
         }
     }
 
     // Only MAX_DUST_OUTPUTS_PER_TX dust is permitted(on otherwise valid ephemeral dust)
-    if (num_dust_outputs > MAX_DUST_OUTPUTS_PER_TX) {
+    if (GetDust(tx, dust_relay_fee).size() > MAX_DUST_OUTPUTS_PER_TX) {
         reason = "dust";
         return false;
     }
diff --git a/src/policy/policy.h b/src/policy/policy.h
index 0488f8dbee..9e36d3f610 100644
--- a/src/policy/policy.h
+++ b/src/policy/policy.h
@@ -129,6 +129,9 @@ CAmount GetDustThreshold(const CTxOut& txout, const CFeeRate& dustRelayFee);
 
 bool IsDust(const CTxOut& txout, const CFeeRate& dustRelayFee);
 
+/** Get the vout index numbers of all dust outputs */
+std::vector<uint32_t> GetDust(const CTransaction& tx, CFeeRate dust_relay_rate);
+
 bool IsStandard(const CScript& scriptPubKey, const std::optional<unsigned>& max_datacarrier_bytes, TxoutType& whichType);
 
 

That said, the current code is fine too, and I suspect you'll find this too big of a change for a PR of this size that otherwise seems close to merge.

Comment on lines +13 to +21
bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state)
{
// We never want to give incentives to mine this transaction alone
if ((base_fee != 0 || mod_fee != 0) && HasDust(tx, dust_relay_rate)) {
return state.Invalid(TxValidationResult::TX_NOT_STANDARD, "dust", "tx with dust output must be 0-fee");
}

return true;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ephemeral dust is a new concept that allows a single
dust output in a transaction, provided the transaction
is zero fee.

As per this definition, shouldn't this function also check that there is only one dust output in the transaction? I can see it's checked in the IsStandardTx() but IMO this function that should check the complete validity of the ephemeral transaction should encapsulate this single dust output check as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From an implementation perspective it was cleaner to enforce it in IsStandardTx, and I'm not sure I see the value in doubling up enforcement of it vs a single location.

If we already had fee information by the time we would call IsStandardTx it might be even cleaner, but I expect it would be a very difficult to evaluate diff.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could rename it PreCheckValidEphemeralTx to signify that it doesn't perform full validation.

Comment on lines +58 to +59
unspent_parent_dust.insert(COutPoint(parent_txid, out_index));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although it seems correct to run the loop for the outputs of the parent transaction but as per the definition of Ephemeral Dust, we can break (and end) the loop here after the dust is found because only one is allowed per parent transaction?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it would make the logic simpler or significantly more performant, and would rather the IsStandardTx check not be so tightly bound.

return true;
}

std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this is a pretty neat function that I enjoyed going through!

Comment on lines +150 to +154
// Only MAX_DUST_OUTPUTS_PER_TX dust is permitted(on otherwise valid ephemeral dust)
if (num_dust_outputs > MAX_DUST_OUTPUTS_PER_TX) {
reason = "dust";
return false;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this change, IsStandardTx() allows having only 1 dust output per tx and makes such txs technically standard but without any constraint of the 0-fee part that comes later down in the PreChecks(). Although there are no other usages of the IsStandardTx() in the source code (besides the CPP tests) but if later usages do arise, they would miss out on the 0-fee check.
TL;DR: The 0-fee check and the 1-dust output check that are tied by the Ephemeral Dust concept don't exist together within one function in code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, IsStandardTx check is very early in PreChecks, not requiring access to any utxo information. including fees.

Comment on lines +80 to +83
/**
* Maximum number of ephemeral dust outputs allowed.
*/
static constexpr unsigned int MAX_DUST_OUTPUTS_PER_TX{1};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a underlying assumption here that this constant is used only for the ephemeral dust use case but it is not evident in the naming of this constant. Related to the previous comment on IsStandardTx().

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can change to MAX_EPHEMERAL_DUST_OUTPUTS_PER_TX if I touch things

// Non-0 fee dust transactions are not allowed for entry, and modification not allowed afterwards
const auto& tx = mempool.get(hash);
if (tx && HasDust(tx, mempool.m_opts.dust_relay_feerate)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Priority is not supported for transactions with dust outputs.");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Priority is not supported for transactions with dust outputs.

Nit: IIUC, there should not be more than 1 dust output per transaction but this reads otherwise.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is as intended. We don't allow priority on anything with dust, no matter how many non-zero dust outputs.

Comment on lines +40 to +41
for (const auto& tx_input : tx->vin) {
const Txid& parent_txid{tx_input.prevout.hash};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must be called for each transaction(package) if any dust is in the package. Checks that each transaction's parents have their dust spent by the child,

Should this loop run only for dust inputs by adding a IsDust() right at the beginning of this loop? I'm assuming this function is not responsible for the standard transaction validity checks as per the comment in the header file.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aah nvm, IsDust works on TxOuts.

Copy link
Member

@glozow glozow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code review ack. I still need to review tests more thoroughly. None of these comments are blocking.

Trying to summarize why these would propagate via 1p1c package relay, please correct me if I'm wrong:

  • Essentially there is no longer anything "wrong" with a transaction that has 1 dust output in it since IsStandardTx has been changed. It's not TX_NOT_STANDARD. It needs to be 0 fee to pass CheckValidEphemeralTx, and then it should fail on feerate for TX_RECONSIDERABLE. That's why the parent can be picked up by the package relay logic. Child also needs to pass CheckEphemeralSpends, but otherwise neatly fits into 1p1c.
  • It's nice that CheckValidEphemeralTx comes before TX_RECONSIDERABLE, so we don't waste 1p1c cycles. But it's not strictly necessary to ensure an ephemeral tx is eligible for reconsideration.

@@ -71,9 +71,39 @@ def test_dust_output(self, node: TestNode, dust_relay_fee: Decimal,
# finally send the transaction to avoid running out of MiniWallet UTXOs
self.wallet.sendrawtransaction(from_node=node, tx_hex=tx_good_hex)

def test_dustrelay(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

191ca05 nit: maybe test_dustrelayfee_zero or something

}

for (const auto& tx : package) {
Txid txid = tx->GetHash();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: const reference would be better

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: txid is only used once in the return statement at the bottom of the loop, so could skip creating this variable...

It seems to be introduced in response to #30239 (comment) - arguably we should probably tolerate GetHash() until the day it is renamed/aliased to GetTxid(). Lifetime is unnecessarily long as well.


processed_parent_set.insert(parent_txid);
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could speed up the common case a bit by checking unspent_parent_dust.empty() here, but perhaps not significantly

TestMemPoolEntryHelper entry;
CTxMemPool::setEntries empty_ancestors;

CFeeRate minrelay(1000);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question, why minrelay instead of DUST_RELAY_TX_FEE?

// Spend dust from one but not another is ok, as long as second grandparent has no child
BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1, grandparent_tx_2, dust_spend}, minrelay, pool).has_value());

auto dust_non_spend_both_parents = make_tx({COutPoint{dust_txid, dust_index}, COutPoint{dust_txid_2, dust_index - 1}}, /*version=*/2);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this supposed to spend the non-dust from dust_txid 1?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's correct, spending dust from first dusty tx but not second

// Add first grandparent to mempool and fetch entry
pool.addUnchecked(entry.FromTx(grandparent_tx_1));

// Ignores ancestors that aren't direct parents
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also doesn't know about ancestors, given that the parent isn't given, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe not the most important check, just makes sure that being an ancestor in mempool alone somehow isn't enough to get checked for dust

Comment on lines +465 to +467
res = self.nodes[0].submitpackage([dusty_tx["hex"] for dusty_tx in dusty_txs] + [sweep_tx["hex"]])
assert_equal(res['package_msg'], "success")
assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"] for dusty_tx in dusty_txs] + [sweep_tx["tx"], cancel_sweep["tx"]])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting multiple ephemeral txns spent by 1 child is allowed in this logic, but not won't propagate via opportunistic package relay. This would still be the case if we relax the child-with-unconfirmed-parents requirement because none of the parents could be submitted alone.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be worth a comment

self.wallet.rescan_utxos()
assert_equal(self.nodes[0].getrawmempool(), [])

# Other topology tests require relaxation of submitpackage topology
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think "other topology" is a little under-specified here, could be helpful to name what tests you think could be added

self.sync_all()

# N.B. this extra_args can be removed post cluster mempool
def test_free_relay(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: test_no_minrelay_fee could be better

// Non-0 fee dust transactions are not allowed for entry, and modification not allowed afterwards
const auto& tx = mempool.get(hash);
if (tx && HasDust(tx, mempool.m_opts.dust_relay_feerate)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Priority is not supported for transactions with dust outputs.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be added to release notes for RPC? I think the only way it'd be relevant to existing usage is in a acceptnonstdtxn=1 and non-0 dustrelayfee situation. But worth noting imo.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternatively could just check mempool.m_opts.require_standard as well?

@DrahtBot DrahtBot requested a review from glozow November 6, 2024 13:21
Copy link
Contributor

@hodlinator hodlinator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code reviewed up until most of aa7c90a.

Disclaimer: I posses far from full context when it comes to validation code, but at least as far as I can tell we are only modifying policy, not consensus.

PR title should probably have "p2p:" or some other prefix.

assert_equal(self.nodes[0].getrawmempool(), [])

# Double dust, both unspent, with fees. Would have failed individual checks.
# Dust is 1 satoshi create_self_transfer_multi disallows 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Missing word

Suggested change
# Dust is 1 satoshi create_self_transfer_multi disallows 0
# Dust is 1 satoshi since create_self_transfer_multi disallows 0

Comment on lines +83 to +84
dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=1000, amount_per_output=1, num_outputs=2)
dust_txid = self.nodes[0].sendrawtransaction(hexstring=dusty_tx["hex"], maxfeerate=0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I like the transaction being called "dusty" as it creates dust but is not itself dust. Dust refers only to outputs, not to transactions. Would prefer dusty_txid.

}

for (const auto& tx : package) {
Txid txid = tx->GetHash();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: txid is only used once in the return statement at the bottom of the loop, so could skip creating this variable...

It seems to be introduced in response to #30239 (comment) - arguably we should probably tolerate GetHash() until the day it is renamed/aliased to GetTxid(). Lifetime is unnecessarily long as well.

for (const auto& tx_input : tx->vin) {
const Txid& parent_txid{tx_input.prevout.hash};
// Skip parents we've already checked dust for
if (processed_parent_set.contains(parent_txid)) continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Since we unconditionally insert at the end of the loop, we might as well insert up here instead and check if it already existed in the set.

Suggested change
if (processed_parent_set.contains(parent_txid)) continue;
if (!processed_parent_set.insert(parent_txid).second) continue;

Comment on lines +499 to +501
if (tx && HasDust(tx, mempool.m_opts.dust_relay_feerate)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Priority is not supported for transactions with dust outputs.");
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it is good to limit dust in the UTXO-set, I'm not sure adding this limitation is wise.

If we want mining pools to use as un-patched versions of Bitcoin Core as possible, we probably shouldn't try to limit what they can do with this RPC.

IMO this commit d147d92 should be split out to its own PR.

(My argument is weakened by the fact that this function already requires the transaction to have made it into the mempool in the first place).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I independently came to the same conclusion, moving my review comment here:

I feel like d147d92 could just be dropped entirely? If a miner has a reason to prioritize a transaction, I don't see why they'd be okay with that not being possible just because it has a dust output? So since this is a mining RPC, I feel like practically speaking they'd just patch it out, and there'd be no real use case left for this exception, so let's just save everyone involved the hassle?

Comment on lines +195 to +197
# We aren't checking spending, allow it in with no fee
self.restart_node(0, extra_args=["-minrelaytxfee=0"])
self.restart_node(1, extra_args=["-minrelaytxfee=0"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could add a comment about why -dustrelayfee is not a concern here?

Comment on lines +240 to +257
# Valid sweep we will RBF incorrectly by not spending dust as well
sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3)
self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]])
assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]])

# Doesn't spend in-mempool dust output from parent
unspent_sweep_tx = self.wallet.create_self_transfer_multi(fee_per_output=2000, utxos_to_spend=[dusty_tx["new_utxos"][0]], version=3)
assert_greater_than(unspent_sweep_tx["fee"], sweep_tx["fee"])
res = self.nodes[0].submitpackage([dusty_tx["hex"], unspent_sweep_tx["hex"]])
assert_equal(res["tx-results"][unspent_sweep_tx["wtxid"]]["error"], f"missing-ephemeral-spends, tx {unspent_sweep_tx['txid']} did not spend parent's ephemeral dust")
assert_raises_rpc_error(-26, f"missing-ephemeral-spends, tx {unspent_sweep_tx['txid']} did not spend parent's ephemeral dust", self.nodes[0].sendrawtransaction, unspent_sweep_tx["hex"])
assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]])

# Spend works with dust spent
sweep_tx_2 = self.wallet.create_self_transfer_multi(fee_per_output=2000, utxos_to_spend=dusty_tx["new_utxos"], version=3)
assert sweep_tx["hex"] != sweep_tx_2["hex"]
res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx_2["hex"]])
assert_equal(res["package_msg"], "success")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly easier to follow comments?

Suggested change
# Valid sweep we will RBF incorrectly by not spending dust as well
sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3)
self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]])
assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]])
# Doesn't spend in-mempool dust output from parent
unspent_sweep_tx = self.wallet.create_self_transfer_multi(fee_per_output=2000, utxos_to_spend=[dusty_tx["new_utxos"][0]], version=3)
assert_greater_than(unspent_sweep_tx["fee"], sweep_tx["fee"])
res = self.nodes[0].submitpackage([dusty_tx["hex"], unspent_sweep_tx["hex"]])
assert_equal(res["tx-results"][unspent_sweep_tx["wtxid"]]["error"], f"missing-ephemeral-spends, tx {unspent_sweep_tx['txid']} did not spend parent's ephemeral dust")
assert_raises_rpc_error(-26, f"missing-ephemeral-spends, tx {unspent_sweep_tx['txid']} did not spend parent's ephemeral dust", self.nodes[0].sendrawtransaction, unspent_sweep_tx["hex"])
assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]])
# Spend works with dust spent
sweep_tx_2 = self.wallet.create_self_transfer_multi(fee_per_output=2000, utxos_to_spend=dusty_tx["new_utxos"], version=3)
assert sweep_tx["hex"] != sweep_tx_2["hex"]
res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx_2["hex"]])
assert_equal(res["package_msg"], "success")
# Setup valid sweep in mempool
sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3)
self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]])
assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]])
# RBF-attempt fails when not spending in-mempool dust output from parent
unspent_sweep_tx = self.wallet.create_self_transfer_multi(fee_per_output=2000, utxos_to_spend=[dusty_tx["new_utxos"][0]], version=3)
assert_greater_than(unspent_sweep_tx["fee"], sweep_tx["fee"])
res = self.nodes[0].submitpackage([dusty_tx["hex"], unspent_sweep_tx["hex"]])
assert_equal(res["tx-results"][unspent_sweep_tx["wtxid"]]["error"], f"missing-ephemeral-spends, tx {unspent_sweep_tx['txid']} did not spend parent's ephemeral dust")
assert_raises_rpc_error(-26, f"missing-ephemeral-spends, tx {unspent_sweep_tx['txid']} did not spend parent's ephemeral dust", self.nodes[0].sendrawtransaction, unspent_sweep_tx["hex"])
assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]])
# RBF works when dust is spent
sweep_tx_2 = self.wallet.create_self_transfer_multi(fee_per_output=2000, utxos_to_spend=dusty_tx["new_utxos"], version=3)
assert sweep_tx["hex"] != sweep_tx_2["hex"]
res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx_2["hex"]])
assert_equal(res["package_msg"], "success")

Comment on lines +290 to +293
dusty_tx = self.wallet.create_self_transfer_multi(
fee_per_output=0,
version=3
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the sudden change in style?

Suggested change
dusty_tx = self.wallet.create_self_transfer_multi(
fee_per_output=0,
version=3
)
dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3)

@@ -21,6 +21,20 @@

ORPHAN_TX_EXPIRE_TIME = 1200

def assert_mempool_contents(test_framework, node, expected=None, sync=True):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: My naive LSP jumped to the PackageRBFTest-version when going to the definition of assert_mempool_contents from EphemeralDustTest. Would prefer if the commit introducing this one was also removed the identically named function from PackageRBFTest.

Comment on lines +352 to +353
block_res = self.nodes[0].rpc.generateblock(self.wallet.get_address(), [dusty_tx["hex"]])
self.nodes[0].invalidateblock(block_res["hash"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Could validate/document that tx made it into the block to be sure, before invalidating.

Suggested change
block_res = self.nodes[0].rpc.generateblock(self.wallet.get_address(), [dusty_tx["hex"]])
self.nodes[0].invalidateblock(block_res["hash"])
block_res = self.nodes[0].rpc.generateblock(self.wallet.get_address(), [dusty_tx["hex"]])
assert_equal(self.nodes[0].getrawtransaction(dusty_tx["txid"], blockhash=block_res["hash"]), dusty_tx["hex"])
self.nodes[0].invalidateblock(block_res["hash"])

Copy link
Contributor

@stickies-v stickies-v left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK. I've mostly reviewed the non-test code so far. No comments are blocking, I appreciate on a PR of this scope we can't address everything and things may need to happen in a follow-up.

Comment on lines +81 to +82
# Double dust, both unspent, with fees. Would have failed individual checks.
# Dust is 1 satoshi create_self_transfer_multi disallows 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 2873351:

nit: I would either remove these docstrings, or fix up the grammar, I spent way more time trying to parse these (and failed) than just reading the code

        # Create two dust outputs. Both are unspent, have fees, and would have failed individual checks.
        # The amount is 1 satoshi because create_self_transfer_multi disallows 0.

// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.

#include <policy/ephemeral_policy.h>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: includes are not quite up to date (in both files)

git diff on d5c564a to make iwyu happy
diff --git a/src/policy/ephemeral_policy.cpp b/src/policy/ephemeral_policy.cpp
index 6854822e35..3a9a3fa530 100644
--- a/src/policy/ephemeral_policy.cpp
+++ b/src/policy/ephemeral_policy.cpp
@@ -5,6 +5,22 @@
 #include <policy/ephemeral_policy.h>
 #include <policy/policy.h>
 
+#include <consensus/validation.h>
+#include <policy/feerate.h>
+#include <policy/packages.h>
+#include <primitives/transaction.h>
+#include <txmempool.h>
+#include <util/check.h>
+#include <util/hasher.h>
+
+#include <algorithm>
+#include <cstdint>
+#include <map>
+#include <memory>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
 bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate)
 {
     return std::any_of(tx->vout.cbegin(), tx->vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); });
diff --git a/src/policy/ephemeral_policy.h b/src/policy/ephemeral_policy.h
index 26140f9a02..742cb30280 100644
--- a/src/policy/ephemeral_policy.h
+++ b/src/policy/ephemeral_policy.h
@@ -5,10 +5,15 @@
 #ifndef BITCOIN_POLICY_EPHEMERAL_POLICY_H
 #define BITCOIN_POLICY_EPHEMERAL_POLICY_H
 
+#include <consensus/amount.h>
 #include <policy/packages.h>
-#include <policy/policy.h>
 #include <primitives/transaction.h>
-#include <txmempool.h>
+
+#include <optional>
+
+class CFeeRate;
+class CTxMemPool;
+class TxValidationState;
 
 /** These utility functions ensure that ephemeral dust is safely
  * created and spent without unduly risking them entering the utxo


bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate)
{
return std::any_of(tx->vout.cbegin(), tx->vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In d5c564a:

nit: could make this a bit nicer with std::ranges:

git diff on d5c564a
diff --git a/src/policy/ephemeral_policy.cpp b/src/policy/ephemeral_policy.cpp
index 6854822e35..569f9a95b5 100644
--- a/src/policy/ephemeral_policy.cpp
+++ b/src/policy/ephemeral_policy.cpp
@@ -7,7 +7,7 @@
 
 bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate)
 {
-    return std::any_of(tx->vout.cbegin(), tx->vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); });
+    return std::ranges::any_of(tx->vout, [&](const auto& output) { return IsDust(output, dust_relay_rate); });
 }
 
 bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state)
@@ -22,7 +22,7 @@ bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate,
 
 std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool)
 {
-    if (!Assume(std::all_of(package.cbegin(), package.cend(), [](const auto& tx){return tx != nullptr;}))) {
+    if (!Assume(std::ranges::all_of(package, [](const auto& tx){return tx != nullptr;}))) {
         // Bail out of spend checks if caller gave us an invalid package
         return std::nullopt;
     }

* where parents are either in the mempool or in the package itself.
* The function returns std::nullopt if all dust is properly spent, or the txid of the violating child spend.
*/
std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think returning a falsy value for a successful check is an antipattern. This would be nicely addressed by #25665, but until that's merged I think I'd prefer returning a std::pair<bool, std::optional<Txid>>, even if it's a bit more verbose:

git diff on d5c564a
diff --git a/src/policy/ephemeral_policy.cpp b/src/policy/ephemeral_policy.cpp
index 6854822e35..10a8ab1938 100644
--- a/src/policy/ephemeral_policy.cpp
+++ b/src/policy/ephemeral_policy.cpp
@@ -5,6 +5,8 @@
 #include <policy/ephemeral_policy.h>
 #include <policy/policy.h>
 
+#include <utility>
+
 bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate)
 {
     return std::any_of(tx->vout.cbegin(), tx->vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); });
@@ -20,11 +22,11 @@ bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate,
     return true;
 }
 
-std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool)
+std::pair<bool, std::optional<Txid>> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool)
 {
     if (!Assume(std::all_of(package.cbegin(), package.cend(), [](const auto& tx){return tx != nullptr;}))) {
         // Bail out of spend checks if caller gave us an invalid package
-        return std::nullopt;
+        return {true, std::nullopt};
     }
 
     std::map<Txid, CTransactionRef> map_txid_ref;
@@ -70,9 +72,9 @@ std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_r
         }
 
         if (!unspent_parent_dust.empty()) {
-            return txid;
+            return {false, txid};
         }
     }
 
-    return std::nullopt;
+    return {true, std::nullopt};
 }
diff --git a/src/policy/ephemeral_policy.h b/src/policy/ephemeral_policy.h
index 26140f9a02..7235dd7500 100644
--- a/src/policy/ephemeral_policy.h
+++ b/src/policy/ephemeral_policy.h
@@ -10,6 +10,8 @@
 #include <primitives/transaction.h>
 #include <txmempool.h>
 
+#include <utility>
+
 /** These utility functions ensure that ephemeral dust is safely
  * created and spent without unduly risking them entering the utxo
  * set.
@@ -50,6 +52,6 @@ bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate,
  *  where parents are either in the mempool or in the package itself.
  *  The function returns std::nullopt if all dust is properly spent, or the txid of the violating child spend.
  */
-std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool);
+std::pair<bool, std::optional<Txid>> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool);
 
 #endif // BITCOIN_POLICY_EPHEMERAL_POLICY_H
diff --git a/src/validation.cpp b/src/validation.cpp
index 2f3e7d61a8..94cbdf2766 100644
--- a/src/validation.cpp
+++ b/src/validation.cpp
@@ -1456,11 +1456,11 @@ MempoolAcceptResult MemPoolAccept::AcceptSingleTransaction(const CTransactionRef
     }
 
     if (m_pool.m_opts.require_standard) {
-        if (const auto ephemeral_violation{CheckEphemeralSpends(/*package=*/{ptx}, m_pool.m_opts.dust_relay_feerate, m_pool)}) {
-            const Txid& txid = ephemeral_violation.value();
-            Assume(txid == ptx->GetHash());
+        const auto& [is_ephemeral, offending_txid] = CheckEphemeralSpends(/*package=*/{ptx}, m_pool.m_opts.dust_relay_feerate, m_pool);
+        if (!is_ephemeral) {
+            Assume(offending_txid.value() == ptx->GetHash());
             ws.m_state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "missing-ephemeral-spends",
-                          strprintf("tx %s did not spend parent's ephemeral dust", txid.ToString()));
+                          strprintf("tx %s did not spend parent's ephemeral dust", offending_txid.value().ToString()));
             return MempoolAcceptResult::Failure(ws.m_state);
         }
     }
@@ -1605,13 +1605,13 @@ PackageMempoolAcceptResult MemPoolAccept::AcceptMultipleTransactions(const std::
 
     // Now that we've bounded the resulting possible ancestry count, check package for dust spends
     if (m_pool.m_opts.require_standard) {
-        if (const auto ephemeral_violation{CheckEphemeralSpends(txns, m_pool.m_opts.dust_relay_feerate, m_pool)}) {
-            const Txid& child_txid = ephemeral_violation.value();
+        const auto& [is_ephemeral, offending_txid] = CheckEphemeralSpends(txns, m_pool.m_opts.dust_relay_feerate, m_pool);
+        if (!is_ephemeral) {
             TxValidationState child_state;
             child_state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "missing-ephemeral-spends",
-                          strprintf("tx %s did not spend parent's ephemeral dust", child_txid.ToString()));
+                          strprintf("tx %s did not spend parent's ephemeral dust", offending_txid.value().ToString()));
             package_state.Invalid(PackageValidationResult::PCKG_TX, "unspent-dust");
-            results.emplace(child_txid, MempoolAcceptResult::Failure(child_state));
+            results.emplace(offending_txid.value(), MempoolAcceptResult::Failure(child_state));
             return PackageMempoolAcceptResult(package_state, std::move(results));
         }
     }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, returning a bool and adding a TxValidationState& out-parameter would simultaneously avoid the anti-pattern and reduce code duplication:

git diff on d5c564a
diff --git a/src/policy/ephemeral_policy.cpp b/src/policy/ephemeral_policy.cpp
index 6854822e35..94868f8fcb 100644
--- a/src/policy/ephemeral_policy.cpp
+++ b/src/policy/ephemeral_policy.cpp
@@ -20,11 +20,11 @@ bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate,
     return true;
 }
 
-std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool)
+bool CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool, TxValidationState& child_state)
 {
     if (!Assume(std::all_of(package.cbegin(), package.cend(), [](const auto& tx){return tx != nullptr;}))) {
         // Bail out of spend checks if caller gave us an invalid package
-        return std::nullopt;
+        return true;
     }
 
     std::map<Txid, CTransactionRef> map_txid_ref;
@@ -70,9 +70,11 @@ std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_r
         }
 
         if (!unspent_parent_dust.empty()) {
-            return txid;
+            child_state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "missing-ephemeral-spends",
+                                strprintf("tx %s did not spend parent's ephemeral dust", txid.ToString()));
+            return false;
         }
     }
 
-    return std::nullopt;
+    return true;
 }
diff --git a/src/policy/ephemeral_policy.h b/src/policy/ephemeral_policy.h
index 26140f9a02..bf2c9691a7 100644
--- a/src/policy/ephemeral_policy.h
+++ b/src/policy/ephemeral_policy.h
@@ -48,8 +48,8 @@ bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate,
 /** Must be called for each transaction(package) if any dust is in the package.
  *  Checks that each transaction's parents have their dust spent by the child,
  *  where parents are either in the mempool or in the package itself.
- *  The function returns std::nullopt if all dust is properly spent, or the txid of the violating child spend.
+ *  The function returns true if all dust is properly spent.
  */
-std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool);
+bool CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool, TxValidationState& child_state);
 
 #endif // BITCOIN_POLICY_EPHEMERAL_POLICY_H
diff --git a/src/validation.cpp b/src/validation.cpp
index 2f3e7d61a8..f6d05956ee 100644
--- a/src/validation.cpp
+++ b/src/validation.cpp
@@ -1456,11 +1456,7 @@ MempoolAcceptResult MemPoolAccept::AcceptSingleTransaction(const CTransactionRef
     }
 
     if (m_pool.m_opts.require_standard) {
-        if (const auto ephemeral_violation{CheckEphemeralSpends(/*package=*/{ptx}, m_pool.m_opts.dust_relay_feerate, m_pool)}) {
-            const Txid& txid = ephemeral_violation.value();
-            Assume(txid == ptx->GetHash());
-            ws.m_state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "missing-ephemeral-spends",
-                          strprintf("tx %s did not spend parent's ephemeral dust", txid.ToString()));
+        if (!CheckEphemeralSpends(/*package=*/{ptx}, m_pool.m_opts.dust_relay_feerate, m_pool, ws.m_state)) {
             return MempoolAcceptResult::Failure(ws.m_state);
         }
     }
@@ -1605,13 +1601,9 @@ PackageMempoolAcceptResult MemPoolAccept::AcceptMultipleTransactions(const std::
 
     // Now that we've bounded the resulting possible ancestry count, check package for dust spends
     if (m_pool.m_opts.require_standard) {
-        if (const auto ephemeral_violation{CheckEphemeralSpends(txns, m_pool.m_opts.dust_relay_feerate, m_pool)}) {
-            const Txid& child_txid = ephemeral_violation.value();
-            TxValidationState child_state;
-            child_state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "missing-ephemeral-spends",
-                          strprintf("tx %s did not spend parent's ephemeral dust", child_txid.ToString()));
+        if (TxValidationState child_state; !CheckEphemeralSpends(txns, m_pool.m_opts.dust_relay_feerate, m_pool, child_state)) {
             package_state.Invalid(PackageValidationResult::PCKG_TX, "unspent-dust");
-            results.emplace(child_txid, MempoolAcceptResult::Failure(child_state));
+            results.emplace(txns.back()->GetHash(), MempoolAcceptResult::Failure(child_state));
             return PackageMempoolAcceptResult(package_state, std::move(results));
         }
     }

@@ -142,11 +143,16 @@ bool IsStandardTx(const CTransaction& tx, const std::optional<unsigned>& max_dat
reason = "bare-multisig";
return false;
} else if (IsDust(txout, dust_relay_fee)) {
reason = "dust";
return false;
num_dust_outputs++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In d5c564a

developer-notes-nit: ++i is preferred over i++.

#include <policy/ephemeral_policy.h>
#include <policy/policy.h>

bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In d5c564a

related: the code could be simplified a bit (imo) by changing HasDust to GetDust, which would require to move it to policy.h

git diff on d5c564a
diff --git a/src/policy/ephemeral_policy.cpp b/src/policy/ephemeral_policy.cpp
index 6854822e35..6066d9b3ac 100644
--- a/src/policy/ephemeral_policy.cpp
+++ b/src/policy/ephemeral_policy.cpp
@@ -5,15 +5,10 @@
 #include <policy/ephemeral_policy.h>
 #include <policy/policy.h>
 
-bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate)
-{
-    return std::any_of(tx->vout.cbegin(), tx->vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); });
-}
-
 bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state)
 {
     // We never want to give incentives to mine this transaction alone
-    if ((base_fee != 0 || mod_fee != 0) && HasDust(tx, dust_relay_rate)) {
+    if ((base_fee != 0 || mod_fee != 0) && !GetDust(*tx, dust_relay_rate).empty()) {
         return state.Invalid(TxValidationResult::TX_NOT_STANDARD, "dust", "tx with dust output must be 0-fee");
     }
 
@@ -52,11 +47,8 @@ std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_r
 
             // Check for dust on parents
             if (parent_ref) {
-                for (uint32_t out_index = 0; out_index < parent_ref->vout.size(); out_index++) {
-                    const auto& tx_output = parent_ref->vout[out_index];
-                    if (IsDust(tx_output, dust_relay_rate)) {
-                        unspent_parent_dust.insert(COutPoint(parent_txid, out_index));
-                    }
+                for (const auto& out_index : GetDust(*parent_ref, dust_relay_rate)) {
+                    unspent_parent_dust.insert(COutPoint{parent_txid, out_index});
                 }
             }
 
diff --git a/src/policy/ephemeral_policy.h b/src/policy/ephemeral_policy.h
index 26140f9a02..98f40d38ff 100644
--- a/src/policy/ephemeral_policy.h
+++ b/src/policy/ephemeral_policy.h
@@ -34,9 +34,6 @@
  * are the only way to bring fees.
  */
 
-/** Returns true if transaction contains dust */
-bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate);
-
 /* All the following checks are only called if standardness rules are being applied. */
 
 /** Must be called for each transaction once transaction fees are known.
diff --git a/src/policy/policy.cpp b/src/policy/policy.cpp
index 21c35af5cc..ed33692823 100644
--- a/src/policy/policy.cpp
+++ b/src/policy/policy.cpp
@@ -67,6 +67,15 @@ bool IsDust(const CTxOut& txout, const CFeeRate& dustRelayFeeIn)
     return (txout.nValue < GetDustThreshold(txout, dustRelayFeeIn));
 }
 
+std::vector<uint32_t> GetDust(const CTransaction& tx, CFeeRate dust_relay_rate)
+{
+    std::vector<uint32_t> dust_outputs;
+    for (uint32_t i{0}; i < tx.vout.size(); ++i) {
+        if (IsDust(tx.vout[i], dust_relay_rate)) dust_outputs.push_back(i);
+    }
+    return dust_outputs;
+}
+
 bool IsStandard(const CScript& scriptPubKey, const std::optional<unsigned>& max_datacarrier_bytes, TxoutType& whichType)
 {
     std::vector<std::vector<unsigned char> > vSolutions;
@@ -129,7 +138,6 @@ bool IsStandardTx(const CTransaction& tx, const std::optional<unsigned>& max_dat
     }
 
     unsigned int nDataOut = 0;
-    unsigned int num_dust_outputs{0};
     TxoutType whichType;
     for (const CTxOut& txout : tx.vout) {
         if (!::IsStandard(txout.scriptPubKey, max_datacarrier_bytes, whichType)) {
@@ -142,13 +150,11 @@ bool IsStandardTx(const CTransaction& tx, const std::optional<unsigned>& max_dat
         else if ((whichType == TxoutType::MULTISIG) && (!permit_bare_multisig)) {
             reason = "bare-multisig";
             return false;
-        } else if (IsDust(txout, dust_relay_fee)) {
-            num_dust_outputs++;
         }
     }
 
     // Only MAX_DUST_OUTPUTS_PER_TX dust is permitted(on otherwise valid ephemeral dust)
-    if (num_dust_outputs > MAX_DUST_OUTPUTS_PER_TX) {
+    if (GetDust(tx, dust_relay_fee).size() > MAX_DUST_OUTPUTS_PER_TX) {
         reason = "dust";
         return false;
     }
diff --git a/src/policy/policy.h b/src/policy/policy.h
index 0488f8dbee..9e36d3f610 100644
--- a/src/policy/policy.h
+++ b/src/policy/policy.h
@@ -129,6 +129,9 @@ CAmount GetDustThreshold(const CTxOut& txout, const CFeeRate& dustRelayFee);
 
 bool IsDust(const CTxOut& txout, const CFeeRate& dustRelayFee);
 
+/** Get the vout index numbers of all dust outputs */
+std::vector<uint32_t> GetDust(const CTransaction& tx, CFeeRate dust_relay_rate);
+
 bool IsStandard(const CScript& scriptPubKey, const std::optional<unsigned>& max_datacarrier_bytes, TxoutType& whichType);
 
 

That said, the current code is fine too, and I suspect you'll find this too big of a change for a PR of this size that otherwise seems close to merge.

Comment on lines +817 to +819
// Add dust output to take dust slot, still standard!
t.vout.emplace_back(0, t.vout[0].scriptPubKey);
CheckIsStandard(t);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In d5c564a

Since MAX_DUST_OUTPUTS_PER_TX is parameterized, it would make the tests more robust to dynamically add dust outputs too?

git diff on d5c564a
diff --git a/src/policy/policy.h b/src/policy/policy.h
index 0488f8dbee..e023c99560 100644
--- a/src/policy/policy.h
+++ b/src/policy/policy.h
@@ -80,7 +80,7 @@ static constexpr unsigned int EXTRA_DESCENDANT_TX_SIZE_LIMIT{10000};
 /**
  * Maximum number of ephemeral dust outputs allowed.
  */
-static constexpr unsigned int MAX_DUST_OUTPUTS_PER_TX{1};
+static constexpr unsigned int MAX_DUST_OUTPUTS_PER_TX{4};
 
 /**
  * Mandatory script verification flags that all new transactions must comply with for
diff --git a/src/test/transaction_tests.cpp b/src/test/transaction_tests.cpp
index 3e4c085c0e..f38c015f6d 100644
--- a/src/test/transaction_tests.cpp
+++ b/src/test/transaction_tests.cpp
@@ -814,9 +814,11 @@ BOOST_AUTO_TEST_CASE(test_IsStandard)
     CAmount nDustThreshold = 182 * g_dust.GetFeePerK() / 1000;
     BOOST_CHECK_EQUAL(nDustThreshold, 546);
 
-    // Add dust output to take dust slot, still standard!
-    t.vout.emplace_back(0, t.vout[0].scriptPubKey);
-    CheckIsStandard(t);
+    // Add dust outputs up to allowed maximum, still standard!
+    for (size_t i{0}; i < MAX_DUST_OUTPUTS_PER_TX; ++i) {
+        t.vout.emplace_back(0, t.vout[0].scriptPubKey);
+        CheckIsStandard(t);
+    }
 
     // dust:
     t.vout[0].nValue = nDustThreshold - 1;
@@ -974,9 +976,9 @@ BOOST_AUTO_TEST_CASE(test_IsStandard)
     CheckIsNotStandard(t, "bare-multisig");
     g_bare_multi = DEFAULT_PERMIT_BAREMULTISIG;
 
-    // Add dust output to take dust slot
+    // Add dust outputs up to allowed maximum
     assert(t.vout.size() == 1);
-    t.vout.emplace_back(0, t.vout[0].scriptPubKey);
+    t.vout.insert(t.vout.end(), MAX_DUST_OUTPUTS_PER_TX, {0, t.vout[0].scriptPubKey});
 
     // Check compressed P2PK outputs dust threshold (must have leading 02 or 03)
     t.vout[0].scriptPubKey = CScript() << std::vector<unsigned char>(33, 0x02) << OP_CHECKSIG;

return std::any_of(tx->vout.cbegin(), tx->vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); });
}

bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason for these to be const CTransactionRef& instead of const CTransaction&? The latter would avoid nullptr issues as well as improve reusability.

git diff on d5c564a
diff --git a/src/policy/ephemeral_policy.cpp b/src/policy/ephemeral_policy.cpp
index 6854822e35..c9f1ba076d 100644
--- a/src/policy/ephemeral_policy.cpp
+++ b/src/policy/ephemeral_policy.cpp
@@ -5,12 +5,12 @@
 #include <policy/ephemeral_policy.h>
 #include <policy/policy.h>
 
-bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate)
+bool HasDust(const CTransaction& tx, CFeeRate dust_relay_rate)
 {
-    return std::any_of(tx->vout.cbegin(), tx->vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); });
+    return std::any_of(tx.vout.cbegin(), tx.vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); });
 }
 
-bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state)
+bool CheckValidEphemeralTx(const CTransaction& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state)
 {
     // We never want to give incentives to mine this transaction alone
     if ((base_fee != 0 || mod_fee != 0) && HasDust(tx, dust_relay_rate)) {
diff --git a/src/policy/ephemeral_policy.h b/src/policy/ephemeral_policy.h
index 26140f9a02..10a5edf337 100644
--- a/src/policy/ephemeral_policy.h
+++ b/src/policy/ephemeral_policy.h
@@ -35,7 +35,7 @@
  */
 
 /** Returns true if transaction contains dust */
-bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate);
+bool HasDust(const CTransaction& tx, CFeeRate dust_relay_rate);
 
 /* All the following checks are only called if standardness rules are being applied. */
 
@@ -43,7 +43,7 @@ bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate);
  * Does context-less checks about a single transaction.
  * Returns false if the fee is non-zero and dust exists, populating state. True otherwise.
  */
-bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state);
+bool CheckValidEphemeralTx(const CTransaction& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state);
 
 /** Must be called for each transaction(package) if any dust is in the package.
  *  Checks that each transaction's parents have their dust spent by the child,
diff --git a/src/validation.cpp b/src/validation.cpp
index 2f3e7d61a8..8387a7f971 100644
--- a/src/validation.cpp
+++ b/src/validation.cpp
@@ -930,7 +930,7 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)
 
     // Enforces 0-fee for dust transactions, no incentive to be mined alone
     if (m_pool.m_opts.require_standard) {
-        if (!CheckValidEphemeralTx(ptx, m_pool.m_opts.dust_relay_feerate, ws.m_base_fees, ws.m_modified_fees, state)) {
+        if (!CheckValidEphemeralTx(tx, m_pool.m_opts.dust_relay_feerate, ws.m_base_fees, ws.m_modified_fees, state)) {
             return false; // state filled in by CheckValidEphemeralTx
         }
     }

// Enforces 0-fee for dust transactions, no incentive to be mined alone
if (m_pool.m_opts.require_standard) {
if (!CheckValidEphemeralTx(ptx, m_pool.m_opts.dust_relay_feerate, ws.m_base_fees, ws.m_modified_fees, state)) {
return false; // state filled in by CheckValidEphemeralTx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer actually asserting this instead of just documenting it (unsure if we prefer !IsValid() or IsInvalid() here, the latter not capturing IsError()).

git diff on d5c564a
diff --git a/src/validation.cpp b/src/validation.cpp
index 2f3e7d61a8..ac619fa385 100644
--- a/src/validation.cpp
+++ b/src/validation.cpp
@@ -931,7 +931,8 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)
     // Enforces 0-fee for dust transactions, no incentive to be mined alone
     if (m_pool.m_opts.require_standard) {
         if (!CheckValidEphemeralTx(ptx, m_pool.m_opts.dust_relay_feerate, ws.m_base_fees, ws.m_modified_fees, state)) {
-            return false; // state filled in by CheckValidEphemeralTx
+            Assert(!state.IsValid());
+            return false;
         }
     }
 

Comment on lines +499 to +501
if (tx && HasDust(tx, mempool.m_opts.dust_relay_feerate)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Priority is not supported for transactions with dust outputs.");
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I independently came to the same conclusion, moving my review comment here:

I feel like d147d92 could just be dropped entirely? If a miner has a reason to prioritize a transaction, I don't see why they'd be okay with that not being possible just because it has a dust output? So since this is a mining RPC, I feel like practically speaking they'd just patch it out, and there'd be no real use case left for this exception, so let's just save everyone involved the hassle?

Copy link
Contributor

@hodlinator hodlinator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK 131bed1

Thanks for working on this! Seems like an obvious win for L2s.

Haven't yet absorbed the full context sufficiently to fully A-C-K it myself. Hopefully my comments here and above provide value even though most are surface level. My personal priority with this review is to get it in better shape for others so they can A-C-K more easily if they agree.

self.nodes[0].invalidateblock(block_res["hash"])
assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"]], sync=False)

# Also should happen if dust is swept
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Also"? But in this case the sweep-transaction survives the reorg, unlike the case above.

assert_equal(res['package_msg'], "success")
assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"] for dusty_tx in dusty_txs] + [sweep_tx["tx"], cancel_sweep["tx"]])

self.generate(self.nodes[0], 25)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 25 and not 1 block?


chainstate.SetMempool(&tx_pool);

LIMITED_WHILE(fuzzed_data_provider.ConsumeBool(), 300)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Just curious - this is a very common pattern, why do we leave it up to the fuzz engine instead of just doing:

Suggested change
LIMITED_WHILE(fuzzed_data_provider.ConsumeBool(), 300)
LIMITED_WHILE(true, 300)

Does it help define discrete sections of the fuzz data, which ends up being useful somehow?

These kind of uses make more sense to me: LIMITED_WHILE(provider.remaining_bytes() > 0, iter_limit), LIMITED_WHILE(!available_coins.empty(), 500).)

Comment on lines +223 to +224
std::optional<COutPoint> outpoint_to_rbf{GetChildEvictingPrevout(tx_pool)};
bool should_rbf_eph_spend = outpoint_to_rbf && fuzzed_data_provider.ConsumeBool();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Reduce the number of variables:

Suggested change
std::optional<COutPoint> outpoint_to_rbf{GetChildEvictingPrevout(tx_pool)};
bool should_rbf_eph_spend = outpoint_to_rbf && fuzzed_data_provider.ConsumeBool();
std::optional<COutPoint> outpoint_to_rbf{fuzzed_data_provider.ConsumeBool() ? GetChildEvictingPrevout(tx_pool) : std::nullopt};

// Create transaction to add to the mempool
const CTransactionRef tx = [&] {
CMutableTransaction tx_mut;
tx_mut.version = CTransaction::CURRENT_VERSION;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only exercising v2 - maybe you could describe the reasoning behind the test at the top of fuzz target?

}
for (auto i{0}; i < 3; ++i) {
mtx.vout[i].scriptPubKey = CScript() << OP_TRUE;
mtx.vout[i].nValue = (i == 2) ? 0 : 10000;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass in dust_index to make this 2 less magical, or make it a file-local constant used both in this utility function and in the new test case?

// We first start with nothing "in the mempool", using package checks

// Trivial single transaction with no dust
BOOST_CHECK(!CheckEphemeralSpends({dust_spend}, minrelay, pool).has_value());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could rely on optional's bool operator to decrease noise here and below:

Suggested change
BOOST_CHECK(!CheckEphemeralSpends({dust_spend}, minrelay, pool).has_value());
BOOST_CHECK(!CheckEphemeralSpends({dust_spend}, minrelay, pool));

auto dust_non_spend = make_tx({COutPoint{dust_txid, dust_index - 1}}, /*version=*/2);

// Child spending non-dust only from parent should be disallowed even if dust otherwise spent
BOOST_CHECK(CheckEphemeralSpends({grandparent_tx_1, dust_non_spend, dust_spend}, minrelay, pool).has_value());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might as well be explicit about expected txid here and elsewhere?

Suggested change
BOOST_CHECK(CheckEphemeralSpends({grandparent_tx_1, dust_non_spend, dust_spend}, minrelay, pool).has_value());
BOOST_CHECK_EQUAL(CheckEphemeralSpends({grandparent_tx_1, dust_non_spend, dust_spend}, minrelay, pool), dust_non_spend->GetHash());

}

// Tx with many outputs
CMutableTransaction tx1 = CMutableTransaction();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and for tx2:

Suggested change
CMutableTransaction tx1 = CMutableTransaction();
CMutableTransaction tx1;


bench.run([&]() NO_THREAD_SAFETY_ANALYSIS {

CheckEphemeralSpends({tx2_r}, /*dust_relay_rate=*/CFeeRate(iteration * COIN / 10), pool);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the diff:

diff --git a/src/bench/mempool_ephemeral_spends.cpp b/src/bench/mempool_ephemeral_spends.cpp
index e867c61752..dc5e577caf 100644
--- a/src/bench/mempool_ephemeral_spends.cpp
+++ b/src/bench/mempool_ephemeral_spends.cpp
@@ -72,12 +72,16 @@ static void MempoolCheckEphemeralSpends(benchmark::Bench& bench)
     AddTx(tx1_r, pool);
 
     uint32_t iteration{0};
+    uint32_t failure{0};
 
     bench.run([&]() NO_THREAD_SAFETY_ANALYSIS {
 
-        CheckEphemeralSpends({tx2_r}, /*dust_relay_rate=*/CFeeRate(iteration * COIN / 10), pool);
+        if (CheckEphemeralSpends({tx2_r}, /*dust_relay_rate=*/CFeeRate(iteration * COIN / 10), pool))
+            ++failure;
         iteration++;
     });
+
+    printf("success: %d, failure: %d\n", iteration - failure, failure);
 }
 
 BENCHMARK(MempoolCheckEphemeralSpends, benchmark::PriorityLevel::HIGH);

The result was:

₿ build/src/bench/bench_bitcoin -filter=MempoolCheckEphemeralSpends -min-time=30000
|               ns/op |                op/s |    err% |          ins/op |          cyc/op |    IPC |         bra/op |   miss% |     total | benchmark
|--------------------:|--------------------:|--------:|----------------:|----------------:|-------:|---------------:|--------:|----------:|:----------
|          234,976.30 |            4,255.75 |    0.4% |    2,384,033.36 |      859,700.17 |  2.773 |     178,732.26 |    1.3% |     32.29 | `MempoolCheckEphemeralSpends`
success: 1, failure: 138329

Is the intention that we only succeed on iteration 0? Could maybe initialize the iteration to 1 and change the feerate expression, making sure it still succeeds at least once?

Copy link
Contributor

@tdb3 tdb3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK

Great policy update that will provide flexibility to L2s.
Left a few comments, and want to take a deeper look at test cases.

assert_equal(self.nodes[0].getrawmempool(), [dust_txid])

# Spends one dust along with fee input, leave other dust unspent to check ephemeral dust checks aren't being enforced
sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=[self.wallet.get_utxo(), dusty_tx["new_utxos"][0]])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought about what guarantees we would have for get_utxo() to not return one of the dust. At first glance, looks like currently get_utxo() would return the largest utxo (i.e. not dust). Could have a stronger guarantee if we used get_utxo(confirmed_only=True) instead (dusty_tx is in the mempool but not yet confirmed). Could be left for a follow-up unless updating for another reason.

Comment on lines +37 to +53
/** Returns true if transaction contains dust */
bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate);

/* All the following checks are only called if standardness rules are being applied. */

/** Must be called for each transaction once transaction fees are known.
* Does context-less checks about a single transaction.
* Returns false if the fee is non-zero and dust exists, populating state. True otherwise.
*/
bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state);

/** Must be called for each transaction(package) if any dust is in the package.
* Checks that each transaction's parents have their dust spent by the child,
* where parents are either in the mempool or in the package itself.
* The function returns std::nullopt if all dust is properly spent, or the txid of the violating child spend.
*/
std::optional<Txid> CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The comment blocks before each function could be updated to use doxygen commands. Could be left for follow-up.

Example:

 /** Must be called for each transaction once transaction fees are known.
  * Does context-less checks about a single transaction.
- * Returns false if the fee is non-zero and dust exists, populating state. True otherwise.
+ * @returns false if the fee is non-zero and dust exists, populating state. True otherwise.
  */
 bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state);

/** Must be called for each transaction(package) if any dust is in the package.
* Checks that each transaction's parents have their dust spent by the child,
* where parents are either in the mempool or in the package itself.
* The function returns std::nullopt if all dust is properly spent, or the txid of the violating child spend.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function also returns nullopt for an invalid package. Maybe I'm missing something, but this seems to conjoin a failure case (invalid package) with the success case (all dust properly spent). Something like #30239 (comment) could increase consistency.

bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state)
{
// We never want to give incentives to mine this transaction alone
if ((base_fee != 0 || mod_fee != 0) && HasDust(tx, dust_relay_rate)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's decided to allow non-zero modified fee, then the latter half of the || might need to be adjusted here (#30239 (comment)).

}
}

processed_parent_set.insert(parent_txid);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing something simple, but could this line insert the parent txid into processed_parent_set even when the parent couldn't be checked for dust (parent_ref is nullptr)? If so, would we want to handle this as an error condition?

Maybe it's not an issue currently (e.g. reliance on 1P1C), but could be defensive to check to protect in the event of future change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.