Skip to content

feat(l1+precompiles): add L1-backed zone storage provider#626

Open
0xrusowsky wants to merge 17 commits into
mainfrom
rus/more-l1-awareness
Open

feat(l1+precompiles): add L1-backed zone storage provider#626
0xrusowsky wants to merge 17 commits into
mainfrom
rus/more-l1-awareness

Conversation

@0xrusowsky

@0xrusowsky 0xrusowsky commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

High-level goal

before this PR stack, Zones had two representations of Tempo L1 state:

  1. A semantic PolicyCache / PolicyProvider, populated from typed TIP-403 and TIP-20 events with RPC fallback. Custom ZoneTip20Token and ZoneTip403ProxyRegistry implementations used it to reproduce upstream policy behavior.
  2. A generic block-versioned raw storage cache keyed by (address, slot), used by TempoState and Zone system contracts for portal storage reads.

this duplicated policy semantics and left two L1-state paths that had to remain aligned in block anchoring, event decoding, invalidation, RPC fallback, and upstream behavior. The goal is to remove the semantic policy implementation from the EVM path and execute upstream Tempo precompiles against an L1-aware storage provider. The provider wraps the normal EVM storage provider and:

  • reads the Tempo L1 anchor from local TempoState
  • serves TIP-403 registry storage from L1 at that anchor
  • overlays only the L1-owned transferPolicyId field in TIP-20’s packed slot, preserving Zone-local fields
  • delegates ordinary storage to local Zone state
  • rejects writes to L1-owned mirrored state

from the upstream precompile's perspective these remain ordinary storage operations, but selected slots are virtualized to finalized L1 state. This makes upstream Tempo code the source of execution semantics rather than maintaining Zone-specific policy implementations.

Raw L1 cache design

the existing generic cache has this shape: (address, storage slot) → BTreeMap<L1 block number, storage value>. A lookup at block N returns the latest cached value at or before N. The intended benefit is that mirrored policy state changes infrequently, so one fetched value can serve many later Tempo blocks without another synchronous L1 RPC call.

I added contract-level mutation barriers, which basically track when the cache for a given contract is invalidated: A value fetched at block V may serve block N only if there is no barrier in (V, N]. The subscriber installs barriers for ZonePortal logs, TIP-403 registry logs, and tracked TIP-20 policy updates. Exact sequencer-slot updates are applied after the portal barrier, so same-block values represent post-block state.

the cache now has two independent progress markers:

  • anchor: latest confirmed L1 block observed by the subscriber; it can run ahead while blocks are queued
  • block_floor: latest L1 block consumed by the Zone engine

the engine advances block_floor in O(1). Slot and barrier histories are pruned lazily when that specific slot/address is next mutated: retain the latest pre-floor baseline, retain entries at or above the floor, and remove older redundant history. Reads below the floor bypass inherited cache state because the pruned barrier history can no longer answer historical inheritance safely. Historical fallback results are not inserted back into the forward cache. Reorgs clear values, barriers, and the subscriber anchor while preserving the monotonic engine floor.

Precompile Execution

storage.rs adds ZonePrecompileStorageProvider, a wrapper around Tempo's normal EVM precompile storage provider. It resolves the finalized L1 block once from local TempoState, serves TIP-403 storage reads from that exact L1 block, and overlays only the L1-owned transferPolicyId field in TIP-20's packed slot while preserving Zone-local fields. All other storage remains local. Mirrored reads still perform the local SLOAD first so warming, gas accounting, and storage-action recording are preserved, and writes to L1-owned state are rejected.

execution.rs centralizes local and L1-backed precompile execution. It installs the appropriate storage provider, preserves the active composed Tempo hardfork and storage accounting, and applies CallRules in two phases:

  • cheap Zone-local admission checks run before resolving L1 state
  • checks that need policy state run against the anchored overlay

CallRules also centralizes direct-call enforcement, fixed-gas handling, calldata gas, and forwarding of the original calldata and caller.

Together these changes let us drop the Zone-specific L1 precompile implementations from the execution path. TIP-20, TIP-403, and TipFeeManager now execute the upstream Tempo implementations directly against anchored L1-aware storage; the remaining Zone-specific behavior is expressed only as CallRules around that upstream execution.

Follow-up work

