This PR supersedes #14387 and is an alternative to #14397.
Summary
Previously, “no duplicate inputs” was checked on a per transaction basis, with this PR, they are checked block wide. This PR also checks that long chains are in valid order, which was previously implicitly checked only. The resulting code is also more performant.
Benefits
- Stricter Invariants This PR checks stricter properties. Before this PR, a block might pass checkblock with duplicate inputs spent across transactions and invalid longchain order. Enforcing stricter invariants in CheckBlock helps us guard against potential errors in later sections of the code and opens up the door to new optimizations, parallelizations, or simplifications in the application of transactions in a block. For instance, we could create all new UTXOS in parallel and then spend all inputs in parallel.
- Better Performance This checker is 6.5x faster on the duplicate inputs benchmark, and 1.1x faster on a more general test. Before this PR:
0DuplicateInputs, 5, 10, 0.334544, 0.00665873, 0.00671868, 0.00669463
1DeserializeAndCheckBlockTest, 5, 160, 2.17535, 0.00267973, 0.00273858, 0.00272915
After this PR:
0DuplicateInputs, 5, 10, 0.0513754, 0.00100089, 0.00105766, 0.00102823
1DeserializeAndCheckBlockTest, 5, 160, 1.97574, 0.00245479, 0.00248704, 0.00246725
- Better Complexity The time complexity of the algorithm is E[O(N)], compared to E[O(N log(N)]. It also, in theory uses much less memory in the worst case (constant 300k) compared to O(N) (i.e., 1MB) worst case for the previous algorithm. Furthermore, the number of actual hash comparisons is substantially reduced, which contributes a big constant factor. The analysis is given in more detail in the PR which justifies the constants selected.
Potential Risks & Redresses
In this section I’ll try to exhaustively go through the various risks of this PR.
- Probabilistic Algorithms are Confusing. Should the PCG based hashing algorithm (or other probabilistic aspects) be broken in this code, then an attacker could theoretically cause a block to take O(N^2) time to validate. One way to de-risk this issue would be to, upon erroneous collision, fall back to checking against a std::set which would have O(n log n) worst-case behavior (the existing runtime). In general, the theory behind PCGs makes it unlikely that an attacker could predict collisions across the network because each PCG is salted individually (random seed xor’d in) and started with a different increment (the increment includes the output’s n, and the seed is the 64 bit hash value – this prevents adversary from using the output’s n to generate collisions). Therefore, a spurious collision on one instance should have an exceedingly small probability of colliding on another. Perhaps there are further ways to harden this hash scheme while maintaining performance. In any case, the block still validates, just more slowly.
- Code Complexity. Should the code be simply incorrect (e.g., not actually checking that duplicate inputs are not present somehow) then unintended inflation could occur. This is always a risk with any change. I believe the stricter invariant checking justifies this change as reducing likelihood of bugs long term, as this protects sensitive code in block application from encountering certain types of invalid blocks based on sequential application of transactions.
- Block Size dependency. Technically this algorithm degrades in expected performance the more transactions are in a block, which may make it more difficult to increase block sizes. Fortunately, should block sizes ever be increased, the hash table size can be increased too (e.g. a 1MB table would support 4MB blocks easily).
- Loss of WTXID caching of duplicate input checks. It has been proposed WTXIDs could cache that they don’t have duplicate inputs. I don’t see WTXID caching this property as a really useful thing to cache because it needs to be checked at the block level across all transactions anyways. Further, the use of PCG for hashing makes the performance overhead negligible compared to a cache lookup.
- Testing Coverage Difficulty Because the algorithm fails infrequently, it’s difficult to write a test that covers the case where the algorithm sees a spurious collision and the continues checking. This could be fixed by templating the Invariant function to allow for a biased PCG implementation which only generates collisions.
- Changed failure messages Because the algorithm checks more invariants at once and detects them in a different order than before, clients relying on an exact reject reason may be confused. Clients should already not rely on exact reject reasons, so I don’t believe this has a ramification.