Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions proto/vaas/provider/v1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,26 @@ message MsgUpdateParams {

message MsgUpdateParamsResponse {}

// MsgRemoveConsumer defines the message used to remove (and stop) a consumer chain.
// If it passes, all the consumer chain's state is eventually removed from the provider chain.
// Only the governance authority can remove a consumer chain.
// MsgRemoveConsumer defines the message used to remove a consumer chain. Its
// effect depends on the consumer's phase.
//
// A consumer that has not launched yet (registered or initialized) is erased
// right away -- a chain no validator ever validated needs no unbonding delay --
// its fee pool is refunded to its depositors and its chain id is released for
// immediate reuse. Either the consumer owner or the governance authority may
// sign: the owner to abandon a chain it no longer intends to launch,
// governance when the owner key is lost, which would otherwise pin the
// consumer -- and its chain id -- in place forever.
//
// A launched or paused consumer is stopped, and its state is erased only once
// the unbonding period has elapsed. Only the governance authority may sign.
message MsgRemoveConsumer {
option (cosmos.msg.v1.signer) = "authority";
option (cosmos.msg.v1.signer) = "signer";

// the consumer id of the consumer chain to be stopped
// the consumer id of the consumer chain to be removed
uint64 consumer_id = 1;
// authority is the address of the governance account.
string authority = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"];
// signer is the consumer owner (pre-launch only) or the governance authority
string signer = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"];
}

// MsgRemoveConsumerResponse defines response type for MsgRemoveConsumer messages
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/e2e_consumer_liveness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func (s *IntegrationTestSuite) testLivenessRemoval() {
removeJSON := fmt.Sprintf(`{
"messages": [{
"@type": "/vaas.provider.v1.MsgRemoveConsumer",
"authority": %q,
"signer": %q,
"consumer_id": %q
}],
"metadata": "ipfs://test",
Expand Down
43 changes: 43 additions & 0 deletions x/vaas/provider/client/cli/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func GetTxCmd() *cobra.Command {
cmd.AddCommand(NewSubmitConsumerDoubleVotingCmd())
cmd.AddCommand(NewCreateConsumerCmd())
cmd.AddCommand(NewUpdateConsumerCmd())
cmd.AddCommand(NewRemoveConsumerCmd())
cmd.AddCommand(NewFundConsumerFeePoolCmd())
cmd.AddCommand(NewWithdrawConsumerFeePoolCmd())
cmd.AddCommand(NewSweepConsumerFeePoolCmd())
Expand Down Expand Up @@ -406,6 +407,48 @@ If one of the fields is missing, it will be set to its zero value.
return cmd
}

func NewRemoveConsumerCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "remove-consumer [consumer-id]",
Short: "Remove a consumer chain",
Long: strings.TrimSpace(
fmt.Sprintf(`Remove a consumer chain. The effect depends on the consumer's phase.

A consumer that has not launched (registered or initialized) is erased right
away, its fee-pool balance returns to its depositors, and its chain id frees
for reuse; the consumer owner may sign, or the governance authority when the
owner key is lost. A launched or paused consumer is stopped and erased after
the unbonding period, and only the governance authority may sign, so from the
CLI that path is a governance proposal rather than a direct transaction.

Example:
%s tx vaasprovider remove-consumer 0 --from mykey
`, version.AppName)),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
clientCtx, err := client.GetClientTxContext(cmd)
if err != nil {
return err
}
consumerId, err := parseConsumerIdArg(args[0])
if err != nil {
return err
}
msg := &types.MsgRemoveConsumer{
Signer: clientCtx.GetFromAddress().String(),
ConsumerId: consumerId,
}
if err := msg.ValidateBasic(); err != nil {
return err
}
return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg)
},
}
flags.AddTxFlagsToCmd(cmd)
_ = cmd.MarkFlagRequired(flags.FlagFrom)
return cmd
}

func NewFundConsumerFeePoolCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "fund-consumer-fee-pool [consumer-id] [amount]",
Expand Down
79 changes: 75 additions & 4 deletions x/vaas/provider/keeper/consumer_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -607,11 +607,68 @@ func (k Keeper) BeginBlockRemoveConsumers(ctx sdk.Context) error {
return nil
}

// RetireConsumerChain erases a consumer that has not launched, i.e. one still
// in the registered or initialized phase.
//
// Such a consumer has no counterpart to wind down: no IBC client was ever
// associated with it (that only happens once a consumer launches), no
// validator set was ever computed or sent, and evidence,
// downtime accusations and fee distribution all require phase LAUNCHED. No
// validator ever validated it, so there is nothing to keep slashable for an
// unbonding period either -- hence it does not go through STOPPED and the
// removal queue the way StopAndPrepareForConsumerRemoval does for a live
// chain, but straight to the shared teardown in DeleteConsumerChain.
//
// Two pieces of live state a pre-launch consumer can hold are worth naming:
// key assignments, which AssignConsumerKey accepts for registered and
// initialized consumers, and a funded fee pool, which anyone may deposit into
// before launch. DeleteConsumerChain clears the former and pays the latter
// back to its depositors.
func (k Keeper) RetireConsumerChain(ctx sdk.Context, consumerId uint64) error {
phase := k.GetConsumerPhase(ctx, consumerId)
if !k.IsConsumerPrelaunched(ctx, consumerId) {
return errorsmod.Wrapf(types.ErrInvalidPhase,
"cannot retire consumer %d: expected phase registered or initialized, got %s", consumerId, phase)
}

// An initialized consumer waits in the spawn-time queue: drop its entry so
// the queue cannot hand an erased consumer to BeginBlockLaunchConsumers.
// The phase and the entry are always written together (see
// InitializeConsumer plus PrepareConsumerForLaunch, and the same derivation
// at InitGenesis), so a missing entry means inconsistent state and is
// reported rather than ignored.
if phase == types.CONSUMER_PHASE_INITIALIZED {
initializationParameters, err := k.GetConsumerInitializationParameters(ctx, consumerId)
if err != nil {
return fmt.Errorf("getting initialization parameters, consumerId(%d): %w", consumerId, err)
}
if err := k.RemoveConsumerToBeLaunched(ctx, consumerId, initializationParameters.SpawnTime); err != nil {
return errorsmod.Wrapf(vaastypes.ErrInvalidConsumerState,
"cannot remove consumer %d from the launch queue: %s", consumerId, err.Error())
}
}

return k.DeleteConsumerChain(ctx, consumerId)
}

// DeleteConsumerChain cleans up the state of the given consumer chain.
//
// It accepts a consumer in either of the two positions from which erasure is
// final: STOPPED, reached via StopAndPrepareForConsumerRemoval once
// BeginBlockRemoveConsumers has waited out the unbonding delay, or a pre-launch
// phase, reached via RetireConsumerChain, which needs no such delay (see there).
func (k Keeper) DeleteConsumerChain(ctx sdk.Context, consumerId uint64) (err error) {
// The three deletable phases are named positively: STOPPED (the deferred
// route, its unbonding delay already served by BeginBlockRemoveConsumers)
// and the two pre-launch phases (the immediate route, see
// RetireConsumerChain). Both existing callers gate their phase before
// calling; this check is the teardown's own contract, independent of
// caller discipline, so no future caller can erase a launched consumer.
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 &&
phase != types.CONSUMER_PHASE_REGISTERED &&
phase != types.CONSUMER_PHASE_INITIALIZED {
return fmt.Errorf("cannot delete chain %d in phase %s", consumerId, phase)
}

// Auto-sweep the fee pool. This cannot fail under valid state; on state
Expand Down Expand Up @@ -671,8 +728,22 @@ func (k Keeper) DeleteConsumerChain(ctx sdk.Context, consumerId uint64) (err err
return fmt.Errorf("clearing downtime window floors for consumer %d: %w", consumerId, err)
}

// Note that we do not delete ConsumerIdToChainIdKey and ConsumerIdToPhase, as well
// as consumer metadata and initialization parameters.
// Release the chain id. The provider stores it to keep two consumers from
// claiming the same chain (ChainIdInUse, consulted by MsgCreateConsumer and
// MsgUpdateConsumer), and by this point nothing can name this consumer's
// chain any more: its client mapping has just been removed, so an inbound
// packet can no longer be attributed to it (the provider resolves packets
// by destination client, see OnRecvPacket), and evidence, downtime
// accusations and fee distribution all require phase LAUNCHED. On the
// stop-then-remove path a full unbonding period has additionally passed
// since the consumer was stopped, so any infraction it could still be
// 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.


// Note that we do not delete ConsumerIdToPhase, as well as consumer
// metadata, initialization parameters and owner address.
// This is to enable block explorers and front ends to show information of
// consumer chains that were removed without needing an archive node.

Expand Down
22 changes: 15 additions & 7 deletions x/vaas/provider/keeper/genesis.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ func (k Keeper) InitGenesis(ctx sdk.Context, genState *types.GenesisState) []abc
maxConsumerId = consumerId
}

k.SetConsumerChainId(ctx, consumerId, cs.ChainId)
// A deleted consumer carries no chain id (see DeleteConsumerChain):
// writing one back would re-reserve a chain id that the deletion
// released, so the release survives a state-export restart.
if cs.ChainId != "" {
k.SetConsumerChainId(ctx, consumerId, cs.ChainId)
}
k.SetConsumerPhase(ctx, consumerId, cs.Phase)

if cs.OwnerAddress != "" {
Expand Down Expand Up @@ -395,18 +400,21 @@ func (k Keeper) ExportGenesis(ctx sdk.Context) *types.GenesisState {
for _, consumerId := range allConsumerIds {
phase := k.GetConsumerPhase(ctx, consumerId)

chainId, err := k.GetConsumerChainId(ctx, consumerId)
if err != nil {
panic(fmt.Errorf("export: failed to read chain id for consumer %d: %w", consumerId, err))
}

cs := types.ConsumerState{
ConsumerId: consumerId,
ChainId: chainId,
Phase: phase,
PendingValsetChanges: k.GetPendingVSCPackets(ctx, consumerId),
}

// A deleted consumer has no chain id: the teardown released it so the
// chain id can be registered again (see DeleteConsumerChain). Every
// other phase must have one.
if chainId, err := k.GetConsumerChainId(ctx, consumerId); err == nil {
cs.ChainId = chainId
} else if !errors.Is(err, collections.ErrNotFound) {
panic(fmt.Errorf("export: failed to read chain id for consumer %d: %w", consumerId, err))
}

if owner, err := k.GetConsumerOwnerAddress(ctx, consumerId); err == nil {
cs.OwnerAddress = owner
} else if !errors.Is(err, collections.ErrNotFound) {
Expand Down
50 changes: 39 additions & 11 deletions x/vaas/provider/keeper/genesis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,13 @@ func TestExportGenesisIncludesNewFields(t *testing.T) {
require.NoError(t, pk.SetConsumerGenesis(ctx, 3, cg))
require.NoError(t, pk.SetConsumerRemovalTime(ctx, 3, removalTime))

// id 4 DELETED.
// id 4 DELETED: the teardown released its chain id (see
// DeleteConsumerChain), so the exported tombstone carries none.
pk.SetConsumerPhase(ctx, 4, providertypes.CONSUMER_PHASE_DELETED)
pk.SetConsumerOwnerAddress(ctx, 4, owner)
require.NoError(t, pk.SetConsumerMetadata(ctx, 4, metadata))
require.NoError(t, pk.SetConsumerInitializationParameters(ctx, 4, initParams))
pk.DeleteConsumerChainId(ctx, 4)

pk.SetParams(ctx, providertypes.DefaultParams())
pk.SetValidatorSetUpdateId(ctx, 1)
Expand All @@ -138,7 +140,8 @@ func TestExportGenesisIncludesNewFields(t *testing.T) {
byID[cs.ChainId] = cs
}

for _, id := range chainIDs {
// The deleted consumer no longer has a chain id, so it is keyed by "".
for _, id := range chainIDs[:4] {
cs, ok := byID[id]
require.True(t, ok, "consumer %s missing from export", id)
require.Equal(t, owner, cs.OwnerAddress, "owner missing on consumer %s", id)
Expand All @@ -150,7 +153,14 @@ func TestExportGenesisIncludesNewFields(t *testing.T) {
require.Equal(t, "07-tendermint-0", byID["consumer-gamma"].ClientId, "LAUNCHED must have client_id")
require.NotNil(t, byID["consumer-delta"].RemovalTime, "STOPPED must carry removal_time")
require.Equal(t, removalTime, *byID["consumer-delta"].RemovalTime)
require.Equal(t, providertypes.CONSUMER_PHASE_DELETED, byID["consumer-epsilon"].Phase)

deleted, ok := byID[""]
require.True(t, ok, "DELETED consumer missing from export")
require.Equal(t, uint64(4), deleted.ConsumerId)
require.Equal(t, providertypes.CONSUMER_PHASE_DELETED, deleted.Phase)
require.Equal(t, owner, deleted.OwnerAddress, "DELETED must keep its owner")
require.NotNil(t, deleted.Metadata, "DELETED must keep its metadata")
require.NotNil(t, deleted.InitParams, "DELETED must keep its init_params")

// LAUNCHED consumer carries the liveness clock (last-ack + resync counters).
require.NotNil(t, byID["consumer-gamma"].LastAckTime, "LAUNCHED must carry last_ack_time")
Expand Down Expand Up @@ -212,8 +222,11 @@ func TestInitGenesisRestoresPerConsumerStateAndDerivedQueues(t *testing.T) {
OwnerAddress: owner, Metadata: &md, InitParams: &ip,
ClientId: "07-tendermint-1", ConsumerGenesis: cg, RemovalTime: &removeAt,
},
// A deleted consumer is imported without a chain id: its teardown
// released it (see DeleteConsumerChain) and importing one back would
// reserve that chain id again.
{
ConsumerId: 4, ChainId: "consumer-epsilon", Phase: providertypes.CONSUMER_PHASE_DELETED,
ConsumerId: 4, Phase: providertypes.CONSUMER_PHASE_DELETED,
OwnerAddress: owner, Metadata: &md, InitParams: &ip,
},
},
Expand All @@ -231,11 +244,11 @@ func TestInitGenesisRestoresPerConsumerStateAndDerivedQueues(t *testing.T) {

// ConsumerStates are imported in order; InitGenesis allocates numeric ids
// starting at "0":
// "0" consumer-alpha (REGISTERED)
// "1" consumer-beta (INITIALIZED)
// "2" consumer-gamma (LAUNCHED)
// "3" consumer-delta (STOPPED)
// "4" → consumer-epsilon (DELETED)
// "0" -> consumer-alpha (REGISTERED)
// "1" -> consumer-beta (INITIALIZED)
// "2" -> consumer-gamma (LAUNCHED)
// "3" -> consumer-delta (STOPPED)
// "4" -> no chain id (DELETED)
idChain := []struct {
consumerId uint64
chainId string
Expand All @@ -244,10 +257,10 @@ func TestInitGenesisRestoresPerConsumerStateAndDerivedQueues(t *testing.T) {
{1, "consumer-beta"},
{2, "consumer-gamma"},
{3, "consumer-delta"},
{4, "consumer-epsilon"},
}

// Per-consumer fields: chain id, owner, metadata must be present on all five.
// Per-consumer fields: chain id, owner, metadata must be present on all
// four consumers that still hold a chain id.
for _, entry := range idChain {
gotChain, err := pk.GetConsumerChainId(ctx, entry.consumerId)
require.NoError(t, err, "chain id missing for consumer %d", entry.consumerId)
Expand All @@ -262,6 +275,16 @@ func TestInitGenesisRestoresPerConsumerStateAndDerivedQueues(t *testing.T) {
require.Equal(t, md, gotMd)
}

// The deleted consumer keeps owner and metadata but no chain id.
_, err := pk.GetConsumerChainId(ctx, 4)
require.ErrorIs(t, err, collections.ErrNotFound, "DELETED must not hold a chain id")
gotOwner, err := pk.GetConsumerOwnerAddress(ctx, 4)
require.NoError(t, err)
require.Equal(t, owner, gotOwner)
gotMd, err := pk.GetConsumerMetadata(ctx, 4)
require.NoError(t, err)
require.Equal(t, md, gotMd)

// init_params are set on INITIALIZED, LAUNCHED, STOPPED, DELETED (ids 1–4).
for _, consumerId := range []uint64{1, 2, 3, 4} {
gotIp, err := pk.GetConsumerInitializationParameters(ctx, consumerId)
Expand Down Expand Up @@ -347,6 +370,11 @@ func TestGenesisRoundTrip(t *testing.T) {
if s.phase != providertypes.CONSUMER_PHASE_REGISTERED {
require.NoError(t, pkA.SetConsumerInitializationParameters(ctxA, id, ip))
}
// A deleted consumer holds no chain id: its teardown released it (see
// DeleteConsumerChain), so seed the state deletion actually leaves.
if s.phase == providertypes.CONSUMER_PHASE_DELETED {
pkA.DeleteConsumerChainId(ctxA, id)
}
if s.clientId != "" {
pkA.SetConsumerClientId(ctxA, id, s.clientId)
}
Expand Down
Loading
Loading