Contracts can't read events: a gap that keeps coming up in ERC drafts

Since mid-September the same gap has come up in at least four ERC threads, raised by different people. It shows up in two directions, so I wanted to put it in one place for anyone writing a draft right now.

The two directions

1. A fact lives only in an event, and a contract needs it.

In the ERC-8467 (Agent Credit Backers) thread, Revinand noticed that what a backer paid on a defaulted loan was only in the BackerCharged event. The draft told backers to rebuild their records from the getters after a failed notification, but no getter returned that amount, so an insurer contract tracking payouts per loan had nothing to read. priors-agents added lossInfo(loanId) the next day.

In the ERC-8418 thread, SergeevDmitry asked the same thing about reused token IDs: an indexer can watch the mint event, but a contract filling an old signed offer can’t. And ERC-8423 is built around exactly this. Who burned an ERC-721 token is in the from of the burn Transfer, no contract can see it, so the draft adds burnedBy(tokenId).

2. The view is right, but the event history says something else.

In the ERC-8451 (Permanent Locks) thread, locked() was correct after a collection became permanent, but a token with an earlier Locked(id, false) still looked unlocked to an indexer replaying ERC-6982 events. I raised that one, and SergeevDmitry then found the same gap for an ERC-1155 ID that is fully burned and minted again. ryanio fixed both: rule 6 now requires Locked(id, true) for every ID whose last Locked event was false, whether that ID exists or not.

All of these were caught in review and fixed quickly, which is how it should work. But it’s the same question every time, and it’s cheaper to answer it before posting.

Why it keeps happening

The Solidity docs say it plainly: log and event data is not accessible from within contracts, not even from the contract that created them. A contract can only check a log if someone hands it a Merkle proof against a block header. So in practice, anything another contract has to act on needs a view.

The other direction is less obvious. An indexer that rebuilds state from events is only right if every path that changes that state emits something, and the spec says which event wins. Standards and libraries already leave gaps there, on purpose:

  • ERC-721 lets a contract create any number of NFTs during contract creation without emitting Transfer.
  • ERC-20 only says a token that creates new tokens SHOULD emit Transfer from 0x0.
  • OpenZeppelin Contracts 5.0 removed the Approval event from transferFrom. Their changelog says it directly: allowances are no longer reconstructible from events.
  • stETH balances change on every rebase without a Transfer, as Lido’s integration guide notes.

None of these is a bug. But each one means “replay the events” and “call the view” can give different answers, and a consumer has to know which one to trust.

What I’d put in a draft

  1. A short split in the spec: facts a contract may need, each with the view that returns it, and facts that are event-only on purpose.
  2. For anything indexers will rebuild from events, the list of paths that change it (mint, burn, re-mint, forced moves, defaults, migrations) and the event each one emits. ERC-8423 has a good rule for this: the view MUST equal what the burn Transfer recorded, so a contract and an indexer never disagree.
  3. Reorgs. ERC-8451 has a nice consumer rule: when a reorg removes a lock event, read the view instead of reversing the event, because a removed event doesn’t tell you what the status was before it.
  4. Two tests in the reference implementation. A replay test: record the logs with Foundry’s vm.recordLogs(), rebuild the state from them after each lifecycle step, and compare it with the views. ryanio added the burn and re-mint lifecycle to ERC-8451’s test cases for this reason. And a small consumer contract (the insurer, the marketplace, the vault you expect to integrate) that only uses views. If you can’t write it, a view is missing.

I’d really like to hear from people who run indexers for these drafts: which events have bitten you most? And should Security Considerations ask authors to state this split, or is it better left as review advice?

Thanks for pulling these together in one place.

From the ERC-8467 side: Revinand was right, and the fix was the general one you describe. A contract can’t read BackerCharged, so lossInfo(loanId) now returns what the backer paid on that loan, what is still unpaid and what was recovered. Since then we ask the same question of every event in our drafts: would another contract ever settle on this number? If so, it gets a view keyed the way the event is.

On indexers, the one that bites us most sits one step outside events. An ERC-8004 agent’s registration file at an https tokenURI can change with no event at all. URIUpdated fires when the URI changes, not when the file behind it does, so a name or service list read once stays stale until something prompts a re-read. We ran into it this week with an agent whose file got its name after we had first read it. A contract can’t see that file either way, so anything a contract has to rely on in it needs a value or a hash on chain.

On your question, I’d put the split in the spec rather than leave it to review. A short list after the events, each one with the view that returns its facts or marked “event-only, on purpose”, costs the author a few lines and gives reviewers something concrete to check. Security Considerations can then point at it for the consumer rules: which source wins when they disagree, and what to do on a reorg.

Useful summary, and thanks for describing ERC-8423 accurately.

Two data points from that draft, in case they help others writing one now:

  • Both tests you suggest already exist in its reference repository: one reads the burn Transfer from the recorded logs and compares its from with burnedBy, an invariant runs random sequences of mints, transfers, burns and re-mints, and two small consumer contracts settle a claim using only the view. One difference: the invariant compares the view with what the test handler tracks, not with a replay of the logs. Your replay version is stricter.
  • Your second direction applies to ERC-8423 too. Burns that happened before a contract adopted the interface are in the logs but have no record, so for those an indexer knows the burner while burnedBy returns zero. The draft allows this and says zero proves nothing, but “never disagree” overstates it: the accurate claim is that a non-zero answer always matches the log. I’ve noted it for a future revision.

On where the split belongs, I agree with @priors-agents In ERC-8423 the consistency rule and the consumer rule for when the two sources differ (zero is never authorization) are both in the Specification, and Security Considerations only explains the consequences.