Skip to content

fix: dos protection for NetworkProcessor - #10039

Merged
nflaig merged 5 commits into
unstablefrom
te/network_processor_dos_resistant
Sep 11, 2026
Merged

nflaig merged 5 commits into
unstablefrom
te/network_processor_dos_resistant

Conversation

@twoeths

@twoeths twoeths commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

Motivation

  • NetworkProcessor holds unverified messages and was not designed with DOS protection in mind

Description

  • only support to buffer up to 5 roots per message slot, this is to prevent OOM
    • cap it via bufferedRootsBySlot = new MapDef<Slot, Set<RootHex>>
  • but support up to 32 searched roots per message slot. This is to make sure the node is always synced by searching for all roots, and we will penalize peers in UnknownBlockInput per pruneFinalized()
    • cap it via searchedRootsBySlot = new MapDef<Slot, MapDef<RootHex, SearchedRootEntry>>
  • changed data structure
    • awaitingMessagesByBlockRoot is tracked per topic, same to envelope
    • unite unknownBlocksBySlot and unknownEnvelopesBySlot to searchedRootsBySlot so that we can cap roots per slot easier

Closes https://github.com/ethereum-bounty/lodestar/issues/19

AI Assistance Disclosure

  • created with the help of Claude

@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Performance Report

✔️ no performance regression detected

