Skip to content

feat: verifiable downtime evidence with optimistic challenges and consumer pausing - #63

Open
giunatale wants to merge 12 commits into
mainfrom
giunatale/feat/offline-detection
Open

feat: verifiable downtime evidence with optimistic challenges and consumer pausing#63
giunatale wants to merge 12 commits into
mainfrom
giunatale/feat/offline-detection

Conversation

@giunatale

Copy link
Copy Markdown
Contributor

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.

  • Consumers track missed blocks over tumbling windows (x/slashing downtime
    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
  • The provider verifies and prices the infraction (validator's epoch fee
    share x missed fraction, converted via photon), then queues the slash
    behind a challenge window instead of executing it. DowntimeSlashFraction
    acts as a per-window ceiling (default 0.0001), repeated
    windows queue independently and can compound
  • MsgChallengeConsumerDowntime lets anyone cancel a validator's pending
    slashes 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, resumable
    by governance (MsgResumeConsumer, with a forced snapshot resync), and
    auto-stopped after MaxPauseDuration.
  • Inbound VSC packets are now authenticated by source port and a pinned
    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.

giunatale added 12 commits July 16, 2026 20:32
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.

@julienrbrt julienrbrt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some tiny nits i'll share, but amazing work! so ACK

@tbruyelle tbruyelle left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/consumer-downtime.md
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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).
  3. register the counterparty (at this point, everything is legit)
  4. send a VSC packet from the attacker chain, using a valset_update_id large to exceeds the existing one (e.g. 999999)
  5. 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.

// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are three other places where paused consumer chains are excluded:

  1. UpdateConsumer : maybe it's intended, maybe it's not ?
  2. 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?
  3. AssignConsumerKey (key_assignment.go:74): new keys to the paused consumer are rejected

}

lightClientModule := ibctmtypes.NewLightClientModule(k.cdc, k.clientKeeper.GetStoreProvider())
return lightClientModule.VerifyClientMessage(ctx, clientId, header)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


current := k.GetConsumerParams(ctx)
if current.SignedBlocksWindow == p.SignedBlocksWindow && current.MinSignedPerWindow.Equal(p.MinSignedPerWindow) {
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This return prevents a revert of the downtime params if the revert happens in the same window of the initial set.

  1. Provider params A to B: a VSC packet carries B; consumer stages B (window still running under A).
  2. 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.
  3. closeWindow: applyStagedDowntimeParams writes 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
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: handle offline validators on the consumer chain side and punish them on provider

3 participants