-
Notifications
You must be signed in to change notification settings - Fork 37.5k
Add package acceptance logic to mempool #16401
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
Conversation
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers. ConflictsReviewers, this pull request conflicts with the following ones:
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. |
dddfe51
to
149930d
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this works as a proof of concept.
|
||
// Make sure all transactions are ancestors of the last one. | ||
// For now, just check that the last transaction has all prior transactions | ||
// as direct inputs. We can relax this in the future for bigger packages. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This check could be done first, before even setting up the workspaces, as far as I can see.
// not fail unless there's a logic bug in our script validation, but if | ||
// it somehow were to fail on some child tx, we would potentially be | ||
// allowing parents into the mempool with this logic. | ||
if (!ConsensusScriptChecks(args, *wit, txdata[i])) return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like it would be a good case for the CHECK
macro from #16136 (if it logged failures, anyway)...
// Add everything to the mempool, and make sure the last transaction makes | ||
// it in. | ||
size_t i=0; | ||
for (auto wit = tx_workspaces.begin(); wit != tx_workspaces.end(); ++wit, ++i) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be nice if C++11 had a "zip" iterator... FWIW, you could do this as:
struct WorkspaceAndTxData { Workspace ws; PrecomputedTransactionData* ptxdata; };
std::list<WorkspaceAndTxData> tx_workspaces;
...
for (auto& wstxd : tx_workspaces) {
txdata.emplace_back(*wstxd.ws.m_ptx);
wstxd.ptxdata = &txdata.back();
}
and only have to loop over tx_workspaces
, but it's probably not worth the hassle.
GetMainSignals().TransactionAddedToMempool(ptx); | ||
// In case we have no in-mempool ancestors, we must check the transaction | ||
// package itself. | ||
if (total_size > m_limit_descendant_size) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might as well do this test before looping over all_ancestors
src/validation.cpp
Outdated
} | ||
|
||
|
||
bool MemPoolAccept::AcceptMultipleTransactions(std::list<CTransactionRef> tx_list, ATMPArgs& args) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think once this is more baked, it'd be good to pull sections of this out into their own private MemPoolAccept:: methods so the overall logic in this function is as clear as the logic in AcceptSingleTransaction
6938af6
to
779debf
Compare
779debf
to
cd8f11a
Compare
@@ -751,8 +751,12 @@ class CTxMemPool | |||
*/ | |||
class CCoinsViewMemPool : public CCoinsViewBacked | |||
{ | |||
public: | |||
void AddPotentialTransaction(const CTransactionRef& ptx) { package_tx.emplace(ptx->GetHash(), ptx); } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As far as I see, in the current code code paths, the max number of elements in package_tx
is two.
But maybe there should be a hard limit, even if high, at this level just in case?
std::string dummy_string; | ||
// Don't worry about the return value here; it must pass based on | ||
// the checks above. TODO: assert on passing? | ||
m_pool.CalculateMemPoolAncestors(*(wit->m_entry), wit->m_ancestors, m_limit_ancestors, m_limit_ancestor_size, m_limit_descendants, m_limit_descendant_size, dummy_string); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is probably also a good spot for a DCHECK
just to ensure that the assumption - "this must pass based on the checks above" - holds here.
self.nodes[1].generate(101) | ||
self.sync_all(self.nodes[0:2]) | ||
|
||
# On node1, generate a 0-fee transaction, and then a 2-satoshi-per-byte |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might be misunderstanding, but it looks like the first tx fee rate is set to 2 sat/kB (actual total fee on this tx comes out to a single sat) and the second tx fee rate is set to 50000 (actual total fee ~0.000083)?
Agree with others that I think this works as a proof-of-concept. In my opinion, this still needs more work on tests, more extensive testing, and generalization beyond 2 transaction packages (up to some maximum package size). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This works for me as a proof of concept. Obviously the tests still need some work but thanks for the extensive commenting in the code.
src/net_processing.cpp
Outdated
// one parent. | ||
// Try to process the pair as a package. | ||
bool added_as_package = false; | ||
if (state.GetRejectCode() == REJECT_INSUFFICIENTFEE) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe also add a check for g_orphan_list.size() > 0
here? But I am not experienced with the orphan pool so it may be fair to leave it out if the orphan pool is populated 99% of the time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concept/approach ACK cd8f11a, tests pass here as well as rebased on master, node running since a few hours with net logging. The code documentation is helpful. Am still reviewing the code, logic, and test, particularly the checks to satisfy and the complexity introduced in AcceptMultipleTransactions()
.
Suggestion to reviewers: It may be helpful to review merged PR #16400 and issue #14895 before this one.
Given that this PR is labelled Draft
and the ATMP refactoring in #16400 has been merged, what are your plans going forward beyond concept/approach ACKs? For example, continue refactoring the p2p protocol for package relay computational efficiency/simplicity/multiparents, or moving forward with a standalone PoC implementation like this one?
|
||
# Delivering tx2 should result it being in the orphan pool, and then | ||
# delivering tx should cause both to be accepted. | ||
p2p = self.nodes[0].add_p2p_connection(BaseNode()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couldn't this p2p
variable be defined up on line 67?
Add some additional tests in this gist and have also addressed the TODO item: https://gist.github.com/etscrivner/19d5f942a973940aaaeb397bc5e0e0d9 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reviewed only mempool/validation change for now.
No transactions in the mempool conflict with any transactions in the package.
This is a simplification that makes the logic easier to write. Without this
requirement, we would need to do additional checks to ensure that no parent
transaction would evict a transaction from the mempool that some other child
depends on.
I assume we can still RBF both parents or CPFP child in case our package txn are still stucking in the mempool? It shouldn't be an issue as after acceptance txn are normal mempool txn, assuming we don't have package garbage value for ancestors/descendants.
The ancestor/descendant size limits are calculated assuming that any mempool
ancestor of any candidate transaction is an ancestor of all the candidate
transactions.
It should be fine for LN as unconfirmed ancestors should be limited to 2 (commitment tx and CPFP) unless people batch their commitment txn with one CPFP.
@@ -311,6 +308,14 @@ bool CheckSequenceLocks(const CTxMemPool& pool, const CTransaction& tx, int flag | |||
return EvaluateSequenceLocks(index, lockPair); | |||
} | |||
|
|||
bool CheckSequenceLocks(const CTxMemPool& pool, const CTransaction& tx, int flags, LockPoints* lp, bool useExistingLockPoints) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the new wrapper doesn't do what the removed comment intended? Can't we pass coins_cache
defined above instead of defining a new one, would work too in removeForReorg
? Also isn't this a behavior change as coin is fetched from UTXO set instead from mempool?
This change is may worth its own commit.
@@ -239,10 +239,9 @@ bool TestLockPointValidity(const LockPoints* lp) | |||
return true; | |||
} | |||
|
|||
bool CheckSequenceLocks(const CTxMemPool& pool, const CTransaction& tx, int flags, LockPoints* lp, bool useExistingLockPoints) | |||
static bool CheckSequenceLocks(CCoinsViewCache &view, const CTransaction& tx, int flags, LockPoints* lp, bool useExistingLockPoints=false) EXCLUSIVE_LOCKS_REQUIRED(cs_main) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
EXCLUSIVE_LOCKS_REQUIRED(pool.cs, cs_main)
? (wrapper and AcceptMultipleTransactions
too)
// No transactions are allowed below minRelayTxFee except from disconnected | ||
// blocks | ||
// No transactions are allowed below minRelayTxFee/mempool min fee except | ||
// from disconnected blocks |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"or being CPFP'ed by another tx"
protected: | ||
const CTxMemPool& mempool; | ||
std::map<uint256, const CTransactionRef> package_tx; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could have a better name like package_pool
just to underscore we have multiple pending txn. I didn't get the rational at first read, I think you can explain given we have mempool locked and can't write until we have the whole set of tx, we need this temporary buffer to find outpoints of package txn after first row of parents. If this to work assuming topological ordering it should be documented?
if (!PreChecks(args, ws)) return false; | ||
|
||
// For now, do not allow replacements in package transactions. If we | ||
// relax this, we would need to check that no child transaction depends |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 for conflicts isolation for now. But IMO we may have to solve child conflicts in a way or another if people start to use one CPFP to bump multiple multi-party txn, like Alice broadcast commitment tx 1 + CPFP child tx, txn get into mempool, now she have to broadcast commitment tx 2 but doesn't have another UTXO available and can't CPFP output as going beyond carve-out relaxation..
// This package should be accepted except possibly for failing in | ||
// TrimToSize(), which we can't exercise without actually adding to the | ||
// mempool and seeing what would happen. Note that we are not adding | ||
// these transactions to the script cache, unlike in the single-tx case. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rational?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It should be made really clear for off-chain protocol devs to always rebroadcast the whole package instead of only the parent or the child. I'm worrying about scenarii where a a commitment tx is broadcast, don't get into the mempool neither orphan one, latter the high-fee child is broadcast at the application logic and get into the orphan, an attacker overfulfill the orphan pool to discard the child, the application logic rebroadcast the parent tx but not the CPFP assuming it's already there...
That's said, I think the P2P work on principle but could be made more robust and tests should include multiple bumped childs and orphan pool overflow.
@@ -748,6 +748,20 @@ void RequestTx(CNodeState* state, const uint256& txid, std::chrono::microseconds | |||
peer_download_state.m_tx_process_time.emplace(process_time, txid); | |||
} | |||
|
|||
// Add to a peer's orphan_work_set after processing a given transaction. | |||
void UpdateOrphanWorkSet(const CTransaction& tx, CNode *peer) EXCLUSIVE_LOCKS_REQUIRED(g_cs_orphans) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can be reused too in ProcessOrphanTx
} | ||
} | ||
if (!orphans_missing_this_tx.empty()) { | ||
const COrphanTx &orphan_tx = orphans_missing_this_tx.front()->second; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I broadcast a second CPFP due to to the first one being still too low but this one still being in the orphan pool, you need to iter and try with the whole set not only picking up the first transaction ? That would be a DoS vector bounded by the size of the orphan pool, and maybe it can be mitigate by caching the package already-tried.
|
||
// Recursively process any orphan transactions that depended on these | ||
ProcessOrphanTx(connman, pfrom->orphan_work_set, lRemovedTxn); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If package wasn't accepted (like a high-fee invalid orphan tx), and if inputs are not missing, if orphan_tx is invalid we may punish peer, or at least erase it for not being standard or insufficient fee?
Accepting a single transaction to the mempool only succeeds if (among other things) the feerate of the transaction is greater than both the min relay fee and the mempool min fee. Consequently, a transaction below the minimum fee may not be accepted to the mempool, even if we later learn of a transaction with a high relay fee that depends on it. This commit adds a function that will accept a package of transactions to the mempool, with the following restrictions: - All package transactions must be direct parents of the final transaction. This is a simple heuristic for ensuring that a candidate list of transactions is in fact a package (we wouldn't want arbitrary transactions to be paying for random low feerate transactions) - The feerate of the package, as a whole, exceeds the mempool min fee and the min relay fee. - No transactions in the mempool conflict with any transactions in the package. This is a simplification that makes the logic easier to write. Without this requirement, we would need to do additional checks to ensure that no parent transaction would evict a transaction from the mempool that some other child depends on. - The ancestor/descendant size limits are calculated assuming that any mempool ancestor of any candidate transaction is an ancestor of all the candidate transactions. This allows for doing simpler calculations to ensure that we're staying within the mempool's package limits. If we eliminated this, we would need to do much more careful package calculations for each candidate transaction and each in-mempool ancestor. This commit does not include any accessor function for utilizing this logic (eg by exposing this function at the p2p or rpc layer).
In fact worry described here holds, I've modified the new test a bit to overflow the orphan pool with dumb-but-valid orphan pools and it succeeds to evict high-fee child tx easily, even with a low number of malicious orphans (200) : ariard@e20ad2a |
Exposes a simple use case for package relay -- if we receive a child transaction that is missing a parent, then we request the parent from our peers. If a peer responds with a transaction that is rejected from the mempool due to feerate, we have an opportunity to accept that parent along with the child, if the child's feerate is sufficiently high and the child is missing no other parents.
cd8f11a
to
6a3bdba
Compare
@ariard It wasn't my goal in this PR to deploy a new p2p package relay scheme that is resilient to DoS attack; instead I wanted to take a smaller use case (resolving orphan transactions missing a single low fee parent) to motivate adding the package acceptance logic. I think once we have the mempool package acceptance logic in, we could then improve the p2p layer further to allow fancier relay schemes. (See also my comment in #14895 (comment).) I'll update the title of the PR to make this more clear. |
Thanks for clarifying scope of this PR. I do think too it's good to split this project in multi-parts to focus on new mempool acceptance logic, specially what the worst width/depth of a chain of messy transactions we select as a package submission DoS bound. That's said, we may reduce the risks of mempool CPU DoS by implementing the right measures at the p2p level like rate-limiting per-peer package reception. Also if we have different p2p package relay mechanisms (receiver-initiated, sender p2p messages, ...) we need to think how we coordinate them between different releases to avoid applications relying on the wrong ones for their uses cases and falsely assuming they are secure. |
I'm starting to think that perhaps the use case here isn't really worth it -- I tried to split off what I thought would be a simple use case (of processing some orphan transactions as packages with their rejected parents), but it turns out to be a bit trickier than I expected, and given that we'd just throw this away if we implemented an actual package relay protocol anyway this seems like wasted effort. |
Accepting a single transaction to the mempool only succeeds if (among other things) the feerate of the transaction is greater than both the min relay fee and the mempool min fee. Consequently, a transaction below the minimum fee may not be accepted to the mempool, even if we later learn of a transaction with a high fee that depends on it.
This PR adds code to validation that will accept a package of transactions to the mempool under the following conditions:
All package transactions must be direct parents of the final transaction.
This is a simple heuristic for ensuring that a candidate list of transactions
is in fact a package (we wouldn't want arbitrary transactions to be paying
for random low feerate transactions)
The feerate of the package, as a whole, exceeds the mempool min fee and the
min relay fee.
No transactions in the mempool conflict with any transactions in the package.
This is a simplification that makes the logic easier to write. Without this
requirement, we would need to do additional checks to ensure that no parent
transaction would evict a transaction from the mempool that some other child
depends on.
The ancestor/descendant size limits are calculated assuming that any mempool
ancestor of any candidate transaction is an ancestor of all the candidate
transactions.
This allows for doing simpler calculations to ensure that we're staying
within the mempool's package limits. If we eliminated this, we would need to
do much more careful package calculations for each candidate transaction and each
in-mempool ancestor.
This PR also adds code to net_processing that will attempt to process transaction packages in one narrow case: if a transaction fails to get into the mempool due to insufficient fee, but has an orphan in the orphan pool, then we will try to process the pair together to see if they can be accepted as a package.
This PR is definitely WIP, but I'm opening it as a proof-of-concept motivation for refactoring ATMP (#16400).