diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e9bae8473af..1e04659be515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Bug Fixes +* (runtime) [#26469](https://github.com/cosmos/cosmos-sdk/issues/26469) Accumulate all registered autocli msg/query service names per module instead of keeping only the last, so multi-service modules expose every service. * (client/tx) [#26759](https://github.com/cosmos/cosmos-sdk/issues/26759) Populate the multisig bit array in simulation txs so `--gas auto` works for multisig senders. * (blockstm) [#26772](https://github.com/cosmos/cosmos-sdk/pull/26772) Panic with a descriptive error when accessing an unregistered store instead of silently using store index zero. * (x/genutil) [#26741](https://github.com/cosmos/cosmos-sdk/issues/26741) Preserve vote extension enable height when exporting genesis state. diff --git a/runtime/services/autocli.go b/runtime/services/autocli.go index 7290b8fc0f21..0a125488b99b 100644 --- a/runtime/services/autocli.go +++ b/runtime/services/autocli.go @@ -2,6 +2,7 @@ package services import ( "context" + "strings" gogogrpc "github.com/cosmos/gogoproto/grpc" "github.com/cosmos/gogoproto/proto" @@ -48,8 +49,8 @@ func ExtractAutoCLIOptions(appModules map[string]any) map[string]*autocliv1.Modu cfg := &autocliConfigurator{} - // try to auto-discover options based on the last msg and query - // services registered for the module + // try to auto-discover options based on the msg and query services + // registered for the module if mod, ok := mod.(module.HasServices); ok { mod.RegisterServices(cfg) } @@ -66,23 +67,11 @@ func ExtractAutoCLIOptions(appModules map[string]any) map[string]*autocliv1.Modu panic(cfg.Error()) } - haveServices := false modOptions := &autocliv1.ModuleOptions{} - if cfg.msgServer.serviceName != "" { - haveServices = true - modOptions.Tx = &autocliv1.ServiceCommandDescriptor{ - Service: cfg.msgServer.serviceName, - } - } - - if cfg.queryServer.serviceName != "" { - haveServices = true - modOptions.Query = &autocliv1.ServiceCommandDescriptor{ - Service: cfg.queryServer.serviceName, - } - } + modOptions.Tx = newServiceCommandDescriptor(cfg.msgServer.serviceNames) + modOptions.Query = newServiceCommandDescriptor(cfg.queryServer.serviceNames) - if haveServices { + if modOptions.Tx != nil || modOptions.Query != nil { moduleOptions[modName] = modOptions } } @@ -132,13 +121,57 @@ func (a *autocliConfigurator) RegisterService(sd *grpc.ServiceDesc, ss any) { } func (a *autocliConfigurator) Error() error { return nil } -// autocliServiceRegistrar is used to capture the service name for registered services +// autocliServiceRegistrar captures the names of every service registered for a +// module. A module may register more than one msg or query service, so all of +// them are retained instead of only the last one. type autocliServiceRegistrar struct { - serviceName string + serviceNames []string } func (a *autocliServiceRegistrar) RegisterService(sd *grpc.ServiceDesc, _ any) { - a.serviceName = sd.ServiceName + a.serviceNames = append(a.serviceNames, sd.ServiceName) +} + +// newServiceCommandDescriptor builds a ServiceCommandDescriptor from the service +// names registered for a single command type (tx or query). It returns nil when +// no service was registered. +// +// The first service is exposed as the primary command. Any additional services +// are attached as sub-commands so they are no longer silently dropped. The +// sub-command name defaults to the lowercased short name of the service, falling +// back to the fully qualified name when that would collide. +func newServiceCommandDescriptor(serviceNames []string) *autocliv1.ServiceCommandDescriptor { + if len(serviceNames) == 0 { + return nil + } + + desc := &autocliv1.ServiceCommandDescriptor{Service: serviceNames[0]} + if len(serviceNames) == 1 { + return desc + } + + desc.SubCommands = make(map[string]*autocliv1.ServiceCommandDescriptor, len(serviceNames)-1) + for _, name := range serviceNames[1:] { + key := subCommandKey(name, desc.SubCommands) + desc.SubCommands[key] = &autocliv1.ServiceCommandDescriptor{Service: name} + } + return desc +} + +// subCommandKey derives a unique sub-command name for a service. It prefers the +// lowercased short name (the segment after the last dot) and falls back to the +// lowercased fully qualified name when the short name is empty or already taken. +func subCommandKey(serviceName string, taken map[string]*autocliv1.ServiceCommandDescriptor) string { + key := serviceName + if i := strings.LastIndexByte(serviceName, '.'); i >= 0 { + key = serviceName[i+1:] + } + key = strings.ToLower(key) + + if _, exists := taken[key]; exists || key == "" { + key = strings.ToLower(serviceName) + } + return key } var _ autocliv1.QueryServer = &AutoCLIQueryService{} diff --git a/runtime/services/autocli_test.go b/runtime/services/autocli_test.go new file mode 100644 index 000000000000..80571bb47c34 --- /dev/null +++ b/runtime/services/autocli_test.go @@ -0,0 +1,53 @@ +package services + +import ( + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +func TestAutoCLIServiceRegistrarAccumulatesServices(t *testing.T) { + var reg autocliServiceRegistrar + reg.RegisterService(&grpc.ServiceDesc{ServiceName: "cosmos.foo.v1.Query"}, nil) + reg.RegisterService(&grpc.ServiceDesc{ServiceName: "cosmos.foo.v2.Query"}, nil) + + // Both services must be retained; the second one previously overwrote the first. + require.Equal(t, []string{"cosmos.foo.v1.Query", "cosmos.foo.v2.Query"}, reg.serviceNames) +} + +func TestNewServiceCommandDescriptor(t *testing.T) { + require.Nil(t, newServiceCommandDescriptor(nil)) + + // Single service stays the primary command, no sub-commands. + single := newServiceCommandDescriptor([]string{"cosmos.bank.v1beta1.Query"}) + require.Equal(t, "cosmos.bank.v1beta1.Query", single.Service) + require.Empty(t, single.SubCommands) + + // Multiple services: first is primary, the rest become sub-commands and are + // no longer dropped. + multi := newServiceCommandDescriptor([]string{ + "cosmos.foo.v1.Query", + "cosmos.foo.v1.SecondaryQuery", + }) + require.Equal(t, "cosmos.foo.v1.Query", multi.Service) + require.Len(t, multi.SubCommands, 1) + require.Contains(t, multi.SubCommands, "secondaryquery") + require.Equal(t, "cosmos.foo.v1.SecondaryQuery", multi.SubCommands["secondaryquery"].Service) +} + +func TestSubCommandKeyCollisionFallsBackToFullName(t *testing.T) { + // Two services share the short name "Query" but differ by version, so the + // second must fall back to its fully qualified name to stay unique. + desc := newServiceCommandDescriptor([]string{ + "cosmos.foo.v1.Service", + "cosmos.foo.v1.Query", + "cosmos.foo.v2.Query", + }) + require.Equal(t, "cosmos.foo.v1.Service", desc.Service) + require.Len(t, desc.SubCommands, 2) + require.Contains(t, desc.SubCommands, "query") + require.Contains(t, desc.SubCommands, "cosmos.foo.v2.query") + require.Equal(t, "cosmos.foo.v1.Query", desc.SubCommands["query"].Service) + require.Equal(t, "cosmos.foo.v2.Query", desc.SubCommands["cosmos.foo.v2.query"].Service) +}