Skip to content

fix: let MsgRemoveConsumer erase a pre-launch consumer and release its chain id - #78

Open
giunatale wants to merge 5 commits into
giunatale/feat/offline-detectionfrom
giunatale/fix/consumer-retirement
Open

fix: let MsgRemoveConsumer erase a pre-launch consumer and release its chain id#78
giunatale wants to merge 5 commits into
giunatale/feat/offline-detectionfrom
giunatale/fix/consumer-retirement

Conversation

@giunatale

@giunatale giunatale commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Closes two gaps in the consumer lifecycle: a consumer that never launched could
not be terminated, and a chain id was reserved forever by the first transaction
that used it.

What was wrong

MsgRemoveConsumer requires LAUNCHED or PAUSED, MsgUpdateConsumer is
owner-only with no authority path, and the chain-id uniqueness check iterates
every registered chain id with no phase filter — while DeleteConsumerChain
deliberately kept the chain-id entry and the function that would remove it had no
caller. Since consumer creation is permissionless with no deposit, that means:
one transaction permanently reserves any chain-id string with no governance
remedy; an owner who loses their key before launch strands a consumer in
REGISTERED forever with state nothing can prune; and a consumer removed by the
liveness sweep can never re-register under its own chain id.

What this changes

MsgRemoveConsumer now covers the whole lifecycle, with an effect that
depends on the consumer's phase. Pre-launch (REGISTERED or INITIALIZED) it
erases the consumer immediately: no validator ever validated the chain, so no
unbonding delay applies, and either the consumer's owner or the governance
authority may sign — which covers the lost-key case. Launched or paused it
keeps today's governance-only stop with erasure deferred by the unbonding
period. The message's authority field becomes signer, since it no longer
always holds the governance account, and the remove event carries the phase at
removal so indexers can tell the immediate arm from the deferred one. The
pre-launch arm reuses the full deletion teardown rather than reimplementing a
partial one, and additionally drops the launch-queue entry an INITIALIZED
consumer holds. A funded fee pool is swept to its depositors pro rata on the
way out, with truncation dust to the community pool, so removing a pre-launch
consumer never strands deposits. The remove-consumer CLI command submits the
message directly; the post-launch arm remains a governance proposal.

Chain ids are released at deletion, immediately before the phase becomes
DELETED, on both routes into it. That point is deliberate: the client mapping is
already gone by then and the provider authenticates inbound packets by
destination client, evidence and fee paths all require LAUNCHED, and nothing
anywhere reverse-maps a chain id to a consumer — so a later registration of the
same chain id cannot inherit anything. Releasing at STOPPED would not have been
safe, because the client mapping still exists there and a look-alike could get
the old consumer's still-pinned client adopted by chain-id matching.

Releasing the chain id turned out not to be self-contained: the DELETED
tombstone depends on the chain-id row, and dropping it naively would have made
provider genesis export panic after the first deletion, made import panic
on a legitimately exported genesis, and broken the main consumer-list query
entirely. All four sites are fixed, and genesis now requires a deleted consumer's
chain id to be empty so the release survives an export/restart. A latent panic in
the client-id deletion path for a consumer that never adopted a client — hidden
because every existing test set one first — is fixed too.

Testing

Owner erases REGISTERED and INITIALIZED; governance erases a lost-key consumer;
launched and paused reject the owner and stop under governance with the chain id
still reserved; STOPPED and DELETED are rejected for both signer arms; a
stranger is rejected and state is untouched; a funded pool is returned to
depositors; the chain id is re-registrable afterwards and is not released
while the consumer is non-terminal; and the stop-then-remove route is seeded
with fully lived state so the teardown assertion is non-vacuous.
Mutation-checked: removing the chain-id release fails four tests, keeping the
launch-queue entry fails the INITIALIZED case.

@giunatale
giunatale changed the base branch from main to giunatale/feat/offline-detection July 31, 2026 18:56
@giunatale

Copy link
Copy Markdown
Contributor Author

