Web3 Security 101Part 10 of 12SmartAlert7 min read

Events vs state: when no event will come

Events tell you what a contract chose to announce. State is what it holds. Why Ronin's threshold went to 0 silently and how a view-function rule catches it.

Most monitoring starts from events: the contract logs OwnershipTransferred, you get a message. It is the right place to start, and part 9 is built on it. This part is about what events cannot tell you.

What an event is, and what it is not

In part 1 we saw that a contract has state (the values it stores) and emits events (log entries it writes on purpose). The important word is *on purpose*. An event is a line the developer decided to write when some code path ran. Three things follow.

  1. No code path, no event. If a value changes through a path that does not emit, the log is silent.
  2. An event says what happened, not what is true now. Upgraded says the code changed. It does not say whether the new code left the contract in a safe state.
  3. Nothing is not an event. A value that was wrong from the start, or a thing that stopped happening, emits nothing.

State is the opposite. Any public variable or view function can be read at any block by anyone, for free. It always shows what is true now.

The case: Ronin, August 2024

We covered it in detail in the Ronin post, so only the mechanics here.

Ronin's bridge gateway on Ethereum pays out a withdrawal only when the signers' total vote weight reaches a threshold. minimumVoteWeight() returns that threshold. Before the upgrade on August 6 it returned 15401.

The upgrade code computed the threshold as 70% of the total operator weight, and the total was set in an initializer for version 3. The upgrade jumped straight to version 4 and that initializer never ran. The total stayed 0, 70% of 0 is 0, and the threshold became 012. A simplified sketch of the pattern (not Ronin's exact code):

solidity
function initializeV3(...) external reinitializer(3) { _totalOperatorWeight = ...; }
function initializeV4(...) external reinitializer(4) { /* ... */ }
// the proxy was upgraded to V4 and initializeV4 ran, initializeV3 never did

Now look at the logs of that upgrade. They contain Upgraded and Initialized(4). There is no Initialized(3), and nothing that carries the new threshold. Every event the team expected was there. Forty-eight minutes later an MEV bot withdrew about 4,000 ETH, with no operator signatures, and the money left in several more transactions1.

An event rule on Upgraded fires here, but it is the team's own planned upgrade, so the alert says "yes, that is ours". The signal that something was wrong was a number, and only a question to the contract could show it.

Four kinds of value that change without an event

When you are choosing what to watch, these are the usual suspects.

  • Parameters set in initializers or by a plain setter. Thresholds, limits, fees, the guardian address. Some setters emit an event, many do not, and an initializer that did not run emits nothing at all.
  • Computed or external values. A price from an oracle, a collateral ratio, a total supply, an exchange rate. They move because inputs moved, not because anyone called a function.
  • Flags that can be set indirectly. paused() normally comes with a Paused event. A contract that sets the flag another way, or a pause that happened before you started watching, gives you no event to catch.
  • Who is in charge now. owner(), a proxy admin, a guardian. The event tells you about the change. The view function tells you the current answer, even if you missed the event.

The test for a good state rule: *if this value were wrong, would the contract still run normally until money moved?* If yes, write the value down and read it.

How a state rule works

A state rule calls a view function on a schedule, every minute, 5 minutes, 15 minutes or hour, and compares the answer with a condition. In SmartAlert it is called "Contract state", and it needs Pro or above.

  • Pick a function and press "Check function". You see the answer now, before you save anything.
  • Choose the condition, for example "equals true" for paused(), or "below threshold" for a number like minimumVoteWeight().
  • Severity and channel are set the same way as for any rule.

What to know before you rely on it:

  • It reads confirmed blocks. The rule reads the block 12 confirmations behind the head on Ethereum, about 144 seconds, then the polling and the check. The first call that sees a bad value lands 2.5 to 4 minutes after the transaction that caused it3. For Ronin that was still about 45 minutes before the first withdrawal.
  • It has no past. The answer history starts when you create the rule, and past values are not read back. You cannot create the rule after an incident and replay the moment of change. To catch Ronin at 08:48, the rule had to exist before 08:48. In practice that means: create state rules before the next upgrade, not after.
  • A revert is not a value. If the function reverts, the rule shows "no data", and does not alert on its own unless you set "no data" to alert. Decide how loud you want that to be.
  • It does not know your invariants. SmartAlert checks the numbers you point it at. It does not guess that minimumVoteWeight() should never be 0. You know that, because you read the code.

What to read, by contract type

Any pausable
paused()
Bridge or gateway
signature threshold or vote weight, withdrawal limit, guardian
Lending or vault
price from the oracle (latestAnswer()), the collateral parameters you rely on
Token
owner(), a minter or admin address if it is exposed
After an upgrade
the two or three values the new code relies on

Functions with simple arguments work too, such as balanceOf(vault) or hasRole(role, account). For bool and address answers there is a "value changed" condition, so owner() or a minter address alerts on any change, without you knowing the new value in advance. What does not work: functions that return arrays or several values at once, such as a Safe's getOwners(), a pool's getReserves() or Chainlink's latestRoundData(). For those, watch the matching events or a single-number function next to them, like the Safe's getThreshold().

Where state does not help

If the bad value and the loss happen in one transaction, a state check sees the result, not the warning. This is the class of attack we describe in part 8. State rules earn their keep where there is a gap between the change and the exploit, as at Ronin: 48 minutes.

How to catch it

  1. Write down the values. For each contract, list view functions whose wrong answer would break your security assumptions. Start with paused(), owner(), thresholds and limits.
  2. Create a "Contract state" rule on each, with the condition that means "wrong". For paused(), the contract-state-paused recipe is the template: the same rule with a number in place of true/false works for a threshold, as in the Ronin case: call minimumVoteWeight() every minute, fire below 1. Severity Critical.
  3. Create them before your next upgrade. The first call after the upgrade then shows whether the numbers survived.
  4. Keep the event rules too. Upgraded says when the code changed, the state rule says what it changed. For native ETH leaving a bridge, add treasury-below-usd; for tokens, large-transfer-usd.
  5. Know what you will do when the state alert fires at night. That is part 12.

Sources

  1. 1.SmartAlert, Ronin Bridge hack, August 2024: minimumVoteWeight() read with archive eth_call at the blocks before and after the upgrade, upgrade logs (Upgraded, Initialized(4), no Initialized(3)), withdrawals with block times. ↩
  2. 2.Three Sigma, analysis of the Ronin Network exploit; Rekt, Ronin Network, Rekt II. ↩
  3. 3.SmartAlert, alert delay on Ethereum: 12 confirmations (about 144 s), head polling up to 12 s, storage up to 15 s (P95), rule check every 60 s on Pro. ↩

Try it free

An address is enough. The first 14 days get Pro limits, and rules are checked on history before they go live.