feat: verifiable downtime evidence with optimistic challenges and consumer pausing - #63
feat: verifiable downtime evidence with optimistic challenges and consumer pausing#63giunatale wants to merge 13 commits into
Conversation
app.NewContext(true) builds a context from an empty header, so the export context reported block height 0. x/distribution's CalculateDelegationRewards replays validator slash events between the delegation's creation height and the context height, so at height 0 it replayed none: for any validator slashed after its delegation was created, the recomputed final stake exceeded the current stake and the export panicked in prepForZeroHeightGenesis. Use NewContextLegacy with LastBlockHeight, matching upstream simapp.
…vidence packets the consumer stored packet.SourceClient (the provider's own client) as its ProviderClientID on first VSC recv, guarded by a "set once" check. that value is meaningless for the consumer's own outbound sends -- it needs packet.DestinationClient, its own client id, which is guaranteed by ibc-go's RecvPacket to already have a registered counterparty. this was invisible until now because nothing before the downtime evidence feature ever needed the consumer to send an IBC v2 packet back to the provider; the genesis-time self-created client (never linked to a counterparty by the relayer) was silently latched onto forever, so every evidence packet failed to send with "counterparty not found". discovery now resyncs on every accepted VSC packet instead of once, so a stale value from a placeholder client heals itself.
d6b755a to
be849ec
Compare
There was a problem hiding this comment.
I have some tiny nits i'll share, but amazing work! so ACK
GLM 5.2 review
PR Review: feat: verifiable downtime evidence with optimistic challenges and consumer pausing (#63)
Overall assessment: approve with minor comments. This is a high-quality, well-reasoned PR that implements a subtle consensus-critical feature with rare attention to soundness and edge cases. The design document is excellent, and the implementation traces it faithfully. I verified the build compiles and all new and existing tests pass.
Scope
12 commits, 100 files, +18986/−1846. The first two commits (d25a408, a48a7e0) are standalone bug fixes against main; the rest build the downtime pipeline. New Go code is concentrated in:
x/vaas/consumer/keeper/downtime.go— consumer-side tumbling-window detectionx/vaas/provider/keeper/downtime*.go— evidence validation, slash pricing/execution, challenge verification, pruningx/vaas/provider/keeper/fees.go— fee exclusion + pool-as-escrowx/vaas/provider/keeper/consumer_lifecycle.go— PAUSED phase + auto-stop/resumex/vaas/consumer/ibc_module.go,relay.go— source-port + chain-id pinning
Soundness of the core design
The asymmetry the PR builds around (downtime is disprovable, not provable; only chain-sealed signatures are unforgeable) is correctly translated into code:
- Challenge soundness (
verifySealedCommitSignature): thecommit.Hash() == header.Header.LastCommitHashcheck is the load-bearing seal. TheVoteSignBytes(chainID, int32(sigIdx))uses the array index of the matching signature, which is what cometbft expects. Accepting bothCommitandNilflags is correct — Nil still proves liveness. Pubkey self-authentication viaed25519.PubKey(pubKey).Address() == valAddrcorrectly decouples from key-assignment state. - Re-acceptance prevention: the
DowntimeWindowFloors+AcceptedDowntimeWindowsinteraction is sound. The floor advances monotonically to the max pruned window end (guarded bykey.K3() > floor), and the floor write precedes the record delete so a mid-prune failure cannot make a pruned window re-acceptable. Ancient windows hit the floor, live windows hit the retained records. - BeginBlock ordering: the explicit
TestBeginBlockOrdering_UnresponsiveStopCancelsPendingSlashBeforeSweepencodes the contract thatSweepUnresponsiveConsumers/BeginBlockAutoStopPausedConsumersrun beforeSweepPendingDowntimeSlashes. Same-block cancellation wins over execution. - Resume atomicity:
ResumeConsumerChainusessendVSCPacketsToChainStrict(propagates errors) rather than the EndBlock wrapper (swallows them), with a clear explanation of why a silent queue-only "success" would corrupt the consumer's validator set under out-of-order IBC v2 delivery.
What I verified
go vetclean on consumer and provider packagesgo test ./x/vaas/provider/keeper/— pass (93s)go test ./x/vaas/consumer/...— pass- Targeted run of all downtime/pause/challenge/resume/withheld-fee tests — pass
Minor comments / questions
These don't block merge but are worth considering.
-
Genesis import of
StagedDowntimeParamspanics on invalid input (x/vaas/consumer/keeper/genesis.go~L105–128). The comment justifies this ("Halt InitChain on unusable staged params"), andGenesisState.Validatealso rejects them, so an honest operator never hits it. But a state-export with a future schema change could halt InitChain. The defensive stance is defensible; flagging in case you prefer a log-and-skip. -
recordWithheldFee's expired-but-not-swept branch (fees.goL337–354). Whenexisting.ExpiresAtis in the past but the record hasn't been swept yet, the code overwritesamount(discardingexisting.Amount) and sets a fresh expiry. This is consistent with the doc ("an expired-but-not-yet-swept record is replaced outright"), but the expired funds are still in the pool — the consumer kept them when the record expired. Re-escrowing a fresh amount against funds that were never moved is fine, but the validator loses the previously-escrowed claim. Intended? Given fee exclusion is a side effect of accusation (not a right), probably acceptable, but worth confirming this matches the intent. -
liveEpochSharerecomputesnumBondedat pricing time (fees.goL141–151).DistributeConsumerFeesandliveEpochShareboth callGetBondedValidatorsByPower, but the consumer'snumBondedcan change between pricing (receipt of evidence in the current epoch) and distribution (epoch boundary). If a validator unbonds in between, the share recorded at distribution diverges from the share used to price. The doc acknowledgesPresolves "live" for current-epoch windows; just noting the small window of inconsistency is inherent. -
DowntimeEvidenceMaxAge + DowntimeChallengeWindow < trustingis checked againstDefaultConsumerUnbondingPeriodonly (ValidateInfractionParamsAgainst, params.go L207–220). The comment acknowledges this is "per-consumer deviations are operator guidance." Since the default is the only checked bound, a chain that configures longer consumer unbonding periods gets a stricter-than-needed constraint, and one with shorter is unprotected. The doc is honest about this; consider whether the operator guidance (section 9) is the right place vs. a runtime per-consumer check. -
E2e test genesis patch note (e2e_setup_test.go): the comment about
slash_fractiondeserializing from nil to zero is an important footgun. SinceInfractionParametersis "unmarshaled directly into InfractionParameters with no defaulting pass," any operator writing genesis by hand who omitsslash_fractionsilently gets zero-cap downtime slashes. Worth either (a) defaulting at genesis unmarshal time, or (b) a louder callout in the genesis documentation. The same applies toMinSignedPerWindow/SignedBlocksWindowat the consumer genesis. -
findPendingDowntimeSlashContainingiterates pending slashes linearly (downtime_challenge.goL156–175). Bounded by the number of pending windows for a single (consumer, validator) pair — typically 1–2. Fine in practice; flagging only because a validator with a large backlog (e.g. long network partition) would make each challenge O(n) in pending windows.
Non-issues I checked and confirmed safe
- The
BitmapSetbounds in the consumer'sTrackMissedBlocksare safe: stale bitmaps are padded to(window+7)/8before indexing. MaxMissedformulaW − ceil(M·W)matches the doc and is used identically on consumer (close) and provider (threshold check).SlashTokensunits work out:P(fee tokens)· M / C(photons/bond_token) → bond tokens;fraction = slashTokens / totalTokensis dimensionless and capped bySlashFraction ∈ [0,1].- Chain-id pinning is pre-seeded at genesis from the trusted provider client state, closing the "first packet teaches the pin" window.
PendingDowntimeSlasheskeyed by(consumer, validator, window_end_height)correctly coexists with multiple windows per pair; deletion-on-last-execute correctly leaves theWithheldFeeRecordalive while any window is still pending.
Recommendation
Approve. The design is thoughtful, the implementation is careful and matches the doc, tests cover positive paths, negative controls, ordering contracts, and the full queue-then-execute lifecycle. The six minor items above are worth a follow-up issue or a quick reply, none are blockers.
tbruyelle
left a comment
There was a problem hiding this comment.
Incredible work, the design is very solid.
Me and my friend claude just found a couple of bugs that needs to be fixed IMO, see the comments.
| existing misbehaviour machinery. | ||
| - An attacker chain reusing the provider's exact chain-id string against the pinning check: | ||
| distinguishing it requires a forged light-client history, which again lands in the | ||
| misbehaviour machinery's domain. |
There was a problem hiding this comment.
I've discovered later that the issue raised in this comment has been addressed in #65. I keep the comment because I spent too much time on it and it would be painful for me to remove it xD. In addition the issue still exists right after launch, as mentioned in the Note2.
I have doubts about this point being "out of scope". Indeed it looks quite easy to override a provider client id, there is no need to forge a light-client history, you can just create a legit one:
- run a one-validator chain with a chain-id equivalent to the provider chain (e.g. `atomone-1). Let's call it the attacker chain.
- create a new IBC client on the consumer chain for the attacker chain. While the chain-id is identical to the provider chain, the consensus state comes from the attacker's valset (surprisingly IBC has no notion of chain-id uniqueness).
- register the counterparty (at this point, everything is legit)
- send a VSC packet from the attacker chain, using a
valset_update_idlarge to exceeds the existing one (e.g.999999) - the consumer chain accepts it, chain-id passes the check as it corresponds to the pinned one (
atomone-1),SetProviderClientID()is invoked with the client id of the attacker chain, overriding the legit one from the provider chain.
Afterwards SendEvidencePackets every downtime packets to the attacker chain (so they can be silently ignored: the real purpose of this attack), and the real provider's next VSC also fails because the valset update id stays below the highest registered one (999999).
A naive fix could be to simply reject the VSC packet when a client id is already registered and is different from the provided one:
// in OnRecvVSCPacketV2, once a provider client is established
if current, found := k.GetProviderClientID(ctx); found && current != consumerClientID {
return errorsmod.Wrapf(types.ErrInvalidProviderClient,
"packet arrived over client %s, expected established provider client %s",
consumerClientID, current)
}But this fix prevents the consumer chain from restoring an expired client. So let's just add a condition to the client status, the consumer chain rejects the override of an existing client id if the related client is still active:
current, found := k.GetProviderClientID(ctx)
switch {
case !found:
k.SetProviderClientID(ctx, consumerClientID)
case current != consumerClientID:
_, hasCounterparty := k.clientV2Keeper.GetClientCounterparty(ctx, current)
if hasCounterparty && k.clientKeeper.GetClientStatus(ctx, current) == ibcexported.Active {
return errorsmod.Wrapf(types.ErrInvalidProviderClient,
"packet arrived over client %s but %s is already established and active",
consumerClientID, current)
}
k.SetProviderClientID(ctx, consumerClientID)
}Note1: before this PR, this valset injection was even easier, so this PR makes it harder but there's still some vulns that need to be fixed.
Note2: this doesn't fully close the issue, two windows remain where the established client can still be overrided:
- an expired or frozen light client: though this needs the relayer to stop refreshing for a whole trusting peruid.
- right after launch: this is the most reachable one, the attacker only has to beat the relayer's first VSC delivery.
There was a problem hiding this comment.
Confirmed, and the attack works as written. I reproduced the reasoning against the code
rather than only the docs: authenticateProviderChainID was the entire gate and it compares
the chain-id string, so a chain reporting atomone-1 passes it while carrying a different
valset, and SetProviderClientID then overwrote the pin unconditionally. The consequence you
name is the serious half. Evidence packets get addressed to the attacker's client and
disappear, and the large valset_update_id in the same packet strands the real provider
below the dedup watermark permanently. Both fail silently.
Fixed here rather than left to #65, so main is never in this state. I used a slightly
different discriminator than your patch: a registered IBC v2 counterparty instead of
client status.
pinned, found := k.GetProviderClientID(ctx)
if !found {
// both genesis paths pin, so this is malformed genesis or corrupted state
return errorsmod.Wrapf(types.ErrInvalidProviderClient, ...)
}
if pinned == consumerClientID {
return nil
}
if _, hasCounterparty := k.clientV2Keeper.GetClientCounterparty(ctx, pinned); hasCounterparty {
return errorsmod.Wrapf(types.ErrInvalidProviderClient, ...)
}
k.SetProviderClientID(ctx, consumerClientID) // the unroutable genesis pin, replaced onceThe reason is your own Note2 window (a). Counterparties cannot be unregistered, so a pin that
has one is routable for good and neither expiry nor a freeze reopens the override. A
status check would reopen it, since it lets a client that has gone non-Active be replaced.
The tradeoff is that recovering an expired pin becomes a governance MsgRecoverClient, which
substitutes fresh client state under the same client id and leaves the pin intact; that is
the intended and only re-key path. It also happens to be the mechanism #65 already carries,
so the two converge instead of competing.
Your Note2 window (b), the launch race, is real and survives this. The genesis client has no
counterparty, so the first routable client to deliver wins the pin. It is being handled in
#65, where the consumer can content-check the delivering client's consensus state against the
provider valset it already holds in genesis (ProviderInfo.InitialValSet) instead of
trusting whoever arrives first.
Two things fell out of this that are worth flagging:
- The threat-model section you commented on listed this under Out of scope, claiming it
"requires a forged light-client history". That was wrong on both counts and is now
corrected to describe the attack and what stops it. - There was a test asserting the vulnerable behaviour as a requirement:
TestOnRecvVSCPacketV2SameChainIdHealsClient, with"a same-chain-id client replacement must still be accepted". Removed, and its one still-valid claim (the chain-id pin survives
a rejection) moved into the new override test.
Three tests cover it now: a routable pin cannot be overridden, an unroutable genesis pin is
adopted once, and no pin at all is rejected rather than established by the packet.
| // downtime slash and this epoch's downtime marks for the consumer are | ||
| // cancelled via CancelConsumerDowntimeState. A paused consumer is excluded from VSC packet | ||
| // queuing (QueueVSCPackets iterates GetAllLaunchedConsumerIds), fee | ||
| // distribution, and evidence handling -- all of which require phase LAUNCHED. |
There was a problem hiding this comment.
There are three other places where paused consumer chains are excluded:
UpdateConsumer: maybe it's intended, maybe it's not ?ValidatorConsensusKeyInUse(key_assignement.go:235): this one is more annoying because a paused consumer's assigned keys become invisible to the collision guard. Can potientially be exploited?AssignConsumerKey(key_assignment.go:74): new keys to the paused consumer are rejected
There was a problem hiding this comment.
All three are real. They split into one safety bug and two policy calls. I checked each
against IsConsumerActive, which was {REGISTERED, INITIALIZED, LAUNCHED}, so PAUSED was
invisible to every caller.
2. ValidatorConsensusKeyInUse: yes, exploitable, and it was the worst of the four. The
guard runs from the AfterValidatorCreated staking hook and iterates
GetAllActiveConsumerIds, which skipped PAUSED. So while a consumer is paused, a new provider
validator can be created with a consensus key that already serves as some other validator's
assigned key on that consumer. A pause does not release assignments, since they are what the
resume snapshot is rebuilt from, so on resume ResumeConsumerChain queues an immediate
snapshot carrying two validators at one consensus address, and CreateConsumerValidators
on this branch has no dedup. That halts the consumer.
Fixed by making PAUSED part of IsConsumerActive, since the predicate means "still exists and
holds state" and a paused consumer does.
3. AssignConsumerKey: agreed, it should be allowed. A pause runs up to
MaxPauseDuration and resolves on someone else's governance decision, so a validator that
needs to replace a compromised consumer key should not have to wait for that. It follows from
the IsConsumerActive change.
That one had a consequence you didn't mention and I nearly missed. AssignConsumerKey
branches on phase == LAUNCHED to decide whether to schedule the old consumer address for
pruning or delete it outright. A paused consumer would have taken the delete branch, and a
paused consumer is precisely the one with downtime state in flight, since a pause is entered
by a successful challenge. Both the challenge lookup and the re-submission defence resolve an
accused consumer address through that mapping, so deleting it would strand them. That branch
now treats PAUSED like LAUNCHED.
1. UpdateConsumer: intended, and now explicit instead of incidental. An update can
rewrite the infraction parameters and initialization parameters that the challenge causing
the pause was judged under, and the resume path replays state built from them. Governance
resumes or removes first, then updates. It no longer relies on IsConsumerActive to express
that: there is an explicit PAUSED check with the reason, plus a test pinning the asymmetry so
nobody "tidies" it back into symmetry with key assignment later.
Not changed: fees.go also iterates GetAllActiveConsumerIds, but it immediately filters to
LAUNCHED, so it was and remains LAUNCHED-only.
| } | ||
|
|
||
| lightClientModule := ibctmtypes.NewLightClientModule(k.cdc, k.clientKeeper.GetStoreProvider()) | ||
| return lightClientModule.VerifyClientMessage(ctx, clientId, header) |
There was a problem hiding this comment.
verifyClientMessage() doesnt check the client status. It incidentally returns an error for an expired client because the trusting period is checked, but returns no error for a frozen client.
Given that a client becomes frozen when there is equivocations, we cannot rely on the return value of VerifyClientMessage() to approve the header in that case. I suggest we gate this call to active clients only.
There was a problem hiding this comment.
Confirmed. verifyDowntimeChallengeHeader called lightClientModule.VerifyClientMessage
with no status check, and your reading of why is right: that path validates the header against
the trusted state, trust level and trusting period, so expiry surfaces only incidentally
through the trusting-period check and a freeze does not surface at all. Frozen is exactly the
state a client enters once the chain behind it is proven to have equivocated, and on this
branch nothing stops a consumer whose client is frozen, so its headers could keep cancelling
slashes and pausing it.
Gated on Active, with every other status failing closed rather than enumerating the bad
ones. Placed at the top of verifyDowntimeChallengeHeader rather than in
HandleChallengeConsumerDowntime on purpose: the gate belongs next to the call it is
compensating for, and there it cannot be bypassed by a second caller later. It also sits
deliberately outside the OverrideVerifyDowntimeChallengeHeaderForTest seam, because that
seam exists so unit tests need not fabricate a client store for header verification, not so
they can skip the status check. The gate is therefore enforced on every path, tests included.
Covered for Frozen, Expired and Unknown, each asserting the pending slash survives and the
consumer stays LAUNCHED.
|
|
||
| current := k.GetConsumerParams(ctx) | ||
| if current.SignedBlocksWindow == p.SignedBlocksWindow && current.MinSignedPerWindow.Equal(p.MinSignedPerWindow) { | ||
| return |
There was a problem hiding this comment.
This return prevents a revert of the downtime params if the revert happens in the same window of the initial set.
- Provider params A to B: a VSC packet carries B; consumer stages B (window still running under A).
- Provider reverts B to A before the window closes. Another VSC packet carries A, but it's not staged since it's the same as the window.
closeWindow:applyStagedDowntimeParamswrites B. Consumer now measures under B while the provider is on A.
What's missing is a remove of the potential staged downtime params in case the new ones are equal to window's ones.
if current.SignedBlocksWindow == p.SignedBlocksWindow && current.MinSignedPerWindow.Equal(p.MinSignedPerWindow) {
// The incoming params match what is already active, so any pending stage
// was reverted before it took effect -- drop it rather than let the next
// window boundary activate a value the provider no longer uses.
if err := k.StagedDowntimeParams.Remove(ctx); err != nil {
panic(err)
}
return
}There was a problem hiding this comment.
Confirmed, and the sequence reproduces exactly as you wrote it. StageDowntimeParams
compares the incoming params against the active ones, so a revert arriving before the window
closed looked like a no-op and returned without touching the stage, leaving the abandoned
value to activate at the boundary.
The consequence is the one this whole design cannot absorb: consumer and provider then
disagree on the parameters for the same window. The consumer closes it computing MaxMissed
from B while the provider checks the threshold echoed on the packet against A, so the same
window is measured one way and priced another.
Fixed essentially as you proposed. The equal-params branch now removes any pending stage
before returning, with a comment on why the branch is not simply a no-op. The test stages a
change mid-window, reverts it, closes the window, and asserts the consumer is still on the
original params; it fails without the Remove.
…atus, and fix paused-consumer and staged-param holes Four defects raised in review, each with a test that fails without the fix. The consumer's provider client could be replaced by any client tracking the same chain id. IBC attaches no meaning to chain-id uniqueness, so an attacker chain reporting the provider's chain id passed the only check there was and took over the pin -- redirecting every downtime evidence packet to a chain that drops them, and stranding the real provider below the dedup watermark with a large valset_update_id. The established client is now permanent: once the pin has a registered IBC v2 counterparty, packets over any other client are rejected. A pin without one is the genesis client nothing can be delivered over, and the first client that does deliver replaces it. Recovery from an expired or frozen pin is a governance MsgRecoverClient under the same client id. Downtime challenges were verified through the light client's VerifyClientMessage, which checks the trusted state but not the client's status: an expired client failed only incidentally via the trusting period, and a frozen one not at all. Frozen is what a client becomes once its chain is proven to have equivocated, so its headers now cannot cancel a slash or pause a consumer. Paused consumers were invisible to the consensus-key collision guard, so a new validator could take a key already assigned on a paused consumer and the resume snapshot would carry one consensus address twice. PAUSED now counts as active, which also opens key assignment during a pause -- replacing a key there prunes the old consumer address instead of deleting it, since a paused consumer is the one with downtime state in flight. MsgUpdateConsumer stays refused: it can rewrite the parameters the challenge was judged under. Staged downtime params leaked a reverted change. Staging compares against the active params, so a revert arriving before the window closed read as a no-op and left the abandoned value to activate at the boundary, leaving the consumer measuring windows the provider prices differently. A revert now drops the stage. Also from review: a zero double_sign slash fraction is rejected, since zero passes the shared fraction check and silently removes the entire penalty for equivocation, while zero downtime slashing stays valid as a jail-only policy.
|
@julienrbrt answering the six minor items from your review, since they are in the review body 1 · Genesis panics on invalid 2 · 3 · 4 · 5 · I did not take the "default at genesis unmarshal time" option. Substituting a value the 6 · Linear scan in On the verification you ran, the same three are clean here, along with |
Closes #38
Downtime on a consumer chain is unprovable on the provider, but it is disprovable: a single validator signature for a claimed-missed height, sealed under a light-client-verified header, indicts the evidence source.
This PR builds the downtime pipeline around that asymmetry.
handling becomes log-only) and report a per-window bitmap to the provider.
Window parameters are provider-owned and distributed via consumer genesis
and VSC packets, with staged activation
share x missed fraction, converted via photon), then queues the slash
behind a challenge window instead of executing it.
DowntimeSlashFractionacts as a per-window ceiling (default 0.0001), repeated
windows queue independently and can compound
MsgChallengeConsumerDowntimelets anyone cancel a validator's pendingslashes by proving a claimed-missed block was actually signed. A
successful challenge refunds the withheld fee shares (escrowed in the
consumer fee pool for the window's duration) and moves the consumer to a
new
CONSUMER_PHASE_PAUSED: no VSC packets, fee accrual stopped, resumableby governance (
MsgResumeConsumer, with a forced snapshot resync), andauto-stopped after
MaxPauseDuration.provider chain id.
The first two commits are standalone fixes for 2 pre-existing bugs on main (export at a zero height panicked after any slash & the consumer stored the provider's client id instead of its own)
Full design and operational notes in
docs/consumer-downtime.md.