Full benchmark results
Benchmark suite Current: d1e2cba Previous: 540d803 Ratio
getPubkeys - native cache - req 1000 vs - 250000 vc 598.41 us/op 420.49 us/op 1.42
getPubkeys - validatorsArr - req 1000 vs - 250000 vc 47.487 us/op 39.509 us/op 1.20
BLS verify - blst 858.28 us/op 859.85 us/op 1.00
BLS verifyMultipleSignatures 3 - blst 1.2890 ms/op 1.3037 ms/op 0.99
BLS verifyMultipleSignatures 8 - blst 2.1373 ms/op 2.1461 ms/op 1.00
BLS verifyMultipleSignatures 32 - blst 7.0433 ms/op 7.0117 ms/op 1.00
BLS verifyMultipleSignatures 64 - blst 13.581 ms/op 13.467 ms/op 1.01
BLS verifyMultipleSignatures 128 - blst 25.567 ms/op 26.025 ms/op 0.98
BLS deserializing 10000 signatures 620.32 ms/op 620.89 ms/op 1.00
BLS deserializing 100000 signatures 6.2817 s/op 6.2725 s/op 1.00
BLS verifyMultipleSignatures - same message - 3 - blst 919.18 us/op 935.89 us/op 0.98
BLS verifyMultipleSignatures - same message - 8 - blst 1.0570 ms/op 1.0706 ms/op 0.99
BLS verifyMultipleSignatures - same message - 32 - blst 1.6240 ms/op 1.6932 ms/op 0.96
BLS verifyMultipleSignatures - same message - 64 - blst 2.4048 ms/op 2.5080 ms/op 0.96
BLS verifyMultipleSignatures - same message - 128 - blst 4.0538 ms/op 4.1262 ms/op 0.98
BLS aggregatePubkeys 32 - blst 18.840 us/op 19.112 us/op 0.99
BLS aggregatePubkeys 128 - blst 68.008 us/op 69.064 us/op 0.98
getSlashingsAndExits - default max 51.433 us/op 40.967 us/op 1.26
getSlashingsAndExits - 2k 548.43 us/op 406.13 us/op 1.35
proposeBlockBody type=full, size=empty 902.91 us/op 1.3584 ms/op 0.66
isKnown best case - 1 super set check 194.00 ns/op 164.00 ns/op 1.18
isKnown normal case - 2 super set checks 209.00 ns/op 171.00 ns/op 1.22
isKnown worse case - 16 super set checks 163.00 ns/op 158.00 ns/op 1.03
validate api signedAggregateAndProof - struct 1.5038 ms/op 1.4647 ms/op 1.03
validate gossip signedAggregateAndProof - struct 1.4997 ms/op 1.4650 ms/op 1.02
batch validate gossip attestation - vc 640000 - chunk 32 140.33 us/op 123.08 us/op 1.14
batch validate gossip attestation - vc 640000 - chunk 64 121.65 us/op 104.90 us/op 1.16
batch validate gossip attestation - vc 640000 - chunk 128 107.85 us/op 96.963 us/op 1.11
batch validate gossip attestation - vc 640000 - chunk 256 108.01 us/op 102.63 us/op 1.05
bytes32 toHexString 326.00 ns/op 304.00 ns/op 1.07
bytes32 Buffer.toString(hex) 162.00 ns/op 161.00 ns/op 1.01
bytes32 Buffer.toString(hex) from Uint8Array 232.00 ns/op 230.00 ns/op 1.01
bytes32 Buffer.toString(hex) + 0x 164.00 ns/op 161.00 ns/op 1.02
Return object 10000 times 0.21330 ns/op 0.21300 ns/op 1.00
Throw Error 10000 times 3.4950 us/op 3.4735 us/op 1.01
toHex 96.524 ns/op 94.188 ns/op 1.02
Buffer.from 86.276 ns/op 88.745 ns/op 0.97
shared Buffer 57.363 ns/op 56.263 ns/op 1.02
fastMsgIdFn sha256 / 200 bytes 1.5640 us/op 1.4440 us/op 1.08
fastMsgIdFn h32 xxhash / 200 bytes 156.00 ns/op 157.00 ns/op 0.99
fastMsgIdFn h64 xxhash / 200 bytes 196.00 ns/op 203.00 ns/op 0.97
fastMsgIdFn sha256 / 1000 bytes 4.8730 us/op 4.5860 us/op 1.06
fastMsgIdFn h32 xxhash / 1000 bytes 239.00 ns/op 237.00 ns/op 1.01
fastMsgIdFn h64 xxhash / 1000 bytes 248.00 ns/op 245.00 ns/op 1.01
fastMsgIdFn sha256 / 10000 bytes 41.469 us/op 40.609 us/op 1.02
fastMsgIdFn h32 xxhash / 10000 bytes 1.2860 us/op 1.2510 us/op 1.03
fastMsgIdFn h64 xxhash / 10000 bytes 846.00 ns/op 813.00 ns/op 1.04
send data - 1000 256B messages 5.8858 ms/op 4.0674 ms/op 1.45
send data - 1000 512B messages 6.6793 ms/op 5.2297 ms/op 1.28
send data - 1000 1024B messages 7.7069 ms/op 5.3742 ms/op 1.43
send data - 1000 1200B messages 9.2092 ms/op 8.2597 ms/op 1.11
send data - 1000 2048B messages 18.390 ms/op 19.755 ms/op 0.93
send data - 1000 4096B messages 38.933 ms/op 109.91 ms/op 0.35
send data - 1000 16384B messages 588.67 ms/op 485.32 ms/op 1.21
send data - 1000 65536B messages 1.7083 s/op 2.0763 s/op 0.82
enrSubnets - fastDeserialize 64 bits 731.00 ns/op 779.00 ns/op 0.94
enrSubnets - ssz BitVector 64 bits 275.00 ns/op 277.00 ns/op 0.99
enrSubnets - fastDeserialize 4 bits 97.000 ns/op 96.000 ns/op 1.01
enrSubnets - ssz BitVector 4 bits 273.00 ns/op 285.00 ns/op 0.96
prioritizePeers score -10:0 att 32-0.1 sync 2-0 193.40 us/op 208.77 us/op 0.93
prioritizePeers score 0:0 att 32-0.25 sync 2-0.25 220.90 us/op 236.40 us/op 0.93
prioritizePeers score 0:0 att 32-0.5 sync 2-0.5 323.14 us/op 346.46 us/op 0.93
prioritizePeers score 0:0 att 64-0.75 sync 4-0.75 573.59 us/op 639.44 us/op 0.90
prioritizePeers score 0:0 att 64-1 sync 4-1 676.12 us/op 710.36 us/op 0.95
array of 16000 items push then shift 1.2302 us/op 1.2774 us/op 0.96
LinkedList of 16000 items push then shift 7.3620 ns/op 8.2940 ns/op 0.89
array of 16000 items push then pop 78.234 ns/op 89.817 ns/op 0.87
LinkedList of 16000 items push then pop 5.8590 ns/op 6.4790 ns/op 0.90
array of 24000 items push then shift 1.8133 us/op 1.8911 us/op 0.96
LinkedList of 24000 items push then shift 7.1400 ns/op 7.8730 ns/op 0.91
array of 24000 items push then pop 112.31 ns/op 124.27 ns/op 0.90
LinkedList of 24000 items push then pop 5.9350 ns/op 6.1460 ns/op 0.97
intersect bitArray bitLen 8 3.7780 ns/op 3.8360 ns/op 0.98
intersect array and set length 8 28.405 ns/op 30.014 ns/op 0.95
intersect bitArray bitLen 128 23.496 ns/op 23.215 ns/op 1.01
intersect array and set length 128 486.18 ns/op 496.90 ns/op 0.98
bitArray.getTrueBitIndexes() bitLen 128 921.00 ns/op 956.00 ns/op 0.96
bitArray.getTrueBitIndexes() bitLen 248 1.6490 us/op 1.7880 us/op 0.92
bitArray.getTrueBitIndexes() bitLen 512 3.4980 us/op 3.7220 us/op 0.94
Full columns - reconstruct all 6 blobs 148.39 us/op 168.96 us/op 0.88
Full columns - reconstruct half of the blobs out of 6 96.812 us/op 77.577 us/op 1.25
Full columns - reconstruct single blob out of 6 42.087 us/op 38.175 us/op 1.10
Half columns - reconstruct all 6 blobs 383.04 ms/op 393.02 ms/op 0.97
Half columns - reconstruct half of the blobs out of 6 191.87 ms/op 199.05 ms/op 0.96
Half columns - reconstruct single blob out of 6 68.282 ms/op 72.174 ms/op 0.95
Set add up to 64 items then delete first 1.5643 us/op 1.6043 us/op 0.98
OrderedSet add up to 64 items then delete first 2.4133 us/op 2.5322 us/op 0.95
Set add up to 64 items then delete last 1.7722 us/op 1.8818 us/op 0.94
OrderedSet add up to 64 items then delete last 2.6924 us/op 2.8066 us/op 0.96
Set add up to 64 items then delete middle 1.7936 us/op 1.8721 us/op 0.96
OrderedSet add up to 64 items then delete middle 4.1129 us/op 4.2366 us/op 0.97
Set add up to 128 items then delete first 3.5251 us/op 3.7339 us/op 0.94
OrderedSet add up to 128 items then delete first 5.4328 us/op 5.6701 us/op 0.96
Set add up to 128 items then delete last 3.3537 us/op 3.6009 us/op 0.93
OrderedSet add up to 128 items then delete last 5.0936 us/op 5.4980 us/op 0.93
Set add up to 128 items then delete middle 3.4059 us/op 3.5558 us/op 0.96
OrderedSet add up to 128 items then delete middle 10.805 us/op 11.288 us/op 0.96
Set add up to 256 items then delete first 6.9108 us/op 7.3765 us/op 0.94
OrderedSet add up to 256 items then delete first 11.636 us/op 12.333 us/op 0.94
Set add up to 256 items then delete last 6.7132 us/op 7.0777 us/op 0.95
OrderedSet add up to 256 items then delete last 11.092 us/op 12.126 us/op 0.91
Set add up to 256 items then delete middle 6.6169 us/op 7.1069 us/op 0.93
OrderedSet add up to 256 items then delete middle 33.861 us/op 35.074 us/op 0.97
runFastConfirmationRules vc:100000 bc:96 eq:0 4.5276 ms/op 4.5082 ms/op 1.00
runFastConfirmationRules vc:600000 bc:96 eq:0 36.083 ms/op 36.760 ms/op 0.98
runFastConfirmationRules vc:1000000 bc:96 eq:0 61.030 ms/op 60.393 ms/op 1.01
runFastConfirmationRules vc:600000 bc:320 eq:0 36.154 ms/op 35.817 ms/op 1.01
runFastConfirmationRules vc:100000 bc:96 eq:1000 1.0937 s/op 1.1344 s/op 0.96
pass gossip attestations to forkchoice per slot 2.5227 ms/op 2.6293 ms/op 0.96
forkChoice updateHead vc 100000 bc 64 eq 0 390.63 us/op 401.98 us/op 0.97
forkChoice updateHead vc 600000 bc 64 eq 0 2.3438 ms/op 2.4157 ms/op 0.97
forkChoice updateHead vc 1000000 bc 64 eq 0 3.8624 ms/op 3.9627 ms/op 0.97
forkChoice updateHead vc 600000 bc 320 eq 0 2.3323 ms/op 2.4221 ms/op 0.96
forkChoice updateHead vc 600000 bc 1200 eq 0 2.3771 ms/op 2.4519 ms/op 0.97
forkChoice updateHead vc 600000 bc 7200 eq 0 3.3473 ms/op 3.6080 ms/op 0.93
forkChoice updateHead vc 600000 bc 64 eq 1000 2.3348 ms/op 2.6583 ms/op 0.88
forkChoice updateHead vc 600000 bc 64 eq 10000 2.4216 ms/op 2.4929 ms/op 0.97
forkChoice updateHead vc 600000 bc 64 eq 300000 6.7283 ms/op 7.2535 ms/op 0.93
forkChoice updateHead vc 600000 bc 64 eq 0 gloas boosted 2.3534 ms/op 2.4655 ms/op 0.95
computeDeltas 1400000 validators 0% inactive 11.401 ms/op 11.795 ms/op 0.97
computeDeltas 1400000 validators 10% inactive 10.857 ms/op 11.209 ms/op 0.97
computeDeltas 1400000 validators 20% inactive 10.235 ms/op 10.544 ms/op 0.97
computeDeltas 1400000 validators 50% inactive 8.3507 ms/op 8.6284 ms/op 0.97
computeDeltas 2100000 validators 0% inactive 17.138 ms/op 17.691 ms/op 0.97
computeDeltas 2100000 validators 10% inactive 16.135 ms/op 16.901 ms/op 0.95
computeDeltas 2100000 validators 20% inactive 15.230 ms/op 15.831 ms/op 0.96
computeDeltas 2100000 validators 50% inactive 10.282 ms/op 10.439 ms/op 0.98
altair processAttestation - 250000 vs - 7PWei normalcase 2.0749 ms/op 2.4267 ms/op 0.86
altair processAttestation - 250000 vs - 7PWei worstcase 2.8002 ms/op 3.2679 ms/op 0.86
altair processAttestation - setStatus - 1/6 committees join 93.340 us/op 99.461 us/op 0.94
altair processAttestation - setStatus - 1/3 committees join 188.99 us/op 199.96 us/op 0.95
altair processAttestation - setStatus - 1/2 committees join 272.96 us/op 277.64 us/op 0.98
altair processAttestation - setStatus - 2/3 committees join 345.74 us/op 364.63 us/op 0.95
altair processAttestation - setStatus - 4/5 committees join 492.50 us/op 530.00 us/op 0.93
altair processAttestation - setStatus - 100% committees join 577.85 us/op 631.71 us/op 0.91
altair processBlock - 250000 vs - 7PWei normalcase 4.5198 ms/op 5.5931 ms/op 0.81
altair processBlock - 250000 vs - 7PWei normalcase hashState 22.192 ms/op 28.406 ms/op 0.78
altair processBlock - 250000 vs - 7PWei worstcase 23.338 ms/op 25.888 ms/op 0.90
altair processBlock - 250000 vs - 7PWei worstcase hashState 48.656 ms/op 61.575 ms/op 0.79
phase0 processBlock - 250000 vs - 7PWei normalcase 1.2216 ms/op 1.4723 ms/op 0.83
phase0 processBlock - 250000 vs - 7PWei worstcase 18.948 ms/op 21.984 ms/op 0.86
altair processEth1Data - 250000 vs - 7PWei normalcase 275.01 us/op 291.81 us/op 0.94
getExpectedWithdrawals 250000 eb:1,eth1:1,we:0,wn:0,smpl:16 2.9090 us/op 5.6390 us/op 0.52
getExpectedWithdrawals 250000 eb:0.95,eth1:0.1,we:0.05,wn:0,smpl:220 19.269 us/op 21.384 us/op 0.90
getExpectedWithdrawals 250000 eb:0.95,eth1:0.3,we:0.05,wn:0,smpl:43 5.6410 us/op 5.9170 us/op 0.95
getExpectedWithdrawals 250000 eb:0.95,eth1:0.7,we:0.05,wn:0,smpl:19 3.4370 us/op 3.8870 us/op 0.88
getExpectedWithdrawals 250000 eb:0.1,eth1:0.1,we:0,wn:0,smpl:1021 85.876 us/op 90.967 us/op 0.94
getExpectedWithdrawals 250000 eb:0.03,eth1:0.03,we:0,wn:0,smpl:11778 1.3653 ms/op 1.4347 ms/op 0.95
getExpectedWithdrawals 250000 eb:0.01,eth1:0.01,we:0,wn:0,smpl:16384 1.7687 ms/op 1.8577 ms/op 0.95
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,smpl:16384 1.7716 ms/op 1.8840 ms/op 0.94
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,nocache,smpl:16384 3.5412 ms/op 3.9158 ms/op 0.90
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,smpl:16384 2.0588 ms/op 2.1489 ms/op 0.96
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,nocache,smpl:16384 3.8607 ms/op 4.2662 ms/op 0.90
Tree 40 250000 create 319.91 ms/op 359.72 ms/op 0.89
Tree 40 250000 get(125000) 87.906 ns/op 91.507 ns/op 0.96
Tree 40 250000 set(125000) 959.14 ns/op 981.65 ns/op 0.98
Tree 40 250000 toArray() 12.692 ms/op 16.917 ms/op 0.75
Tree 40 250000 iterate all - toArray() + loop 12.425 ms/op 19.163 ms/op 0.65
Tree 40 250000 iterate all - get(i) 36.855 ms/op 42.884 ms/op 0.86
Array 250000 create 2.3909 ms/op 2.6975 ms/op 0.89
Array 250000 clone - spread 759.22 us/op 813.03 us/op 0.93
Array 250000 get(125000) 0.28200 ns/op 0.28600 ns/op 0.99
Array 250000 set(125000) 0.28400 ns/op 0.29600 ns/op 0.96
Array 250000 iterate all - loop 55.127 us/op 56.960 us/op 0.97
phase0 afterProcessEpoch - 250000 vs - 7PWei 38.012 ms/op 65.752 ms/op 0.58
Array.fill - length 1000000 4.2031 ms/op 4.4474 ms/op 0.95
Array push - length 1000000 15.594 ms/op 16.268 ms/op 0.96
Array.get 0.20108 ns/op 0.20648 ns/op 0.97
Uint8Array.get 0.24278 ns/op 0.23739 ns/op 1.02
phase0 beforeProcessEpoch - 250000 vs - 7PWei 17.572 ms/op 29.838 ms/op 0.59
altair processEpoch - mainnet_e81889 226.51 ms/op 284.11 ms/op 0.80
mainnet_e81889 - altair beforeProcessEpoch 34.299 ms/op 43.341 ms/op 0.79
mainnet_e81889 - altair processJustificationAndFinalization 5.4990 us/op 4.6460 us/op 1.18
mainnet_e81889 - altair processInactivityUpdates 5.3591 ms/op 4.6390 ms/op 1.16
mainnet_e81889 - altair processRewardsAndPenalties 18.547 ms/op 20.528 ms/op 0.90
mainnet_e81889 - altair processRegistryUpdates 552.00 ns/op 639.00 ns/op 0.86
mainnet_e81889 - altair processSlashings 134.00 ns/op 145.00 ns/op 0.92
mainnet_e81889 - altair processEth1DataReset 131.00 ns/op 136.00 ns/op 0.96
mainnet_e81889 - altair processEffectiveBalanceUpdates 5.9885 ms/op 5.2242 ms/op 1.15
mainnet_e81889 - altair processSlashingsReset 655.00 ns/op 744.00 ns/op 0.88
mainnet_e81889 - altair processRandaoMixesReset 870.00 ns/op 1.0020 us/op 0.87
mainnet_e81889 - altair processHistoricalRootsUpdate 131.00 ns/op 145.00 ns/op 0.90
mainnet_e81889 - altair processParticipationFlagUpdates 423.00 ns/op 474.00 ns/op 0.89
mainnet_e81889 - altair processSyncCommitteeUpdates 103.00 ns/op 110.00 ns/op 0.94
mainnet_e81889 - altair afterProcessEpoch 40.775 ms/op 42.383 ms/op 0.96
capella processEpoch - mainnet_e217614 700.19 ms/op 820.85 ms/op 0.85
mainnet_e217614 - capella beforeProcessEpoch 67.934 ms/op 67.447 ms/op 1.01
mainnet_e217614 - capella processJustificationAndFinalization 4.4770 us/op 5.2670 us/op 0.85
mainnet_e217614 - capella processInactivityUpdates 15.981 ms/op 19.761 ms/op 0.81
mainnet_e217614 - capella processRewardsAndPenalties 92.889 ms/op 102.93 ms/op 0.90
mainnet_e217614 - capella processRegistryUpdates 4.2750 us/op 4.4780 us/op 0.95
mainnet_e217614 - capella processSlashings 146.00 ns/op 136.00 ns/op 1.07
mainnet_e217614 - capella processEth1DataReset 132.00 ns/op 138.00 ns/op 0.96
mainnet_e217614 - capella processEffectiveBalanceUpdates 18.113 ms/op 11.527 ms/op 1.57
mainnet_e217614 - capella processSlashingsReset 667.00 ns/op 682.00 ns/op 0.98
mainnet_e217614 - capella processRandaoMixesReset 880.00 ns/op 1.0200 us/op 0.86
mainnet_e217614 - capella processHistoricalRootsUpdate 144.00 ns/op 137.00 ns/op 1.05
mainnet_e217614 - capella processParticipationFlagUpdates 439.00 ns/op 459.00 ns/op 0.96
mainnet_e217614 - capella afterProcessEpoch 108.57 ms/op 112.19 ms/op 0.97
phase0 processEpoch - mainnet_e58758 247.41 ms/op 237.14 ms/op 1.04
mainnet_e58758 - phase0 beforeProcessEpoch 58.638 ms/op 52.631 ms/op 1.11
mainnet_e58758 - phase0 processJustificationAndFinalization 5.4230 us/op 4.6910 us/op 1.16
mainnet_e58758 - phase0 processRewardsAndPenalties 16.392 ms/op 16.405 ms/op 1.00
mainnet_e58758 - phase0 processRegistryUpdates 2.2260 us/op 2.2250 us/op 1.00
mainnet_e58758 - phase0 processSlashings 147.00 ns/op 137.00 ns/op 1.07
mainnet_e58758 - phase0 processEth1DataReset 138.00 ns/op 133.00 ns/op 1.04
mainnet_e58758 - phase0 processEffectiveBalanceUpdates 806.81 us/op 792.93 us/op 1.02
mainnet_e58758 - phase0 processSlashingsReset 917.00 ns/op 900.00 ns/op 1.02
mainnet_e58758 - phase0 processRandaoMixesReset 1.0180 us/op 1.0010 us/op 1.02
mainnet_e58758 - phase0 processHistoricalRootsUpdate 363.00 ns/op 219.00 ns/op 1.66
mainnet_e58758 - phase0 processParticipationRecordUpdates 1.2250 us/op 1.0220 us/op 1.20
mainnet_e58758 - phase0 afterProcessEpoch 33.900 ms/op 33.720 ms/op 1.01
phase0 processEffectiveBalanceUpdates - 250000 normalcase 980.87 us/op 967.85 us/op 1.01
phase0 processEffectiveBalanceUpdates - 250000 worstcase 0.5 1.5485 ms/op 1.5774 ms/op 0.98
gloas processInactivityUpdates - 250000 inactivity leak all eligible missed target 10.836 ms/op 10.905 ms/op 0.99
phase0 processRegistryUpdates - 250000 normalcase 2.3540 us/op 2.9960 us/op 0.79
phase0 processRegistryUpdates - 250000 badcase_full_deposits 139.33 us/op 145.73 us/op 0.96
phase0 processRegistryUpdates - 250000 worstcase 0.5 64.818 ms/op 78.413 ms/op 0.83
altair processRewardsAndPenalties - 250000 normalcase 15.383 ms/op 16.131 ms/op 0.95
altair processRewardsAndPenalties - 250000 worstcase 15.637 ms/op 15.559 ms/op 1.00
phase0 getAttestationDeltas - 250000 normalcase 5.6317 ms/op 5.6420 ms/op 1.00
phase0 getAttestationDeltas - 250000 worstcase 5.5427 ms/op 5.7178 ms/op 0.97
phase0 processSlashings - 250000 worstcase 62.595 us/op 63.349 us/op 0.99
altair processSyncCommitteeUpdates - 250000 11.332 ms/op 10.116 ms/op 1.12
BeaconState.hashTreeRoot - No change 190.00 ns/op 169.00 ns/op 1.12
BeaconState.hashTreeRoot - 1 full validator 79.704 us/op 108.25 us/op 0.74
BeaconState.hashTreeRoot - 32 full validator 961.76 us/op 981.76 us/op 0.98
BeaconState.hashTreeRoot - 512 full validator 9.7382 ms/op 9.9425 ms/op 0.98
BeaconState.hashTreeRoot - 1 validator.effectiveBalance 105.27 us/op 118.38 us/op 0.89
BeaconState.hashTreeRoot - 32 validator.effectiveBalance 1.5777 ms/op 1.5184 ms/op 1.04
BeaconState.hashTreeRoot - 512 validator.effectiveBalance 20.435 ms/op 25.007 ms/op 0.82
BeaconState.hashTreeRoot - 1 balances 85.358 us/op 91.762 us/op 0.93
BeaconState.hashTreeRoot - 32 balances 808.42 us/op 845.63 us/op 0.96
BeaconState.hashTreeRoot - 512 balances 6.9048 ms/op 8.1828 ms/op 0.84
BeaconState.hashTreeRoot - 250000 balances 148.26 ms/op 142.92 ms/op 1.04
aggregationBits - 2048 els - zipIndexesInBitList 20.126 us/op 22.010 us/op 0.91
regular array get 100000 times 23.004 us/op 22.683 us/op 1.01
wrappedArray get 100000 times 22.617 us/op 22.678 us/op 1.00
arrayWithProxy get 100000 times 9.8075 ms/op 9.6113 ms/op 1.02
ssz.Root.equals 21.283 ns/op 21.378 ns/op 1.00
byteArrayEquals 20.937 ns/op 21.112 ns/op 0.99
Buffer.compare 8.7330 ns/op 8.6520 ns/op 1.01
processSlot - 1 slots 10.744 us/op 12.431 us/op 0.86
processSlot - 32 slots 2.1588 ms/op 2.5122 ms/op 0.86
getEffectiveBalanceIncrementsZeroInactive - 250000 vs - 7PWei 5.6817 ms/op 6.6751 ms/op 0.85
getCommitteeAssignments - req 1 vs - 250000 vc 1.6164 ms/op 2.3190 ms/op 0.70
getCommitteeAssignments - req 100 vs - 250000 vc 3.2804 ms/op 3.4092 ms/op 0.96
getCommitteeAssignments - req 1000 vs - 250000 vc 3.5386 ms/op 3.6898 ms/op 0.96
findModifiedValidators - 10000 modified validators 841.91 ms/op 1.0443 s/op 0.81
findModifiedValidators - 1000 modified validators 646.03 ms/op 802.18 ms/op 0.81
findModifiedValidators - 100 modified validators 304.71 ms/op 501.87 ms/op 0.61
findModifiedValidators - 10 modified validators 286.68 ms/op 397.22 ms/op 0.72
findModifiedValidators - 1 modified validators 196.09 ms/op 284.97 ms/op 0.69
findModifiedValidators - no difference 201.86 ms/op 271.96 ms/op 0.74
migrate state 1500000 validators, 3400 modified, 2000 new 2.8767 s/op 3.1406 s/op 0.92
RootCache.getBlockRootAtSlot - 250000 vs - 7PWei 3.2900 ns/op 3.5100 ns/op 0.94
state getBlockRootAtSlot - 250000 vs - 7PWei 403.97 ns/op 425.85 ns/op 0.95
computeProposerIndex 100000 validators 1.3370 ms/op 1.3593 ms/op 0.98
getNextSyncCommitteeIndices 1000 validators 2.7955 ms/op 2.7845 ms/op 1.00
getNextSyncCommitteeIndices 10000 validators 25.274 ms/op 25.557 ms/op 0.99
getNextSyncCommitteeIndices 100000 validators 85.761 ms/op 86.896 ms/op 0.99
computeProposers - vc 250000 572.46 us/op 527.85 us/op 1.08
computeEpochShuffling - vc 250000 39.669 ms/op 37.461 ms/op 1.06
getNextSyncCommittee - vc 250000 9.3281 ms/op 9.6471 ms/op 0.97
nodejs block root to RootHex using toHex 93.357 ns/op 92.990 ns/op 1.00
nodejs block root to RootHex using toRootHex 60.261 ns/op 60.936 ns/op 0.99
nodejs fromHex(blob) 905.65 us/op 969.37 us/op 0.93
nodejs fromHexInto(blob) 604.91 us/op 606.39 us/op 1.00
nodejs block root to RootHex using the deprecated toHexString 659.34 ns/op 634.54 ns/op 1.04
nodejs byteArrayEquals 32 bytes (block root) 24.995 ns/op 25.613 ns/op 0.98
nodejs byteArrayEquals 48 bytes (pubkey) 36.589 ns/op 36.933 ns/op 0.99
nodejs byteArrayEquals 96 bytes (signature) 33.551 ns/op 34.289 ns/op 0.98
nodejs byteArrayEquals 1024 bytes 38.666 ns/op 39.769 ns/op 0.97
nodejs byteArrayEquals 131072 bytes (blob) 1.6980 us/op 1.7411 us/op 0.98
browser block root to RootHex using toHex 139.52 ns/op 143.78 ns/op 0.97
browser block root to RootHex using toRootHex 126.22 ns/op 128.21 ns/op 0.98
browser fromHex(blob) 1.7318 ms/op 1.6919 ms/op 1.02
browser fromHexInto(blob) 601.22 us/op 609.93 us/op 0.99
browser block root to RootHex using the deprecated toHexString 424.90 ns/op 395.97 ns/op 1.07
browser byteArrayEquals 32 bytes (block root) 27.038 ns/op 27.191 ns/op 0.99
browser byteArrayEquals 48 bytes (pubkey) 38.118 ns/op 38.258 ns/op 1.00
browser byteArrayEquals 96 bytes (signature) 71.315 ns/op 72.286 ns/op 0.99
browser byteArrayEquals 1024 bytes 727.15 ns/op 725.73 ns/op 1.00
browser byteArrayEquals 131072 bytes (blob) 91.576 us/op 92.608 us/op 0.99

