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
25 changes: 25 additions & 0 deletions proto/vaas/provider/v1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ service Msg {
rpc CreateConsumer(MsgCreateConsumer) returns (MsgCreateConsumerResponse);
rpc UpdateConsumer(MsgUpdateConsumer) returns (MsgUpdateConsumerResponse);
rpc RemoveConsumer(MsgRemoveConsumer) returns (MsgRemoveConsumerResponse);
rpc RetireConsumer(MsgRetireConsumer) returns (MsgRetireConsumerResponse);
rpc UpdateParams(MsgUpdateParams) returns (MsgUpdateParamsResponse);
rpc SetConsumerFeesPerBlock(MsgSetConsumerFeesPerBlock) returns (MsgSetConsumerFeesPerBlockResponse);
rpc FundConsumerFeePool(MsgFundConsumerFeePool) returns (MsgFundConsumerFeePoolResponse);
Expand Down Expand Up @@ -120,6 +121,30 @@ message MsgRemoveConsumer {
// MsgRemoveConsumerResponse defines response type for MsgRemoveConsumer messages
message MsgRemoveConsumerResponse {}

// MsgRetireConsumer defines the message used to terminate a consumer chain that
// has not launched yet, i.e. one still in the registered or initialized phase.
// Its provider state is erased right away -- a chain no validator ever
// validated needs no unbonding delay -- and its chain id is released for reuse.
// A launched or paused consumer is removed with MsgRemoveConsumer instead,
// which stops it first and only erases its state once the unbonding period has
// elapsed.
//
// Either the consumer owner or the governance authority may sign. The owner
// 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?

option (cosmos.msg.v1.signer) = "signer";

// signer is either the consumer owner or the governance authority
string signer = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
// the consumer id of the consumer chain to retire
uint64 consumer_id = 2;
}

// MsgRetireConsumerResponse defines response type for MsgRetireConsumer messages
message MsgRetireConsumerResponse {}

// MsgCreateConsumer defines the message that creates a consumer chain.
message MsgCreateConsumer {
option (cosmos.msg.v1.signer) = "submitter";
Expand Down
41 changes: 41 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(NewRetireConsumerCmd())
cmd.AddCommand(NewFundConsumerFeePoolCmd())
cmd.AddCommand(NewWithdrawConsumerFeePoolCmd())
cmd.AddCommand(NewSweepConsumerFeePoolCmd())
Expand Down Expand Up @@ -405,6 +406,46 @@ If one of the fields is missing, it will be set to its zero value.
return cmd
}

func NewRetireConsumerCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "retire-consumer [consumer-id]",
Short: "Erase a consumer chain that has not launched and release its chain id",
Long: strings.TrimSpace(
fmt.Sprintf(`Erase a consumer chain that is still registered or initialized, freeing its
chain id for reuse and returning any fee-pool balance to its depositors.

Signed by the consumer owner, or by the governance authority when the owner key
is lost. A launched or paused consumer cannot be retired: governance removes it
with a MsgRemoveConsumer proposal instead.

Example:
%s tx vaasprovider retire-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.MsgRetireConsumer{
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
71 changes: 67 additions & 4 deletions x/vaas/provider/keeper/consumer_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -599,11 +599,60 @@ 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
// adopted for it (discoverActiveConsumerClient runs only for launched
// consumers), 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) {
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) {
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 @@ -663,8 +712,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)

// 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 @@ -204,7 +214,10 @@ func TestInitGenesisRestoresPerConsumerStateAndDerivedQueues(t *testing.T) {
{ConsumerId: 3, ChainId: "consumer-delta", Phase: providertypes.CONSUMER_PHASE_STOPPED,
OwnerAddress: owner, Metadata: &md, InitParams: &ip,
ClientId: "07-tendermint-1", ConsumerGenesis: cg, RemovalTime: &removeAt},
{ConsumerId: 4, ChainId: "consumer-epsilon", Phase: providertypes.CONSUMER_PHASE_DELETED,
// 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, Phase: providertypes.CONSUMER_PHASE_DELETED,
OwnerAddress: owner, Metadata: &md, InitParams: &ip},
},
}
Expand All @@ -221,11 +234,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 @@ -234,10 +247,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 @@ -252,6 +265,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 @@ -337,6 +360,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
25 changes: 16 additions & 9 deletions x/vaas/provider/keeper/grpc_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,15 @@ func (k Keeper) QueryConsumerChains(goCtx context.Context, req *types.QueryConsu
return &types.QueryConsumerChainsResponse{Chains: chains, Pagination: pageRes}, nil
}

// GetConsumerChain returns a Chain data structure with all the necessary fields
// GetConsumerChain returns a Chain data structure with all the necessary fields.
// A deleted consumer comes back with an empty chain id: its teardown released
// the chain id for reuse (see DeleteConsumerChain), and the record is still
// listed so the deletion stays visible without an archive node.
func (k Keeper) GetConsumerChain(ctx sdk.Context, consumerId uint64) (types.Chain, error) {
phase := k.GetConsumerPhase(ctx, consumerId)

chainID, err := k.GetConsumerChainId(ctx, consumerId)
if err != nil {
if err != nil && phase != types.CONSUMER_PHASE_DELETED {
return types.Chain{}, fmt.Errorf("cannot find chainID for consumer (%d)", consumerId)
}

Expand All @@ -93,7 +98,7 @@ func (k Keeper) GetConsumerChain(ctx sdk.Context, consumerId uint64) (types.Chai
return types.Chain{
ChainId: chainID,
ClientId: clientID,
Phase: k.GetConsumerPhase(ctx, consumerId).String(),
Phase: phase.String(),
Metadata: metadata,
ConsumerId: consumerId,
FeePoolAddress: k.GetConsumerFeePoolAddress(consumerId).String(),
Expand Down Expand Up @@ -278,8 +283,15 @@ func (k Keeper) QueryConsumerChain(goCtx context.Context, req *types.QueryConsum
consumerId := req.ConsumerId
ctx := sdk.UnwrapSDKContext(goCtx)

phase := k.GetConsumerPhase(ctx, consumerId)
if phase == types.CONSUMER_PHASE_UNSPECIFIED {
return nil, status.Errorf(codes.InvalidArgument, "cannot retrieve phase for consumer id: %d", consumerId)
}

// A deleted consumer answers with an empty chain id: the teardown released
// the chain id for reuse (see DeleteConsumerChain).
chainId, err := k.GetConsumerChainId(ctx, consumerId)
if err != nil {
if err != nil && phase != types.CONSUMER_PHASE_DELETED {
return nil, status.Errorf(codes.InvalidArgument, "cannot retrieve chain id for consumer id: %d", consumerId)
}

Expand All @@ -288,11 +300,6 @@ func (k Keeper) QueryConsumerChain(goCtx context.Context, req *types.QueryConsum
return nil, status.Errorf(codes.InvalidArgument, "cannot retrieve owner address for consumer id: %d", consumerId)
}

phase := k.GetConsumerPhase(ctx, consumerId)
if phase == types.CONSUMER_PHASE_UNSPECIFIED {
return nil, status.Errorf(codes.InvalidArgument, "cannot retrieve phase for consumer id: %d", consumerId)
}

metadata, err := k.GetConsumerMetadata(ctx, consumerId)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "cannot retrieve metadata for consumer id: %d", consumerId)
Expand Down
Loading
Loading