Skip to content

fix(l1): scope TIP-403 policy-set membership to the queried block#651

Open
MattewGraham wants to merge 2 commits into
tempoxyz:mainfrom
MattewGraham:fix/tip403-cache-block-scoped-membership
Open

fix(l1): scope TIP-403 policy-set membership to the queried block#651
MattewGraham wants to merge 2 commits into
tempoxyz:mainfrom
MattewGraham:fix/tip403-cache-block-scoped-membership

Conversation

@MattewGraham

Copy link
Copy Markdown

Scope TIP-403 policy-set membership to the queried block

What breaks

A blacklisted account can transact on a zone, and a whitelisted account can be
wrongly rejected, whenever the L1 subscriber has observed a later policy-set
change for that account than the block the engine is currently authorizing.

Invariant at risk: TEMPO-ZONE-TIP403-INHERITANCE (🔴) — "Zone token transfer,
mint, and withdrawal paths enforce the TIP-403 policy inherited from the current
finalized Tempo view."

Root cause

PolicySet answers membership per block via contains(user, block_number), but the
gate that decides whether that answer is trustworthy was block-agnostic:

pub fn is_known(&self, user: &Address) -> bool {
    self.observed.contains(user) || self.baseline.contains(user)
}

observed was populated the moment any event for the user was applied, at any
height. The subscriber runs ahead of the engine (the cache advances only after the
engine finishes a block), so the set routinely holds a user's future delta while the
engine queries an earlier block. is_known then reported true, check_policy /
check_simple trusted contains, and the RPC fallback that exists precisely for
"we don't actually know" was skipped.

Worked example (blacklist bypass)

  1. Token T has a BLACKLIST policy. Account B is blacklisted as of L1 block 1 —
    before the subscriber started, so no add event is ever seen and the baseline is
    empty for B.
  2. Subscriber starts at block 100; at block 150 B is removed from the blacklist
    (MembershipChanged{in_set: false}), recorded as a pending delta and marking B
    observed.
  3. The engine authorizes a transfer at block 120: is_known(B) is true (observed),
    contains(B, 120) finds no delta ≤ 120 and no baseline entry → false, so
    BLACKLIST yields Some(!false) = Some(true)B is authorized at a block
    where it is blacklisted.
    No RPC fallback fires.

The whitelist case is the mirror image: a genuinely-authorized user is rejected.

Fix

Membership from an event is only meaningful at and after the block the event was
observed on. Replace the block-agnostic observed set with first_seen: HashMap<Address, u64> — the earliest block at which we recorded any event for the
address — and make known-ness block-scoped:

pub fn is_known(&self, user: &Address, block_number: u64) -> bool {
    self.first_seen.get(user).is_some_and(|&first| first <= block_number)
}

For a query strictly earlier than the first event we hold for a user (the case
above), and for users we've never seen, is_known now returns false, so
check_policy/check_simple return None and the provider falls back to
authoritative L1 RPC. first_seen survives advance() (folding deltas into the
baseline keeps the earliest-seen block) and is cleared by clear(), mirroring the
previous observed lifecycle.

The four production call sites in cache.rs already have block_number in scope;
they now pass it through.

Tests

  • New policy_set_future_delta_does_not_authorize_earlier_query reproduces the
    ahead-of-engine scenario and asserts the earlier block is reported unknown.
  • Existing PolicySet tests updated to pass the query block; the "observed survives
    advance" guarantee is retained (renamed to first_seen).
  • cargo test -p zone-l1 --lib policy_set (rustc 1.95.0).

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.

1 participant