by benchmarkbot/action

@twoeths
twoeths marked this pull request as ready for review September 8, 2026 11:00
@twoeths
twoeths requested a review from a team as a code owner September 8, 2026 11:00

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae2fb3f081

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/beacon-node/src/network/processor/index.ts Outdated
Comment thread packages/beacon-node/src/network/processor/index.ts Outdated
Comment thread packages/beacon-node/src/network/processor/index.ts Outdated

@twoeths twoeths left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@lodekeeper please review

@lodekeeper lodekeeper left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Did a focused security pass on the DOS logic at b8c3e39bc6, reading the actual code rather than trusting the fix-commit messages (since this closes a bounty finding). Verdict on the three Codex findings:

  • #1 (keep multiple unvalidated payload candidates) — fixed: execution_payload per-root cap is raised to 2, so a malformed-first envelope no longer starves the valid one. Minor headroom note in-thread (not blocking).
  • #2 (buffer cap bypass via searchUnknownRoot) — fixed: bufferedRootsBySlot is now tracked separately and populated only when a message is actually buffered, so resending a rejected root can't climb to the 32-root search cap. Solid.
  • #3 (cross-slot prune deleting the wrong waits) — the flagged block↔payload case is fixed via the split wait maps + blockPeerIds/envelopePeerIds gating. I did find one low-severity residual of the same pattern within a single wait-type (root-keyed maps pruned wholesale by slot) — detailed inline; non-blocking since it errs toward dropping within a 3-slot window, no OOM/DOS risk.