Branched from giunatale/feat/offline-detection (#63).
Opened against that branch so only this PR's commit shows; will retarget to main after #63 lands and this rebases.

Comment thread proto/vaas/provider/v1/tx.proto Outdated
// signs to abandon a chain it no longer intends to launch; governance signs
// when the owner key is lost, which would otherwise pin the consumer -- and its
// chain id -- in place forever.
message MsgRetireConsumer {

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 understand the reason of this PR but I do not like the new message. Why cannot we simply tweak MsgRemoveConsumer instead?

@giunatale giunatale Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Folded in b15867f. MsgRemoveConsumer is now the one removal message: pre-launch it erases immediately under the owner-or-gov admission (fee pool refunded, chain id released), launched or paused it keeps the gov-only stop with deferred erasure, and the event carries the phase at removal to tell the arms apart. The authority field became signer, retire-consumer's CLI became remove-consumer, and #76 and #67 were updated accordingly.

@giunatale
giunatale force-pushed the giunatale/fix/consumer-retirement branch from c8dbbcb to 58b4ae9 Compare August 26, 2026 14:39
giunatale added a commit that referenced this pull request Aug 26, 2026
…umer

PR #78 now removes pre-launch consumers through MsgRemoveConsumer and
emits the remove event on both arms, so vaas_retire_consumer has no
user anywhere.
giunatale added a commit that referenced this pull request Aug 26, 2026
…umer

PR #78 now removes pre-launch consumers through MsgRemoveConsumer and
emits the remove event on both arms, so vaas_retire_consumer has no
user anywhere.
giunatale added a commit that referenced this pull request Aug 26, 2026
PR #78 merged MsgRetireConsumer into MsgRemoveConsumer per review: one
message whose effect depends on the consumer's phase. The lifecycle,
runbook, fee-pool, events, and queries pages now describe that shape,
and the remove event documents its new consumer_phase attribute.
giunatale added a commit that referenced this pull request Aug 26, 2026
PR #78 merged MsgRetireConsumer into MsgRemoveConsumer per review: one
message whose effect depends on the consumer's phase. The lifecycle,
runbook, fee-pool, events, and queries pages now describe that shape,
and the remove event documents its new consumer_phase attribute.
@giunatale giunatale changed the title fix: let a pre-launch consumer be retired and release its chain id fix: let MsgRemoveConsumer erase a pre-launch consumer and release its chain id Aug 26, 2026
// punished for is already outside the slashable window. Keeping the id past
// this point would reserve that chain id for good, since DELETED is
// terminal.
k.DeleteConsumerChainId(ctx, consumerId)

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.

Unless I am wrong this makes BeginBlockRemoveConsumers delete as well the chainID of a previously launched chain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It does, deliberately: releasing at deletion on both routes is half the point of the PR. Without it a consumer removed by the liveness sweep can never re-register its own chain id, since DELETED is terminal. The safety argument is in the comment at this line: by deletion time the client mapping is already removed, so no inbound packet can be attributed to the chain (the provider resolves packets by destination client), evidence, downtime and fee distribution all require LAUNCHED, and on this route a full unbonding period has passed since STOPPED, so nothing is still slashable. Releasing earlier, at STOPPED, would be unsafe precisely because the client mapping still exists there. If you see a concrete path this rationale misses, that is exactly what I want to know.

phase := k.GetConsumerPhase(ctx, consumerId)
if phase != types.CONSUMER_PHASE_STOPPED {
return fmt.Errorf("cannot delete non-stopped chain: %d", consumerId)
if phase != types.CONSUMER_PHASE_STOPPED && !k.IsConsumerPrelaunched(ctx, consumerId) {

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It does not error there: the condition is a conjunction, phase != STOPPED && !IsConsumerPrelaunched, so a STOPPED consumer fails the first clause and passes the gate. BeginBlockRemoveConsumers only dequeues consumers that StopAndPrepareForConsumerRemoval put in STOPPED, and TestBeginBlockRemoveConsumers_DeletesEligible drives exactly that route through the real queue. That said, the negated composition invited the misread, so 5ff01bc names the three deletable phases positively, the same readability fix the UpdateConsumer gate got on #63.

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.

yeah, well it feels weird to even call it then.

@giunatale giunatale Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deliberate: the callers' checks are semantic gates (retire is prelaunch-only, the removal queue holds STOPPED by construction), while this one is the teardown's own contract. DeleteConsumerChain is the destructive, irreversible half (pool sweep, state erasure, chain-id release), so it enforces the union of legal entry phases itself rather than trusting caller discipline, and a future third caller cannot erase a launched consumer by accident. That is the same keeper-level-guard posture the rest of the module takes. I added a sentence at the check saying exactly this (0f5cfb9).

giunatale added a commit that referenced this pull request Aug 27, 2026
PR #78 merged MsgRetireConsumer into MsgRemoveConsumer per review: one
message whose effect depends on the consumer's phase. The lifecycle,
runbook, fee-pool, events, and queries pages now describe that shape,
and the remove event documents its new consumer_phase attribute.
giunatale added a commit that referenced this pull request Aug 27, 2026
…umer

PR #78 now removes pre-launch consumers through MsgRemoveConsumer and
emits the remove event on both arms, so vaas_retire_consumer has no
user anywhere.
Creating a consumer is permissionless and, until now, nothing could undo it
before launch: MsgRemoveConsumer accepts only a launched or paused consumer,
MsgUpdateConsumer is owner-only, and DeleteConsumerChain deliberately kept the
chain-id entry that ChainIdInUse consults with no phase filter. So a single
transaction reserved any chain-id string for good, a consumer whose owner key
was lost sat in the registered phase forever with state nobody could prune, and
a consumer the liveness sweep removed could never re-register under its own
chain id.

MsgRetireConsumer erases a consumer that is still registered or initialized.
Its signer may be the consumer owner or the gov authority, the same
owner-or-gov admission MsgFundConsumerFeePool and MsgWithdrawConsumerFeePool
use: the owner arm lets whoever registered a chain abandon it, and the gov arm
is the remedy when the owner key is lost. MsgRemoveConsumer keeps its gov-only,
launched-or-paused scope, because a live chain must stop and wait out the
unbonding period before its state can go; a chain no validator ever validated
has nothing to unbond, so retirement runs the same teardown immediately. It
reuses DeleteConsumerChain instead of repeating part of it, so a funded fee
pool is still paid back to its depositors pro rata, and it drops the
spawn-time queue entry an initialized consumer holds.

DeleteConsumerChain now also releases the chain id, on both routes into it.
That point is safe and an earlier one is not: the client mapping is removed in
the same call, so no inbound packet can be attributed to the consumer any more
(the provider resolves packets by destination client), and evidence, downtime
accusations and fee distribution all require the launched phase; on the
stop-then-remove route a full unbonding period has additionally passed since
the stop, so nothing the chain did remains punishable. The tombstone still
keeps owner, metadata, initialization parameters and phase so a removed
consumer stays visible to explorers, and genesis now requires a deleted
consumer's chain id to be empty, which is what makes the release survive a
state-export restart.

Two assumptions the pre-launch path broke are fixed alongside:
DeleteConsumerClientId panicked on a consumer that never had a client adopted,
and the genesis export/import path plus the consumer-chain queries assumed
every consumer record carries a chain id.
Fee-pool funding is permissionless and withdrawal is depositor-gated
with a gov-only arm while the consumer is launched or paused; neither
uses the owner-or-gov admission this handler does.
Per review, one message removes a consumer whatever its phase, instead
of a separate MsgRetireConsumer. A consumer that has not launched is
erased immediately by RetireConsumerChain under the owner-or-gov
admission, its fee pool refunded and its chain id released; a launched
or paused consumer keeps today's gov-only stop with erasure deferred by
the unbonding period; stopped and deleted are rejected. The message's
authority field becomes signer, since it no longer always holds the gov
account, and the one event carries the phase at removal so indexers can
tell the immediate arm from the deferred one. The retire-consumer CLI
command becomes remove-consumer.
The stopped-or-prelaunched admission read as a negated composition that
review found easy to misparse; the three phases it admits are now named
positively, same as the UpdateConsumer gate.
@giunatale
giunatale force-pushed the giunatale/fix/consumer-retirement branch from 84edb8f to 5ff01bc Compare August 28, 2026 11:18
The review stumbled here twice: the callers gate their own phases, and
this check is the teardown's, independent of caller discipline.
@giunatale
giunatale force-pushed the giunatale/fix/consumer-retirement branch from 23a41ea to 0f5cfb9 Compare August 31, 2026 14:51
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.

2 participants