Skip to content

Commit eb3a9ce

Browse files
bond00729claude
andcommitted
fix: cap channel-owner hints per verification to bound the shared event-id budget
An Ethereum verification for an address owning many channels emitted one ChannelOwnerChangeHint per owned channel, unbounded. Each hint draws an id from the block-scoped 14-bit HubEvent sequence (16384 events/block) that the hook shares with the merge loop and the mandatory BlockConfirmed commit. Verifications sort to priority 1, so a single verification against an address owning ~16k channels (accumulated over prior blocks) could exhaust the block's event-id budget: later message merges then fail via commit_transaction and drop from the shard root (deterministic censorship), and BlockConfirmed's commit unwrap panics on the overflow, halting nodes on replay. Cap the fan-out at MAX_CHANNEL_OWNER_HINTS_PER_VERIFICATION = 256 (~1.5% of the block budget, far above any plausible single-owner channel count). On exceeding the cap the scan warns once and stops in ascending key order, so which channels survive truncation is deterministic across nodes. Truncation is contract-legal: a shard is already not guaranteed to carry every hint, and consumers reconcile the full owner set via GetChannelOwner. The public hook becomes a thin wrapper over a cap-parameterized seam so tests can drive the cap at a small value; the merge-loop call site and the swallow-all-errors contract are unchanged. The pre-existing BlockConfirmed unwrap-on-overflow is left as a separate follow-up. Also folds in review hardening on the same hook: a decode helper dedup in the tests, a malformed/None/wrong-variant body no-panic test, and doc-comment accuracy fixes. New tests pin cap truncation, its trie-inertness, and that a cap above the owned count still emits every hint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8dbd61e commit eb3a9ce

2 files changed

Lines changed: 256 additions & 32 deletions

File tree

src/storage/store/engine.rs

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -803,20 +803,51 @@ impl ShardEngine {
803803
}
804804
}
805805

