June 23, 2026
The Setter That Changed State in Silence
An incident response begins with one question: when did this address change, and who changed it? If the contract that holds the money changed it in silence, the answer becomes a manual walk through state diffs across thousands of blocks. The function below moves the destination of every fee a vault collects, and it leaves nothing behind for a watcher to find.
contract Vault {
address public owner;
address public feeRecipient;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function setFeeRecipient(address account) external onlyOwner {
feeRecipient = account;
}
}
The owner calls it, storage updates, the transaction succeeds. That is the whole story the chain tells.
Pick concrete values. The owner sends a transaction calling setFeeRecipient with 0x00...BEEF. The EVM writes the new address into the feeRecipient slot and returns. The receipt shows status 1 and an empty logs array. A monitoring service subscribed to the contract's events receives nothing, because there were none. The fees that accrue from the next block onward now flow to 0x00...BEEF, and the first signal anyone outside the transaction gets is the balance landing at an address they never approved. By then the question is not whether to respond but how many blocks of fees are already gone.
Compare the cost of detection. With an event, an indexer can alert in the same block the change lands. Without one, detection means polling feeRecipient on a timer and diffing the result, or piecing the change together long after the funds have moved.
The class
The class is functions that mutate governance-critical state and return without emitting. The state write is correct. The observability is the part that went missing. It shows up anywhere a single privileged call can move something a human is later asked to audit: the owner address, the fee recipient, an oracle pointer, a pause flag, a parameter bound. The storage slot ends up right. The off-chain record of how it got there does not exist. The severity is rarely a direct loss of funds. It is the loss of the timeline you need when something else goes wrong, which is exactly the moment you cannot afford to piece it back together by hand.
Why it survives the test suite
This is why it sails through review and the test suite alike. Unit tests assert post-conditions. They call the setter, read feeRecipient back, confirm it equals the new value, and pass. Events are not a return value and not a storage slot, so an assertion that never inspects the log stream never notices the log stream is empty. The invariant the suite protects is state, not the audit trail. The reproduction records logs around the call, confirms the write landed, then asserts that zero events were emitted. The assertion holds, which is the bug.
The fix
Declare an event and emit it with both the old and the new value, so a reader can rebuild the full history from logs alone.
event FeeRecipientUpdated(address indexed previous, address indexed next);
function setFeeRecipient(address account) external onlyOwner {
emit FeeRecipientUpdated(feeRecipient, account);
feeRecipient = account;
}
Name the previous holder and the next one, and index both so watchers can filter on either side of the change. The lesson is short. A state change a human will be asked to audit must leave a log a machine can index. If the only proof a critical address moved is the address now sitting in storage, the timeline is already gone.
Daniel Kuppitz · Founder & Principal · Solidity audits at Cimora