The caps read sensibly (5 buffered / 32 searched / per-topic message caps), and the +308-line networkProcessor.test.ts is a good addition. Overall LGTM — the two P2s and the P1 are genuinely closed; the only open item is the optional #3 residual for you to decide on.

(Minor bookkeeping: the SHA cited for finding #2 is 5da6834738, but that commit is the MAX_AWAITING_MESSAGES_PER_ROOT change; #2's actual fix is b8c3e39bc6.)

* Max distinct roots we BUFFER messages for, per slot. Kept tight to avoid the OOM
* risk. We don't support super forky condition where there are more than this many roots per slot via gossip.
*/
export const MAX_BUFFERED_ROOTS_PER_SLOT = 5;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

haven't seen this many forks in any devnet so I think this is enough but I'd check with @nflaig

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

no I think this is totally fine, I doubt we would need more than 5 ever, but my concern with such a limit is rather that an attacker can fill this up quite easily and then lodestar does not queue the actual messages?

@lodekeeper lodekeeper Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified against head 5af43b15. Your premise is correct for the raw message: once bufferedRootsBySlot.get(slot).size reaches MAX_BUFFERED_ROOTS_PER_SLOT (5), tryReserveBufferedRoot returns false (L636-642) and maybeAwaitBlock/maybeAwaitPayload drop the gossip message with reject{reason: reached_root_limit} (L589-592 / L614-617). An attacker can evict the buffered-replay path for a legit 6th+ root.

But message buffering and block/envelope acquisition are two independent gates on the same incoming path, and the acquisition one is deliberately looser:

  • searchUnknownRoot fires before the buffer step (L434/488/507/518/537/543) and is gated only by tooManySearchedRoots → MAX_SEARCHED_ROOTS_PER_SLOT = 32 (L627-631, L651) — not the 5-root buffer cap.
  • So a legit root 6–32 still gets an active searchUnknownBlock/searchUnknownEnvelope even though its passive gossip message was dropped. The block/envelope is recovered via sync; what's lost is the fast replay of that one waiting message (att vote / column / envelope-forward), which falls back to the slower sync/sampling path.

That's the intended tradeoff — the L70-73 comment calls out this exact case ("the genuine root may come after the first MAX_BUFFERED_ROOTS_PER_SLOT roots ... if we cannot search, we'll penalize peers in UnknownBlockInput"). The 32>5 gap exists so a buffer-fill attack degrades to slow-path acquisition instead of dropping the block.

Residual exposure, to be upfront:

  1. Roots beyond 32/slot get neither buffered nor searched — but 32 distinct roots/slot is well past any real fork, so that's an attacker-only regime.
  2. Garbage-root spammers aren't penalized yet — the UnknownBlockInput scoring you flagged in the sibling thread (r3965403952), tracked as follow-up (per-pruneFinalized).

So not a "block never queued" hole — legit blocks within the 32-budget still get fetched — but it is a fast→slow degradation under attack, whose cost to the attacker depends on that follow-up peer-scoring landing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

yeah this is how I understand it works after reviewing the code, cc @wemeetagain for another sanity check

* - in the attack scenario, the genuine root may comes after the first MAX_BUFFERED_ROOTS_PER_SLOT roots
* - if we cannot search, we'll penalize peers in UnknownBlockInput, not in NetworkProcessor
*/
export const MAX_SEARCHED_ROOTS_PER_SLOT = 32;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this can only happen in some attack scenarios, we need to bound it anyway
right now UnknownBlockInput sync does not penalize peers
but we should safely do it per pruneFinalized() (not right after a failed download, it's not safe to do so)
will do it next

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense — bounding the search budget here and delegating peer penalization to UnknownBlockInput sync is the right split. Agree pruneFinalized() is the safe cadence: by finalization a genuine root would've resolved through forkchoice/sync, so anything still unresolved is safe to penalize, and it avoids the false positive of scoring a peer right after a download that only failed because the EL wasn't ready yet (same caution as the existing "removing the block(s) without penalizing the peers" path). Not a blocker for this PR — tracking it as follow-up.

@nflaig nflaig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

my main concern is that this could be abused too, but it's much better and should prevent the dos. As for the queuing in general, the important messages are data columns and payloads, on mainnet, we do not expect them to arrive before the beacon block, but we definitely need to have this soaking on a devnet for a while and see if it causes any issues in the happy path, could also do some attack scenarios on kurtosis if we have the time for it

const MAX_UNKNOWN_ROOTS_SLOT_CACHE_SIZE = 3;

/**
* Max distinct roots we BUFFER messages for, per slot. Kept tight to avoid the OOM

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the term "buffer" is a bit ambiguous

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agree it's ambiguous — "buffer" reads like a queue you drain, but here it's the set of unknown roots we hold pending gossip messages for until the block shows up. The test file already leans on "hold" language ("how many unknown blocks we hold messages for, per slot"), so aligning the JSDoc/const comment on that — e.g. "Max distinct unknown roots we hold pending messages for, per slot" — would remove the ambiguity without renaming the symbol.


/**
* Max distinct roots we BUFFER messages for, per slot. Kept tight to avoid the OOM
* risk. We don't support super forky condition where there are more than this many roots per slot via gossip.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have never seen more than 5 tbh, even in holesky, while there were more than 5 forks, most of those were dead and didn't produce messages at the head of each chain, so I think this should be plenty, cc @wemeetagain to double check

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The exact number matters less than it looks because of the backstop: MAX_SEARCHED_ROOTS_PER_SLOT (32) is separate from and higher than the hold limit, so even if honest forks occasionally push past 5 held roots, real blocks beyond the hold limit still get looked up by root and recover (the "keeps looking up a real block even after it stops holding messages for it" test covers this). So 5 for the hold buffer looks safe regardless of the precise live-fork count — leaving the empirical "is 5 enough" to @wemeetagain's check.

const MAX_DATA_COLUMN_PER_ROOT = MAX_AWAITING_MESSAGES_PER_ROOT[GossipType.data_column_sidecar] as number;

// When a gossip message points at a block we don't have yet, we hold the message and look the block up.
// A malicious peer could flood us with messages pointing at blocks that will never arrive, so we put two

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we test a malicious peer polluting our queue for a block root, so if they real messages arrives, we will not queue it, right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is already covered in networkProcessor.test.ts, split across two cases:

  • "stops holding messages once too many different blocks are unknown in the same slot" — floods 5 distinct (garbage) roots then a 6th; bufferedBlockCount() stays at 5, i.e. the 6th root's real messages are not held/queued. That's exactly your "real message arrives → we won't queue it".
  • "keeps looking up a real block even after it stops holding messages for it" — same flood, but asserts the 6th root is still looked up (unknownBlockRoot fired 6×), so the legit block is fetched via sync and the node recovers.

The same-root flood variant (attacker piles junk onto one root) is bounded by the per-(root,topic) cap and covered too — "holds a limited number of data column / execution payload / bid messages for one block".

So the behavior holds: a real message for an over-limit root is dropped, but the block itself is still recovered via lookup. If you'd prefer it spelled out as a single explicitly-named "malicious peer pollutes the buffer, then the legit victim's message is dropped but still looked up" case, happy to add that (or @twoeths may want to fold it in) — the mechanics are all there, it'd just consolidate the narrative.

On the review-body point re abuse + soaking: agree that's the right validation. Static read is the happy path is unaffected (columns/payloads/bids for a known-soon block don't depend on the hold buffer surviving an attack, and the abuse case degrades to slow-path lookup rather than dropping the block), but a devnet soak + a kurtosis flood scenario is the way to confirm it under real timing.

@matthewkeil matthewkeil moved this from In Progress to Awaiting Author in Lodestar Team Coordination Sep 10, 2026
@twoeths

twoeths commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

my main concern is that this could be abused too, but it's much better and should prevent the dos. As for the queuing in general, the important messages are data columns and payloads, on mainnet, we do not expect them to arrive before the beacon block, but we definitely need to have this soaking on a devnet for a while and see if it causes any issues in the happy path, could also do some attack scenarios on kurtosis if we have the time for it

  • in the abuse case, payloads/columns are malicious with canonical block roots, they will not be queued and will be processed immediately and penalized and disconnected (with 2 REJECTED messages), see feat: penalize peers for forwarding REJECTED gossip messages #10059
    • in very rare case they'll be queued if blocks are not known. But we'll have block very soon and process/penalize peers right after. Attestations should come before columns/payloads which trigger block search. Sometimes UnknownBlockInput sync even get the block before gossip, that's a nice race we designed to get block as soon as we can.
  • if they are malicious with random block roots, then it's not the abuse case. We will still send those peers to UnknownBlockInput sync and will penalize them in the end (will work on this next). We bound that too so OOM should not happen.

@nflaig

nflaig commented Sep 11, 2026

Copy link
Copy Markdown
Member

going to merge this, @wemeetagain feel free to do a post-merge review

@nflaig
nflaig merged commit 60b5607 into unstable Sep 11, 2026
30 checks passed
@nflaig
nflaig deleted the te/network_processor_dos_resistant branch September 11, 2026 10:48
@github-project-automation github-project-automation Bot moved this from Awaiting Author to Done in Lodestar Team Coordination Sep 11, 2026
@twoeths

twoeths commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

deployed to feat groups an devnets, nothing caught my eyes

  • dev node (glamsterdam-devnet-8)
Screenshot 2026-09-11 at 17 50 20
  • mainnet
Screenshot 2026-09-11 at 17 51 02
  • hoodi sas:
Screenshot 2026-09-11 at 17 51 27

the PR mainly handles the DOS resistant, it should not affect the normal scenario

@wemeetagain

Copy link
Copy Markdown
Member

🎉 This PR is included in v1.48.0 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

5 participants