PolicyProvider is no longer used by EVM precompile execution, but it is still retained for encrypted-deposit authorization in the engine/payload-builder path. To remove it completely, that path must be migrated to perform the same checks from the block-anchored raw L1 storage provider. Once that cutover is complete, we can remove the PolicyProvider/PolicyCache wiring from the node and subscriber, the policy prefetch task and typed policy-cache event plumbing, and the associated legacy tests and documentation.

@0xrusowsky 0xrusowsky changed the title Rus/more l1 awareness feat(l1): make raw state cache hardfork and mutation aware Jul 13, 2026
@0xrusowsky
0xrusowsky force-pushed the rus/more-l1-awareness branch from 7e797c0 to 6e20d87 Compare July 13, 2026 18:00
@0xrusowsky
0xrusowsky marked this pull request as ready for review July 13, 2026 20:42
@0xKitsune 0xKitsune added cyclops and removed cyclops labels Jul 14, 2026
@0xKitsune

Copy link
Copy Markdown
Collaborator

cyclops audit

Comment thread crates/l1/src/subscriber.rs Outdated
Comment thread crates/l1/src/state/cache.rs Outdated
Comment thread crates/l1/src/state/cache.rs Outdated
@0xrusowsky
0xrusowsky force-pushed the rus/more-l1-awareness branch from d96421e to 5ba8103 Compare July 15, 2026 06:03
@0xrusowsky

Copy link
Copy Markdown
Contributor Author

cyclops audit

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

cc @0xrusowsky

Cyclops audit event published. View workflow run

Config: config: default, iterations: default, hours: default

@0xKitsune 0xKitsune added cyclops and removed cyclops labels Jul 15, 2026

@tempoxyz-bot tempoxyz-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.

👁️ Cyclops Review

PR #626 adds mutation-aware L1 state-cache barriers, shares the cache with the subscriber/EVM/engine, and prunes it after accepted L1-backed zone blocks. I found three actionable issues in the integration; see inline comments for details. VULNERABILITY_FOUND

Reviewer Callouts
  • ZonePortal raw storage surface: Review every ZonePortal storage slot reachable through TempoState and ensure each is either exact-diffed by subscriber events or protected by conservative portal invalidation barriers.
  • Consensus vs RPC cache boundary: Decide which execution modes may populate consensus-critical node state; read-only RPC currently writes into the cache pruned on the block-production path.
  • Historical execution contract: If historical replay/RPC is supported after pruning, enforce hash-qualified L1 lookups, bounded errors, and archival RPC requirements.
  • Sequencer privacy lookup: Workers flagged a pre-existing sensitive boundary where ZoneTip20Token private-read authorization uses SequencerExt::latest_sequencer() rather than the zone-committed tempoBlockNumber; this was not consolidated as a PR #626 finding but merits separate review.

Comment thread crates/l1/src/subscriber.rs
Comment thread crates/node/src/engine.rs Outdated
Comment thread crates/l1/src/subscriber.rs
Comment thread crates/node/src/engine.rs Outdated
@0xrusowsky

Copy link
Copy Markdown
Contributor Author

cyclops audit note="verify whether the proposed fix addresses the concern about 3rd party attacks via eth_call #626 (comment)"

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

cc @0xrusowsky

Cyclops audit event published. View workflow run

Config: config: default, iterations: default, hours: default, note: verify whether the proposed fix addresses the concern about 3rd party attacks via eth_call https://github.com/tempoxyz/zones/pull/626#discussion_r3590524183

@tempoxyz-bot tempoxyz-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.

👁️ Cyclops Review

PR #626 adds mutation barriers and a floor-driven lazy-pruning scheme for the raw L1 state cache. The forward execution path looks mostly sound, but two cache issues remain actionable: untrusted eth_call traffic can still grow the shared cache without a real bound, and pruned invalidation history can make below-floor historical reads return stale L1 storage.

Reviewer Callouts
  • Floor ownership on non-sequencer/RPC roles: advance_floor() is driven by ZoneEngine::advance, while the engine is only spawned when sequencer_config is present. If non-sequencer nodes are intended to expose RPC, confirm how their shared L1StateCache is bounded.
  • RPC-to-system-contract boundary: private RPC authorization controls from, but public system contracts such as ZoneConfig can still invoke the TempoState precompile as an allowed immediate caller. Treat EVM simulations as untrusted writers unless cache admission is separated by execution context.

