claimExpired() does not save the registry state that existed when the pool expired. It reads the
current registry state when the first caller resolves the pool.
Because of this, a corruption that happens after the pool term can change an old unresolved pool
from EXPIRED to CORRUPTED. If the corruption happens after expiry + 180 days, any address can
resolve and sweep the pool at once. The moderator gets zero time after the corruption. The staker
can lose all principal and bonus.
When a pool reaches expiry while the agreement is still UNDER_ATTACK, the stakers have covered
the full pool term. If someone calls claimExpired() at that time, the outcome is EXPIRED. The
stakers can then claim their principal and bonus.
However, the contract does not freeze this result at expiry. It waits for the first call to
claimExpired() and reads the live registry state in that call.
The moderator deadline is also based only on the pool expiry:
It is not based on the time when the registry becomes CORRUPTED.
This creates the following path:
Stakers fund Pool A.
Pool A observes UNDER_ATTACK before its expiry.
Pool A reaches expiry while the agreement is still UNDER_ATTACK.
Nobody resolves Pool A, so it stays UNRESOLVED for more than 180 days.
The agreement later becomes CORRUPTED.
Any address calls claimExpired() and claimCorrupted() in the same transaction.
Pool A sends its full balance to recoveryAddress.
The final caller has no protocol role. The call sets mechanical bad-faith CORRUPTED and sets
claimsStarted = true. The moderator can no longer select SURVIVED, classify the event as
good-faith, or name the whitehat.
The issue is more realistic when one agreement has several pools. Pool A may be old and expired,
while Pool B is still active. A corruption inside Pool B's term is valid for Pool B, but the same
registry state also changes Pool A. In the PoC, Pool A sends 125e18 to recovery while Pool B pays
its correct 60e18 good-faith bounty.
The root cause has two parts:
claimExpired() uses the live registry state instead of a result fixed at the pool expiry.
The moderator grace deadline is fixed at expiry + 180 days, even when CORRUPTED did not exist
during that period.
The late terminal observation is also capped to expiry. As a result, the contract records a
post-term corruption as if the terminal event happened at the old pool expiry.
DESIGN.md documents several related choices:
Section 2 says that a pool which expires during active risk has survived its full term and should
resolve as EXPIRED.
Section 6 accepts a scope-blind auto-CORRUPTED backstop when the moderator is unavailable for the
full grace period.
Section 13 says that claimExpired() uses the live registry state at the first post-expiry call.
This report does not dispute the general backstop. The problem is the time link between these rules.
The moderator cannot classify a CORRUPTED result before the registry is CORRUPTED. If that state
appears after the expiry-based deadline, the moderator gets zero seconds to act.
The documentation also says that delayed observation should only change bonus distribution and
should not cause principal loss or third-party loss. The PoC shows a different result: all principal
and bonus move to recoveryAddress.
The old pool can lose its full live balance. The PoC moves 125e18 to recoveryAddress and the
old staker receives zero.
The roleless finalizer can close the moderator correction window in the same timestamp as the
corruption.
A good-faith whitehat can lose the full bounty because the moderator cannot name the whitehat
before claimsStarted becomes true.
One late agreement corruption can affect several old unresolved pools that share the agreement.
The finalizer does not receive the funds. This report does not claim direct profit for that caller.
The proven impact is full staker loss, value sent to the sponsor-selected recoveryAddress, and
loss of the good-faith bounty.
The pool must stay unresolved for more than 180 days after expiry.
The agreement must remain in active risk and become CORRUPTED after that deadline.
Once this state exists, exploitation is simple and permissionless. The finalizer needs no admin,
moderator, DAO, or sponsor role.
For these reasons, I rate the finding as Medium severity: High Impact and Low Likelihood.
The PoC runs on a BattleChain testnet fork at block 16000. It deploys the exact audited
ConfidencePool and ConfidencePoolFactory code. It also uses the same UUPS proxy and clone path as
the project.
The test connects this code to the live SafeHarborRegistry, AttackRegistry, and demo Agreement.
The Agreement is already UNDER_ATTACK at the fork block. Its real attack moderator calls the real
markCorrupted() function as a normal setup actor. A different contract with no protocol role calls
the vulnerable pool functions.
The stake token is a standard ERC20. The PoC does not use vm.store, vm.etch, a fake registry, or
a fake agreement. vm.warp only moves time forward.
The four tests prove:
Late corruption immediately sends the old pool to recovery.
Resolving at expiry protects the staker against the same later corruption.
The permissionless finalizer removes the moderator's good-faith bounty window.
A corruption inside a new pool term also changes an older expired pool.
PoC file:
Full PoC:
Set the BattleChain RPC variable. Without this variable, the fork test is skipped.
The positive and negative tests use the same funded pool and the same registry lifecycle. The only
important difference is when the old pool is first resolved:
Manual code review
Foundry
BattleChain testnet fork
Live Safe Harbor registry contracts
The safest fix is to use the real time when the agreement became CORRUPTED.
The registry should store and expose a timestamp such as corruptedAt. Then claimExpired() should
only use automatic CORRUPTED when the corruption happened during the pool term:
This keeps the current backstop for a corruption that happened inside the pool term. It also stops a
future corruption from changing an old pool.
If the registry cannot provide a trusted corruption timestamp, the pool cannot safely prove that a
late CORRUPTED state belongs to its term. In that case, the contract should not automatically apply
that state to an expired pool. It should keep the staker-safe EXPIRED result or require a moderator
decision for the late case.
A local corruptedObservedAt value can give the moderator a real response period, but it does not
prove when the corruption happened. If this extra timestamp is added, the contract must save it in a
successful transaction. It must not write the value and then revert, because the revert would remove
the timestamp.
The risk window in this PoC starts normally before expiry. The issue does not depend on a
post-expiry pokeRiskWindow() call.
The real attack moderator is only a setup actor. The harmful finalizer has no protocol role.
The finalizer receives no tokens. The proven impact is the loss of the old staker funds and the
whitehat bounty.
The pool must stay unresolved for more than 180 days, so Medium is more accurate than High.
The report covers one root cause: lazy expiry resolution against a future registry state combined
with an expiry-based moderator deadline.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.