806+
/// Per-verification cap on channel-owner hints. An unbounded fan-out could let a
807+
/// single verification exhaust the shared 16384/block HubEvent id budget (see the
808+
/// hook's SHARED-BUDGET COUPLING note). One verification's hints must stay a small
809+
/// fraction of that budget so a single message can never approach exhaustion; 256
810+
/// is ~1.5% of the budget, far above any plausible single-owner channel count, and
811+
/// well below the block-safety threshold. An address owning more than this has its
812+
/// hint set truncated in ascending key order (deterministic across nodes);
813+
/// consumers reconcile the full set via `GetChannelOwner`.
814+
const MAX_CHANNEL_OWNER_HINTS_PER_VERIFICATION: usize = 256;
815+
806816
/// Emit `ChannelOwnerChangeHint` HubEvents for a just-merged Ethereum
807817
/// verification whose verified address owns one or more channels, by scanning
808818
/// THIS shard's own ByOwnerAddress replica (the trie-free ownership index built
809819
/// by the increment-7 block-event fold). Called only from the user-message
810820
/// merge loop's `Ok(merge_events)` arm — the hottest path this feature touches.
811821
///
812822
/// STRUCTURAL SAFETY CONTRACT (the rule this increment lives or dies by): this
813-
/// hook is unable to fail, alter, or panic a message merge. Its ONLY effect on
814-
/// the caller is the `Vec<HubEvent>` it returns, which the caller
815-
/// `events.extend(...)`s — it never sees the merge result, `validation_errors`,
816-
/// or the trie. Every error — a missing/malformed body, a replica read/decode
817-
/// error, an event-persist error — is swallowed with a `warn!` and yields fewer
818-
/// hints, never an `Err`, never a panic, never a mutation of the merge. Do NOT
819-
/// add a `?`, an `Err` early-return, or an unwrap on message/body data here.
823+
/// hook never sees the merge result, `validation_errors`, or the trie, so it
824+
/// cannot change what merged or the shard root *directly*. Its effects are: (1) the
825+
/// `Vec<HubEvent>` it returns, which the caller `events.extend(...)`s, and (2) the
826+
/// trie-free hint events it commits into the caller's `txn_batch` via
827+
/// `commit_transaction`. Every error — a missing/malformed body, a replica
828+
/// read/decode error, an event-persist error — is swallowed with a `warn!` and
829+
/// yields fewer hints, never an `Err`, never a panic on message/body data. Do NOT
830+
/// add a `?`, an `Err` early-return, or an unwrap on message/body data here. (The
831+
/// one non-message panic path, `commit_transaction`'s poisoned-mutex
832+
/// `lock().unwrap()`, is shared with the whole merge loop and would already have
833+
/// aborted upstream — this hook adds no new panic surface.)
834+
///
835+
/// SHARED-BUDGET COUPLING — bounded by a per-verification cap. Effect (2) advances
836+
/// the block-scoped, 14-bit `HubEventIdGenerator` sequence (`SEQUENCE_BITS`, 16384
837+
/// events/block) that this hook SHARES with the merge loop and the mandatory
838+
/// `BlockConfirmed` commit. An
839+
/// unbounded fan-out (one verification → N hints for N owned channels) would let a
840+
/// single verification against an address owning ~16k channels exhaust that shared
841+
/// budget within the block — failing later merges (`commit_transaction(...)?`) and
842+
/// tripping `BlockConfirmed`'s `.unwrap()`. So the fan-out is capped at
843+
/// `MAX_CHANNEL_OWNER_HINTS_PER_VERIFICATION`: one verification can never draw more
844+
/// than a small fraction of the block budget, restoring the property that flooding
845+
/// requires many independently rate-limited messages. Truncation is contract-legal
846+
/// — the amended `hub_event.proto` already tells consumers a shard may not carry
847+
/// every hint; they reconcile via `GetChannelOwner`. Hints emit in ascending key
848+
/// order, so WHICH channels survive truncation is deterministic across all nodes.
849+
/// (The pre-existing `BlockConfirmed` `.unwrap()` fragility is a separate,
850+
/// out-of-scope follow-up.)
820851
///
821852
/// Hints are deliberately NOT trie-indexed. The sibling `merge_events` flow
822853
/// through the `update_trie` loop at the call site; these hints are appended to
@@ -837,6 +868,27 @@ impl ShardEngine {
837868
msg: &proto::Message,
838869
txn_batch: &mut RocksDbTransactionBatch,
839870
version: EngineVersion,
871+
) -> Vec<HubEvent> {
872+
self.emit_channel_owner_hints_for_verification_capped(
873+
msg,
874+
txn_batch,
875+
version,
876+
Self::MAX_CHANNEL_OWNER_HINTS_PER_VERIFICATION,
877+
)
878+
}
879+
880+
/// Cap-parameterized body of [`Self::emit_channel_owner_hints_for_verification`].
881+
/// `max` is `MAX_CHANNEL_OWNER_HINTS_PER_VERIFICATION` in the single production
882+
/// call (the public wrapper above); it is a parameter, and this is `pub(crate)`,
883+
/// ONLY so a test can drive the truncation path at a small `max` without
884+
/// registering hundreds of channels. Do not call this from production with any
885+
/// other value.
886+
pub(crate) fn emit_channel_owner_hints_for_verification_capped(
887+
&self,
888+
msg: &proto::Message,
889+
txn_batch: &mut RocksDbTransactionBatch,
890+
version: EngineVersion,
891+
max: usize,
840892
) -> Vec<HubEvent> {
841893
if !version.is_enabled(ProtocolFeature::ChannelOwnershipEvents) {
842894
return vec![];
@@ -888,15 +940,16 @@ impl ShardEngine {
888940
_ => return vec![],
889941
};
890942

891-
// Scan this shard's own ByOwnerAddress replica to exhaustion in ascending
892-
// key order — the pinned deterministic hint order. One hint per owned
893-
// channel. N is unbounded but deterministic and economically bounded by
894-
// registry fees (no cap/dedup by design). Every read/persist error is
895-
// swallowed with a `warn!`: the merge has already committed and stays
896-
// untouchable.
943+
// Scan this shard's own ByOwnerAddress replica in ascending key order — the
944+
// pinned deterministic hint order — emitting at most `max` hints. The cap
945+
// bounds the fan-out so one verification can never approach the shared
946+
// 16384/block event-id budget; truncation is contract-legal and, because
947+
// emission is ascending-key, deterministic across nodes. Every read/persist
948+
// error is swallowed with a `warn!`: the merge has
949+
// already committed and stays untouchable.
897950
let mut hints = vec![];
898951
let mut page_token: Option<Vec<u8>> = None;
899-
loop {
952+
'scan: loop {
900953
let page_options = PageOptions {
901954
page_token: page_token.take(),
902955
..PageOptions::default()
@@ -919,6 +972,22 @@ impl ShardEngine {
919972
};
920973

921974
for channel_key in channel_keys {
975+
// Cap the fan-out. `hints.len()` counts hints ACTUALLY emitted — a
976+
// persist failure doesn't consume an event id, so it doesn't count
977+
// toward the budget the cap protects. On reaching the cap, `warn!`
978+
// once and stop the scan; the merge is unaffected and consumers
979+
// reconcile the untruncated ownership set via `GetChannelOwner`.
980+
if hints.len() >= max {
981+
warn!(
982+
fid = msg.fid(),
983+
hash = msg.hex_hash(),
984+
owner_address = hex::encode(&address),
985+
cap = max,
986+
"Truncating channel-owner hints: verified address owns more channels than the per-verification cap"
987+
);
988+
break 'scan;
989+
}
990+
922991
let mut hint = HubEvent::new_event(
923992
HubEventType::ChannelOwnerChangeHint,
924993
hub_event::Body::ChannelOwnerChangeHintBody(

src/storage/store/engine_tests.rs

Lines changed: 173 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5090,18 +5090,25 @@ mod tests {
50905090
.collect()
50915091
}
50925092

5093+
// Decode a hint's body. Every event reaching this is already filtered to
5094+
// `type == ChannelOwnerChangeHint` (by `owner_change_hints`), so the body variant is
5095+
// guaranteed; a mismatch is a test-harness bug and panics loudly.
5096+
fn hint_body(hint: &HubEvent) -> &proto::ChannelOwnerChangeHintBody {
5097+
match hint.body.as_ref().unwrap() {
5098+
proto::hub_event::Body::ChannelOwnerChangeHintBody(body) => body,
5099+
other => panic!("expected ChannelOwnerChangeHintBody, got {:?}", other),
5100+
}
5101+
}
5102+
50935103
// Only VERIFICATION_* hints, in emission (id) order — filters out the REGISTER/TRANSFER
50945104
// hints that the block-event fold emits while seeding the replica.
50955105
fn verification_hints(engine: &ShardEngine) -> Vec<HubEvent> {
50965106
owner_change_hints(engine)
50975107
.into_iter()
5098-
.filter(|hint| match hint.body.as_ref() {
5099-
Some(proto::hub_event::Body::ChannelOwnerChangeHintBody(body)) => {
5100-
body.cause == proto::ChannelOwnerChangeCause::VerificationAdd as i32
5101-
|| body.cause
5102-
== proto::ChannelOwnerChangeCause::VerificationRemove as i32
5103-
}
5104-
_ => false,
5108+
.filter(|hint| {
5109+
let cause = hint_body(hint).cause;
5110+
cause == proto::ChannelOwnerChangeCause::VerificationAdd as i32
5111+
|| cause == proto::ChannelOwnerChangeCause::VerificationRemove as i32
51055112
})
51065113
.collect()
51075114
}
@@ -5112,10 +5119,7 @@ mod tests {
51125119
owner_address: &[u8],
51135120
cause: proto::ChannelOwnerChangeCause,
51145121
) {
5115-
let body = match hint.body.as_ref().unwrap() {
5116-
proto::hub_event::Body::ChannelOwnerChangeHintBody(body) => body,
5117-
other => panic!("expected ChannelOwnerChangeHintBody, got {:?}", other),
5118-
};
5122+
let body = hint_body(hint);
51195123
assert_eq!(body.channel_key, channel_key);
51205124
assert_eq!(body.owner_address, owner_address);
51215125
assert_eq!(body.cause, cause as i32);
@@ -5393,6 +5397,163 @@ mod tests {
53935397
);
53945398
}
53955399

5400+
#[tokio::test]
5401+
async fn test_malformed_verification_body_skips_without_panic() {
5402+
// Adversarial-input pin (plan risk 1): the message body is untrusted network input.
5403+
// A message whose type passes the filter but whose body is missing or the WRONG
5404+
// variant — plus one with no `data` at all — must hit the warn-and-skip path: no
5405+
// hint, and crucially no panic (no unwrap on body/message data). The replica is
5406+
// populated for this address, so a well-formed body WOULD emit; the empties below are
5407+
// the malformed body's doing, not an empty replica.
5408+
let (mut engine, _tmp) = new_verifier_engine().await;
5409+
register_channel(&mut engine, "pets", verified_address()).await;
5410+
5411+
// Sanity: a well-formed add for this owner emits one hint (so "empty" below is meaningful).
5412+
let mut txn = RocksDbTransactionBatch::new();
5413+
assert_eq!(
5414+
engine
5415+
.emit_channel_owner_hints_for_verification(
5416+
&verification_add(messages_factory::farcaster_time()),
5417+
&mut txn,
5418+
EngineVersion::V20,
5419+
)
5420+
.len(),
5421+
1,
5422+
"sanity: a well-formed add for this owner emits one hint"
5423+
);
5424+
5425+
// 1. type == VerificationAddEthAddress but body = None.
5426+
let mut no_body = verification_add(messages_factory::farcaster_time());
5427+
no_body.data.as_mut().unwrap().body = None;
5428+
5429+
// 2. type == VerificationAddEthAddress but body is a DIFFERENT (Remove) variant.
5430+
let mut wrong_variant = verification_add(messages_factory::farcaster_time());
5431+
wrong_variant.data.as_mut().unwrap().body = Some(
5432+
proto::message_data::Body::VerificationRemoveBody(proto::VerificationRemoveBody {
5433+
address: verified_address(),
5434+
protocol: proto::Protocol::Ethereum as i32,
5435+
}),
5436+
);
5437+
5438+
// 3. No `data` at all — msg_type() falls back to None, so the hook takes no hint.
5439+
let mut no_data = verification_add(messages_factory::farcaster_time());
5440+
no_data.data = None;
5441+
5442+
for (label, msg) in [
5443+
("body = None", &no_body),
5444+
("wrong body variant", &wrong_variant),
5445+
("data = None", &no_data),
5446+
] {
5447+
let mut txn = RocksDbTransactionBatch::new();
5448+
let hints = engine.emit_channel_owner_hints_for_verification(
5449+
msg,
5450+
&mut txn,
5451+
EngineVersion::V20,
5452+
);
5453+
assert!(
5454+
hints.is_empty(),
5455+
"malformed message ({label}) must emit no hint and not panic"
5456+
);
5457+
}
5458+
}
5459+
5460+
#[tokio::test]
5461+
async fn test_hint_fan_out_is_capped() {
5462+
// One verification for an address owning many channels must not emit an
5463+
// unbounded number of hints — that would draw down the shared 16384/block
5464+
// event-id budget. Drive the cap at a small value via the `_capped` seam:
5465+
// 5 owned channels registered in non-sorted order, cap 3 → exactly the 3
5466+
// LOWEST channel_keys, in ascending order (truncation is order-deterministic,
5467+
// not insertion-ordered). Production uses the 256 constant.
5468+
let (mut engine, _tmp) = new_verifier_engine().await;
5469+
for key in ["e", "b", "d", "a", "c"] {
5470+
register_channel(&mut engine, key, verified_address()).await;
5471+
}
5472+
5473+
let add = verification_add(messages_factory::farcaster_time());
5474+
let mut txn = RocksDbTransactionBatch::new();
5475+
let hints = engine.emit_channel_owner_hints_for_verification_capped(
5476+
&add,
5477+
&mut txn,
5478+
EngineVersion::V20,
5479+
3,
5480+
);
5481+
5482+
assert_eq!(hints.len(), 3, "fan-out truncated to the cap");
5483+
for (hint, channel_key) in hints.iter().zip(["a", "b", "c"]) {
5484+
assert_hint(
5485+
hint,
5486+
channel_key,
5487+
&verified_address(),
5488+
proto::ChannelOwnerChangeCause::VerificationAdd,
5489+
);
5490+
}
5491+
}
5492+
5493+
#[tokio::test]
5494+
async fn test_truncated_hints_do_not_touch_the_trie() {
5495+
// Truncation must be as trie-inert as normal emission: capping the fan-out
5496+
// mutates zero trie state, so it can never move the shard root (and thus can
5497+
// never affect the merge). Mirrors `test_hints_do_not_touch_the_trie`, but on
5498+
// the truncation path. (That a REAL merge survives ANY hook outcome — error,
5499+
// empty, or this truncation, which is strictly milder than the scan error
5500+
// tested there — is pinned by `test_verification_merge_survives_corrupt_replica`.)
5501+
let (mut engine, _tmp) = new_verifier_engine().await;
5502+
for key in ["a", "b", "c", "d", "e"] {
5503+
register_channel(&mut engine, key, verified_address()).await;
5504+
}
5505+
5506+
let root_before = engine.trie_root_hash();
5507+
5508+
let add = verification_add(messages_factory::farcaster_time());
5509+
let mut txn = RocksDbTransactionBatch::new();
5510+
let hints = engine.emit_channel_owner_hints_for_verification_capped(
5511+
&add,
5512+
&mut txn,
5513+
EngineVersion::V20,
5514+
3,
5515+
);
5516+
assert_eq!(hints.len(), 3, "the cap truncated to 3 hints");
5517+
engine
5518+
.get_stores()
5519+
.onchain_event_store
5520+
.db
5521+
.commit(txn)
5522+
.unwrap();
5523+
5524+
assert_eq!(
5525+
to_hex(&root_before),
5526+
to_hex(&engine.trie_root_hash()),
5527+
"emitting truncated hints must not touch the shard root"
5528+
);
5529+
}
5530+
5531+
#[tokio::test]
5532+
async fn test_cap_above_owned_count_emits_every_hint() {
5533+
// Regression guard: the cap only truncates when owned channels EXCEED it.
5534+
// With the cap well above the owned count, every owned channel still gets a
5535+
// hint — the normal path is unchanged by the cap.
5536+
let (mut engine, _tmp) = new_verifier_engine().await;
5537+
for key in ["a", "b", "c"] {
5538+
register_channel(&mut engine, key, verified_address()).await;
5539+
}
5540+
5541+
let add = verification_add(messages_factory::farcaster_time());
5542+
let mut txn = RocksDbTransactionBatch::new();
5543+
let hints = engine.emit_channel_owner_hints_for_verification_capped(
5544+
&add,
5545+
&mut txn,
5546+
EngineVersion::V20,
5547+
256,
5548+
);
5549+
5550+
assert_eq!(
5551+
hints.len(),
5552+
3,
5553+
"a cap above the owned count emits one hint per channel"
5554+
);
5555+
}
5556+
53965557
#[tokio::test]
53975558
async fn test_end_to_end_ownership_lifecycle() {
53985559
// Capstone: a channel is registered by one address, transferred to the verified
@@ -5444,13 +5605,7 @@ mod tests {
54445605

54455606
// The full hint sequence across both paths, in emission order.
54465607
let hints = owner_change_hints(&engine);
5447-
let causes: Vec<i32> = hints
5448-
.iter()
5449-
.map(|hint| match hint.body.as_ref().unwrap() {
5450-
proto::hub_event::Body::ChannelOwnerChangeHintBody(body) => body.cause,
5451-
other => panic!("expected ChannelOwnerChangeHintBody, got {:?}", other),
5452-
})
5453-
.collect();
5608+
let causes: Vec<i32> = hints.iter().map(|hint| hint_body(hint).cause).collect();
54545609
assert_eq!(
54555610
causes,
54565611
vec![

0 commit comments

Comments
 (0)