Comment thread crates/l1/src/state/cache.rs
Comment thread crates/l1/src/state/cache.rs
Comment thread crates/l1/src/state/cache.rs
Comment thread crates/l1/src/state/cache.rs
@0xrusowsky 0xrusowsky changed the title feat(l1): make raw state cache hardfork and mutation aware feat(l1+precompiles): add L1-backed zone storage provider Jul 16, 2026

@mattsse mattsse 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.

Holistic pass after the stacked changes were consolidated into #626. I found four approval blockers and one follower-lifetime issue; inline details below. The previously discussed global cache-cardinality issue remains accepted follow-up work.

sequencer.clone(),
))
} else if *address == TIP_FEE_MANAGER_ADDRESS {
Some(execution::create_l1_backed_precompile(

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.

[P1] Apply the L1 overlay to protocol fee collection — This wraps only external calls to the FeeManager address. Tempo transaction handling invokes ProtocolFeeManager::collect_fee_pre_tx directly under the ordinary EVM storage provider, so that path never reaches this lookup or ZonePrecompileStorageProvider. Zone tokens keep local transferPolicyId = 1; consequently a sender blacklisted by the anchored L1 policy can still pay the fee-token transfer and execute a transaction whose body does not call TIP-20. Please route the internal fee hooks through the same anchored overlay and add an empty-transaction regression test for an L1-blacklisted sender.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

pending upstream tempoxyz/tempo#6879 to unlock the fix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

tentatively fixed with 1e130d4 (this PR) which assumes upstream PR gets merged as is

Comment thread crates/evm/src/lib.rs Outdated
extend_zone_precompiles(
precompiles,
&cfg,
self.l1_provider.clone(),

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.

[P1] Do not install the dead L1 reader in executable CLI configsnew_without_l1() supplies 127.0.0.1:1; this provider now reaches every migrated L1-backed precompile through this argument, while get_storage() retries forever. import, stage, or re-execute will hang on the first replayed block with a TIP-20/TIP-403 storage read. Please give maintenance execution a usable historical L1 source or fail before execution instead of installing an indefinitely retrying reader.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

addressed in c8b557d (this PR)

self.check_balance_read(decoded.account, call.caller)
})
}
_ => Ok(()),

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.

[P1] Preserve privacy for reward getters — Both userRewardInfo(address) and getPendingRewards(address) now fall through this default branch, allowing an authenticated outsider to query another account reward recipient, index, and pending balance. The previous wrapper gated both using the balance-read rule, and the associated tests were removed. Add both selectors through decode_and_check(... check_balance_read) and restore owner/sequencer/outsider coverage.

// Populate raw L1 state: policy 5 = WHITELIST, Alice is in the set, Bob is not.
seed_raw_tip403_policy(
zone.l1_state_cache(),
1,

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.

[P1] Seed before advancing the cache floor — This test waits until tempoBlockNumber == 3 and then seeds at block 1. L1StateCacheInner::set discards writes below block_floor (now 3), so the first registry call misses and the local dummy L1 reader retries forever. The same ordering appears in the blacklist, compound, and block-versioned tests. Seed versions before advancing the floor or use the current accepted anchor; the helper should also surface rejected insertions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

addressed in bd7cf42 (this PR)

Comment thread crates/node/tests/it/utils.rs Outdated
}

/// Seed the absence of an address-level TIP-403 receive policy for the next fixture block.
pub(crate) fn seed_no_receive_policy(&self, recipient: Address) {

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.

[P1] Seed receive-policy slots for all transfer fixtures — Only the migrated policy test calls this helper. The unchanged transfer suite (test_deposit_then_transfer, sequential transfers, events, and memo) executes T6+ transfers to Bob or Charlie without seeding their receive-policy slots; estimates or execution miss the cache and retry the dummy RPC forever. Seed recipients before estimate/send, or make fixture misses finite and explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

addressed in bd7cf42 (this PR)

Comment thread crates/node/src/engine.rs Outdated
// The engine has committed this L1 height, so advance the forward-cache floor.
// Individual slot and mutation histories are compacted lazily when next touched, so
// RPC-created slots cannot amplify engine work.
self.l1_state_cache

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.

[P2] Advance the floor on follower imports too — This is only called from ZoneEngine::advance, but that engine is spawned only when sequencer_config is present. The subscriber still runs on non-sequencer nodes and adds invalidations and slot versions while the floor remains zero, so follower memory grows for the lifetime of the process. Drive the floor from the common canonical import/commit path for every node role and initialize it from local TempoState on startup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

addressed in 2ec6aab (this PR)

@mattsse mattsse 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.

Follow-up holistic review at 2ec6aab0. The bounded retry, fixture seeding, receive-policy seeding, and canonical follower-floor fixes look good. Two P1 approval blockers remain; inline comments include concrete implementation suggestions.

sequencer.clone(),
))
} else if *address == TIP_FEE_MANAGER_ADDRESS {
Some(execution::create_l1_backed_precompile(

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.

[P1] Apply the anchored L1 overlay to internal fee collection

This lookup only wraps external calls to TIP_FEE_MANAGER_ADDRESS. Tempo's transaction handler invokes TipFeeManager::collect_fee_pre_tx and collect_fee_post_tx directly against the ordinary journal-backed StorageCtx, so those fee-token transfers never see ZonePrecompileStorageProvider. An L1-blacklisted fee payer can therefore submit an otherwise empty transaction and still execute it.

Suggested implementation: factor the anchored policy-storage setup out of create_l1_backed_precompile and make it available to ZoneBlockExecutor/the Tempo fee hooks. Resolve the TempoState anchor once for the transaction, then run both pre- and post-fee collection through the same ZonePrecompileStorageProvider (preserving local balances/allowances while sourcing TIP-403 policy slots from L1). Add an end-to-end empty-call transaction test with the sender blacklisted at the anchor; it should be rejected before the body executes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

pending upstream tempoxyz/tempo#6879 to unlock the fix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

tentatively fixed with 1e130d4 (this PR) which assumes upstream PR gets merged as is

self.check_balance_read(decoded.account, call.caller)
})
}
ITIP20::allowanceCall::SELECTOR => {

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.

[P1] Restore privacy checks for reward getters

userRewardInfo(address) and getPendingRewards(address) still fall through to upstream dispatch without applying the account/sequencer authorization used by the other private reads. The previous wrapper protected both selectors, so this exposes per-account reward state to arbitrary callers.

This compiles against the current ITIP20 bindings:

Suggested change
ITIP20::allowanceCall::SELECTOR => {
ITIP20::userRewardInfoCall::SELECTOR => {
decode_and_check::<ITIP20::userRewardInfoCall>(args, |decoded| {
self.check_balance_read(decoded.account, call.caller)
})
}
ITIP20::getPendingRewardsCall::SELECTOR => {
decode_and_check::<ITIP20::getPendingRewardsCall>(args, |decoded| {
self.check_balance_read(decoded.account, call.caller)
})
}
ITIP20::allowanceCall::SELECTOR => {

Please also restore owner/sequencer/outsider coverage for both selectors; outsiders should receive Unauthorized.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i did it on purpose cause rewards are disabled on TIP20s now but the selectors were preserved as a no-op.

IMO fine to leave as is (cause they won't like anything and simply return 0), and simply update the spec.

wdyt @0xKitsune @mattsse?

@0xrusowsky

Copy link
Copy Markdown
Contributor Author

cyclops audit note="we decided we will address unbounded cache growth in a follow-up PR, so don't report this issue"

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

cc @0xrusowsky

Cyclops audit event published. View workflow run

Config: config: default, iterations: default, hours: default, note: we decided we will address unbounded cache growth in a follow-up PR, so don't report this issue

@socket-security

socket-security Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedalloy-primitives@​1.6.0 ⏵ 1.6.110010093100100
Updatedalloy-sol-types@​1.6.0 ⏵ 1.6.110010093100100

View full report

@tempoxyz-bot tempoxyz-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.

👁️ Cyclops Review

Head drift detected: the audit reports targeted 1e130d4acd7c, while the current PR head is d4db039ac9e148f3d239ca19a48757623b886287. I rechecked the candidate findings against the current head and diff; the actionable findings below still apply.

PR #626 refactors zone precompiles to execute TIP-20/TIP-403/FeeManager logic over a finalized L1-backed storage overlay. The implementation has multiple security regressions in the new TIP-20 rule layer and L1 integration: private reads can be bypassed or use the wrong L1 anchor, reward views lost their privacy gate, receive-policy recovery is omitted, and cached policy IDs can become stale after subscriber reconnects.

Reviewer Callouts
  • Dynamic precompile lookup subset: set_precompile_lookup replaces Tempo's upstream dynamic lookup. Any upstream precompile needed by forwarded TIP-20/TIP-403/FeeManager code must be explicitly re-registered by the zone.
  • L1 storage hash anchoring: TempoState stores an L1 hash and number, but the L1 storage reader API passes only a block number to RPC. Please confirm that finality/trusted-RPC assumptions make number-only reads acceptable for proof/replay requirements.
  • Decode symmetry: Any rule layer that pre-decodes calldata with stricter validation than the executor it guards can fail open. Review other abi_decode_raw_validate gates in front of non-validating upstream dispatch.

) -> CallCheckResult {
match C::abi_decode_raw_validate(args) {
Ok(decoded) => check(decoded),
Err(_) => Ok(()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 [SECURITY] TIP-20 privacy checks fail open on non-canonical ABI encoding

decode_and_check returns Ok(()) on strict ABI decode failure. That maps to CallCheck::Continue, so the original calldata is forwarded to upstream TIP-20. Upstream dispatch uses non-validating decoding, so dirty high bytes in an address word can skip the zone owner/sequencer check while still decoding upstream to the victim account, leaking balanceOf, allowance, or hasRole data.

Recommended Fix:
Fail closed for decode errors on privacy-gated selectors, matching the previous decode_or_revert! behavior, or decode with the same ABI semantics upstream uses and enforce the check on those decoded values.

self.check_balance_read(decoded.account, call.caller)
})
}
_ => Ok(()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 [SECURITY] zTIP20 reward views bypass the owner/sequencer privacy gate

The catch-all admits selectors that are not explicitly listed. The upstream account-scoped userRewardInfo(address) and getPendingRewards(address) selectors were gated before this PR, but now fall through and are forwarded for any caller, exposing per-account reward state and potentially balance-derived information.

Recommended Fix:
Add explicit arms for ITIP20::userRewardInfoCall::SELECTOR and ITIP20::getPendingRewardsCall::SELECTOR that decode account and call check_balance_read(account, call.caller), with regression tests for unrelated callers.


fn is_sequencer(&self, caller: Address) -> bool {
self.sequencer
fn check_sequencer(&self, caller: Address) -> CallCheckResult {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 [SECURITY] TIP-20 sequencer privacy gate uses latest L1 state instead of the TempoState anchor

This local rule authorizes sequencer-visible private reads before ZonePrecompileStorageProvider resolves the finalized TempoState anchor, and it ultimately calls latest_sequencer(). The production provider reads the portal sequencer at the cache/RPC latest L1 head, not the L1 block being executed. Around handovers, a future sequencer can read private state before the handover block is imported, and historical replay can depend on current L1 head.

Recommended Fix:
Move sequencer-dependent privacy checks into the L1-backed phase, or add an anchored sequencer_at(block_number) path that reads the portal sequencer slot through the same TempoState anchor used by the storage overlay.

} else if *address == ACCOUNT_KEYCHAIN_ADDRESS {
Some(AccountKeychain::create_precompile(&tempo_env))
} else {
None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 [SECURITY] L1-backed receive policies can lock TIP-20 funds because ReceivePolicyGuard is not registered

This replacement dynamic lookup falls through to None for upstream dynamic precompiles that the forwarded TIP-20 implementation can depend on, including RECEIVE_POLICY_GUARD_ADDRESS. Upstream TIP-20 can route blocked inbound transfers/mints into the guard and create claim receipts, but the zone then has no callable ReceivePolicyGuard::claim/burn recovery entrypoint, so funds can remain stuck.

Recommended Fix:
Register RECEIVE_POLICY_GUARD_ADDRESS for the relevant hardforks using an L1-backed execution environment consistent with TIP-20 policy reads, and audit other omitted upstream dynamic precompiles for reachable dependencies.

{
policy_events.push(event);
// TODO: Remove once Tempo has migrated policy IDs to the TIP-403 registry.
mutated_accounts.insert(addr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 [SECURITY] TIP-20 transfer-policy IDs can remain stale after subscriber reconnects

The mutation barrier for a TIP-20 TransferPolicyUpdate is only recorded inside the tracked_tokens branch. Portal-enabled tokens are added to the ephemeral tracked_tokens list but are not persisted into policy_cache, and reconnects reset tracked_tokens from that cache. After reconnect, a token can be dropped, future policy-update logs skip invalidation, and L1StateCache can keep serving a stale transferPolicyId indefinitely.

Recommended Fix:
Make invalidation coverage match overlay read coverage: record a mutation barrier for any TIP-20-prefixed address emitting TransferPolicyUpdate, or persist/reseed portal-enabled tokens so updates cannot be skipped after reconnect.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants