Security
Restart a Halted Chain and Six Hourly Quotas Can Leave in 36 Seconds
IBC's rate-limiting module exists, in its own words, to protect chains from economic attacks and unintended token drainage. A bug report filed on Tuesday night shows that in the minutes after a chain restarts — which is exactly when an attack has just happened — it reopens the hourly quota once per block until its clock catches up.
The rate-limiting module in ibc-go is a circuit breaker. Its purpose is stated in a nine-line file comment at the top of the package, and the file comment does not hedge: the module exists "to protect chains from economic attacks or unintended token drainage." A chain operator sets a quota — a percentage of a channel's value that may move in or out — and a window, expressed in whole hours. When the window ends, the meter resets and the next window begins.
On Tuesday night a contributor going by 0xShadowX filed issue #9092 against ibc-go, with a patch twelve minutes behind it, arguing that the meter resets far more often than that after a chain has been stopped. This desk read the shipped code rather than the report, and the code says the same thing.
One hour per block, however long the gap
The relevant function is CheckHourEpochStarting, and it is nineteen lines long. It reads the stored hour epoch, adds the epoch's duration to its start time, and asks whether the current block time is later than that. If it is, the function does three things: it increments the epoch number by one, moves the start time forward by exactly one duration, and returns true.
Note what it does not do. It does not ask how far past the end time the block is. A block arriving ninety seconds late and a block arriving nine hours late are handled identically — the clock steps forward one hour and reports that a new epoch has started. The next block, still ahead of the freshly-advanced start time, does the same. The epoch catches up to real time at a rate of one hour per block, which on a six-second Cosmos chain means the catch-up happens roughly six hundred times faster than the thing it is catching up to, and produces one "new epoch" announcement at every step.
That announcement is consumed by BeginBlocker, which runs before the transactions in every block. Its logic is four lines: if a new epoch is starting, walk every configured rate limit, and reset the ones whose DurationHours divides the new epoch number evenly. For a limit configured with a one-hour window, that test is epochNumber % 1 != 0, which is never true, so the limit is reset every single time. And ResetRateLimit is not a partial gesture — it writes a fresh flow with inflow and outflow both set to zero, and clears the channel's pending send and receive packets.
Put those three functions in a line and the consequence falls out. After an N-hour stoppage, a one-hour rate limit is zeroed in each of the next N blocks. Its quota is not available once during that period. It is available N times.
The trace
The issue includes a keeper test run against main: hour epoch 100 starting at midnight, one rate limit with a one-hour window, blocks six seconds apart, and a first block at 06:01 — a five-hour gap of the kind an upgrade or an incident produces routinely. Before each block the flow is set to 10, then BeginBlocker runs and the flow is read back.
Six resets in six blocks after a five-hour gap
| Block after restart | Epoch number | Hourly outflow after BeginBlocker |
|---|---|---|
| 0 | 101 | 0 — reset |
| 1 | 102 | 0 — reset |
| 2 | 103 | 0 — reset |
| 3 | 104 | 0 — reset |
| 4 | 105 | 0 — reset |
| 5 | 106 | 0 — reset |
| 6 | 106 | 10 — flow now persists |
Six blocks at six seconds each is about thirty-six seconds. In that window the hourly ceiling is lifted and re-lifted six times, so an amount equal to six hours of permitted outflow can leave through the channel while the limit reports that it is working.
The behaviour is tested, which is the uncomfortable part
This is not an oversight that slipped past the test suite. It is enshrined in it. The existing epoch_test.go contains a case named "next epoch skipped," with a block time set ten durations past the epoch start, and an inline comment that reads: // way after next epoch (still increments only once). The single-step catch-up is the specified behaviour. Somebody thought about the case where a block lands far in the future and decided that incrementing once was correct.
In isolation, it is defensible. The filer traces the design to Stride's epochs module, where an epoch counter that advances one step at a time is a reasonable way to keep epoch-triggered work from stampeding. The problem is what the counter was wired to when it moved house. In ibc-go it is not merely counting epochs; it is the sole trigger for zeroing a security control. A design decision that was conservative in one context became the opposite in another, and the test that documents it never had to say which.
How new this is, and what is not known
The module is recent in ibc-go proper. It was merged into main in June 2025, but it is absent from the v10.7.0 and v11.0.0 release trees and present in v11.2.0, published on July 15, 2026 — the current release. This desk confirmed the affected code is byte-for-byte the same in that tag as on main. Rate limiting itself is older than that: the same lineage has run in Stride's own module and in the cosmos/ibc-apps repository for considerably longer.
Several things are not known, and this desk is not going to fill them in. Nobody has said how many production chains currently run the ibc-go module with a one-hour quota, which is the configuration where every catch-up block resets the meter. Longer windows are affected more narrowly — a 24-hour limit only resets when the epoch number happens to divide by 24 — and the patch addresses that case too. There is no evidence that any chain has lost funds this way, and nothing in the record suggests anyone has tried. What exists is a shipped control with a defect in the circumstance it was built for, found by reading, not by loss.
The proposed fix is small and its author marked it breaking. PR #9093 makes CheckHourEpochStarting jump straight to the epoch containing the block time — computing how many whole durations have elapsed and landing on the same grid in one step — and changes BeginBlocker to reset a limit when epochNumber / DurationHours has changed rather than when the modulo is zero. For a normal one-epoch step the two tests are equivalent; across a gap, the new one fires once. It also rejects negative durations, which the old division would have mishandled.
As of publication the issue and the pull request are both open, unlabelled, and carry no response from an ibc-go maintainer. The only comment on the PR is from a review bot declining to look at it, because its author is not on the allowed-authors list.
The Take
Guardrails are judged by the hour they are needed, not the year they are quiet, and this one has a bad hour. Chains stop for ordinary reasons — a scheduled upgrade, a consensus bug, a node fleet that fell over — but they also stop because someone is draining them, and the restart is the moment the operators are least able to watch a ledger and most dependent on automation to hold the line. A limiter that hands back six hours of headroom in the first thirty-six seconds of that morning is not a limiter; it is a formality. None of which makes anyone a villain. The patch is twelve minutes younger than the report, the analysis is careful, the tests are real, and the underlying mistake — moving a counter from a context where its quirk was harmless into one where it governs a safety control — is the single most ordinary way security bugs get made. The part worth watching is what happens next. An unreviewed pull request from an outside contributor, sitting against a module that shipped into core ten weeks ago, is a test of whether a project's security posture extends to the code it inherited.