diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e767a4be5..973fdf533 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -48,6 +48,15 @@ jobs: uses: ./.github/actions/setup-ubuntu - run: make build-contracts test-full + grpc-surface: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup environment + uses: ./.github/actions/setup-ubuntu + - run: make build-contracts test-grpc-surface + coverage: runs-on: ubuntu-latest steps: diff --git a/cmd/akash/cmd/root.go b/cmd/akash/cmd/root.go index a6991800f..a4c87421a 100644 --- a/cmd/akash/cmd/root.go +++ b/cmd/akash/cmd/root.go @@ -79,6 +79,7 @@ func initRootCmd(rootCmd *cobra.Command, encodingConfig sdkutil.EncodingConfig) rosettaCmd.RosettaCommand(encodingConfig.InterfaceRegistry, encodingConfig.Codec), pruning.Cmd(ac.newApp, home), snapshot.Cmd(ac.newApp), + sdkserver.NewRollbackCmd(ac.newApp, home), testnetCmd(app.ModuleBasics(), banktypes.GenesisBalancesIterator{}), PrepareGenesisCmd(app.DefaultHome, app.ModuleBasics()), testnetify.GetCmd(ac.newTestnetApp), diff --git a/make/test-integration.mk b/make/test-integration.mk index eb4a593ce..778a2f5c4 100644 --- a/make/test-integration.mk +++ b/make/test-integration.mk @@ -22,6 +22,21 @@ test-full: wasmvm-libs test-integration: $(GO_TEST) -v -tags="e2e.integration" -ldflags '$(ldflags)' ./tests/e2e/... +# test-grpc-surface runs the exhaustive gRPC transaction/query suite against an +# in-process single-validator network (the fast, standalone-compilable mirror of +# the post-upgrade verification that runs against a testnetify-forked node). +.PHONY: test-grpc-surface +test-grpc-surface: wasmvm-libs + $(GO_TEST) -v -tags="e2e.integration" -ldflags '$(ldflags)' -timeout 30m ./tests/fullsurface/... -args -grpc-suite-mode=all + +.PHONY: test-grpc-surface-tx +test-grpc-surface-tx: wasmvm-libs + $(GO_TEST) -v -tags="e2e.integration" -ldflags '$(ldflags)' -timeout 30m ./tests/fullsurface/... -args -grpc-suite-mode=tx + +.PHONY: test-grpc-surface-query +test-grpc-surface-query: wasmvm-libs + $(GO_TEST) -v -tags="e2e.integration" -ldflags '$(ldflags)' -timeout 10m ./tests/fullsurface/... -args -grpc-suite-mode=query + .PHONY: test-coverage test-coverage: wasmvm-libs $(GO_TEST) $(BUILD_FLAGS) -coverprofile=coverage.txt \ diff --git a/make/test-upgrade.mk b/make/test-upgrade.mk index e9f208d38..30df7b412 100644 --- a/make/test-upgrade.mk +++ b/make/test-upgrade.mk @@ -84,11 +84,11 @@ test-reset: $(ROOT_DIR)/script/upgrades.sh --workdir=$(AP_RUN_DIR) --config="$(PWD)/config.json" --uto=$(UPGRADE_TO) --snapshot-url=$(SNAPSHOT_URL) --chain-meta=$(CHAIN_METADATA_URL) --max-validators=$(MAX_VALIDATORS) clean $(ROOT_DIR)/script/upgrades.sh --workdir=$(AP_RUN_DIR) --config="$(PWD)/config.json" --uto=$(UPGRADE_TO) --snapshot-url=$(SNAPSHOT_URL) --gbv=$(GENESIS_BINARY_VERSION) --chain-meta=$(CHAIN_METADATA_URL) bins $(ROOT_DIR)/script/upgrades.sh --workdir=$(AP_RUN_DIR) --config="$(PWD)/config.json" --uto=$(UPGRADE_TO) --snapshot-url=$(SNAPSHOT_URL) --chain-meta=$(CHAIN_METADATA_URL) keys - $(ROOT_DIR)/script/upgrades.sh --workdir=$(AP_RUN_DIR) --config="$(PWD)/config.json" --state-config=$(STATE_CONFIG) --snapshot-url=$(SNAPSHOT_URL) --chain-meta=$(CHAIN_METADATA_URL) --max-validators=$(MAX_VALIDATORS) prepare-state + $(ROOT_DIR)/script/upgrades.sh --workdir=$(AP_RUN_DIR) --config="$(PWD)/config.json" --state-config=$(STATE_CONFIG) --snapshot-url=$(SNAPSHOT_URL) --chain-meta=$(CHAIN_METADATA_URL) --uto=$(UPGRADE_TO) --max-validators=$(MAX_VALIDATORS) prepare-state .PHONY: prepare-state prepare-state: - $(ROOT_DIR)/script/upgrades.sh --workdir=$(AP_RUN_DIR) --config="$(PWD)/config.json" --state-config=$(STATE_CONFIG) --chain-meta=$(CHAIN_METADATA_URL) --max-validators=$(MAX_VALIDATORS) prepare-state + $(ROOT_DIR)/script/upgrades.sh --workdir=$(AP_RUN_DIR) --config="$(PWD)/config.json" --state-config=$(STATE_CONFIG) --snapshot-url=$(SNAPSHOT_URL) --chain-meta=$(CHAIN_METADATA_URL) --uto=$(UPGRADE_TO) --max-validators=$(MAX_VALIDATORS) prepare-state .PHONY: bins bins: diff --git a/script/upgrades.sh b/script/upgrades.sh index 2fe6291b2..495eb0036 100755 --- a/script/upgrades.sh +++ b/script/upgrades.sh @@ -438,6 +438,13 @@ function prepare_state() { cosmovisor_dir=$valdir/cosmovisor genesis_bin=$cosmovisor_dir/genesis/bin + rm -rf "$cosmovisor_dir/current" + rm -f "$cosmovisor_dir/upgrades/${UPGRADE_TO}/upgrade-info.json" + pushd "$(pwd)" + cd "$cosmovisor_dir" + ln -snf genesis current + popd + genesis_file=${valdir}/config/genesis.json addrbook_file=${valdir}/config/genesis.json rm -f "$genesis_file" @@ -491,11 +498,13 @@ function prepare_state() { cd "${valdir}" echo "Unpacking snapshot from $snap_file..." + rm -rf data # shellcheck disable=SC2086 (pv -petrafb -i 5 "$snap_file" | eval "$tar_cmd") 2>&1 | stdbuf -o0 tr '\r' '\n' rm -f upgrade-info.json + rm -f data/upgrade-info.json popd else @@ -520,8 +529,72 @@ function prepare_state() { rvaldir=$validators_dir/.akash0 + if [[ -n "$UPGRADE_TO" && -x "$rvaldir/cosmovisor/upgrades/$UPGRADE_TO/bin/akash" ]]; then + echo "rolling snapshot back before testnetify" + # Testnetify must commit one fork-state block before the scheduled upgrade + # height. Cached snapshots can sit at the last pre-upgrade height, so roll + # back twice to start one block earlier. + "$rvaldir/cosmovisor/upgrades/$UPGRADE_TO/bin/akash" rollback --home "$rvaldir" --hard + "$rvaldir/cosmovisor/upgrades/$UPGRADE_TO/bin/akash" rollback --home "$rvaldir" --hard + cat >"$rvaldir/data/priv_validator_state.json" </dev/null; do + if [[ -f "$rvaldir/data/upgrade-info.json" ]]; then + kill "$rpid" 2>/dev/null || true + wait "$rpid" || true + rpid= + break + fi + if [[ $testnetify_wait -ge $testnetify_timeout ]]; then + kill "$rpid" 2>/dev/null || true + wait "$rpid" || true + echoerr "testnetify did not complete within ${testnetify_timeout}s" + return 1 + fi + sleep 1 + testnetify_wait=$((testnetify_wait + 1)) + done + if [[ -n "$rpid" ]]; then + if ! wait "$rpid"; then + if [[ ! -f "$rvaldir/data/upgrade-info.json" ]]; then + return 1 + fi + fi + fi + + if [[ -f "$rvaldir/data/upgrade-info.json" ]]; then + local rollback_upgrade + rollback_upgrade=$UPGRADE_TO + if [[ -z "$rollback_upgrade" ]]; then + rollback_upgrade=$(jq -r '.name' "$rvaldir/data/upgrade-info.json") + fi + + echo "testnetify reached upgrade height; rolling back pending upgrade block" + "$rvaldir/cosmovisor/upgrades/$rollback_upgrade/bin/akash" rollback --home "$rvaldir" --hard + rm -f "$rvaldir/data/upgrade-info.json" + cat >"$rvaldir/data/priv_validator_state.json" <.go` implementing `Pack` (`Name`, `Available`, `Run`). +2. Gate `Available` on `d.HasModule("akash..")` (Query service). +3. In `Run`, fund accounts via `s.FundAccountDefault`, build msgs with the SDK + types, broadcast with `s.BroadcastOK` (happy path) or `s.BroadcastExpectErr` + (negative / disabled), query with the generated `NewQueryClient(s.Conn)`. +4. For gov-gated msgs, build them with `Authority: s.GovAuthority()` and pass via + `s.PassGovProposal(...)`. +5. Publish handles other packs need via `s.World.Set`; read with `s.World.Get`. +6. Register the pack in `pack.go` in dependency order. + +The deployment, provider and gov-params packs are worked examples. + +## Status + +**Full in-scope coverage** — run `make test-grpc-surface` and read the +`coverage:` line: +- **Queries: 130/130 in-scope methods** (smoke sweep + authored typed cases). +- **Transactions: 93/93 in-scope messages.** Akash coverage includes deployment, + provider, market, audit, escrow, cert, oracle, bme and module params. Cosmos SDK + coverage includes auth, authz, bank, consensus, distribution, evidence, + feegrant, gov v1, legacy gov v1beta1, mint, slashing, staking, upgrade and + vesting. CosmWasm coverage includes store, instantiate, execute, migrate, admin, + code config, pin/unpin and wasm params. + +`RequireFullCoverage` is `true` in both drivers, so the gate **fails** if any +in-scope tx or query stops being exercised — e.g. when a future upgrade adds a new +Akash or mounted Cosmos SDK RPC, until a case is authored for it. + +The suite targets exactly the surface the running `main` binary serves: discovery +is driven by gRPC reflection + the interface registry, and every pack is +reflection-gated (`Available`/`HasModule`). A pack whose module is not served by +the binary under test is skipped automatically, so the suite always matches the +active surface with no manual bookkeeping. diff --git a/tests/upgrade/grpcsuite/bootstrap.go b/tests/upgrade/grpcsuite/bootstrap.go new file mode 100644 index 000000000..3d9913878 --- /dev/null +++ b/tests/upgrade/grpcsuite/bootstrap.go @@ -0,0 +1,180 @@ +package grpcsuite + +import ( + "context" + "time" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" + + bmev1 "pkg.akt.dev/go/node/bme/v1" + oraclev2 "pkg.akt.dev/go/node/oracle/v2" + "pkg.akt.dev/go/sdkutil" +) + +const ( + funderUactTarget = int64(100_000_000) + bmeVaultSeedUakt = int64(100_000_000) + bmeMintBurnUakt = int64(50_000_000) +) + +func (s *Suite) bootstrapFunderUact() { + s.T.Helper() + + if !s.Disc.HasModule("akash.deployment.v1beta4") && !s.Disc.HasModule("akash.escrow.v1") { + return + } + + target := sdk.NewInt64Coin(sdkutil.DenomUact, funderUactTarget) + current := s.balanceOf(s.Env.FunderAddr, sdkutil.DenomUact) + if current.Amount.GTE(target.Amount) { + s.logf("funder already has %s; uact bootstrap skipped", current) + return + } + + require.True(s.T, s.Disc.HasModule("akash.oracle.v2"), "grpcsuite: oracle is required to mint uact") + require.True(s.T, s.Disc.HasModule("akash.bme.v1"), "grpcsuite: bme is required to mint uact") + + s.logf("funder has %s; minting at least %s before deposit-bearing packs", current, target) + s.authorizeFunderOracleAndFundBMEVault() + s.feedAKTPriceAs(s.Env.Funder, s.Env.FunderAddr, 3) + s.waitBMEMintsAllowed() + + for attempt := 1; attempt <= 3; attempt++ { + current = s.balanceOf(s.Env.FunderAddr, sdkutil.DenomUact) + if current.Amount.GTE(target.Amount) { + s.logf("funder uact bootstrap complete: %s", current) + return + } + + s.feedAKTPriceAs(s.Env.Funder, s.Env.FunderAddr, 3) + s.BroadcastOK(s.Env.Funder, &bmev1.MsgMintACT{ + Owner: s.Env.FunderAddr.String(), + To: s.Env.FunderAddr.String(), + CoinsToBurn: sdk.NewCoin(sdkutil.DenomUakt, sdkmath.NewInt(bmeMintBurnUakt)), + }) + if s.waitForFunderUact(target, 2*time.Minute) { + return + } + s.logf("funder uact still below target after mint attempt %d: %s", attempt, s.balanceOf(s.Env.FunderAddr, sdkutil.DenomUact)) + } + + final := s.balanceOf(s.Env.FunderAddr, sdkutil.DenomUact) + require.Truef(s.T, final.Amount.GTE(target.Amount), "grpcsuite: funder uact bootstrap ended with %s, need at least %s", final, target) +} + +func (s *Suite) authorizeFunderOracleAndFundBMEVault() { + s.T.Helper() + + authority := s.GovAuthority() + var msgs []sdk.Msg + + q := oraclev2.NewQueryClient(s.Conn) + pr, err := q.Params(s.Ctx, &oraclev2.QueryParamsRequest{}) + require.NoError(s.T, err, "oracle Params") + + params := pr.Params + if !stringInSlice(params.Sources, s.Env.FunderAddr.String()) { + params.Sources = append(params.Sources, s.Env.FunderAddr.String()) + msgs = append(msgs, &oraclev2.MsgUpdateParams{Authority: authority, Params: params}) + } + + msgs = append(msgs, &bmev1.MsgFundVault{ + Authority: authority, + Amount: sdk.NewCoin(sdkutil.DenomUakt, sdkmath.NewInt(bmeVaultSeedUakt)), + Source: s.Env.FunderAddr.String(), + }) + s.PassGovProposal("grpcsuite: bootstrap uact minting", msgs...) +} + +func (s *Suite) waitBMEMintsAllowed() { + s.T.Helper() + + q := bmev1.NewQueryClient(s.Conn) + ctx, cancel := context.WithTimeout(s.Ctx, 90*time.Second) + defer cancel() + + var lastErr error + var lastStatus *bmev1.QueryStatusResponse + lastFeedHeight := s.LatestHeight() + for { + resp, err := q.Status(ctx, &bmev1.QueryStatusRequest{}) + if err == nil { + lastStatus = resp + if resp.MintsAllowed { + return + } + } else { + lastErr = err + } + + latest := s.LatestHeight() + if latest-lastFeedHeight >= 2 { + s.feedAKTPriceAs(s.Env.Funder, s.Env.FunderAddr, 3) + lastFeedHeight = s.LatestHeight() + } + + select { + case <-ctx.Done(): + if lastStatus != nil { + s.T.Fatalf("grpcsuite: bme did not allow mints within timeout: status=%s cr=%s last err=%v", + lastStatus.Status, lastStatus.CollateralRatio, lastErr) + } + s.T.Fatalf("grpcsuite: bme did not allow mints within timeout: %v", lastErr) + case <-time.After(500 * time.Millisecond): + } + } +} + +func (s *Suite) waitForFunderUact(target sdk.Coin, timeout time.Duration) bool { + s.T.Helper() + + ctx, cancel := context.WithTimeout(s.Ctx, timeout) + defer cancel() + + lastFeedHeight := s.LatestHeight() + for { + current := s.balanceOf(s.Env.FunderAddr, sdkutil.DenomUact) + if current.Amount.GTE(target.Amount) { + s.logf("funder uact bootstrap complete: %s", current) + return true + } + + latest := s.LatestHeight() + if latest-lastFeedHeight >= 2 { + s.feedAKTPriceAs(s.Env.Funder, s.Env.FunderAddr, 3) + lastFeedHeight = s.LatestHeight() + } + + select { + case <-ctx.Done(): + return false + case <-time.After(500 * time.Millisecond): + } + } +} + +func (s *Suite) balanceOf(addr sdk.AccAddress, denom string) sdk.Coin { + s.T.Helper() + + resp, err := banktypes.NewQueryClient(s.Conn).Balance(s.Ctx, &banktypes.QueryBalanceRequest{ + Address: addr.String(), + Denom: denom, + }) + require.NoErrorf(s.T, err, "bank Balance %s %s", addr, denom) + if resp.Balance == nil { + return sdk.NewCoin(denom, sdkmath.ZeroInt()) + } + return *resp.Balance +} + +func stringInSlice(vals []string, target string) bool { + for _, v := range vals { + if v == target { + return true + } + } + return false +} diff --git a/tests/upgrade/grpcsuite/coverage.go b/tests/upgrade/grpcsuite/coverage.go new file mode 100644 index 000000000..95f69c48d --- /dev/null +++ b/tests/upgrade/grpcsuite/coverage.go @@ -0,0 +1,388 @@ +package grpcsuite + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "testing" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "google.golang.org/grpc" + "google.golang.org/protobuf/reflect/protoreflect" + + refv1 "google.golang.org/grpc/reflection/grpc_reflection_v1" + refv1alpha "google.golang.org/grpc/reflection/grpc_reflection_v1alpha" +) + +// inScopePrefixes are the proto package prefixes whose query surface the coverage +// gate enforces. Akash + wasm transactions are additionally gated (see Discovery). +var inScopePrefixes = []string{ + "akash.", + "cosmwasm.wasm.", + "cosmos.bank.", + "cosmos.staking.", + "cosmos.gov.", + "cosmos.distribution.", + "cosmos.authz.", + "cosmos.feegrant.", + "cosmos.slashing.", + "cosmos.mint.", + "cosmos.auth.", + "cosmos.upgrade.", + "cosmos.params.", + "cosmos.consensus.", + "cosmos.evidence.", +} + +// strictMsgPrefixes are packages whose every served Msg RPC MUST be exercised by +// an authored tx case. Keep this list aligned with the packs in pack.go: widening +// it without an authored pack turns the coverage gate into a noisy type registry +// check instead of a real post-upgrade acceptance test. +var strictMsgPrefixes = []string{ + "akash.", + "cosmwasm.wasm.", + "cosmos.auth.", + "cosmos.authz.", + "cosmos.bank.", + "cosmos.consensus.", + "cosmos.distribution.", + "cosmos.evidence.", + "cosmos.feegrant.", + "cosmos.gov.", + "cosmos.mint.", + "cosmos.slashing.", + "cosmos.staking.", + "cosmos.upgrade.", + "cosmos.vesting.", +} + +// strictMsgExclusions are registered sdk.Msg implementations that are not public +// transaction service requests. Wasmd registers these internal IBC bridge messages +// for contract dispatch, but they are not signer-addressed gRPC tx messages. +var strictMsgExclusions = map[string]bool{ + "cosmwasm.wasm.v1.MsgIBCCloseChannel": true, + "cosmwasm.wasm.v1.MsgIBCSend": true, +} + +// msgOnlyPkgs are mounted modules with Msg services but no Query service. Most +// modules are discovered by matching Msg packages to served Query packages, which +// filters inactive historical versions. Vesting is different: Akash mounts its Msg +// server but the SDK module has no query service. +var msgOnlyPkgs = map[string]bool{ + "cosmos.vesting.v1beta1": true, +} + +func inScope(name string) bool { + for _, p := range inScopePrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +func strictMsg(name string) bool { + for _, p := range strictMsgPrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +// Discovery is the set of gRPC services the running binary actually serves, +// resolved to concrete Msg/Query methods. It is built from gRPC server reflection +// (which service names are live) intersected with the locally linked descriptors +// (which give the method lists), so it tracks the branch under test: e.g. +// akash.verification.* appears on AEP-86 but not on main. +type Discovery struct { + // served is the set of service full names returned by reflection. + served map[string]bool + // msgs maps an in-scope Msg request type URL -> owning service full name. + msgs map[string]string + // queries maps an in-scope query method path -> owning service full name. + queries map[string]string +} + +// ServesService reports whether the running binary serves the named gRPC service. +func (d *Discovery) ServesService(fullName string) bool { return d.served[fullName] } + +// HasModule reports whether the module identified by its proto package (e.g. +// "akash.deployment.v1beta4") is live, detected by its served Query service. Packs +// use this to skip modules absent on the branch under test. Note: Msg services are +// not served over gRPC, so a module's presence is keyed on its Query service. +func (d *Discovery) HasModule(pkg string) bool { return d.served[pkg+".Query"] } + +// InScopeMsgs returns the in-scope Msg request type URLs that are gated. +func (d *Discovery) InScopeMsgs() []string { return keys(d.msgs) } + +// InScopeQueries returns the in-scope query method paths that are gated. +func (d *Discovery) InScopeQueries() []string { return keys(d.queries) } + +// discover lists served services via reflection, then resolves each in-scope +// service's methods from the linked descriptor set. +func discover(ctx context.Context, conn *grpc.ClientConn, ireg codectypes.InterfaceRegistry) (*Discovery, error) { + served, err := listServices(ctx, conn) + if err != nil { + return nil, err + } + + // Index every service descriptor linked into this binary by full name. + svcByName := map[string]protoreflect.ServiceDescriptor{} + ireg.RangeFiles(func(fd protoreflect.FileDescriptor) bool { + svcs := fd.Services() + for i := 0; i < svcs.Len(); i++ { + sd := svcs.Get(i) + svcByName[string(sd.FullName())] = sd + } + return true + }) + + d := &Discovery{ + served: map[string]bool{}, + msgs: map[string]string{}, + queries: map[string]string{}, + } + for _, name := range served { + d.served[name] = true + } + + // Query services ARE served over gRPC, so reflection + descriptors give the + // exact set of live query methods. The set of served Query packages also tells + // us each module's ACTIVE version (e.g. akash.deployment.v1beta4), which we use + // below to keep inactive-version messages out of the gated Msg set. + servedQueryPkgs := map[string]bool{} + for name := range d.served { + if strings.HasSuffix(name, ".Query") { + servedQueryPkgs[strings.TrimSuffix(name, ".Query")] = true + } + } + for name := range d.served { + if !inScope(name) || !strings.HasSuffix(name, ".Query") { + continue + } + sd, ok := svcByName[name] + if !ok { + continue + } + methods := sd.Methods() + for i := 0; i < methods.Len(); i++ { + d.queries["/"+name+"/"+string(methods.Get(i).Name())] = name + } + } + + // Msg services are NOT served as callable gRPC services (messages are routed + // through the tx service / MsgServiceRouter), so reflection does not list them. + // Instead, enumerate every registered Msg implementation and keep the in-scope + // ones whose package corresponds to a live Query service (the active version). + for _, url := range ireg.ListImplementations(sdkMsgInterfaceName) { + u := withSlash(url) + trimmed := strings.TrimPrefix(u, "/") + if !strictMsg(trimmed) { + continue + } + if strictMsgExclusions[trimmed] { + continue + } + idx := strings.LastIndex(trimmed, ".") + if idx < 0 { + continue + } + if pkg := trimmed[:idx]; servedQueryPkgs[pkg] || msgOnlyPkgs[pkg] { + d.msgs[u] = pkg + } + } + return d, nil +} + +// sdkMsgInterfaceName is the interface registry name under which all sdk.Msg +// implementations are registered. +const sdkMsgInterfaceName = "cosmos.base.v1beta1.Msg" + +func withSlash(s string) string { + if strings.HasPrefix(s, "/") { + return s + } + return "/" + s +} + +// listServices returns the full names of every gRPC service the server exposes, +// trying reflection v1 first and falling back to v1alpha. +func listServices(ctx context.Context, conn *grpc.ClientConn) ([]string, error) { + if names, err := listServicesV1(ctx, conn); err == nil { + return names, nil + } + return listServicesV1Alpha(ctx, conn) +} + +func listServicesV1(ctx context.Context, conn *grpc.ClientConn) ([]string, error) { + cl := refv1.NewServerReflectionClient(conn) + stream, err := cl.ServerReflectionInfo(ctx) + if err != nil { + return nil, err + } + if err := stream.Send(&refv1.ServerReflectionRequest{ + MessageRequest: &refv1.ServerReflectionRequest_ListServices{ListServices: "*"}, + }); err != nil { + return nil, err + } + resp, err := stream.Recv() + if err != nil { + return nil, err + } + ls := resp.GetListServicesResponse() + if ls == nil { + return nil, fmt.Errorf("reflection v1: unexpected response %T", resp.MessageResponse) + } + out := make([]string, 0, len(ls.Service)) + for _, s := range ls.Service { + out = append(out, s.Name) + } + return out, nil +} + +func listServicesV1Alpha(ctx context.Context, conn *grpc.ClientConn) ([]string, error) { + cl := refv1alpha.NewServerReflectionClient(conn) + stream, err := cl.ServerReflectionInfo(ctx) + if err != nil { + return nil, err + } + if err := stream.Send(&refv1alpha.ServerReflectionRequest{ + MessageRequest: &refv1alpha.ServerReflectionRequest_ListServices{ListServices: "*"}, + }); err != nil { + return nil, err + } + resp, err := stream.Recv() + if err != nil { + return nil, err + } + ls := resp.GetListServicesResponse() + if ls == nil { + return nil, fmt.Errorf("reflection v1alpha: unexpected response %T", resp.MessageResponse) + } + out := make([]string, 0, len(ls.Service)) + for _, s := range ls.Service { + out = append(out, s.Name) + } + return out, nil +} + +// Coverage tracks which Msg type URLs and query method paths were actually +// exercised, and asserts the in-scope expected set was fully covered. +type Coverage struct { + mu sync.Mutex + + strict bool + + recordedMethods map[string]bool // gRPC method paths invoked (queries + tx service) + recordedMsgs map[string]bool // Msg request type URLs broadcast + + expectedMsgs map[string]bool + expectedQueries map[string]bool +} + +func newCoverage() *Coverage { + return &Coverage{ + recordedMethods: map[string]bool{}, + recordedMsgs: map[string]bool{}, + expectedMsgs: map[string]bool{}, + expectedQueries: map[string]bool{}, + } +} + +func (c *Coverage) setStrict(v bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.strict = v +} + +func (c *Coverage) recordMethod(method string) { + c.mu.Lock() + defer c.mu.Unlock() + c.recordedMethods[method] = true +} + +// recordMsg notes that a message of the given type URL was broadcast. +func (c *Coverage) recordMsg(typeURL string) { + c.mu.Lock() + defer c.mu.Unlock() + if !strings.HasPrefix(typeURL, "/") { + typeURL = "/" + typeURL + } + c.recordedMsgs[typeURL] = true +} + +func (c *Coverage) setExpected(d *Discovery) { + c.mu.Lock() + defer c.mu.Unlock() + for m := range d.msgs { + c.expectedMsgs[m] = true + } + for q := range d.queries { + c.expectedQueries[q] = true + } +} + +// assert reports the coverage summary and, when strict, fails the test if any +// in-scope RPC for the selected mode was never exercised. +func (c *Coverage) assert(t *testing.T, mode RunMode) { + t.Helper() + c.mu.Lock() + defer c.mu.Unlock() + + var missingMsgs, missingQueries []string + for m := range c.expectedMsgs { + if !c.recordedMsgs[m] { + missingMsgs = append(missingMsgs, m) + } + } + for q := range c.expectedQueries { + if !c.recordedMethods[q] { + missingQueries = append(missingQueries, q) + } + } + sort.Strings(missingMsgs) + sort.Strings(missingQueries) + + if mode == RunModeAll || mode == RunModeTx { + t.Logf("coverage: tx %d/%d", len(c.expectedMsgs)-len(missingMsgs), len(c.expectedMsgs)) + for _, m := range missingMsgs { + t.Logf("coverage: UNCOVERED tx %s", m) + } + } + if mode == RunModeAll || mode == RunModeQuery { + t.Logf("coverage: query %d/%d", len(c.expectedQueries)-len(missingQueries), len(c.expectedQueries)) + for _, q := range missingQueries { + t.Logf("coverage: UNCOVERED query %s", q) + } + } + + if c.strict { + switch mode { + case RunModeAll: + if len(missingMsgs) > 0 || len(missingQueries) > 0 { + t.Errorf("coverage gate: %d tx and %d query RPC(s) were never exercised (see UNCOVERED logs above)", + len(missingMsgs), len(missingQueries)) + } + case RunModeTx: + if len(missingMsgs) > 0 { + t.Errorf("coverage gate: %d tx RPC(s) were never exercised (see UNCOVERED logs above)", len(missingMsgs)) + } + case RunModeQuery: + if len(missingQueries) > 0 { + t.Errorf("coverage gate: %d query RPC(s) were never exercised (see UNCOVERED logs above)", len(missingQueries)) + } + } + } +} + +func keys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/tests/upgrade/grpcsuite/env.go b/tests/upgrade/grpcsuite/env.go new file mode 100644 index 000000000..c5a86a682 --- /dev/null +++ b/tests/upgrade/grpcsuite/env.go @@ -0,0 +1,334 @@ +package grpcsuite + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + sdkmath "cosmossdk.io/math" + sdkclient "github.com/cosmos/cosmos-sdk/client" + cmtservice "github.com/cosmos/cosmos-sdk/client/grpc/cmtservice" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/hd" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + sdk "github.com/cosmos/cosmos-sdk/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type RunMode string + +const ( + RunModeAll RunMode = "all" + RunModeTx RunMode = "tx" + RunModeQuery RunMode = "query" +) + +func ParseRunMode(value string) (RunMode, error) { + if value == "" { + return RunModeAll, nil + } + switch mode := RunMode(value); mode { + case RunModeAll, RunModeTx, RunModeQuery: + return mode, nil + default: + return "", fmt.Errorf("unknown grpcsuite run mode %q", value) + } +} + +func (m RunMode) runsTxPacks() bool { + return m == RunModeAll || m == RunModeTx +} + +func (m RunMode) runsQuerySmoke() bool { + return m == RunModeAll || m == RunModeQuery +} + +// Env is the harness-agnostic environment the suite runs against. Both the upgrade +// post-upgrade worker and the in-process integration driver populate one of these. +type Env struct { + // GRPCEndpoint is the host:port of the node's gRPC server (e.g. 127.0.0.1:9090). + GRPCEndpoint string + // ChainID of the running chain (e.g. localakash). + ChainID string + // RepoRoot is the path to the node repository root, used to locate testdata + // (SDL/provider yaml). For the upgrade worker this is TestParams.SourceDir. + RepoRoot string + + Cdc codec.Codec + InterfaceReg codectypes.InterfaceRegistry + TxConfig sdkclient.TxConfig + Amino *codec.LegacyAmino + + // Keyring contains Funder (and is where the suite creates funded sub-accounts). + Keyring keyring.Keyring + Funder string // key name of a well-funded account (e.g. validator0) + FunderAddr sdk.AccAddress // address of Funder + + BondDenom string // staking/fee denom, e.g. uakt + GasPrices string // e.g. "0.025uakt" + + // RequireFullCoverage makes the coverage gate fail the test if any in-scope + // Msg or query RPC was never exercised. Drivers set this true; it can be + // relaxed while authoring new packs. + RequireFullCoverage bool + + // Mode selects which half of the suite is gated. Empty means RunModeAll. + Mode RunMode +} + +// World threads state created by earlier packs to later ones (a deployment's dseq, +// the provider address, an order/bid/lease id, etc.). Packs read and write it. +type World struct { + mu sync.Mutex + + // Accounts created/funded by the suite, keyed by logical role. + Accounts map[string]sdk.AccAddress + + // Cross-pack handles populated as scenarios run. Concrete types are filled in + // by the packs that own them; consumers type-assert. + Handles map[string]interface{} +} + +func newWorld() *World { + return &World{Accounts: map[string]sdk.AccAddress{}, Handles: map[string]interface{}{}} +} + +// Set stores a cross-pack handle. +func (w *World) Set(key string, v interface{}) { + w.mu.Lock() + defer w.mu.Unlock() + w.Handles[key] = v +} + +// Get retrieves a cross-pack handle (nil if absent). +func (w *World) Get(key string) interface{} { + w.mu.Lock() + defer w.mu.Unlock() + return w.Handles[key] +} + +// Suite is the running context shared by every pack. It owns the gRPC connection, +// the gRPC-backed client.Context, the tx broadcaster, the coverage tracker and the +// World. +type Suite struct { + T *testing.T + Ctx context.Context + + Env Env + Conn *grpc.ClientConn + Cctx sdkclient.Context + + TX *Broadcaster + Cov *Coverage + Disc *Discovery + + World *World + + dseqMu sync.Mutex + dseqSeed uint64 +} + +// Run is the single entrypoint. It connects to the gRPC endpoint, runs every +// registered pack in dependency order, then enforces the coverage gate. +func Run(ctx context.Context, t *testing.T, env Env) { + t.Helper() + + require.NotEmpty(t, env.GRPCEndpoint, "grpcsuite: GRPCEndpoint required") + require.NotNil(t, env.InterfaceReg, "grpcsuite: InterfaceReg required") + mode, err := ParseRunMode(string(env.Mode)) + require.NoError(t, err, "grpcsuite: parse run mode") + + cov := newCoverage() + + conn, err := dialGRPC(env.GRPCEndpoint, env.InterfaceReg, cov) + require.NoError(t, err, "grpcsuite: dial gRPC") + defer func() { _ = conn.Close() }() + + cctx := newGRPCClientContext(env, conn) + + s := &Suite{ + T: t, + Ctx: ctx, + Env: env, + Conn: conn, + Cctx: cctx, + Cov: cov, + World: newWorld(), + } + s.TX = newBroadcaster(s) + + // Register the funder under its own role so packs can address it directly. The + // tenant role is the funder (it is the account guaranteed to hold uact — see + // TenantSigner), so deployment deposits never require provisioning uact to a + // sub-account. + s.World.Accounts[env.Funder] = env.FunderAddr + + // Discover what the running binary actually serves so the coverage gate and + // pack availability adapt to the branch under test (e.g. verification appears + // on AEP-86 but not on main). + disc, err := discover(ctx, conn, env.InterfaceReg) + require.NoError(t, err, "grpcsuite: discover served services") + cov.setExpected(disc) + cov.setStrict(env.RequireFullCoverage) + s.Disc = disc + t.Logf("grpcsuite: mode=%s discovered %d in-scope tx methods, %d in-scope query methods", + mode, + len(disc.InScopeMsgs()), len(disc.InScopeQueries())) + + if mode.runsTxPacks() { + s.bootstrapFunderUact() + + // Packs run sequentially (later packs depend on state earlier ones create via + // the shared World), so it is safe to retarget s.T at the active subtest rather + // than copying the Suite (which holds a mutex). + for _, p := range packs { + if !p.Available(disc) { + t.Logf("grpcsuite: pack %q not available on this binary; skipping", p.Name()) + continue + } + t.Run(p.Name(), func(subT *testing.T) { + prev := s.T + s.T = subT + defer func() { s.T = prev }() + p.Run(s) + }) + } + } + + if mode.runsQuerySmoke() { + // Dynamic query smoke sweep: reach every advertised query handler over gRPC. + // This auto-covers the query surface and catches advertised-but-unimplemented + // methods; authored query cases (in packs) verify correctness with real inputs. + t.Run("query-smoke-sweep", func(subT *testing.T) { + prev := s.T + s.T = subT + defer func() { s.T = prev }() + s.querySmokeSweep(disc) + }) + } + + // Coverage gate: fail if any in-scope tx or query RPC was never exercised. + cov.assert(t, mode) +} + +// FundAccount creates a fresh key named role (if absent) and funds it from the +// suite's Funder with coins. Returns the account address and records it in the +// World. +func (s *Suite) FundAccount(role string, coins sdk.Coins) sdk.AccAddress { + s.T.Helper() + + addr := s.ensureKey(role) + msg := banktypes.NewMsgSend(s.Env.FunderAddr, addr, coins) + s.BroadcastOK(s.Env.Funder, msg) + + s.World.mu.Lock() + s.World.Accounts[role] = addr + s.World.mu.Unlock() + return addr +} + +// FundAccountDefault funds role with uakt — the freely-transferable denom used for +// fees and for the uakt bid deposit. The one deposit that must be in uact (the +// deployment owner's, which has to match the uact group price) is made by the +// funder via TenantSigner, so sub-accounts never need the non-transferable uact. +func (s *Suite) FundAccountDefault(role string) sdk.AccAddress { + return s.FundAccount(role, sdk.NewCoins(sdk.NewCoin(s.Env.BondDenom, sdkmath.NewInt(2_000_000_000)))) // ~2000 AKT +} + +// TenantSigner is the keyring key name that owns the suite's deployments. A +// deployment deposit must be in uact (it has to match the uact group price), and +// the funder is the account guaranteed to hold uact on both the forked chain and +// the in-process net — so the funder doubles as the tenant. This mirrors the real +// chain, where uact is non-transferable and tenants spend their own uact. +func (s *Suite) TenantSigner() string { return s.Env.Funder } + +// TenantAddr is the tenant's address (the funder's). +func (s *Suite) TenantAddr() sdk.AccAddress { return s.Env.FunderAddr } + +// NextDSeq returns a process-unique, monotonically increasing deployment sequence +// number seeded from the chain's current height (the conventional DSeq source). +func (s *Suite) NextDSeq() uint64 { + s.dseqMu.Lock() + defer s.dseqMu.Unlock() + if s.dseqSeed == 0 { + s.dseqSeed = uint64(s.LatestHeight()) + } + s.dseqSeed++ + return s.dseqSeed +} + +// WaitBlocks blocks until the chain advances by at least n blocks. +func (s *Suite) WaitBlocks(n int64) { + s.T.Helper() + start := s.LatestHeight() + ctx, cancel := context.WithTimeout(s.Ctx, 60*time.Second) + defer cancel() + for { + if s.LatestHeight() >= start+n { + return + } + select { + case <-ctx.Done(): + s.T.Fatalf("grpcsuite: WaitBlocks(%d) timed out", n) + return + case <-time.After(500 * time.Millisecond): + } + } +} + +// LatestHeight returns the chain's latest block height over gRPC. +func (s *Suite) LatestHeight() int64 { + s.T.Helper() + resp, err := cmtservice.NewServiceClient(s.Conn).GetLatestBlock(s.Ctx, &cmtservice.GetLatestBlockRequest{}) + require.NoError(s.T, err, "grpcsuite: GetLatestBlock") + if resp.SdkBlock != nil { + return resp.SdkBlock.Header.Height + } + return resp.Block.Header.Height +} + +// LatestBlockTime returns the chain's latest block time over gRPC (use this rather +// than wall-clock time for on-chain timestamp fields). +func (s *Suite) LatestBlockTime() time.Time { + s.T.Helper() + resp, err := cmtservice.NewServiceClient(s.Conn).GetLatestBlock(s.Ctx, &cmtservice.GetLatestBlockRequest{}) + require.NoError(s.T, err, "grpcsuite: GetLatestBlock") + if resp.SdkBlock != nil { + return resp.SdkBlock.Header.Time + } + return resp.Block.Header.Time +} + +// ensureKey returns the address of a keyring key named role, creating it if needed. +func (s *Suite) ensureKey(role string) sdk.AccAddress { + s.T.Helper() + if rec, err := s.Env.Keyring.Key(role); err == nil { + addr, err := rec.GetAddress() + require.NoError(s.T, err) + return addr + } + rec, _, err := s.Env.Keyring.NewMnemonic(role, keyring.English, sdk.FullFundraiserPath, keyring.DefaultBIP39Passphrase, hd.Secp256k1) + require.NoError(s.T, err, "grpcsuite: create key %q", role) + addr, err := rec.GetAddress() + require.NoError(s.T, err) + return addr +} + +// Addr returns the address of a previously funded role, failing if unknown. +func (s *Suite) Addr(role string) sdk.AccAddress { + s.World.mu.Lock() + defer s.World.mu.Unlock() + addr, ok := s.World.Accounts[role] + require.Truef(s.T, ok, "grpcsuite: account role %q not set up", role) + return addr +} + +// logf is a small helper to keep pack output consistent. +func (s *Suite) logf(format string, args ...interface{}) { + s.T.Logf("grpcsuite: "+format, args...) +} diff --git a/tests/upgrade/grpcsuite/gov.go b/tests/upgrade/grpcsuite/gov.go new file mode 100644 index 000000000..dd8fbafa9 --- /dev/null +++ b/tests/upgrade/grpcsuite/gov.go @@ -0,0 +1,181 @@ +package grpcsuite + +import ( + "context" + "strconv" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + govv1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1" + "github.com/stretchr/testify/require" +) + +// GovAuthority returns the bech32 address of the gov module account, which is the +// `authority` for every MsgUpdateParams and other gov-gated message. +func (s *Suite) GovAuthority() string { + s.T.Helper() + if v := s.World.Get("gov.authority"); v != nil { + return v.(string) + } + qc := authtypes.NewQueryClient(s.Conn) + resp, err := qc.ModuleAccountByName(s.Ctx, &authtypes.QueryModuleAccountByNameRequest{Name: "gov"}) + require.NoError(s.T, err, "query gov module account") + + var acc sdk.AccountI + require.NoError(s.T, s.Env.InterfaceReg.UnpackAny(resp.Account, &acc)) + addr := acc.GetAddress().String() + s.World.Set("gov.authority", addr) + return addr +} + +// PassGovProposal submits msgs as a single governance proposal from the Funder, +// votes yes, waits for it to pass and execute, then records each wrapped message for +// coverage. The Funder must hold ~all voting power — true on a testnetify fork and +// on the single-validator in-process network. Requires a short voting period +// (configured in the testnetify state / in-process genesis). +func (s *Suite) PassGovProposal(title string, msgs ...sdk.Msg) { + s.T.Helper() + require.NotEmpty(s.T, msgs, "PassGovProposal needs at least one message") + + gq := govv1.NewQueryClient(s.Conn) + + deposit := s.govMinDeposit(gq) + prop, err := govv1.NewMsgSubmitProposal(msgs, deposit, s.Env.FunderAddr.String(), "", title, title, false) + require.NoError(s.T, err, "build gov proposal") + + lastProposalID := s.latestProposalID(gq) + res := s.BroadcastOK(s.Env.Funder, prop) + pid, ok := proposalIDFromEvents(res) + if !ok { + pid = s.waitSubmittedProposalID(gq, title, lastProposalID) + } + + s.BroadcastOK(s.Env.Funder, govv1.NewMsgVote(s.Env.FunderAddr, pid, govv1.VoteOption_VOTE_OPTION_YES, "")) + s.waitProposalPassed(gq, pid) + + // Wrapped messages executed on-chain when the proposal passed; count them. + for _, m := range msgs { + s.Cov.recordMsg(sdk.MsgTypeURL(m)) + } + s.logf("gov proposal %d passed (%d msg(s)): %s", pid, len(msgs), title) +} + +func (s *Suite) govMinDeposit(gq govv1.QueryClient) sdk.Coins { + resp, err := gq.Params(s.Ctx, &govv1.QueryParamsRequest{ParamsType: "deposit"}) + if err == nil && resp.Params != nil && len(resp.Params.MinDeposit) > 0 { + return sdk.NewCoins(resp.Params.MinDeposit...) + } + return sdk.NewCoins(sdk.NewInt64Coin(s.Env.BondDenom, 10_000_000)) +} + +func (s *Suite) latestProposalID(gq govv1.QueryClient) uint64 { + s.T.Helper() + + var maxID uint64 + err := s.eachProposal(s.Ctx, gq, func(p *govv1.Proposal) bool { + if p.Id > maxID { + maxID = p.Id + } + return false + }) + require.NoError(s.T, err, "query gov proposals before submit") + return maxID +} + +func (s *Suite) waitSubmittedProposalID(gq govv1.QueryClient, title string, after uint64) uint64 { + s.T.Helper() + + ctx, cancel := context.WithTimeout(s.Ctx, 20*time.Second) + defer cancel() + + var lastErr error + for { + var found uint64 + err := s.eachProposal(ctx, gq, func(p *govv1.Proposal) bool { + if p.Id > after && p.Title == title && p.Proposer == s.Env.FunderAddr.String() { + found = p.Id + return true + } + return false + }) + if err == nil && found != 0 { + return found + } + if err != nil { + lastErr = err + } + + select { + case <-ctx.Done(): + s.T.Fatalf("gov proposal %q submitted but was not found in gov state after proposal %d: %v", title, after, lastErr) + case <-time.After(500 * time.Millisecond): + } + } +} + +func (s *Suite) eachProposal(ctx context.Context, gq govv1.QueryClient, visit func(*govv1.Proposal) bool) error { + s.T.Helper() + + var next []byte + for { + resp, err := gq.Proposals(ctx, &govv1.QueryProposalsRequest{ + Pagination: &sdkquery.PageRequest{Key: next, Limit: 100}, + }) + if err != nil { + return err + } + for _, p := range resp.Proposals { + if p != nil && visit(p) { + return nil + } + } + if resp.Pagination == nil || len(resp.Pagination.NextKey) == 0 { + return nil + } + next = resp.Pagination.NextKey + } +} + +func (s *Suite) waitProposalPassed(gq govv1.QueryClient, pid uint64) { + s.T.Helper() + ctx, cancel := context.WithTimeout(s.Ctx, 120*time.Second) + defer cancel() + for { + resp, err := gq.Proposal(ctx, &govv1.QueryProposalRequest{ProposalId: pid}) + if err == nil && resp.Proposal != nil { + switch resp.Proposal.Status { + case govv1.ProposalStatus_PROPOSAL_STATUS_PASSED: + return + case govv1.ProposalStatus_PROPOSAL_STATUS_REJECTED, govv1.ProposalStatus_PROPOSAL_STATUS_FAILED: + s.T.Fatalf("gov proposal %d ended in status %s", pid, resp.Proposal.Status) + } + } + select { + case <-ctx.Done(): + s.T.Fatalf("gov proposal %d did not pass within timeout (last err: %v)", pid, err) + case <-time.After(time.Second): + } + } +} + +// proposalIDFromEvents extracts the proposal_id emitted by a MsgSubmitProposal tx +// when tx indexing is enabled. Forked upgrade nodes may disable tx indexing, so +// callers must fall back to gov state when this returns false. +func proposalIDFromEvents(res *sdk.TxResponse) (uint64, bool) { + if res == nil { + return 0, false + } + for _, ev := range res.Events { + for _, a := range ev.Attributes { + if a.Key == "proposal_id" { + id, err := strconv.ParseUint(a.Value, 10, 64) + if err == nil { + return id, true + } + } + } + } + return 0, false +} diff --git a/tests/upgrade/grpcsuite/grpcconn.go b/tests/upgrade/grpcsuite/grpcconn.go new file mode 100644 index 000000000..1a6540540 --- /dev/null +++ b/tests/upgrade/grpcsuite/grpcconn.go @@ -0,0 +1,73 @@ +// Package grpcsuite implements a harness-agnostic, exhaustive post-upgrade +// verification suite that exercises every Akash (and targeted Cosmos) transaction +// and query strictly over the chain's gRPC API. +// +// The suite is driven by two thin callers: +// - the upgrade post-upgrade worker (tests/upgrade, build tag e2e.upgrade), which +// points it at a testnetify-forked, freshly-upgraded validator; and +// - an in-process integration test (tests/e2e, build tag e2e.integration), which +// points it at a single-validator testutil/network for fast iteration. +// +// Both supply an Env; the suite owns everything else. All checks run against the +// gRPC endpoint: queries route through a gRPC-backed client.Context (WithGRPCClient), +// and transactions are signed locally and broadcast via the cosmos tx ServiceClient +// over the same connection. +package grpcsuite + +import ( + "context" + "fmt" + + sdkclient "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + cflags "pkg.akt.dev/go/cli/flags" +) + +// dialGRPC opens a gRPC connection to endpoint (host:port) configured with the +// cosmos gogoproto codec so that interface (Any) fields decode correctly. A unary +// interceptor records every invoked method path into cov, so that every query made +// through any generated QueryClient is automatically counted for coverage. +func dialGRPC(endpoint string, ireg codectypes.InterfaceRegistry, cov *Coverage) (*grpc.ClientConn, error) { + pc := codec.NewProtoCodec(ireg) + conn, err := grpc.NewClient( + endpoint, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDefaultCallOptions(grpc.ForceCodec(pc.GRPCCodec())), + grpc.WithChainUnaryInterceptor(coverageInterceptor(cov)), + ) + if err != nil { + return nil, fmt.Errorf("dial gRPC %q: %w", endpoint, err) + } + return conn, nil +} + +// coverageInterceptor records every gRPC method invoked through the connection. +func coverageInterceptor(cov *Coverage) grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + cov.recordMethod(method) + return invoker(ctx, method, req, reply, cc, opts...) + } +} + +// newGRPCClientContext builds a client.Context whose query path is the gRPC +// connection. Generated module QueryClients constructed from this context (and the +// auth AccountRetriever) invoke over gRPC rather than the Comet RPC/ABCI path. +func newGRPCClientContext(env Env, conn *grpc.ClientConn) sdkclient.Context { + return sdkclient.Context{}. + WithCodec(env.Cdc). + WithInterfaceRegistry(env.InterfaceReg). + WithTxConfig(env.TxConfig). + WithLegacyAmino(env.Amino). + WithChainID(env.ChainID). + WithKeyring(env.Keyring). + WithAccountRetriever(authtypes.AccountRetriever{}). + WithBroadcastMode(cflags.BroadcastSync). + WithSkipConfirmation(true). + WithSignModeStr("direct"). + WithGRPCClient(conn) +} diff --git a/tests/upgrade/grpcsuite/pack.go b/tests/upgrade/grpcsuite/pack.go new file mode 100644 index 000000000..6f5d20647 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack.go @@ -0,0 +1,38 @@ +package grpcsuite + +// Pack is a per-module group of ordered cases. Packs run in slice order so that +// earlier packs (cert, provider, deployment) can set up the on-chain state that +// later packs (market, escrow, audit) consume, via the shared World. +type Pack interface { + // Name is the pack's display name and subtest name. + Name() string + // Available reports whether the pack's module is served by the running binary. + // Packs for modules absent on the branch under test (e.g. verification on main) + // return false and are skipped. + Available(d *Discovery) bool + // Run executes the pack's cases against s. + Run(s *Suite) +} + +// packs is the ordered list of module packs the suite runs. Order encodes data +// dependencies. New packs are appended here as they are authored. +var packs = []Pack{ + deploymentPack{}, + providerPack{}, + marketPack{}, + auditPack{}, + escrowPack{}, + certPack{}, + oraclePack{}, + bmePack{}, + cosmosBankPack{}, + cosmosAuthVestingPack{}, + cosmosAuthzPack{}, + cosmosFeegrantPack{}, + cosmosGovPack{}, + cosmosStakingPack{}, + cosmosDistributionPack{}, + cosmosMiscPack{}, + cosmwasmPack{}, + govParamsPack{}, +} diff --git a/tests/upgrade/grpcsuite/pack_audit.go b/tests/upgrade/grpcsuite/pack_audit.go new file mode 100644 index 000000000..cabad97b1 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_audit.go @@ -0,0 +1,87 @@ +package grpcsuite + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/stretchr/testify/require" + + av1 "pkg.akt.dev/go/node/audit/v1" + tattr "pkg.akt.dev/go/node/types/attributes/v1" +) + +type auditPack struct{} + +func (auditPack) Name() string { return "audit" } + +func (auditPack) Available(d *Discovery) bool { return d.HasModule("akash.audit.v1") } + +func (auditPack) Run(s *Suite) { + providerSigner, ok := s.World.Get(wProviderSigner).(string) + require.True(s.T, ok, "audit pack needs the provider pack") + require.NotEmpty(s.T, providerSigner, "audit pack needs the provider signer") + provider := s.Addr(providerSigner) + auditor := s.FundAccountDefault("auditor") + + attrs := tattr.Attributes{ + {Key: "region", Value: "us-west"}, + {Key: "audited", Value: "true"}, + } + + // Auditor attests the provider's attributes. + s.BroadcastOK("auditor", &av1.MsgSignProviderAttributes{ + Owner: provider.String(), + Auditor: auditor.String(), + Attributes: attrs, + }) + + // All four audit queries run against the freshly-signed attributes, BEFORE the + // delete below removes them. + q := av1.NewQueryClient(s.Conn) + + all, err := q.AllProvidersAttributes(s.Ctx, &av1.QueryAllProvidersAttributesRequest{Pagination: &sdkquery.PageRequest{Limit: 100}}) + require.NoError(s.T, err, "AllProvidersAttributes") + require.NotEmpty(s.T, all.Providers, "expected at least one audited provider") + + pa, err := q.ProviderAttributes(s.Ctx, &av1.QueryProviderAttributesRequest{Owner: provider.String(), Pagination: &sdkquery.PageRequest{Limit: 50}}) + require.NoError(s.T, err, "ProviderAttributes") + require.NotEmpty(s.T, pa.Providers, "provider should have audited attributes") + require.Equal(s.T, provider.String(), pa.Providers[0].Owner, "ProviderAttributes should be scoped to the provider") + + pca, err := q.ProviderAuditorAttributes(s.Ctx, &av1.QueryProviderAuditorRequest{Owner: provider.String(), Auditor: auditor.String()}) + require.NoError(s.T, err, "ProviderAuditorAttributes") + require.NotEmpty(s.T, pca.Providers, "auditor should have attested the provider") + + aa, err := q.AuditorAttributes(s.Ctx, &av1.QueryAuditorAttributesRequest{Auditor: auditor.String(), Pagination: &sdkquery.PageRequest{Limit: 50}}) + require.NoError(s.T, err, "AuditorAttributes") + require.NotEmpty(s.T, aa.Providers, "auditor should have audited providers") + + auditNegatives(s, providerSigner, auditor) + + // Auditor revokes the attested attributes (kept last so the queries above see them). + s.BroadcastOK("auditor", &av1.MsgDeleteProviderAttributes{ + Owner: provider.String(), + Auditor: auditor.String(), + Keys: []string{"region", "audited"}, + }) + s.logf("audit complete (sign, query all 4, delete provider attributes)") +} + +func auditNegatives(s *Suite, providerSigner string, auditor sdk.AccAddress) { + s.T.Helper() + provider := s.Addr(providerSigner).String() + + // The Auditor field is the required signer. A tx signed by "auditor" but + // declaring a DIFFERENT auditor must be rejected (signer mismatch). This holds + // for both sign and delete. + other := s.FundAccountDefault("auditor2") + s.BroadcastExpectErr("auditor", &av1.MsgSignProviderAttributes{ + Owner: provider, + Auditor: other.String(), + Attributes: tattr.Attributes{{Key: "region", Value: "us-west"}}, + }) + s.BroadcastExpectErr("auditor", &av1.MsgDeleteProviderAttributes{ + Owner: provider, + Auditor: other.String(), + Keys: []string{"region"}, + }) +} diff --git a/tests/upgrade/grpcsuite/pack_bme.go b/tests/upgrade/grpcsuite/pack_bme.go new file mode 100644 index 000000000..86739b9f9 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_bme.go @@ -0,0 +1,107 @@ +package grpcsuite + +import ( + "context" + "time" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/stretchr/testify/require" + + bmev1 "pkg.akt.dev/go/node/bme/v1" + "pkg.akt.dev/go/sdkutil" +) + +// bmePack exercises the burn/mint engine. It depends on the oracle pack having fed +// an AKT price (bme converts via oracle prices) and runs before gov-params. +type bmePack struct{} + +func (bmePack) Name() string { return "bme" } + +func (bmePack) Available(d *Discovery) bool { + return d.HasModule("akash.bme.v1") && d.HasModule("akash.oracle.v2") +} + +func (bmePack) Run(s *Suite) { + q := bmev1.NewQueryClient(s.Conn) + actor := s.FundAccountDefault("bme") // holds both uakt and uact + _ = s.Addr("pricewriter") // created and authorized by the oracle pack + + akt := func(n int64) sdk.Coin { return sdk.NewCoin(sdkutil.DenomUakt, sdkmath.NewInt(n)) } + act := func(n int64) sdk.Coin { return sdk.NewCoin(sdkutil.DenomUact, sdkmath.NewInt(n)) } + + // Seed the vault with AKT from a funded account (gov-gated). + s.PassGovProposal("grpcsuite: fund bme vault", + &bmev1.MsgFundVault{Authority: s.GovAuthority(), Amount: akt(10_000_000), Source: actor.String()}) + + // Re-feed a fresh AKT price: the gov proposal above consumed several seconds and + // bme refuses to queue conversions when the oracle price is not healthy. + s.feedAKTPrice(3) + + // Mint/burn acceptance depends on the live collateral ratio and the circuit + // breaker, which are not deterministically controllable here. Each message is + // exercised over gRPC and must either execute or be correctly rejected by the + // circuit breaker (only minting ACT is typically enabled on main). + tolerate := []string{"circuit breaker", "collateral", "insufficient"} + s.BroadcastTolerant("bme", tolerate, &bmev1.MsgMintACT{Owner: actor.String(), To: actor.String(), CoinsToBurn: akt(1_000_000)}) + s.BroadcastTolerant("bme", tolerate, &bmev1.MsgBurnACT{Owner: actor.String(), To: actor.String(), CoinsToBurn: act(1_000_000)}) + s.BroadcastTolerant("bme", tolerate, &bmev1.MsgBurnMint{Owner: actor.String(), To: actor.String(), CoinsToBurn: akt(1_000_000), DenomToMint: sdkutil.DenomUact}) + + // Queries: every bme query RPC with real inputs. + vs, err := q.VaultState(s.Ctx, &bmev1.QueryVaultStateRequest{}) + require.NoError(s.T, err, "VaultState") + require.NotNil(s.T, vs, "VaultState response") + st := s.bmeStatusWithFreshPrice(q) + require.False(s.T, st.CollateralRatio.IsNil(), "Status should expose a collateral ratio") + _, err = q.LedgerRecords(s.Ctx, &bmev1.QueryLedgerRecordsRequest{Pagination: &sdkquery.PageRequest{Limit: 10}}) + require.NoError(s.T, err, "LedgerRecords") + _, err = q.Params(s.Ctx, &bmev1.QueryParamsRequest{}) + require.NoError(s.T, err, "bme Params") + + bmeNegatives(s, actor) + s.logf("bme complete (fund vault + mint ACT + burn ACT + burn/mint)") +} + +func (s *Suite) bmeStatusWithFreshPrice(q bmev1.QueryClient) *bmev1.QueryStatusResponse { + s.T.Helper() + + ctx, cancel := context.WithTimeout(s.Ctx, 45*time.Second) + defer cancel() + + var lastErr error + var lastFeedHeight int64 + for { + resp, err := q.Status(ctx, &bmev1.QueryStatusRequest{}) + if err == nil { + return resp + } + lastErr = err + + latest := s.LatestHeight() + if latest-lastFeedHeight >= 2 { + s.feedAKTPrice(3) + lastFeedHeight = s.LatestHeight() + } + + select { + case <-ctx.Done(): + require.NoError(s.T, lastErr, "Status") + return nil + case <-time.After(500 * time.Millisecond): + } + } +} + +func bmeNegatives(s *Suite, actor sdk.AccAddress) { + s.T.Helper() + // Zero amount to burn — rejected by validation. + s.BroadcastExpectErr("bme", &bmev1.MsgMintACT{ + Owner: actor.String(), To: actor.String(), CoinsToBurn: sdk.NewInt64Coin(sdkutil.DenomUakt, 0), + }) + // MsgFundVault is gov-gated; broadcasting it directly (authority = a normal + // account, not the gov module account) must be rejected. + s.BroadcastExpectErr("bme", &bmev1.MsgFundVault{ + Authority: actor.String(), Amount: sdk.NewInt64Coin(sdkutil.DenomUakt, 1_000_000), Source: actor.String(), + }) +} diff --git a/tests/upgrade/grpcsuite/pack_cert.go b/tests/upgrade/grpcsuite/pack_cert.go new file mode 100644 index 000000000..b1cb7019e --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cert.go @@ -0,0 +1,90 @@ +package grpcsuite + +import ( + "encoding/pem" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/stretchr/testify/require" + + cv1 "pkg.akt.dev/go/node/cert/v1" + certutils "pkg.akt.dev/go/node/cert/v1/utils" +) + +type certPack struct{} + +func (certPack) Name() string { return "cert" } + +func (certPack) Available(d *Discovery) bool { return d.HasModule("akash.cert.v1") } + +func (certPack) Run(s *Suite) { + owner := s.FundAccountDefault("certowner") + certPEM, pubPEM := certGenerate(s, owner) + + s.BroadcastOK("certowner", &cv1.MsgCreateCertificate{ + Owner: owner.String(), + Cert: certPEM, + Pubkey: pubPEM, + }) + + q := cv1.NewQueryClient(s.Conn) + + // Certificates filtered by owner (with pagination); assert the new cert is + // present and valid. + resp, err := q.Certificates(s.Ctx, &cv1.QueryCertificatesRequest{ + Filter: cv1.CertificateFilter{Owner: owner.String()}, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "Certificates by owner") + require.NotEmpty(s.T, resp.Certificates, "owner should have a certificate") + serial := resp.Certificates[0].Serial + require.Equal(s.T, cv1.CertificateValid, resp.Certificates[0].Certificate.State, "new cert should be valid") + + // Certificates filtered by owner+serial; assert exactly one. + one, err := q.Certificates(s.Ctx, &cv1.QueryCertificatesRequest{ + Filter: cv1.CertificateFilter{Owner: owner.String(), Serial: serial}, + }) + require.NoError(s.T, err, "Certificates by owner+serial") + require.Len(s.T, one.Certificates, 1, "owner+serial should return exactly one cert") + + certNegatives(s, owner, serial) + + // Revoke (kept last) and confirm the state transitions to revoked. + s.BroadcastOK("certowner", &cv1.MsgRevokeCertificate{ID: cv1.ID{Owner: owner.String(), Serial: serial}}) + rev, err := q.Certificates(s.Ctx, &cv1.QueryCertificatesRequest{ + Filter: cv1.CertificateFilter{Owner: owner.String(), Serial: serial}, + }) + require.NoError(s.T, err, "Certificates after revoke") + require.NotEmpty(s.T, rev.Certificates) + require.Equal(s.T, cv1.CertificateRevoked, rev.Certificates[0].Certificate.State, "cert should be revoked") + s.logf("cert lifecycle complete (create + revoke, serial %s)", serial) +} + +// certGenerate produces a PEM cert + pubkey for owner via KeyPairManager. +func certGenerate(s *Suite, owner sdk.AccAddress) (certPEM, pubPEM []byte) { + s.T.Helper() + cctx := s.Cctx.WithHomeDir(s.T.TempDir()) + kpm, err := certutils.NewKeyPairManager(cctx, owner) + require.NoError(s.T, err, "NewKeyPairManager") + require.NoError(s.T, kpm.Generate(time.Now().Add(-time.Hour), time.Now().Add(365*24*time.Hour), nil), "generate cert") + certDER, _, pubDER, err := kpm.Read() + require.NoError(s.T, err, "read cert") + return pem.EncodeToMemory(&pem.Block{Type: cv1.PemBlkTypeCertificate, Bytes: certDER}), + pem.EncodeToMemory(&pem.Block{Type: cv1.PemBlkTypeECPublicKey, Bytes: pubDER}) +} + +func certNegatives(s *Suite, owner sdk.AccAddress, serial string) { + s.T.Helper() + // Malformed certificate / pubkey bytes. + _, _ = s.BroadcastExpectErr("certowner", &cv1.MsgCreateCertificate{ + Owner: owner.String(), + Cert: []byte("-----BEGIN CERTIFICATE-----\nnot-a-real-cert\n-----END CERTIFICATE-----"), + Pubkey: []byte("not-a-pubkey"), + }) + // Revoke a serial that does not exist. + _, _ = s.BroadcastExpectErr("certowner", &cv1.MsgRevokeCertificate{ID: cv1.ID{Owner: owner.String(), Serial: "99999999999999999999"}}) + // Revoke with an owner that does not match the signer (signed by certowner). + other := s.FundAccountDefault("certother") + _, _ = s.BroadcastExpectErr("certowner", &cv1.MsgRevokeCertificate{ID: cv1.ID{Owner: other.String(), Serial: serial}}) +} diff --git a/tests/upgrade/grpcsuite/pack_cosmos_auth_vesting.go b/tests/upgrade/grpcsuite/pack_cosmos_auth_vesting.go new file mode 100644 index 000000000..bebe35f7d --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmos_auth_vesting.go @@ -0,0 +1,99 @@ +package grpcsuite + +import ( + "strings" + "time" + + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + vestingtypes "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" + "github.com/stretchr/testify/require" +) + +type cosmosAuthVestingPack struct{} + +func (cosmosAuthVestingPack) Name() string { return "cosmos-auth-vesting" } + +func (cosmosAuthVestingPack) Available(d *Discovery) bool { + return d.HasModule("cosmos.auth.v1beta1") +} + +func (cosmosAuthVestingPack) Run(s *Suite) { + q := authtypes.NewQueryClient(s.Conn) + + acctResp, err := q.Account(s.Ctx, &authtypes.QueryAccountRequest{Address: s.Env.FunderAddr.String()}) + require.NoError(s.T, err, "auth Account") + require.NotNil(s.T, acctResp.Account, "funder account should exist") + info, err := q.AccountInfo(s.Ctx, &authtypes.QueryAccountInfoRequest{Address: s.Env.FunderAddr.String()}) + require.NoError(s.T, err, "auth AccountInfo") + require.Equal(s.T, s.Env.FunderAddr.String(), info.Info.Address) + byID, err := q.AccountAddressByID(s.Ctx, &authtypes.QueryAccountAddressByIDRequest{AccountId: info.Info.AccountNumber}) + require.NoError(s.T, err, "auth AccountAddressByID") + require.Equal(s.T, s.Env.FunderAddr.String(), byID.AccountAddress) + accounts, err := q.Accounts(s.Ctx, &authtypes.QueryAccountsRequest{Pagination: &sdkquery.PageRequest{Limit: 10}}) + require.NoError(s.T, err, "auth Accounts") + require.NotEmpty(s.T, accounts.Accounts, "auth Accounts should not be empty") + _, err = q.Params(s.Ctx, &authtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "auth Params") + mods, err := q.ModuleAccounts(s.Ctx, &authtypes.QueryModuleAccountsRequest{}) + require.NoError(s.T, err, "auth ModuleAccounts") + require.NotEmpty(s.T, mods.Accounts, "module accounts should not be empty") + gov, err := q.ModuleAccountByName(s.Ctx, &authtypes.QueryModuleAccountByNameRequest{Name: "gov"}) + require.NoError(s.T, err, "auth ModuleAccountByName(gov)") + require.NotNil(s.T, gov.Account, "gov module account should exist") + prefix, err := q.Bech32Prefix(s.Ctx, &authtypes.Bech32PrefixRequest{}) + require.NoError(s.T, err, "auth Bech32Prefix") + require.NotEmpty(s.T, prefix.Bech32Prefix) + asBytes, err := q.AddressStringToBytes(s.Ctx, &authtypes.AddressStringToBytesRequest{ + AddressString: s.Env.FunderAddr.String(), + }) + require.NoError(s.T, err, "auth AddressStringToBytes") + asString, err := q.AddressBytesToString(s.Ctx, &authtypes.AddressBytesToStringRequest{AddressBytes: asBytes.AddressBytes}) + require.NoError(s.T, err, "auth AddressBytesToString") + require.Equal(s.T, s.Env.FunderAddr.String(), asString.AddressString) + _, err = q.AddressStringToBytes(s.Ctx, &authtypes.AddressStringToBytesRequest{AddressString: "not-an-address"}) + require.Error(s.T, err, "invalid address string should be rejected") + + now := s.LatestBlockTime() + continuous := s.ensureKey("cosmos-vesting-continuous") + delayed := s.ensureKey("cosmos-vesting-delayed") + locked := s.ensureKey("cosmos-vesting-locked") + periodic := s.ensureKey("cosmos-vesting-periodic") + + s.BroadcastOK(s.Env.Funder, vestingtypes.NewMsgCreateVestingAccount( + s.Env.FunderAddr, continuous, cosmosCoins(s.Env.BondDenom, 1_000_000), now.Add(time.Hour).Unix(), false, + )) + s.BroadcastOK(s.Env.Funder, vestingtypes.NewMsgCreateVestingAccount( + s.Env.FunderAddr, delayed, cosmosCoins(s.Env.BondDenom, 1_000_000), now.Add(2*time.Hour).Unix(), true, + )) + s.BroadcastOK(s.Env.Funder, vestingtypes.NewMsgCreatePermanentLockedAccount( + s.Env.FunderAddr, locked, cosmosCoins(s.Env.BondDenom, 1_000_000), + )) + s.BroadcastOK(s.Env.Funder, vestingtypes.NewMsgCreatePeriodicVestingAccount( + s.Env.FunderAddr, + periodic, + now.Unix(), + []vestingtypes.Period{{Length: int64(time.Hour.Seconds()), Amount: cosmosCoins(s.Env.BondDenom, 1_000_000)}}, + )) + + requireAccountType(s, q, continuous.String(), "ContinuousVestingAccount") + requireAccountType(s, q, delayed.String(), "DelayedVestingAccount") + requireAccountType(s, q, locked.String(), "PermanentLockedAccount") + requireAccountType(s, q, periodic.String(), "PeriodicVestingAccount") + + s.BroadcastExpectErr(s.Env.Funder, vestingtypes.NewMsgCreateVestingAccount( + s.Env.FunderAddr, continuous, cosmosCoins(s.Env.BondDenom, 1_000_000), now.Add(time.Hour).Unix(), false, + )) + s.BroadcastExpectErr(s.Env.Funder, vestingtypes.NewMsgCreatePeriodicVestingAccount( + s.Env.FunderAddr, s.ensureKey("cosmos-vesting-empty-periods"), now.Unix(), nil, + )) + s.logf("cosmos auth/vesting complete (auth queries + all vesting account messages)") +} + +func requireAccountType(s *Suite, q authtypes.QueryClient, addr string, want string) { + s.T.Helper() + resp, err := q.Account(s.Ctx, &authtypes.QueryAccountRequest{Address: addr}) + require.NoError(s.T, err, "auth Account(%s)", addr) + require.NotNil(s.T, resp.Account, "account should exist") + require.Truef(s.T, strings.Contains(resp.Account.TypeUrl, want), "account type %q should contain %q", resp.Account.TypeUrl, want) +} diff --git a/tests/upgrade/grpcsuite/pack_cosmos_authz.go b/tests/upgrade/grpcsuite/pack_cosmos_authz.go new file mode 100644 index 000000000..42d04fa2e --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmos_authz.go @@ -0,0 +1,79 @@ +package grpcsuite + +import ( + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/cosmos/cosmos-sdk/x/authz" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" +) + +type cosmosAuthzPack struct{} + +func (cosmosAuthzPack) Name() string { return "cosmos-authz" } + +func (cosmosAuthzPack) Available(d *Discovery) bool { return d.HasModule("cosmos.authz.v1beta1") } + +func (cosmosAuthzPack) Run(s *Suite) { + q := authz.NewQueryClient(s.Conn) + + granter := s.FundAccountDefault("cosmos-authz-granter") + grantee := s.FundAccountDefault("cosmos-authz-grantee") + receiver := s.ensureKey("cosmos-authz-receiver") + msgType := "/cosmos.bank.v1beta1.MsgSend" + exp := s.LatestBlockTime().Add(time.Hour) + + grant, err := authz.NewMsgGrant( + granter, + grantee, + banktypes.NewSendAuthorization(cosmosCoins(s.Env.BondDenom, 3_000_000), []sdk.AccAddress{receiver}), + &exp, + ) + require.NoError(s.T, err, "build authz MsgGrant") + s.BroadcastOK("cosmos-authz-granter", grant) + + grants, err := q.Grants(s.Ctx, &authz.QueryGrantsRequest{ + Granter: granter.String(), + Grantee: grantee.String(), + MsgTypeUrl: msgType, + }) + require.NoError(s.T, err, "authz Grants") + require.Len(s.T, grants.Grants, 1, "expected one bank send grant") + granterGrants, err := q.GranterGrants(s.Ctx, &authz.QueryGranterGrantsRequest{ + Granter: granter.String(), + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "authz GranterGrants") + require.NotEmpty(s.T, granterGrants.Grants, "granter should have grants") + granteeGrants, err := q.GranteeGrants(s.Ctx, &authz.QueryGranteeGrantsRequest{ + Grantee: grantee.String(), + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "authz GranteeGrants") + require.NotEmpty(s.T, granteeGrants.Grants, "grantee should have grants") + + exec := authz.NewMsgExec(grantee, []sdk.Msg{ + banktypes.NewMsgSend(granter, receiver, cosmosCoins(s.Env.BondDenom, 1_000_000)), + }) + s.BroadcastOK("cosmos-authz-grantee", &exec) + + tooMuch := authz.NewMsgExec(grantee, []sdk.Msg{ + banktypes.NewMsgSend(granter, receiver, cosmosCoins(s.Env.BondDenom, 100_000_000)), + }) + s.BroadcastExpectErr("cosmos-authz-grantee", &tooMuch) + + revoke := authz.NewMsgRevoke(granter, grantee, msgType) + s.BroadcastOK("cosmos-authz-granter", &revoke) + _, err = q.Grants(s.Ctx, &authz.QueryGrantsRequest{ + Granter: granter.String(), + Grantee: grantee.String(), + MsgTypeUrl: msgType, + }) + require.Error(s.T, err, "authz Grants should be not-found after revoke") + + missing := authz.NewMsgRevoke(granter, grantee, msgType) + s.BroadcastExpectErr("cosmos-authz-granter", &missing) + s.logf("cosmos authz complete (grant, exec, revoke, overspend/missing-grant)") +} diff --git a/tests/upgrade/grpcsuite/pack_cosmos_bank.go b/tests/upgrade/grpcsuite/pack_cosmos_bank.go new file mode 100644 index 000000000..0da24210f --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmos_bank.go @@ -0,0 +1,107 @@ +package grpcsuite + +import ( + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type cosmosBankPack struct{} + +func (cosmosBankPack) Name() string { return "cosmos-bank" } + +func (cosmosBankPack) Available(d *Discovery) bool { return d.HasModule("cosmos.bank.v1beta1") } + +func (cosmosBankPack) Run(s *Suite) { + q := banktypes.NewQueryClient(s.Conn) + + sender := s.FundAccountDefault("cosmos-bank-sender") + recvA := s.ensureKey("cosmos-bank-recv-a") + recvB := s.ensureKey("cosmos-bank-recv-b") + + sendAmt := cosmosCoins(s.Env.BondDenom, 1_000_000) + before := s.balanceOf(recvA, s.Env.BondDenom) + s.BroadcastOK("cosmos-bank-sender", banktypes.NewMsgSend(sender, recvA, sendAmt)) + after := s.balanceOf(recvA, s.Env.BondDenom) + require.Equal(s.T, sendAmt[0].Amount.String(), after.Amount.Sub(before.Amount).String(), "MsgSend receiver delta") + + s.BroadcastOK("cosmos-bank-sender", banktypes.NewMsgMultiSend( + banktypes.Input{Address: sender.String(), Coins: cosmosCoins(s.Env.BondDenom, 2_000_000)}, + []banktypes.Output{ + {Address: recvA.String(), Coins: cosmosCoins(s.Env.BondDenom, 1_000_000)}, + {Address: recvB.String(), Coins: cosmosCoins(s.Env.BondDenom, 1_000_000)}, + }, + )) + + bal, err := q.Balance(s.Ctx, &banktypes.QueryBalanceRequest{Address: recvA.String(), Denom: s.Env.BondDenom}) + require.NoError(s.T, err, "bank Balance") + require.True(s.T, bal.Balance.Amount.IsPositive(), "receiver balance should be positive") + all, err := q.AllBalances(s.Ctx, &banktypes.QueryAllBalancesRequest{ + Address: recvA.String(), + Pagination: &sdkquery.PageRequest{Limit: 1}, + }) + require.NoError(s.T, err, "bank AllBalances") + require.LessOrEqual(s.T, len(all.Balances), 1, "AllBalances pagination Limit=1 must cap results") + spendable, err := q.SpendableBalances(s.Ctx, &banktypes.QuerySpendableBalancesRequest{Address: recvA.String()}) + require.NoError(s.T, err, "bank SpendableBalances") + require.True(s.T, spendable.Balances.AmountOf(s.Env.BondDenom).IsPositive(), "receiver should have spendable uakt") + spendableDenom, err := q.SpendableBalanceByDenom(s.Ctx, &banktypes.QuerySpendableBalanceByDenomRequest{ + Address: recvA.String(), + Denom: s.Env.BondDenom, + }) + require.NoError(s.T, err, "bank SpendableBalanceByDenom") + require.True(s.T, spendableDenom.Balance.Amount.IsPositive(), "receiver should have spendable denom balance") + supply, err := q.TotalSupply(s.Ctx, &banktypes.QueryTotalSupplyRequest{Pagination: &sdkquery.PageRequest{Limit: 10}}) + require.NoError(s.T, err, "bank TotalSupply") + require.NotEmpty(s.T, supply.Supply, "total supply should not be empty") + supplyOf, err := q.SupplyOf(s.Ctx, &banktypes.QuerySupplyOfRequest{Denom: s.Env.BondDenom}) + require.NoError(s.T, err, "bank SupplyOf") + require.Equal(s.T, s.Env.BondDenom, supplyOf.Amount.Denom) + _, err = q.Params(s.Ctx, &banktypes.QueryParamsRequest{}) + require.NoError(s.T, err, "bank Params") + _, err = q.DenomsMetadata(s.Ctx, &banktypes.QueryDenomsMetadataRequest{Pagination: &sdkquery.PageRequest{Limit: 10}}) + require.NoError(s.T, err, "bank DenomsMetadata") + metadata, err := q.DenomMetadata(s.Ctx, &banktypes.QueryDenomMetadataRequest{Denom: s.Env.BondDenom}) + requireBankMetadataResult(s, "bank DenomMetadata", err) + if err == nil { + require.Equal(s.T, s.Env.BondDenom, metadata.Metadata.Base, "metadata base denom should match") + } + metadataByQuery, err := q.DenomMetadataByQueryString(s.Ctx, &banktypes.QueryDenomMetadataByQueryStringRequest{Denom: s.Env.BondDenom}) + requireBankMetadataResult(s, "bank DenomMetadataByQueryString", err) + if err == nil { + require.Equal(s.T, s.Env.BondDenom, metadataByQuery.Metadata.Base, "metadata query base denom should match") + } + owners, err := q.DenomOwners(s.Ctx, &banktypes.QueryDenomOwnersRequest{ + Denom: s.Env.BondDenom, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "bank DenomOwners") + require.NotEmpty(s.T, owners.DenomOwners, "bond denom should have owners") + _, err = q.DenomOwnersByQuery(s.Ctx, &banktypes.QueryDenomOwnersByQueryRequest{ + Denom: s.Env.BondDenom, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "bank DenomOwnersByQuery") + _, err = q.SendEnabled(s.Ctx, &banktypes.QuerySendEnabledRequest{Denoms: []string{s.Env.BondDenom}}) + require.NoError(s.T, err, "bank SendEnabled") + + poor := s.FundAccount("cosmos-bank-poor", cosmosCoins(s.Env.BondDenom, 2_000_000)) + s.BroadcastExpectErr("cosmos-bank-poor", banktypes.NewMsgSend(poor, recvB, cosmosCoins(s.Env.BondDenom, 1_000_000_000_000))) + s.BroadcastExpectErr("cosmos-bank-sender", banktypes.NewMsgSend(sender, recvA, cosmosCoins(s.Env.BondDenom, 0))) + s.BroadcastExpectErr("cosmos-bank-sender", banktypes.NewMsgMultiSend( + banktypes.Input{Address: sender.String(), Coins: cosmosCoins(s.Env.BondDenom, 1_000_000)}, + []banktypes.Output{{Address: recvB.String(), Coins: cosmosCoins(s.Env.BondDenom, 2_000_001)}}, + )) + + s.logf("cosmos bank complete (send, multisend, typed queries, negative sends)") +} + +func requireBankMetadataResult(s *Suite, query string, err error) { + s.T.Helper() + if err == nil { + return + } + require.Equal(s.T, codes.NotFound, status.Code(err), "%s should only fail when metadata is absent", query) +} diff --git a/tests/upgrade/grpcsuite/pack_cosmos_distribution.go b/tests/upgrade/grpcsuite/pack_cosmos_distribution.go new file mode 100644 index 000000000..7d48f9d7b --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmos_distribution.go @@ -0,0 +1,93 @@ +package grpcsuite + +import ( + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" +) + +type cosmosDistributionPack struct{} + +func (cosmosDistributionPack) Name() string { return "cosmos-distribution" } + +func (cosmosDistributionPack) Available(d *Discovery) bool { + return d.HasModule("cosmos.distribution.v1beta1") +} + +func (cosmosDistributionPack) Run(s *Suite) { + q := distrtypes.NewQueryClient(s.Conn) + val := s.primaryValidator() + valAddr := val.OperatorAddress + + delegator := s.FundAccountDefault("cosmos-distr-delegator") + withdrawAddr := s.ensureKey("cosmos-distr-withdraw") + s.BroadcastOK("cosmos-distr-delegator", stakingtypes.NewMsgDelegate( + delegator.String(), valAddr, cosmosCoin(s.Env.BondDenom, 5_000_000), + )) + s.WaitBlocks(1) + + s.BroadcastOK("cosmos-distr-delegator", distrtypes.NewMsgSetWithdrawAddress(delegator, withdrawAddr)) + waddr, err := q.DelegatorWithdrawAddress(s.Ctx, &distrtypes.QueryDelegatorWithdrawAddressRequest{ + DelegatorAddress: delegator.String(), + }) + require.NoError(s.T, err, "distribution DelegatorWithdrawAddress") + require.Equal(s.T, withdrawAddr.String(), waddr.WithdrawAddress) + + s.BroadcastOK("cosmos-distr-delegator", distrtypes.NewMsgFundCommunityPool( + cosmosCoins(s.Env.BondDenom, 2_000_000), delegator.String(), + )) + s.BroadcastOK("cosmos-distr-delegator", distrtypes.NewMsgDepositValidatorRewardsPool( + delegator.String(), valAddr, cosmosCoins(s.Env.BondDenom, 1_000_000), + )) + s.BroadcastOK("cosmos-distr-delegator", distrtypes.NewMsgWithdrawDelegatorReward(delegator.String(), valAddr)) + s.BroadcastTolerant(s.Env.Funder, []string{"no validator commission to withdraw"}, + distrtypes.NewMsgWithdrawValidatorCommission(valAddr)) + + recipient := s.ensureKey("cosmos-distr-community-spend") + s.PassGovProposal("grpcsuite: distribution community pool spend", + &distrtypes.MsgCommunityPoolSpend{ + Authority: s.GovAuthority(), + Recipient: recipient.String(), + Amount: cosmosCoins(s.Env.BondDenom, 1_000_000), + }, + ) + + _, err = q.Params(s.Ctx, &distrtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "distribution Params") + _, err = q.ValidatorDistributionInfo(s.Ctx, &distrtypes.QueryValidatorDistributionInfoRequest{ValidatorAddress: valAddr}) + require.NoError(s.T, err, "distribution ValidatorDistributionInfo") + _, err = q.ValidatorOutstandingRewards(s.Ctx, &distrtypes.QueryValidatorOutstandingRewardsRequest{ValidatorAddress: valAddr}) + require.NoError(s.T, err, "distribution ValidatorOutstandingRewards") + _, err = q.ValidatorCommission(s.Ctx, &distrtypes.QueryValidatorCommissionRequest{ValidatorAddress: valAddr}) + require.NoError(s.T, err, "distribution ValidatorCommission") + _, err = q.ValidatorSlashes(s.Ctx, &distrtypes.QueryValidatorSlashesRequest{ + ValidatorAddress: valAddr, + StartingHeight: 1, + EndingHeight: uint64(s.LatestHeight()), + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "distribution ValidatorSlashes") + _, err = q.DelegationRewards(s.Ctx, &distrtypes.QueryDelegationRewardsRequest{ + DelegatorAddress: delegator.String(), + ValidatorAddress: valAddr, + }) + require.NoError(s.T, err, "distribution DelegationRewards") + total, err := q.DelegationTotalRewards(s.Ctx, &distrtypes.QueryDelegationTotalRewardsRequest{ + DelegatorAddress: delegator.String(), + }) + require.NoError(s.T, err, "distribution DelegationTotalRewards") + require.NotNil(s.T, total, "total rewards response") + dvals, err := q.DelegatorValidators(s.Ctx, &distrtypes.QueryDelegatorValidatorsRequest{ + DelegatorAddress: delegator.String(), + }) + require.NoError(s.T, err, "distribution DelegatorValidators") + require.NotEmpty(s.T, dvals.Validators, "delegator should have validator rewards list") + pool, err := q.CommunityPool(s.Ctx, &distrtypes.QueryCommunityPoolRequest{}) + require.NoError(s.T, err, "distribution CommunityPool") + require.NotNil(s.T, pool, "community pool response") + + s.BroadcastExpectErr(s.Env.Funder, distrtypes.NewMsgSetWithdrawAddress(delegator, s.Env.FunderAddr)) + s.BroadcastExpectErr("cosmos-distr-delegator", distrtypes.NewMsgWithdrawDelegatorReward(delegator.String(), "akashvaloper1deadbeef")) + s.logf("cosmos distribution complete (withdraw address, rewards, community pool)") +} diff --git a/tests/upgrade/grpcsuite/pack_cosmos_feegrant.go b/tests/upgrade/grpcsuite/pack_cosmos_feegrant.go new file mode 100644 index 000000000..56f968f6f --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmos_feegrant.go @@ -0,0 +1,63 @@ +package grpcsuite + +import ( + "time" + + feegrant "cosmossdk.io/x/feegrant" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/stretchr/testify/require" +) + +type cosmosFeegrantPack struct{} + +func (cosmosFeegrantPack) Name() string { return "cosmos-feegrant" } + +func (cosmosFeegrantPack) Available(d *Discovery) bool { return d.HasModule("cosmos.feegrant.v1beta1") } + +func (cosmosFeegrantPack) Run(s *Suite) { + q := feegrant.NewQueryClient(s.Conn) + + granter := s.FundAccountDefault("cosmos-feegrant-granter") + grantee := s.FundAccountDefault("cosmos-feegrant-grantee") + exp := s.LatestBlockTime().Add(time.Hour) + msg, err := feegrant.NewMsgGrantAllowance(&feegrant.BasicAllowance{ + SpendLimit: cosmosCoins(s.Env.BondDenom, 10_000_000), + Expiration: &exp, + }, granter, grantee) + require.NoError(s.T, err, "build feegrant MsgGrantAllowance") + s.BroadcastOK("cosmos-feegrant-granter", msg) + + allowance, err := q.Allowance(s.Ctx, &feegrant.QueryAllowanceRequest{Granter: granter.String(), Grantee: grantee.String()}) + require.NoError(s.T, err, "feegrant Allowance") + require.NotNil(s.T, allowance.Allowance, "expected fee allowance") + byGrantee, err := q.Allowances(s.Ctx, &feegrant.QueryAllowancesRequest{ + Grantee: grantee.String(), + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "feegrant Allowances") + require.NotEmpty(s.T, byGrantee.Allowances, "grantee should have fee allowances") + byGranter, err := q.AllowancesByGranter(s.Ctx, &feegrant.QueryAllowancesByGranterRequest{ + Granter: granter.String(), + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "feegrant AllowancesByGranter") + require.NotEmpty(s.T, byGranter.Allowances, "granter should have fee allowances") + + dup, err := feegrant.NewMsgGrantAllowance(&feegrant.BasicAllowance{ + SpendLimit: cosmosCoins(s.Env.BondDenom, 1_000_000), + Expiration: &exp, + }, granter, grantee) + require.NoError(s.T, err, "build duplicate feegrant allowance") + s.BroadcastExpectErr("cosmos-feegrant-granter", dup) + + revoke := feegrant.NewMsgRevokeAllowance(granter, grantee) + s.BroadcastOK("cosmos-feegrant-granter", &revoke) + _, err = q.Allowance(s.Ctx, &feegrant.QueryAllowanceRequest{Granter: granter.String(), Grantee: grantee.String()}) + require.Error(s.T, err, "feegrant Allowance should fail after revoke") + missing := feegrant.NewMsgRevokeAllowance(granter, grantee) + s.BroadcastExpectErr("cosmos-feegrant-granter", &missing) + + pruner := s.FundAccountDefault("cosmos-feegrant-pruner") + s.BroadcastOK("cosmos-feegrant-pruner", &feegrant.MsgPruneAllowances{Pruner: pruner.String()}) + s.logf("cosmos feegrant complete (grant, revoke, prune, duplicate/missing edges)") +} diff --git a/tests/upgrade/grpcsuite/pack_cosmos_gov.go b/tests/upgrade/grpcsuite/pack_cosmos_gov.go new file mode 100644 index 000000000..dd712ed71 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmos_gov.go @@ -0,0 +1,156 @@ +package grpcsuite + +import ( + "fmt" + "time" + + sdkmath "cosmossdk.io/math" + "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + govv1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1" + govv1beta1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1beta1" + "github.com/stretchr/testify/require" +) + +type cosmosGovPack struct{} + +func (cosmosGovPack) Name() string { return "cosmos-gov" } + +func (cosmosGovPack) Available(d *Discovery) bool { return d.HasModule("cosmos.gov.v1") } + +func (cosmosGovPack) Run(s *Suite) { + gq := govv1.NewQueryClient(s.Conn) + params, err := gq.Params(s.Ctx, &govv1.QueryParamsRequest{}) + require.NoError(s.T, err, "gov Params") + require.NotNil(s.T, params.Params, "gov params") + minDeposit := s.govMinDeposit(gq) + noop := &govv1.MsgUpdateParams{Authority: s.GovAuthority(), Params: govParamsForUpdate(*params.Params)} + + title := fmt.Sprintf("grpcsuite gov weighted vote %d", s.LatestHeight()) + pid := s.submitGovProposal(gq, title, initialGovDeposit(minDeposit), noop) + s.BroadcastOK(s.Env.Funder, govv1.NewMsgDeposit(s.Env.FunderAddr, pid, minDeposit)) + s.BroadcastOK(s.Env.Funder, govv1.NewMsgVoteWeighted( + s.Env.FunderAddr, + pid, + govv1.WeightedVoteOptions{govv1.NewWeightedVoteOption(govv1.OptionYes, sdkmath.LegacyOneDec())}, + "grpcsuite weighted yes", + )) + + prop, err := gq.Proposal(s.Ctx, &govv1.QueryProposalRequest{ProposalId: pid}) + require.NoError(s.T, err, "gov Proposal") + require.Equal(s.T, pid, prop.Proposal.Id) + props, err := gq.Proposals(s.Ctx, &govv1.QueryProposalsRequest{Pagination: &sdkquery.PageRequest{Limit: 10}}) + require.NoError(s.T, err, "gov Proposals") + require.NotEmpty(s.T, props.Proposals, "gov proposals should not be empty") + vote, err := gq.Vote(s.Ctx, &govv1.QueryVoteRequest{ProposalId: pid, Voter: s.Env.FunderAddr.String()}) + require.NoError(s.T, err, "gov Vote") + require.Equal(s.T, s.Env.FunderAddr.String(), vote.Vote.Voter) + votes, err := gq.Votes(s.Ctx, &govv1.QueryVotesRequest{ + ProposalId: pid, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "gov Votes") + require.NotEmpty(s.T, votes.Votes, "proposal should have votes") + dep, err := gq.Deposit(s.Ctx, &govv1.QueryDepositRequest{ProposalId: pid, Depositor: s.Env.FunderAddr.String()}) + require.NoError(s.T, err, "gov Deposit") + require.NotEmpty(s.T, dep.Deposit.Amount, "proposal deposit should not be empty") + deps, err := gq.Deposits(s.Ctx, &govv1.QueryDepositsRequest{ + ProposalId: pid, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "gov Deposits") + require.NotEmpty(s.T, deps.Deposits, "proposal should have deposits") + _, err = gq.TallyResult(s.Ctx, &govv1.QueryTallyResultRequest{ProposalId: pid}) + require.NoError(s.T, err, "gov TallyResult") + _, err = gq.Constitution(s.Ctx, &govv1.QueryConstitutionRequest{}) + require.NoError(s.T, err, "gov Constitution") + s.waitProposalPassed(gq, pid) + + cancelTitle := fmt.Sprintf("grpcsuite gov cancel %d", s.LatestHeight()) + cancelID := s.submitGovProposal(gq, cancelTitle, initialGovDeposit(minDeposit), noop) + s.BroadcastOK(s.Env.Funder, govv1.NewMsgCancelProposal(cancelID, s.Env.FunderAddr.String())) + _, err = gq.Proposal(s.Ctx, &govv1.QueryProposalRequest{ProposalId: cancelID}) + require.Error(s.T, err, "cancelled proposal should be removed from gov state") + + s.runLegacyGovV1Beta1(gq, minDeposit) + + legacy, err := types.NewAnyWithValue(&govv1beta1.TextProposal{Title: "legacy", Description: "legacy"}) + require.NoError(s.T, err, "pack legacy gov content") + s.BroadcastExpectErr(s.Env.Funder, govv1.NewMsgExecLegacyContent(legacy, s.GovAuthority())) + s.BroadcastExpectErr(s.Env.Funder, govv1.NewMsgVote(s.Env.FunderAddr, 9_999_999_999, govv1.OptionYes, "missing proposal")) + s.logf("cosmos gov complete (submit, deposit, weighted vote, cancel, legacy rejection)") +} + +func (s *Suite) submitGovProposal(gq govv1.QueryClient, title string, deposit sdk.Coins, msgs ...sdk.Msg) uint64 { + s.T.Helper() + prop, err := govv1.NewMsgSubmitProposal(msgs, deposit, s.Env.FunderAddr.String(), "", title, title, false) + require.NoError(s.T, err, "build gov proposal") + lastProposalID := s.latestProposalID(gq) + res := s.BroadcastOK(s.Env.Funder, prop) + pid, ok := proposalIDFromEvents(res) + if !ok { + pid = s.waitSubmittedProposalID(gq, title, lastProposalID) + } + return pid +} + +func (s *Suite) runLegacyGovV1Beta1(gq govv1.QueryClient, minDeposit sdk.Coins) { + s.T.Helper() + title := fmt.Sprintf("grpcsuite legacy gov %d", s.LatestHeight()) + content := govv1beta1.NewTextProposal(title, title) + prop, err := govv1beta1.NewMsgSubmitProposal(content, initialGovDeposit(minDeposit), s.Env.FunderAddr) + require.NoError(s.T, err, "build legacy gov proposal") + + lastProposalID := s.latestProposalID(gq) + res := s.BroadcastOK(s.Env.Funder, prop) + pid, ok := proposalIDFromEvents(res) + if !ok { + pid = s.waitSubmittedProposalID(gq, title, lastProposalID) + } + s.BroadcastOK(s.Env.Funder, govv1beta1.NewMsgDeposit(s.Env.FunderAddr, pid, minDeposit)) + s.BroadcastOK(s.Env.Funder, govv1beta1.NewMsgVote(s.Env.FunderAddr, pid, govv1beta1.OptionYes)) + s.BroadcastOK(s.Env.Funder, govv1beta1.NewMsgVoteWeighted( + s.Env.FunderAddr, + pid, + govv1beta1.NewNonSplitVoteOption(govv1beta1.OptionYes), + )) + s.waitProposalPassed(gq, pid) +} + +func initialGovDeposit(min sdk.Coins) sdk.Coins { + if len(min) == 0 { + return nil + } + coin := min[0] + if coin.Amount.IsZero() { + return nil + } + if coin.Amount.GT(sdkmath.OneInt()) { + coin.Amount = coin.Amount.QuoRaw(2) + if coin.Amount.IsZero() { + coin.Amount = sdkmath.OneInt() + } + } + return sdk.NewCoins(coin) +} + +func govParamsForUpdate(params govv1.Params) govv1.Params { + if params.VotingPeriod == nil || params.VotingPeriod.Seconds() <= 0 { + votingPeriod := 2 * time.Minute + params.VotingPeriod = &votingPeriod + } + + if params.ExpeditedVotingPeriod == nil || params.ExpeditedVotingPeriod.Seconds() <= 0 || + params.ExpeditedVotingPeriod.Seconds() >= params.VotingPeriod.Seconds() { + expeditedVotingPeriod := *params.VotingPeriod / 2 + if expeditedVotingPeriod < time.Second { + votingPeriod := 2 * time.Second + expeditedVotingPeriod = time.Second + params.VotingPeriod = &votingPeriod + } + params.ExpeditedVotingPeriod = &expeditedVotingPeriod + } + + return params +} diff --git a/tests/upgrade/grpcsuite/pack_cosmos_helpers.go b/tests/upgrade/grpcsuite/pack_cosmos_helpers.go new file mode 100644 index 000000000..6d8e61c59 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmos_helpers.go @@ -0,0 +1,54 @@ +package grpcsuite + +import ( + sdkmath "cosmossdk.io/math" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" +) + +const wCosmosPrimaryValidator = "cosmos.primaryValidator" + +func cosmosCoin(denom string, amount int64) sdk.Coin { + return sdk.NewCoin(denom, sdkmath.NewInt(amount)) +} + +func cosmosCoins(denom string, amount int64) sdk.Coins { + return sdk.NewCoins(cosmosCoin(denom, amount)) +} + +func (s *Suite) primaryValidator() stakingtypes.Validator { + s.T.Helper() + if v := s.World.Get(wCosmosPrimaryValidator); v != nil { + return v.(stakingtypes.Validator) + } + + q := stakingtypes.NewQueryClient(s.Conn) + resp, err := q.Validators(s.Ctx, &stakingtypes.QueryValidatorsRequest{ + Status: stakingtypes.BondStatusBonded, + Pagination: &sdkquery.PageRequest{Limit: 1}, + }) + require.NoError(s.T, err, "staking Validators (bonded)") + if len(resp.Validators) == 0 { + resp, err = q.Validators(s.Ctx, &stakingtypes.QueryValidatorsRequest{Pagination: &sdkquery.PageRequest{Limit: 1}}) + require.NoError(s.T, err, "staking Validators") + } + require.NotEmpty(s.T, resp.Validators, "expected at least one staking validator") + val := resp.Validators[0] + s.World.Set(wCosmosPrimaryValidator, val) + return val +} + +func (s *Suite) primaryValidatorPubKey(val stakingtypes.Validator) cryptotypes.PubKey { + s.T.Helper() + var pk cryptotypes.PubKey + require.NoError(s.T, s.Env.InterfaceReg.UnpackAny(val.ConsensusPubkey, &pk), "unpack validator consensus pubkey") + return pk +} + +func (s *Suite) primaryValidatorConsAddress(val stakingtypes.Validator) string { + s.T.Helper() + return sdk.ConsAddress(s.primaryValidatorPubKey(val).Address()).String() +} diff --git a/tests/upgrade/grpcsuite/pack_cosmos_misc.go b/tests/upgrade/grpcsuite/pack_cosmos_misc.go new file mode 100644 index 000000000..a42d7c9a4 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmos_misc.go @@ -0,0 +1,101 @@ +package grpcsuite + +import ( + evidencetypes "cosmossdk.io/x/evidence/types" + upgradetypes "cosmossdk.io/x/upgrade/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + consensustypes "github.com/cosmos/cosmos-sdk/x/consensus/types" + minttypes "github.com/cosmos/cosmos-sdk/x/mint/types" + paramproposal "github.com/cosmos/cosmos-sdk/x/params/types/proposal" + slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" + "github.com/stretchr/testify/require" +) + +type cosmosMiscPack struct{} + +func (cosmosMiscPack) Name() string { return "cosmos-misc" } + +func (cosmosMiscPack) Available(d *Discovery) bool { + return d.HasModule("cosmos.mint.v1beta1") || + d.HasModule("cosmos.consensus.v1") || + d.HasModule("cosmos.params.v1beta1") || + d.HasModule("cosmos.slashing.v1beta1") || + d.HasModule("cosmos.evidence.v1beta1") || + d.HasModule("cosmos.upgrade.v1beta1") +} + +func (cosmosMiscPack) Run(s *Suite) { + if s.Disc.HasModule("cosmos.mint.v1beta1") { + q := minttypes.NewQueryClient(s.Conn) + _, err := q.Params(s.Ctx, &minttypes.QueryParamsRequest{}) + require.NoError(s.T, err, "mint Params") + _, err = q.Inflation(s.Ctx, &minttypes.QueryInflationRequest{}) + require.NoError(s.T, err, "mint Inflation") + _, err = q.AnnualProvisions(s.Ctx, &minttypes.QueryAnnualProvisionsRequest{}) + require.NoError(s.T, err, "mint AnnualProvisions") + } + + if s.Disc.HasModule("cosmos.consensus.v1") { + q := consensustypes.NewQueryClient(s.Conn) + _, err := q.Params(s.Ctx, &consensustypes.QueryParamsRequest{}) + require.NoError(s.T, err, "consensus Params") + } + + if s.Disc.HasModule("cosmos.params.v1beta1") { + q := paramproposal.NewQueryClient(s.Conn) + _, err := q.Subspaces(s.Ctx, ¶mproposal.QuerySubspacesRequest{}) + require.NoError(s.T, err, "params Subspaces") + _, _ = q.Params(s.Ctx, ¶mproposal.QueryParamsRequest{Subspace: "staking", Key: "BondDenom"}) + } + + if s.Disc.HasModule("cosmos.slashing.v1beta1") { + q := slashingtypes.NewQueryClient(s.Conn) + val := s.primaryValidator() + _, err := q.Params(s.Ctx, &slashingtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "slashing Params") + infos, err := q.SigningInfos(s.Ctx, &slashingtypes.QuerySigningInfosRequest{Pagination: &sdkquery.PageRequest{Limit: 10}}) + require.NoError(s.T, err, "slashing SigningInfos") + if len(infos.Info) > 0 { + _, err = q.SigningInfo(s.Ctx, &slashingtypes.QuerySigningInfoRequest{ConsAddress: infos.Info[0].Address}) + require.NoError(s.T, err, "slashing SigningInfo") + } else { + _, err = q.SigningInfo(s.Ctx, &slashingtypes.QuerySigningInfoRequest{ConsAddress: s.primaryValidatorConsAddress(val)}) + require.NoError(s.T, err, "slashing SigningInfo(primary validator)") + } + s.BroadcastTolerant(s.Env.Funder, []string{"not jailed", "cannot be unjailed", "validator still jailed"}, + slashingtypes.NewMsgUnjail(val.OperatorAddress)) + } + + if s.Disc.HasModule("cosmos.evidence.v1beta1") { + q := evidencetypes.NewQueryClient(s.Conn) + _, err := q.AllEvidence(s.Ctx, &evidencetypes.QueryAllEvidenceRequest{Pagination: &sdkquery.PageRequest{Limit: 10}}) + require.NoError(s.T, err, "evidence AllEvidence") + _, err = q.Evidence(s.Ctx, &evidencetypes.QueryEvidenceRequest{Hash: "00"}) + require.Error(s.T, err, "unknown evidence hash should be rejected") + s.BroadcastExpectErr(s.Env.Funder, &evidencetypes.MsgSubmitEvidence{Submitter: s.Env.FunderAddr.String()}) + } + + if s.Disc.HasModule("cosmos.upgrade.v1beta1") { + q := upgradetypes.NewQueryClient(s.Conn) + _, err := q.CurrentPlan(s.Ctx, &upgradetypes.QueryCurrentPlanRequest{}) + require.NoError(s.T, err, "upgrade CurrentPlan") + versions, err := q.ModuleVersions(s.Ctx, &upgradetypes.QueryModuleVersionsRequest{}) + require.NoError(s.T, err, "upgrade ModuleVersions") + require.NotEmpty(s.T, versions.ModuleVersions, "module versions should not be empty") + authority, err := q.Authority(s.Ctx, &upgradetypes.QueryAuthorityRequest{}) + require.NoError(s.T, err, "upgrade Authority") + require.Equal(s.T, s.GovAuthority(), authority.Address) + _, _ = q.AppliedPlan(s.Ctx, &upgradetypes.QueryAppliedPlanRequest{Name: "grpcsuite-missing-plan"}) + _, _ = q.UpgradedConsensusState(s.Ctx, &upgradetypes.QueryUpgradedConsensusStateRequest{LastHeight: s.LatestHeight()}) + s.BroadcastExpectErr(s.Env.Funder, &upgradetypes.MsgSoftwareUpgrade{ + Authority: s.GovAuthority(), + Plan: upgradetypes.Plan{ + Name: "grpcsuite-direct-upgrade", + Height: s.LatestHeight() + 100_000, + }, + }) + s.BroadcastExpectErr(s.Env.Funder, &upgradetypes.MsgCancelUpgrade{Authority: s.GovAuthority()}) + } + + s.logf("cosmos misc complete (mint, consensus, params, slashing, evidence, upgrade)") +} diff --git a/tests/upgrade/grpcsuite/pack_cosmos_staking.go b/tests/upgrade/grpcsuite/pack_cosmos_staking.go new file mode 100644 index 000000000..5797db56c --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmos_staking.go @@ -0,0 +1,128 @@ +package grpcsuite + +import ( + "fmt" + + sdkmath "cosmossdk.io/math" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" +) + +type cosmosStakingPack struct{} + +func (cosmosStakingPack) Name() string { return "cosmos-staking" } + +func (cosmosStakingPack) Available(d *Discovery) bool { return d.HasModule("cosmos.staking.v1beta1") } + +func (cosmosStakingPack) Run(s *Suite) { + q := stakingtypes.NewQueryClient(s.Conn) + val := s.primaryValidator() + valAddr := val.OperatorAddress + + validators, err := q.Validators(s.Ctx, &stakingtypes.QueryValidatorsRequest{ + Status: stakingtypes.BondStatusBonded, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "staking Validators") + require.NotEmpty(s.T, validators.Validators, "expected bonded validators") + one, err := q.Validator(s.Ctx, &stakingtypes.QueryValidatorRequest{ValidatorAddr: valAddr}) + require.NoError(s.T, err, "staking Validator") + require.Equal(s.T, valAddr, one.Validator.OperatorAddress) + _, err = q.Pool(s.Ctx, &stakingtypes.QueryPoolRequest{}) + require.NoError(s.T, err, "staking Pool") + _, err = q.Params(s.Ctx, &stakingtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "staking Params") + + dup, err := stakingtypes.NewMsgCreateValidator( + valAddr, + s.primaryValidatorPubKey(val), + cosmosCoin(s.Env.BondDenom, 1), + stakingtypes.NewDescription("duplicate", "", "", "", ""), + val.Commission.CommissionRates, + sdkmath.NewInt(1), + ) + require.NoError(s.T, err, "build duplicate MsgCreateValidator") + s.BroadcastExpectErr(s.Env.Funder, dup) + + edit := stakingtypes.NewMsgEditValidator( + valAddr, + stakingtypes.NewDescription(fmt.Sprintf("grpcsuite-%d", s.LatestHeight()), "", "", "", ""), + nil, + nil, + ) + s.BroadcastOK(s.Env.Funder, edit) + + delegator := s.FundAccountDefault("cosmos-staking-delegator") + delegationAmt := cosmosCoin(s.Env.BondDenom, 10_000_000) + s.BroadcastOK("cosmos-staking-delegator", stakingtypes.NewMsgDelegate(delegator.String(), valAddr, delegationAmt)) + + del, err := q.Delegation(s.Ctx, &stakingtypes.QueryDelegationRequest{ + DelegatorAddr: delegator.String(), + ValidatorAddr: valAddr, + }) + require.NoError(s.T, err, "staking Delegation") + require.Equal(s.T, delegator.String(), del.DelegationResponse.Delegation.DelegatorAddress) + valDels, err := q.ValidatorDelegations(s.Ctx, &stakingtypes.QueryValidatorDelegationsRequest{ + ValidatorAddr: valAddr, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "staking ValidatorDelegations") + require.NotEmpty(s.T, valDels.DelegationResponses, "validator should have delegations") + delDels, err := q.DelegatorDelegations(s.Ctx, &stakingtypes.QueryDelegatorDelegationsRequest{ + DelegatorAddr: delegator.String(), + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "staking DelegatorDelegations") + require.NotEmpty(s.T, delDels.DelegationResponses, "delegator should have delegations") + delVals, err := q.DelegatorValidators(s.Ctx, &stakingtypes.QueryDelegatorValidatorsRequest{ + DelegatorAddr: delegator.String(), + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "staking DelegatorValidators") + require.NotEmpty(s.T, delVals.Validators, "delegator should have validator list") + delVal, err := q.DelegatorValidator(s.Ctx, &stakingtypes.QueryDelegatorValidatorRequest{ + DelegatorAddr: delegator.String(), + ValidatorAddr: valAddr, + }) + require.NoError(s.T, err, "staking DelegatorValidator") + require.Equal(s.T, valAddr, delVal.Validator.OperatorAddress) + + s.BroadcastExpectErr("cosmos-staking-delegator", stakingtypes.NewMsgBeginRedelegate( + delegator.String(), valAddr, valAddr, cosmosCoin(s.Env.BondDenom, 1_000_000), + )) + _, err = q.Redelegations(s.Ctx, &stakingtypes.QueryRedelegationsRequest{ + DelegatorAddr: delegator.String(), + SrcValidatorAddr: valAddr, + DstValidatorAddr: valAddr, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + if err != nil { + require.Contains(s.T, err.Error(), "redelegation not found", "staking Redelegations") + } + + unbondAmt := cosmosCoin(s.Env.BondDenom, 3_000_000) + s.BroadcastOK("cosmos-staking-delegator", stakingtypes.NewMsgUndelegate(delegator.String(), valAddr, unbondAmt)) + unbond, err := q.UnbondingDelegation(s.Ctx, &stakingtypes.QueryUnbondingDelegationRequest{ + DelegatorAddr: delegator.String(), + ValidatorAddr: valAddr, + }) + require.NoError(s.T, err, "staking UnbondingDelegation") + require.NotEmpty(s.T, unbond.Unbond.Entries, "expected an unbonding entry") + creationHeight := unbond.Unbond.Entries[0].CreationHeight + _, err = q.ValidatorUnbondingDelegations(s.Ctx, &stakingtypes.QueryValidatorUnbondingDelegationsRequest{ + ValidatorAddr: valAddr, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "staking ValidatorUnbondingDelegations") + _, err = q.DelegatorUnbondingDelegations(s.Ctx, &stakingtypes.QueryDelegatorUnbondingDelegationsRequest{ + DelegatorAddr: delegator.String(), + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + require.NoError(s.T, err, "staking DelegatorUnbondingDelegations") + s.BroadcastOK("cosmos-staking-delegator", stakingtypes.NewMsgCancelUnbondingDelegation( + delegator.String(), valAddr, creationHeight, unbondAmt, + )) + _, _ = q.HistoricalInfo(s.Ctx, &stakingtypes.QueryHistoricalInfoRequest{Height: s.LatestHeight() - 1}) + s.logf("cosmos staking complete (edit, delegate, undelegate, cancel-unbonding, duplicate/redelegate edges)") +} diff --git a/tests/upgrade/grpcsuite/pack_cosmwasm.go b/tests/upgrade/grpcsuite/pack_cosmwasm.go new file mode 100644 index 000000000..821038d69 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_cosmwasm.go @@ -0,0 +1,210 @@ +package grpcsuite + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/CosmWasm/wasmd/x/wasm/ioutils" + wasmtypes "github.com/CosmWasm/wasmd/x/wasm/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/stretchr/testify/require" +) + +type cosmwasmPack struct{} + +func (cosmwasmPack) Name() string { return "cosmwasm" } + +func (cosmwasmPack) Available(d *Discovery) bool { return d.HasModule("cosmwasm.wasm.v1") } + +func (cosmwasmPack) Run(s *Suite) { + q := wasmtypes.NewQueryClient(s.Conn) + params, err := q.Params(s.Ctx, &wasmtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "wasm Params") + require.NotNil(s.T, params, "wasm Params response") + + actor := s.FundAccountDefault("cosmwasm") + admin := s.FundAccountDefault("cosmwasm-admin") + wasm := loadHackatomWasm(s) + authority := s.GovAuthority() + initMsg := hackatomInstantiateMsg(actor, admin) + emptyMsg := wasmtypes.RawContractMessage(`{}`) + allowEverybody := wasmtypes.AllowEverybody + createErrs := []string{ + "can not create code", + "code upload", + "create wasm contract failed", + "permission", + "unauthorized", + } + contractErrs := []string{ + "Error calling the VM", + "execute wasm contract failed", + "instantiate wasm contract failed", + "migrate wasm contract failed", + "not found", + "no such code", + "no such contract", + "permission", + "unauthorized", + } + + s.PassGovProposal("grpcsuite: store wasm code", + &wasmtypes.MsgStoreCode{ + Sender: authority, + WASMByteCode: wasm, + InstantiatePermission: &allowEverybody, + }) + + codeID := latestWasmCodeID(s, q) + code, err := q.Code(s.Ctx, &wasmtypes.QueryCodeRequest{CodeId: codeID}) + require.NoError(s.T, err, "wasm Code") + require.NotEmpty(s.T, code.Data, "stored wasm code should be queryable") + + s.BroadcastTolerant("cosmwasm", createErrs, &wasmtypes.MsgStoreCode{ + Sender: actor.String(), + WASMByteCode: []byte("not wasm"), + InstantiatePermission: &allowEverybody, + }) + + s.BroadcastOK("cosmwasm", &wasmtypes.MsgInstantiateContract{ + Sender: actor.String(), + Admin: actor.String(), + CodeID: codeID, + Label: "grpcsuite-hackatom", + Msg: initMsg, + Funds: sdk.NewCoins(sdk.NewInt64Coin(s.Env.BondDenom, 1_000_000)), + }) + contract := latestContractForCode(s, q, codeID) + _, err = q.ContractInfo(s.Ctx, &wasmtypes.QueryContractInfoRequest{Address: contract}) + require.NoError(s.T, err, "wasm ContractInfo") + + s.BroadcastOK("cosmwasm", &wasmtypes.MsgInstantiateContract2{ + Sender: actor.String(), + Admin: actor.String(), + CodeID: codeID, + Label: "grpcsuite-hackatom-2", + Msg: initMsg, + Salt: []byte("grpcsuite-hackatom-2"), + FixMsg: true, + }) + + s.BroadcastOK("cosmwasm", &wasmtypes.MsgExecuteContract{ + Sender: actor.String(), + Contract: contract, + Msg: wasmtypes.RawContractMessage(`{"release":{}}`), + }) + s.BroadcastTolerant("cosmwasm", contractErrs, &wasmtypes.MsgMigrateContract{ + Sender: actor.String(), + Contract: contract, + CodeID: codeID, + Msg: emptyMsg, + }) + s.BroadcastOK("cosmwasm", &wasmtypes.MsgUpdateContractLabel{ + Sender: actor.String(), + Contract: contract, + NewLabel: "grpcsuite-hackatom-updated", + }) + s.BroadcastOK("cosmwasm", &wasmtypes.MsgUpdateAdmin{ + Sender: actor.String(), + Contract: contract, + NewAdmin: admin.String(), + }) + s.BroadcastOK("cosmwasm-admin", &wasmtypes.MsgClearAdmin{ + Sender: admin.String(), + Contract: contract, + }) + + s.PassGovProposal("grpcsuite: update wasm code config", + &wasmtypes.MsgUpdateInstantiateConfig{ + Sender: authority, + CodeID: codeID, + NewInstantiatePermission: &allowEverybody, + }, + &wasmtypes.MsgPinCodes{Authority: authority, CodeIDs: []uint64{codeID}}, + &wasmtypes.MsgUnpinCodes{Authority: authority, CodeIDs: []uint64{codeID}}, + ) + + s.BroadcastExpectErr("cosmwasm", &wasmtypes.MsgUpdateParams{Authority: actor.String(), Params: params.Params}) + s.BroadcastExpectErr("cosmwasm", &wasmtypes.MsgSudoContract{ + Authority: actor.String(), + Contract: contract, + Msg: emptyMsg, + }) + s.BroadcastExpectErr("cosmwasm", &wasmtypes.MsgAddCodeUploadParamsAddresses{ + Authority: actor.String(), + Addresses: []string{actor.String()}, + }) + s.BroadcastExpectErr("cosmwasm", &wasmtypes.MsgRemoveCodeUploadParamsAddresses{ + Authority: actor.String(), + Addresses: []string{actor.String()}, + }) + s.BroadcastTolerant("cosmwasm", contractErrs, &wasmtypes.MsgStoreAndInstantiateContract{ + Authority: actor.String(), + WASMByteCode: wasm, + InstantiatePermission: &allowEverybody, + Admin: actor.String(), + Label: "grpcsuite-hackatom-store-instantiate", + Msg: initMsg, + }) + s.BroadcastTolerant("cosmwasm", contractErrs, &wasmtypes.MsgStoreAndMigrateContract{ + Authority: actor.String(), + WASMByteCode: wasm, + InstantiatePermission: &allowEverybody, + Contract: contract, + Msg: emptyMsg, + }) + + s.logf("cosmwasm complete (store, instantiate, execute, admin, pin/unpin, params, negatives)") +} + +func loadHackatomWasm(s *Suite) []byte { + s.T.Helper() + path := filepath.Join(s.Env.RepoRoot, "tests", "upgrade", "testdata", "hackatom.wasm") + wasm, err := os.ReadFile(path) + require.NoError(s.T, err, "read hackatom wasm") + if ioutils.IsWasm(wasm) { + wasm, err = ioutils.GzipIt(wasm) + require.NoError(s.T, err, "gzip hackatom wasm") + } else { + require.True(s.T, ioutils.IsGzip(wasm), "hackatom wasm should be raw wasm or gzip") + } + return wasm +} + +func hackatomInstantiateMsg(verifier, beneficiary sdk.AccAddress) wasmtypes.RawContractMessage { + return wasmtypes.RawContractMessage(fmt.Sprintf(`{"verifier":%q,"beneficiary":%q}`, verifier.String(), beneficiary.String())) +} + +func latestWasmCodeID(s *Suite, q wasmtypes.QueryClient) uint64 { + s.T.Helper() + var latest uint64 + var next []byte + for { + resp, err := q.Codes(s.Ctx, &wasmtypes.QueryCodesRequest{Pagination: &sdkquery.PageRequest{Key: next, Limit: 100}}) + require.NoError(s.T, err, "wasm Codes") + for _, info := range resp.CodeInfos { + if info.CodeID > latest { + latest = info.CodeID + } + } + if resp.Pagination == nil || len(resp.Pagination.NextKey) == 0 { + break + } + next = resp.Pagination.NextKey + } + require.NotZero(s.T, latest, "wasm code id should exist after store") + return latest +} + +func latestContractForCode(s *Suite, q wasmtypes.QueryClient, codeID uint64) string { + s.T.Helper() + resp, err := q.ContractsByCode(s.Ctx, &wasmtypes.QueryContractsByCodeRequest{ + CodeId: codeID, + Pagination: &sdkquery.PageRequest{Limit: 1000}, + }) + require.NoError(s.T, err, "wasm ContractsByCode") + require.NotEmpty(s.T, resp.Contracts, "stored code should have an instantiated contract") + return resp.Contracts[len(resp.Contracts)-1] +} diff --git a/tests/upgrade/grpcsuite/pack_deployment.go b/tests/upgrade/grpcsuite/pack_deployment.go new file mode 100644 index 000000000..1c77e608c --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_deployment.go @@ -0,0 +1,212 @@ +package grpcsuite + +import ( + "path/filepath" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/stretchr/testify/require" + + dv1 "pkg.akt.dev/go/node/deployment/v1" + dvbeta "pkg.akt.dev/go/node/deployment/v1beta4" + depositv1 "pkg.akt.dev/go/node/types/deposit/v1" + "pkg.akt.dev/go/sdkutil" + "pkg.akt.dev/go/sdl" +) + +// World keys published by the deployment pack for downstream packs (market, escrow). +const ( + wDeploymentSigner = "deployment.signer" // keyring name of the tenant + wDeploymentID = "deployment.id" // dv1.DeploymentID of an open deployment + wDeploymentGSeq = "deployment.gseq" // uint32 GSeq of its first group +) + +type deploymentPack struct{} + +func (deploymentPack) Name() string { return "deployment" } + +func (deploymentPack) Available(d *Discovery) bool { + return d.HasModule("akash.deployment.v1beta4") +} + +func (dp deploymentPack) Run(s *Suite) { + q := dvbeta.NewQueryClient(s.Conn) + + // The tenant (deployment owner) is the funder — it holds the uact the deposit + // must be in (see TenantSigner). No sub-account funding is needed. + tenant := s.TenantAddr() + + // Query Params (also sizes the deposit). + pr, err := q.Params(s.Ctx, &dvbeta.QueryParamsRequest{}) + require.NoError(s.T, err, "deployment Params") + deposit := deploymentMinDeposit(s, pr.Params) + + // MsgCreateDeployment from the repo's deployment SDL testdata. + groups, version := readSDLGroups(s, deploymentSDLPath(s)) + depID := dv1.DeploymentID{Owner: tenant.String(), DSeq: s.NextDSeq()} + create := &dvbeta.MsgCreateDeployment{ + ID: depID, + Groups: groups, + Hash: version, + Deposit: depositv1.Deposit{Amount: deposit, Sources: depositv1.Sources{depositv1.SourceBalance}}, + } + s.BroadcastOK(s.TenantSigner(), create) + s.logf("created deployment %s/%d (%d group(s))", depID.Owner, depID.DSeq, len(groups)) + + // Query Deployment by ID — assert the full response (deployment, groups, escrow). + depResp, err := q.Deployment(s.Ctx, &dvbeta.QueryDeploymentRequest{ID: depID}) + require.NoError(s.T, err, "Deployment by id") + require.Equal(s.T, depID, depResp.Deployment.ID, "queried deployment id should match") + require.Equal(s.T, dv1.DeploymentActive, depResp.Deployment.State, "new deployment should be active") + require.NotEmpty(s.T, depResp.Groups, "created deployment should have groups") + require.NotEmpty(s.T, depResp.Groups[0].GroupSpec.Resources, "group should carry resources") + require.NotEmpty(s.T, depResp.EscrowAccount.ID.XID, "deployment should have an escrow account") + gseq := depResp.Groups[0].ID.GSeq + + // Query Group by ID — assert the returned group id matches. + gid := dv1.GroupID{Owner: depID.Owner, DSeq: depID.DSeq, GSeq: gseq} + gResp, err := q.Group(s.Ctx, &dvbeta.QueryGroupRequest{ID: gid}) + require.NoError(s.T, err, "Group by id") + require.Equal(s.T, gid, gResp.Group.ID, "queried group id should match") + + // Query Deployments filtered by owner — assert non-empty and that the filter + // only returns the owner's deployments. + list, err := q.Deployments(s.Ctx, &dvbeta.QueryDeploymentsRequest{ + Filters: dvbeta.DeploymentFilters{Owner: depID.Owner}, + Pagination: &sdkquery.PageRequest{Limit: 100}, + }) + require.NoError(s.T, err, "Deployments") + require.NotEmpty(s.T, list.Deployments, "owner should have at least one deployment") + for _, d := range list.Deployments { + require.Equal(s.T, depID.Owner, d.Deployment.ID.Owner, "owner filter must only return owner's deployments") + } + + // Query Deployments with the active-state filter and with a pagination limit. + active, err := q.Deployments(s.Ctx, &dvbeta.QueryDeploymentsRequest{ + Filters: dvbeta.DeploymentFilters{Owner: depID.Owner, State: dv1.DeploymentActive.String()}, + Pagination: &sdkquery.PageRequest{Limit: 100}, + }) + require.NoError(s.T, err, "Deployments (active filter)") + require.NotEmpty(s.T, active.Deployments, "owner should have an active deployment") + paged, err := q.Deployments(s.Ctx, &dvbeta.QueryDeploymentsRequest{ + Filters: dvbeta.DeploymentFilters{Owner: depID.Owner}, + Pagination: &sdkquery.PageRequest{Limit: 1}, + }) + require.NoError(s.T, err, "Deployments (paginated)") + require.LessOrEqual(s.T, len(paged.Deployments), 1, "pagination Limit=1 must cap results") + + // Publish handles so the market/escrow packs can use this OPEN deployment. + s.World.Set(wDeploymentSigner, s.TenantSigner()) + s.World.Set(wDeploymentID, depID) + s.World.Set(wDeploymentGSeq, gseq) + + // Exercise the rest of the deployment lifecycle on throwaway deployments so the + // primary one above stays open for the market pack. + dp.runLifecycle(s, groups, version, deposit) + + // Negative / edge cases. + dp.deploymentNegatives(s, groups, version, deposit) +} + +// deploymentNegatives exercises edge cases that must be rejected: a duplicate +// deployment id, and close/update/group ops against ids that do not exist. All run +// against throwaway / non-existent ids so the primary open deployment is untouched. +func (dp deploymentPack) deploymentNegatives(s *Suite, groups dvbeta.GroupSpecs, version []byte, deposit sdk.Coin) { + owner := s.TenantAddr() + mkDeposit := depositv1.Deposit{Amount: deposit, Sources: depositv1.Sources{depositv1.SourceBalance}} + + // Duplicate deployment id: create once, then re-create with the same id. + dupID := dv1.DeploymentID{Owner: owner.String(), DSeq: s.NextDSeq()} + dup := &dvbeta.MsgCreateDeployment{ID: dupID, Groups: groups, Hash: version, Deposit: mkDeposit} + s.BroadcastOK(s.TenantSigner(), dup) + s.BroadcastExpectErr(s.TenantSigner(), dup) + + // Operations against ids that were never created. + ghost := dv1.DeploymentID{Owner: owner.String(), DSeq: s.NextDSeq()} + ghostGroup := dv1.GroupID{Owner: ghost.Owner, DSeq: ghost.DSeq, GSeq: 1} + s.BroadcastExpectErr(s.TenantSigner(), &dvbeta.MsgCloseDeployment{ID: ghost}) + s.BroadcastExpectErr(s.TenantSigner(), &dvbeta.MsgUpdateDeployment{ID: ghost, Hash: version}) + s.BroadcastExpectErr(s.TenantSigner(), &dvbeta.MsgCloseGroup{ID: ghostGroup}) + s.BroadcastExpectErr(s.TenantSigner(), &dvbeta.MsgPauseGroup{ID: ghostGroup}) + s.logf("deployment negatives complete (duplicate create + missing-id close/update/closeGroup/pauseGroup rejected)") +} + +// runLifecycle exercises MsgUpdateDeployment, MsgPauseGroup, MsgStartGroup, +// MsgCloseGroup and MsgCloseDeployment on scratch deployments owned by the tenant. +func (dp deploymentPack) runLifecycle(s *Suite, groups dvbeta.GroupSpecs, version []byte, deposit sdk.Coin) { + mkDeposit := func() depositv1.Deposit { + return depositv1.Deposit{Amount: deposit, Sources: depositv1.Sources{depositv1.SourceBalance}} + } + create := func() dv1.DeploymentID { + id := dv1.DeploymentID{Owner: s.TenantAddr().String(), DSeq: s.NextDSeq()} + s.BroadcastOK(s.TenantSigner(), &dvbeta.MsgCreateDeployment{ID: id, Groups: groups, Hash: version, Deposit: mkDeposit()}) + return id + } + + // Scratch deployment #1: update + group pause/start/close. + scratch := create() + s.BroadcastOK(s.TenantSigner(), &dvbeta.MsgUpdateDeployment{ID: scratch, Hash: bumpHash(version)}) + gid := dv1.GroupID{Owner: scratch.Owner, DSeq: scratch.DSeq, GSeq: 1} + s.BroadcastOK(s.TenantSigner(), &dvbeta.MsgPauseGroup{ID: gid}) + s.BroadcastOK(s.TenantSigner(), &dvbeta.MsgStartGroup{ID: gid}) + s.BroadcastOK(s.TenantSigner(), &dvbeta.MsgCloseGroup{ID: gid}) + + // Scratch deployment #2: close the whole deployment. + scratch2 := create() + s.BroadcastOK(s.TenantSigner(), &dvbeta.MsgCloseDeployment{ID: scratch2}) + s.logf("deployment lifecycle complete (update/pause/start/closeGroup/closeDeployment)") +} + +// bumpHash returns a distinct 32-byte hash derived from src so MsgUpdateDeployment +// represents an actual change. +func bumpHash(src []byte) []byte { + out := make([]byte, len(src)) + copy(out, src) + if len(out) > 0 { + out[0] ^= 0xFF + } + return out +} + +func deploymentSDLPath(s *Suite) string { + return filepath.Join(s.Env.RepoRoot, "x", "deployment", "testdata", "deployment.yaml") +} + +// createDeploymentFromSDL creates a fresh deployment owned by the keyring account +// `signer` from the repo's SDL testdata and returns its ID. Used by the market pack +// to obtain additional orders. +func createDeploymentFromSDL(s *Suite, signer string) dv1.DeploymentID { + s.T.Helper() + pr, err := dvbeta.NewQueryClient(s.Conn).Params(s.Ctx, &dvbeta.QueryParamsRequest{}) + require.NoError(s.T, err, "deployment Params") + groups, version := readSDLGroups(s, deploymentSDLPath(s)) + id := dv1.DeploymentID{Owner: s.Addr(signer).String(), DSeq: s.NextDSeq()} + s.BroadcastOK(signer, &dvbeta.MsgCreateDeployment{ + ID: id, + Groups: groups, + Hash: version, + Deposit: depositv1.Deposit{Amount: deploymentMinDeposit(s, pr.Params), Sources: depositv1.Sources{depositv1.SourceBalance}}, + }) + return id +} + +func readSDLGroups(s *Suite, path string) (dvbeta.GroupSpecs, []byte) { + s.T.Helper() + m, err := sdl.ReadFile(path) + require.NoErrorf(s.T, err, "sdl.ReadFile %s", path) + groups, err := m.DeploymentGroups() + require.NoError(s.T, err, "DeploymentGroups") + version, err := m.Version() + require.NoError(s.T, err, "Version") + return groups, version +} + +func deploymentMinDeposit(s *Suite, p dvbeta.Params) sdk.Coin { + // The deposit denom must match the group price denom, which the chain requires + // to be uact; the tenant (the funder) holds uact to cover it. See TenantSigner. + if c, err := p.MinDepositFor(sdkutil.DenomUact); err == nil && !c.IsZero() { + return c + } + return sdk.NewCoin(sdkutil.DenomUact, sdkmath.NewInt(5_000_000)) +} diff --git a/tests/upgrade/grpcsuite/pack_escrow.go b/tests/upgrade/grpcsuite/pack_escrow.go new file mode 100644 index 000000000..6ef89b6ca --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_escrow.go @@ -0,0 +1,66 @@ +package grpcsuite + +import ( + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/stretchr/testify/require" + + dvbeta "pkg.akt.dev/go/node/deployment/v1beta4" + ev1 "pkg.akt.dev/go/node/escrow/v1" + depositv1 "pkg.akt.dev/go/node/types/deposit/v1" + "pkg.akt.dev/go/sdkutil" +) + +type escrowPack struct{} + +func (escrowPack) Name() string { return "escrow" } + +func (escrowPack) Available(d *Discovery) bool { return d.HasModule("akash.escrow.v1") } + +func (escrowPack) Run(s *Suite) { + // Fresh deployment so the escrow account is open and independent of other packs. + dep := createDeploymentFromSDL(s, s.TenantSigner()) + + // The deployment query returns its escrow account (incl. its ID) directly. + depResp, err := dvbeta.NewQueryClient(s.Conn).Deployment(s.Ctx, &dvbeta.QueryDeploymentRequest{ID: dep}) + require.NoError(s.T, err, "Deployment (for escrow account id)") + accountID := depResp.EscrowAccount.ID + + // The deployment's escrow account is uact-denominated (the deployment deposit is + // uact), so the additional deposit must also be uact; the tenant (funder) holds it. + depositCoin := sdk.NewCoin(sdkutil.DenomUact, sdkmath.NewInt(1_000_000)) + + // MsgAccountDeposit — the signer need not be the account owner. + s.BroadcastOK(s.TenantSigner(), &ev1.MsgAccountDeposit{ + Signer: s.TenantAddr().String(), + ID: accountID, + Deposit: depositv1.Deposit{Amount: depositCoin, Sources: depositv1.Sources{depositv1.SourceBalance}}, + }) + + q := ev1.NewQueryClient(s.Conn) + + // Accounts: the deposit above proved the specific account exists; assert the + // account set is non-empty and that the pagination Limit caps results. + accts, err := q.Accounts(s.Ctx, &ev1.QueryAccountsRequest{Pagination: &sdkquery.PageRequest{Limit: 100}}) + require.NoError(s.T, err, "escrow Accounts") + require.NotEmpty(s.T, accts.Accounts, "there should be escrow accounts") + paged, err := q.Accounts(s.Ctx, &ev1.QueryAccountsRequest{Pagination: &sdkquery.PageRequest{Limit: 1}}) + require.NoError(s.T, err, "escrow Accounts (paginated)") + require.LessOrEqual(s.T, len(paged.Accounts), 1, "pagination Limit=1 must cap results") + + // Payments (paginated). + _, err = q.Payments(s.Ctx, &ev1.QueryPaymentsRequest{Pagination: &sdkquery.PageRequest{Limit: 10}}) + require.NoError(s.T, err, "escrow Payments") + + // Negative: deposit to an account id that does not exist. + bogus := accountID + bogus.XID = "akash1nonexistentnonexistentnonexistentnn/999999999" + s.BroadcastExpectErr(s.TenantSigner(), &ev1.MsgAccountDeposit{ + Signer: s.TenantAddr().String(), + ID: bogus, + Deposit: depositv1.Deposit{Amount: depositCoin, Sources: depositv1.Sources{depositv1.SourceBalance}}, + }) + + s.logf("escrow deposit complete (account xid %s)", accountID.XID) +} diff --git a/tests/upgrade/grpcsuite/pack_gov.go b/tests/upgrade/grpcsuite/pack_gov.go new file mode 100644 index 000000000..1c173a616 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_gov.go @@ -0,0 +1,144 @@ +package grpcsuite + +import ( + wasmtypes "github.com/CosmWasm/wasmd/x/wasm/types" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + consensustypes "github.com/cosmos/cosmos-sdk/x/consensus/types" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + govv1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1" + minttypes "github.com/cosmos/cosmos-sdk/x/mint/types" + slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" + + bmev1 "pkg.akt.dev/go/node/bme/v1" + dvbeta "pkg.akt.dev/go/node/deployment/v1beta4" + mvbeta "pkg.akt.dev/go/node/market/v1beta5" + oraclev2 "pkg.akt.dev/go/node/oracle/v2" + wasmv1 "pkg.akt.dev/go/node/wasm/v1" +) + +// govParamsPack exercises every module's MsgUpdateParams by re-applying each +// module's CURRENT params through a single governance proposal. Re-applying the +// current (already valid) params is a safe no-op on state while still routing the +// message through the real handler — so the gov-gated UpdateParams surface is +// verified without risk of breaking the chain. It runs late so other packs see +// unchanged params. +type govParamsPack struct{} + +func (govParamsPack) Name() string { return "gov-params" } + +// Available: gov is always present, but gate on it for symmetry. +func (govParamsPack) Available(d *Discovery) bool { return d.HasModule("cosmos.gov.v1") } + +func (govParamsPack) Run(s *Suite) { + authority := s.GovAuthority() + var msgs []sdk.Msg + + if d := s.Disc; d.HasModule("akash.deployment.v1beta4") { + p, err := dvbeta.NewQueryClient(s.Conn).Params(s.Ctx, &dvbeta.QueryParamsRequest{}) + require.NoError(s.T, err, "deployment Params") + require.NotNil(s.T, p, "deployment Params response") + msgs = append(msgs, &dvbeta.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("akash.market.v1beta5") { + p, err := mvbeta.NewQueryClient(s.Conn).Params(s.Ctx, &mvbeta.QueryParamsRequest{}) + require.NoError(s.T, err, "market Params") + require.NotNil(s.T, p, "market Params response") + msgs = append(msgs, &mvbeta.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("akash.oracle.v2") { + p, err := oraclev2.NewQueryClient(s.Conn).Params(s.Ctx, &oraclev2.QueryParamsRequest{}) + require.NoError(s.T, err, "oracle Params") + require.NotNil(s.T, p, "oracle Params response") + msgs = append(msgs, &oraclev2.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("akash.bme.v1") { + p, err := bmev1.NewQueryClient(s.Conn).Params(s.Ctx, &bmev1.QueryParamsRequest{}) + require.NoError(s.T, err, "bme Params") + require.NotNil(s.T, p, "bme Params response") + msgs = append(msgs, &bmev1.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("akash.wasm.v1") { + p, err := wasmv1.NewQueryClient(s.Conn).Params(s.Ctx, &wasmv1.QueryParamsRequest{}) + require.NoError(s.T, err, "wasm Params") + require.NotNil(s.T, p, "wasm Params response") + msgs = append(msgs, &wasmv1.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("cosmwasm.wasm.v1") { + p, err := wasmtypes.NewQueryClient(s.Conn).Params(s.Ctx, &wasmtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "cosmwasm Params") + require.NotNil(s.T, p, "cosmwasm Params response") + msgs = append(msgs, &wasmtypes.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("cosmos.auth.v1beta1") { + p, err := authtypes.NewQueryClient(s.Conn).Params(s.Ctx, &authtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "auth Params") + require.NotNil(s.T, p, "auth Params response") + msgs = append(msgs, &authtypes.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("cosmos.bank.v1beta1") { + q := banktypes.NewQueryClient(s.Conn) + p, err := q.Params(s.Ctx, &banktypes.QueryParamsRequest{}) + require.NoError(s.T, err, "bank Params") + require.NotNil(s.T, p, "bank Params response") + msgs = append(msgs, &banktypes.MsgUpdateParams{Authority: authority, Params: p.Params}) + msgs = append(msgs, banktypes.NewMsgSetSendEnabled( + authority, + []*banktypes.SendEnabled{{Denom: s.Env.BondDenom, Enabled: true}}, + nil, + )) + } + if d := s.Disc; d.HasModule("cosmos.consensus.v1") { + p, err := consensustypes.NewQueryClient(s.Conn).Params(s.Ctx, &consensustypes.QueryParamsRequest{}) + require.NoError(s.T, err, "consensus Params") + require.NotNil(s.T, p, "consensus Params response") + require.NotNil(s.T, p.Params, "consensus params") + msgs = append(msgs, &consensustypes.MsgUpdateParams{ + Authority: authority, + Block: p.Params.Block, + Evidence: p.Params.Evidence, + Validator: p.Params.Validator, + Abci: p.Params.Abci, + }) + } + if d := s.Disc; d.HasModule("cosmos.distribution.v1beta1") { + p, err := distrtypes.NewQueryClient(s.Conn).Params(s.Ctx, &distrtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "distribution Params") + require.NotNil(s.T, p, "distribution Params response") + msgs = append(msgs, &distrtypes.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("cosmos.gov.v1") { + p, err := govv1.NewQueryClient(s.Conn).Params(s.Ctx, &govv1.QueryParamsRequest{}) + require.NoError(s.T, err, "gov Params") + require.NotNil(s.T, p, "gov Params response") + require.NotNil(s.T, p.Params, "gov params") + msgs = append(msgs, &govv1.MsgUpdateParams{Authority: authority, Params: govParamsForUpdate(*p.Params)}) + } + if d := s.Disc; d.HasModule("cosmos.mint.v1beta1") { + p, err := minttypes.NewQueryClient(s.Conn).Params(s.Ctx, &minttypes.QueryParamsRequest{}) + require.NoError(s.T, err, "mint Params") + require.NotNil(s.T, p, "mint Params response") + msgs = append(msgs, &minttypes.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("cosmos.slashing.v1beta1") { + p, err := slashingtypes.NewQueryClient(s.Conn).Params(s.Ctx, &slashingtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "slashing Params") + require.NotNil(s.T, p, "slashing Params response") + msgs = append(msgs, &slashingtypes.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + if d := s.Disc; d.HasModule("cosmos.staking.v1beta1") { + p, err := stakingtypes.NewQueryClient(s.Conn).Params(s.Ctx, &stakingtypes.QueryParamsRequest{}) + require.NoError(s.T, err, "staking Params") + require.NotNil(s.T, p, "staking Params response") + msgs = append(msgs, &stakingtypes.MsgUpdateParams{Authority: authority, Params: p.Params}) + } + + if len(msgs) == 0 { + s.logf("gov-params: no MsgUpdateParams modules available") + return + } + s.PassGovProposal("grpcsuite: re-apply module params (no-op)", msgs...) +} diff --git a/tests/upgrade/grpcsuite/pack_market.go b/tests/upgrade/grpcsuite/pack_market.go new file mode 100644 index 000000000..be69597e5 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_market.go @@ -0,0 +1,256 @@ +package grpcsuite + +import ( + "context" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/stretchr/testify/require" + + dv1 "pkg.akt.dev/go/node/deployment/v1" + dvbeta "pkg.akt.dev/go/node/deployment/v1beta4" + mv1 "pkg.akt.dev/go/node/market/v1" + mvbeta "pkg.akt.dev/go/node/market/v1beta5" + depositv1 "pkg.akt.dev/go/node/types/deposit/v1" + "pkg.akt.dev/go/sdkutil" +) + +type marketPack struct{} + +func (marketPack) Name() string { return "market" } + +func (marketPack) Available(d *Discovery) bool { + return d.HasModule("akash.market.v1beta5") +} + +func (mp marketPack) Run(s *Suite) { + q := mvbeta.NewQueryClient(s.Conn) + + // Prerequisites created by the deployment and provider packs. + depID, ok := s.World.Get(wDeploymentID).(dv1.DeploymentID) + require.True(s.T, ok, "market pack needs the deployment pack's open deployment") + require.NotEmpty(s.T, s.World.Get(wProviderSigner), "market pack needs the provider pack") + providerAddr := s.Addr("provider") + + // --- order A: full bid -> lease -> withdraw -> close flow --- + orderA := s.findOrder(q, depID.Owner, depID.DSeq) + bidA := s.newBid(q, orderA, providerAddr.String()) + s.BroadcastOK("provider", bidA) + s.logf("created bid on order %s/%d/%d/%d", orderA.ID.Owner, orderA.ID.DSeq, orderA.ID.GSeq, orderA.ID.OSeq) + + // Order queries (order is still open until a lease is created): assert content. + orderResp, err := q.Order(s.Ctx, &mvbeta.QueryOrderRequest{ID: orderA.ID}) + require.NoError(s.T, err, "Order") + require.Equal(s.T, orderA.ID, orderResp.Order.ID, "queried order id should match") + require.Equal(s.T, mvbeta.OrderOpen, orderResp.Order.State, "order should be open before a lease") + require.NotEmpty(s.T, orderResp.Order.Spec.Resources, "order spec should carry resources") + + orders, err := q.Orders(s.Ctx, &mvbeta.QueryOrdersRequest{ + Filters: mvbeta.OrderFilters{Owner: depID.Owner, DSeq: depID.DSeq, State: mvbeta.OrderOpen.String()}, + Pagination: &sdkquery.PageRequest{Limit: 50}, + }) + require.NoError(s.T, err, "Orders") + require.NotEmpty(s.T, orders.Orders, "owner should have an open order") + require.True(s.T, containsOrder(orders.Orders, orderA.ID), "filtered orders should include order A") + for _, o := range orders.Orders { + require.Equal(s.T, depID.Owner, o.ID.Owner, "owner filter must only return owner's orders") + } + mp.assertOrdersPaginate(s, q, depID.Owner) + + // Bid queries: assert content. + bidResp, err := q.Bid(s.Ctx, &mvbeta.QueryBidRequest{ID: bidA.ID}) + require.NoError(s.T, err, "Bid") + require.Equal(s.T, bidA.ID, bidResp.Bid.ID, "queried bid id should match") + require.Equal(s.T, bidA.Price, bidResp.Bid.Price, "queried bid price should match") + require.NotEmpty(s.T, bidResp.EscrowAccount.ID.XID, "bid should have an escrow account") + + bids, err := q.Bids(s.Ctx, &mvbeta.QueryBidsRequest{ + Filters: mvbeta.BidFilters{Owner: depID.Owner, DSeq: depID.DSeq, Provider: providerAddr.String()}, + Pagination: &sdkquery.PageRequest{Limit: 50}, + }) + require.NoError(s.T, err, "Bids") + require.NotEmpty(s.T, bids.Bids, "owner+provider filter should return the bid") + for _, b := range bids.Bids { + require.Equal(s.T, providerAddr.String(), b.Bid.ID.Provider, "provider filter must only return that provider's bids") + } + + // Tenant accepts the bid -> creates a lease. + s.BroadcastOK(s.TenantSigner(), &mvbeta.MsgCreateLease{BidID: bidA.ID}) + leaseA := mv1.LeaseID{ + Owner: bidA.ID.Owner, DSeq: bidA.ID.DSeq, GSeq: bidA.ID.GSeq, + OSeq: bidA.ID.OSeq, Provider: bidA.ID.Provider, + } + + // Lease queries: assert content. + leaseResp, err := q.Lease(s.Ctx, &mvbeta.QueryLeaseRequest{ID: leaseA}) + require.NoError(s.T, err, "Lease") + require.Equal(s.T, leaseA, leaseResp.Lease.ID, "queried lease id should match") + require.Equal(s.T, mv1.LeaseActive, leaseResp.Lease.State, "new lease should be active") + + leases, err := q.Leases(s.Ctx, &mvbeta.QueryLeasesRequest{ + Filters: mv1.LeaseFilters{Owner: depID.Owner, DSeq: depID.DSeq, State: mv1.LeaseActive.String()}, + Pagination: &sdkquery.PageRequest{Limit: 50}, + }) + require.NoError(s.T, err, "Leases") + require.NotEmpty(s.T, leases.Leases, "owner should have an active lease") + for _, l := range leases.Leases { + require.Equal(s.T, depID.Owner, l.Lease.ID.Owner, "owner filter must only return owner's leases") + } + + // Params query. + pResp, err := q.Params(s.Ctx, &mvbeta.QueryParamsRequest{}) + require.NoError(s.T, err, "market Params") + require.False(s.T, pResp.Params.BidMinDeposit.IsNil(), "market params should expose a bid min deposit") + + // Let the lease's escrow payment settle/accrue before withdrawing. + s.WaitBlocks(2) + s.BroadcastOK("provider", &mvbeta.MsgWithdrawLease{ID: leaseA}) + + // MsgLeaseStartReclaim requires a reclamation-enabled lease; this lease has none, + // so exercise it and assert the expected rejection. + _, err = s.TX.Broadcast("provider", &mvbeta.MsgLeaseStartReclaim{ID: leaseA}) + require.Error(s.T, err, "LeaseStartReclaim should be rejected for a non-reclamation lease") + + // Close the lease (tenant). Reason must be in the lease-closed-reason range. + s.BroadcastOK(s.TenantSigner(), &mvbeta.MsgCloseLease{ID: leaseA, Reason: mv1.LeaseClosedReasonDecommissioned}) + + // --- order B: bid then close the bid (un-leased) --- + depB := createDeploymentFromSDL(s, s.TenantSigner()) + orderB := s.findOrder(q, depB.Owner, depB.DSeq) + bidB := s.newBid(q, orderB, providerAddr.String()) + s.BroadcastOK("provider", bidB) + s.BroadcastOK("provider", &mvbeta.MsgCloseBid{ID: bidB.ID, Reason: mv1.LeaseClosedReasonDecommissioned}) + s.logf("market lifecycle complete (bid/lease/withdraw/closeLease/closeBid)") + + // Negative / edge cases. + mp.marketNegatives(s, q, providerAddr.String()) +} + +// assertOrdersPaginate verifies the pagination Limit caps the returned orders. +func (marketPack) assertOrdersPaginate(s *Suite, q mvbeta.QueryClient, owner string) { + // pagination is verified against the global order set (limit must be honored). + resp, err := q.Orders(s.Ctx, &mvbeta.QueryOrdersRequest{ + Filters: mvbeta.OrderFilters{Owner: owner}, + Pagination: &sdkquery.PageRequest{Limit: 1}, + }) + require.NoError(s.T, err, "Orders pagination") + require.NotNil(s.T, resp, "Orders pagination response") + require.LessOrEqual(s.T, len(resp.Orders), 1, "Orders pagination limit must be honored") +} + +// marketNegatives exercises edge cases that must be rejected, against bogus or +// out-of-bounds inputs so the live order/lease state is untouched. +func (marketPack) marketNegatives(s *Suite, q mvbeta.QueryClient, providerAddr string) { + // Fresh open order to derive a structurally-valid bid for the price-too-high case. + depN := createDeploymentFromSDL(s, s.TenantSigner()) + orderN := s.findOrder(q, depN.Owner, depN.DSeq) + deposit := depositv1.Deposit{Amount: s.marketBidDeposit(q), Sources: depositv1.Sources{depositv1.SourceBalance}} + offer := resourcesOfferFrom(orderN.Spec) + + // 1. Bid on a non-existent order (bogus dseq). + s.BroadcastExpectErr("provider", &mvbeta.MsgCreateBid{ + ID: mv1.BidID{Owner: orderN.ID.Owner, DSeq: orderN.ID.DSeq + 9_000_000, GSeq: 1, OSeq: 1, Provider: providerAddr}, + Price: orderN.Spec.Price(), + Deposit: deposit, + ResourcesOffer: offer, + }) + + // 2. Bid priced far above the order's max acceptable price. + max := orderN.Spec.Price() + tooHigh := sdk.NewDecCoinFromDec(max.Denom, max.Amount.MulInt64(1_000_000)) + s.BroadcastExpectErr("provider", &mvbeta.MsgCreateBid{ + ID: mv1.BidID{Owner: orderN.ID.Owner, DSeq: orderN.ID.DSeq, GSeq: orderN.ID.GSeq, OSeq: orderN.ID.OSeq, Provider: providerAddr}, + Price: tooHigh, + Deposit: deposit, + ResourcesOffer: offer, + }) + + // 3. Create a lease referencing a non-existent bid. + s.BroadcastExpectErr(s.TenantSigner(), &mvbeta.MsgCreateLease{ + BidID: mv1.BidID{Owner: orderN.ID.Owner, DSeq: orderN.ID.DSeq + 8_000_000, GSeq: 1, OSeq: 1, Provider: providerAddr}, + }) + + // 4. Close a lease with an invalid (out-of-range) reason. + s.BroadcastExpectErr(s.TenantSigner(), &mvbeta.MsgCloseLease{ + ID: mv1.LeaseID{Owner: orderN.ID.Owner, DSeq: orderN.ID.DSeq, GSeq: 1, OSeq: 1, Provider: providerAddr}, + Reason: 0, + }) + + // 5. Withdraw from a non-existent lease. + s.BroadcastExpectErr("provider", &mvbeta.MsgWithdrawLease{ + ID: mv1.LeaseID{Owner: orderN.ID.Owner, DSeq: orderN.ID.DSeq + 7_000_000, GSeq: 1, OSeq: 1, Provider: providerAddr}, + }) + s.logf("market negatives complete (missing order/bid/lease + over-max price + invalid close reason rejected)") +} + +func containsOrder(orders mvbeta.Orders, id mv1.OrderID) bool { + for _, o := range orders { + if o.ID == id { + return true + } + } + return false +} + +// findOrder polls for an open order belonging to owner/dseq (orders are created in +// the EndBlocker of the block that includes the deployment). +func (s *Suite) findOrder(q mvbeta.QueryClient, owner string, dseq uint64) mvbeta.Order { + s.T.Helper() + ctx, cancel := context.WithTimeout(s.Ctx, 30*time.Second) + defer cancel() + for { + resp, err := q.Orders(ctx, &mvbeta.QueryOrdersRequest{ + Filters: mvbeta.OrderFilters{Owner: owner, DSeq: dseq}, + Pagination: &sdkquery.PageRequest{Limit: 10}, + }) + if err == nil && len(resp.Orders) > 0 { + return resp.Orders[0] + } + select { + case <-ctx.Done(): + s.T.Fatalf("no order found for %s/%d: %v", owner, dseq, err) + case <-time.After(time.Second): + } + } +} + +// newBid builds a MsgCreateBid that matches the order: it offers each of the order's +// resources and bids at the order's max price. +func (s *Suite) newBid(q mvbeta.QueryClient, order mvbeta.Order, provider string) *mvbeta.MsgCreateBid { + s.T.Helper() + return &mvbeta.MsgCreateBid{ + ID: mv1.BidID{ + Owner: order.ID.Owner, DSeq: order.ID.DSeq, GSeq: order.ID.GSeq, + OSeq: order.ID.OSeq, Provider: provider, + }, + Price: order.Spec.Price(), + Deposit: depositv1.Deposit{Amount: s.marketBidDeposit(q), Sources: depositv1.Sources{depositv1.SourceBalance}}, + ResourcesOffer: resourcesOfferFrom(order.Spec), + } +} + +func resourcesOfferFrom(spec dvbeta.GroupSpec) mvbeta.ResourcesOffer { + offer := make(mvbeta.ResourcesOffer, 0, len(spec.Resources)) + for _, ru := range spec.Resources { + offer = append(offer, mvbeta.ResourceOffer{Resources: ru.Resources, Count: ru.Count}) + } + return offer +} + +func (s *Suite) marketBidDeposit(q mvbeta.QueryClient) sdk.Coin { + s.T.Helper() + resp, err := q.Params(s.Ctx, &mvbeta.QueryParamsRequest{}) + require.NoError(s.T, err, "market Params") + // Prefer the uakt min deposit — providers are funded in uakt. + for _, c := range resp.Params.BidMinDeposits { + if c.Denom == sdkutil.DenomUakt && !c.IsZero() { + return c + } + } + if resp.Params.BidMinDeposit.Denom == sdkutil.DenomUakt && !resp.Params.BidMinDeposit.IsZero() { + return resp.Params.BidMinDeposit + } + s.T.Fatal("market params have no uakt bid min deposit") + return sdk.Coin{} +} diff --git a/tests/upgrade/grpcsuite/pack_oracle.go b/tests/upgrade/grpcsuite/pack_oracle.go new file mode 100644 index 000000000..fd5fc5b27 --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_oracle.go @@ -0,0 +1,87 @@ +package grpcsuite + +import ( + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" + + oraclev2 "pkg.akt.dev/go/node/oracle/v2" + "pkg.akt.dev/go/sdkutil" +) + +type oraclePack struct{} + +func (oraclePack) Name() string { return "oracle" } + +func (oraclePack) Available(d *Discovery) bool { return d.HasModule("akash.oracle.v2") } + +func (oraclePack) Run(s *Suite) { + q := oraclev2.NewQueryClient(s.Conn) + writer := s.FundAccountDefault("pricewriter") + + // Authorize the writer as a price source (gov-gated param change). + pr, err := q.Params(s.Ctx, &oraclev2.QueryParamsRequest{}) + require.NoError(s.T, err, "oracle Params") + params := pr.Params + params.Sources = append(params.Sources, writer.String()) + s.PassGovProposal("grpcsuite: authorize oracle price source", + &oraclev2.MsgUpdateParams{Authority: s.GovAuthority(), Params: params}) + + // Submit a price entry as the now-authorized source. + s.feedAKTPrice(3) + + // Params now lists the authorized writer. + pr2, err := q.Params(s.Ctx, &oraclev2.QueryParamsRequest{}) + require.NoError(s.T, err, "oracle Params (after authorize)") + require.Contains(s.T, pr2.Params.Sources, writer.String(), "writer should be an authorized source") + + // Prices: assert at least one entry exists after the feed. + prices, err := q.Prices(s.Ctx, &oraclev2.QueryPricesRequest{}) + require.NoError(s.T, err, "Prices") + require.NotEmpty(s.T, prices.Prices, "expected at least one price entry") + + // AggregatedPrice for the AKT denom. + _, err = q.AggregatedPrice(s.Ctx, &oraclev2.QueryAggregatedPriceRequest{Denom: sdkutil.DenomAkt}) + require.NoError(s.T, err, "AggregatedPrice") + + oracleNegatives(s) + s.logf("oracle price entry submitted") +} + +// feedAKTPrice submits a fresh AKT/USD oracle price from the authorized writer +// ("pricewriter", created by the oracle pack). The oracle requires the AKT/USD pair +// and a timestamp within ~12s of block time, so it uses the chain's latest block +// time. Other packs (bme) re-feed just before use so the price stays healthy. +func (s *Suite) feedAKTPrice(price int64) { + s.T.Helper() + s.feedAKTPriceAs("pricewriter", s.Addr("pricewriter"), price) +} + +func (s *Suite) feedAKTPriceAs(signer string, addr sdk.AccAddress, price int64) { + s.T.Helper() + s.BroadcastOK(signer, &oraclev2.MsgAddPriceEntry{ + Signer: addr.String(), + ID: oraclev2.DataID{Denom: sdkutil.DenomAkt, BaseDenom: sdkutil.DenomUSD}, + Price: sdkmath.LegacyNewDec(price), + Timestamp: s.LatestBlockTime(), + }) +} + +func oracleNegatives(s *Suite) { + s.T.Helper() + // Unauthorized account (not in Params.Sources) submitting a price. + unauth := s.FundAccountDefault("pricewriter-unauth") + s.BroadcastExpectErr("pricewriter-unauth", &oraclev2.MsgAddPriceEntry{ + Signer: unauth.String(), + ID: oraclev2.DataID{Denom: sdkutil.DenomAkt, BaseDenom: sdkutil.DenomUSD}, + Price: sdkmath.LegacyNewDec(1), + Timestamp: s.LatestBlockTime(), + }) + // Authorized writer, but an unsupported denom pair (only AKT/USD is accepted). + s.BroadcastExpectErr("pricewriter", &oraclev2.MsgAddPriceEntry{ + Signer: s.Addr("pricewriter").String(), + ID: oraclev2.DataID{Denom: "uatom", BaseDenom: sdkutil.DenomUSD}, + Price: sdkmath.LegacyNewDec(1), + Timestamp: s.LatestBlockTime(), + }) +} diff --git a/tests/upgrade/grpcsuite/pack_provider.go b/tests/upgrade/grpcsuite/pack_provider.go new file mode 100644 index 000000000..87360f64c --- /dev/null +++ b/tests/upgrade/grpcsuite/pack_provider.go @@ -0,0 +1,100 @@ +package grpcsuite + +import ( + sdkquery "github.com/cosmos/cosmos-sdk/types/query" + "github.com/stretchr/testify/require" + + ptypes "pkg.akt.dev/go/node/provider/v1beta4" + tattr "pkg.akt.dev/go/node/types/attributes/v1" +) + +// World keys published by the provider pack for downstream packs (market, audit). +const wProviderSigner = "provider.signer" // keyring name of the registered provider + +type providerPack struct{} + +func (providerPack) Name() string { return "provider" } + +func (providerPack) Available(d *Discovery) bool { + return d.HasModule("akash.provider.v1beta4") +} + +func (providerPack) Run(s *Suite) { + q := ptypes.NewQueryClient(s.Conn) + + attrs := tattr.Attributes{ + {Key: "region", Value: "us-west"}, + {Key: "tier", Value: "community"}, + } + info := ptypes.Info{EMail: "ops@grpcsuite.test", Website: "https://grpcsuite.test"} + + // Primary provider — registered and kept for the market/audit packs. + provider := s.FundAccountDefault("provider") + s.BroadcastOK("provider", &ptypes.MsgCreateProvider{ + Owner: provider.String(), + HostURI: "https://provider.grpcsuite.test:8443", + Attributes: attrs, + Info: info, + }) + + // MsgUpdateProvider — change host + attributes. + s.BroadcastOK("provider", &ptypes.MsgUpdateProvider{ + Owner: provider.String(), + HostURI: "https://provider.grpcsuite.test:8444", + Attributes: append(attrs, tattr.Attribute{Key: "updated", Value: "true"}), + Info: info, + }) + + // Queries — assert the provider reflects the update, and the list contains it. + pResp, err := q.Provider(s.Ctx, &ptypes.QueryProviderRequest{Owner: provider.String()}) + require.NoError(s.T, err, "Provider by owner") + require.Equal(s.T, provider.String(), pResp.Provider.Owner, "queried provider owner should match") + require.Equal(s.T, "https://provider.grpcsuite.test:8444", pResp.Provider.HostURI, "host_uri should reflect the update") + require.True(s.T, providerHasAttribute(pResp.Provider, "updated"), "updated attribute should be present after update") + + list, err := q.Providers(s.Ctx, &ptypes.QueryProvidersRequest{Pagination: &sdkquery.PageRequest{Limit: 100}}) + require.NoError(s.T, err, "Providers") + require.NotEmpty(s.T, list.Providers, "expected at least one provider") + found := false + for _, p := range list.Providers { + if p.Owner == provider.String() { + found = true + break + } + } + require.True(s.T, found, "Providers list should include the registered provider") + paged, err := q.Providers(s.Ctx, &ptypes.QueryProvidersRequest{Pagination: &sdkquery.PageRequest{Limit: 1}}) + require.NoError(s.T, err, "Providers (paginated)") + require.LessOrEqual(s.T, len(paged.Providers), 1, "pagination Limit=1 must cap results") + + s.World.Set(wProviderSigner, "provider") + + // Negative: re-registering an already-registered owner must be rejected. + s.BroadcastExpectErr("provider", &ptypes.MsgCreateProvider{ + Owner: provider.String(), HostURI: "https://dup.grpcsuite.test:8443", Attributes: attrs, Info: info, + }) + + // Negative: updating from an account that is not a registered provider. + ghost := s.FundAccountDefault("provider-unregistered") + s.BroadcastExpectErr("provider-unregistered", &ptypes.MsgUpdateProvider{ + Owner: ghost.String(), HostURI: "https://ghost.grpcsuite.test:8443", Attributes: attrs, Info: info, + }) + + // MsgDeleteProvider is intentionally disabled on-chain (providers cannot be + // removed, to avoid orphaning leases). Exercise it and assert the expected + // rejection; the primary provider stays registered for downstream packs. + _, err = s.TX.Broadcast("provider", &ptypes.MsgDeleteProvider{Owner: provider.String()}) + require.Error(s.T, err, "MsgDeleteProvider should be rejected (disabled on-chain)") + s.logf("provider lifecycle complete (create/update + queries; duplicate-create, unregistered-update, delete rejected)") +} + +// providerHasAttribute reports whether the provider carries an attribute with the +// given key. +func providerHasAttribute(p ptypes.Provider, key string) bool { + for _, a := range p.Attributes { + if a.Key == key { + return true + } + } + return false +} diff --git a/tests/upgrade/grpcsuite/smoke.go b/tests/upgrade/grpcsuite/smoke.go new file mode 100644 index 000000000..a34bcf1da --- /dev/null +++ b/tests/upgrade/grpcsuite/smoke.go @@ -0,0 +1,97 @@ +package grpcsuite + +import ( + "sort" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/encoding" + "google.golang.org/grpc/status" +) + +// rawCodec is a gRPC codec that passes message bodies through as raw bytes. It lets +// the smoke sweep invoke any query method with an empty request and ignore the +// response body, without needing each method's concrete request/response Go types. +type rawCodec struct{} + +func (rawCodec) Marshal(v interface{}) ([]byte, error) { + switch b := v.(type) { + case []byte: + return b, nil + case *[]byte: + return *b, nil + default: + return nil, status.Errorf(codes.Internal, "rawCodec.Marshal: unexpected %T", v) + } +} + +func (rawCodec) Unmarshal(data []byte, v interface{}) error { + if p, ok := v.(*[]byte); ok { + *p = data + return nil + } + return status.Errorf(codes.Internal, "rawCodec.Unmarshal: unexpected %T", v) +} + +func (rawCodec) Name() string { return "grpcsuite-raw-bytes" } + +func init() { encoding.RegisterCodec(rawCodec{}) } + +// querySmokeSweep invokes every discovered in-scope query method with an empty +// request over gRPC. This is the dynamic half of coverage: it reaches every query +// handler (recording it via the connection's coverage interceptor) and fails when +// a reflected method is not wired, or when the call fails before reaching normal +// query business logic. Business errors (e.g. NotFound/InvalidArgument from an +// empty request) are expected and prove the handler is reachable; the authored +// query cases verify correctness with real inputs. +func (s *Suite) querySmokeSweep(disc *Discovery) { + s.T.Helper() + + methods := make([]string, 0, len(disc.queries)) + for m := range disc.queries { + methods = append(methods, m) + } + sort.Strings(methods) + + var unimplemented int + for _, method := range methods { + var reply []byte + err := s.Conn.Invoke(s.Ctx, method, []byte{}, &reply, grpc.ForceCodec(rawCodec{})) + if err == nil { + continue + } + + switch status.Code(err) { + case codes.Unimplemented: + unimplemented++ + s.T.Errorf("query smoke sweep: %s is advertised by reflection but Unimplemented", method) + case codes.InvalidArgument, + codes.NotFound, + codes.FailedPrecondition, + codes.PermissionDenied, + codes.Unauthenticated, + codes.OutOfRange, + codes.AlreadyExists, + codes.Aborted, + codes.Unknown: + // The handler was reached. Cosmos SDK ABCI query errors commonly map + // to Unknown, while direct request validation uses more specific codes. + case codes.Internal: + if !isEmptyRequestInternal(err) { + s.T.Errorf("query smoke sweep: %s returned unexpected gRPC failure: %v", method, err) + } + default: + s.T.Errorf("query smoke sweep: %s returned unexpected gRPC failure: %v", method, err) + } + } + s.logf("query smoke sweep: invoked %d query methods (%d unimplemented)", len(methods), unimplemented) +} + +func isEmptyRequestInternal(err error) bool { + st, ok := status.FromError(err) + if !ok { + return false + } + return strings.Contains(st.Message(), "empty address string is not allowed") +} diff --git a/tests/upgrade/grpcsuite/txbroadcast.go b/tests/upgrade/grpcsuite/txbroadcast.go new file mode 100644 index 000000000..309995d9e --- /dev/null +++ b/tests/upgrade/grpcsuite/txbroadcast.go @@ -0,0 +1,333 @@ +package grpcsuite + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "strings" + "time" + + cmttypes "github.com/cometbft/cometbft/types" + cmtservice "github.com/cosmos/cosmos-sdk/client/grpc/cmtservice" + clienttx "github.com/cosmos/cosmos-sdk/client/tx" + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + "github.com/cosmos/cosmos-sdk/types/tx/signing" + "github.com/stretchr/testify/require" +) + +// Broadcaster signs transactions locally and broadcasts them over the gRPC tx +// ServiceClient, then polls (over gRPC) for block inclusion. This is the literal +// "broadcast a tx over the gRPC API" path: nothing here touches Comet RPC. +type Broadcaster struct { + s *Suite + txSvc txtypes.ServiceClient + cmtSvc cmtservice.ServiceClient + gasAdj float64 +} + +func newBroadcaster(s *Suite) *Broadcaster { + return &Broadcaster{ + s: s, + txSvc: txtypes.NewServiceClient(s.Conn), + cmtSvc: cmtservice.NewServiceClient(s.Conn), + gasAdj: 1.5, + } +} + +// txFactory prepares the local signing factory. Account number/sequence are +// fetched fresh over gRPC. +func (b *Broadcaster) txFactory(fromName string) (clienttx.Factory, error) { + s := b.s + rec, err := s.Env.Keyring.Key(fromName) + if err != nil { + return clienttx.Factory{}, fmt.Errorf("keyring lookup %q: %w", fromName, err) + } + addr, err := rec.GetAddress() + if err != nil { + return clienttx.Factory{}, err + } + + num, seq, err := s.Cctx.AccountRetriever.GetAccountNumberSequence(s.Cctx, addr) + if err != nil { + return clienttx.Factory{}, fmt.Errorf("account number/sequence for %s: %w", addr, err) + } + + return clienttx.Factory{}. + WithChainID(s.Env.ChainID). + WithKeybase(s.Env.Keyring). + WithTxConfig(s.Env.TxConfig). + WithAccountRetriever(s.Cctx.AccountRetriever). + WithSignMode(signing.SignMode_SIGN_MODE_DIRECT). + WithGasAdjustment(b.gasAdj). + WithGasPrices(s.Env.GasPrices). + WithFromName(fromName). + WithAccountNumber(num). + WithSequence(seq), nil +} + +func (b *Broadcaster) requireLocallySignable(txf clienttx.Factory, fromName string, msgs []sdk.Msg) error { + s := b.s + txb, err := txf.WithGas(1).BuildUnsignedTx(msgs...) + if err != nil { + return err + } + if err := clienttx.Sign(s.Ctx, txf, fromName, txb, true); err != nil { + return fmt.Errorf("sign: %w", err) + } + if _, err := s.Env.TxConfig.TxEncoder()(txb.GetTx()); err != nil { + return err + } + return nil +} + +func (b *Broadcaster) simulateGas(txf clienttx.Factory, msgs []sdk.Msg) (uint64, error) { + txBytes, err := txf.BuildSimTx(msgs...) + if err != nil { + return 0, err + } + simRes, err := b.txSvc.Simulate(b.s.Ctx, &txtypes.SimulateRequest{TxBytes: txBytes}) + if err != nil { + return 0, err + } + if simRes == nil || simRes.GasInfo == nil { + return 0, fmt.Errorf("simulate gas response was empty") + } + return uint64(txf.GasAdjustment() * float64(simRes.GasInfo.GasUsed)), nil +} + +// sign simulates for gas over gRPC and signs a tx in DIRECT mode, returning the +// encoded tx bytes. +func (b *Broadcaster) sign(txf clienttx.Factory, fromName string, msgs []sdk.Msg) ([]byte, error) { + s := b.s + + // Simulate over gRPC to compute an accurate gas limit; fees are derived from + // gas prices in BuildUnsignedTx. + adjusted, err := b.simulateGas(txf, msgs) + if err != nil { + return nil, fmt.Errorf("simulate gas: %w", err) + } + txf = txf.WithGas(adjusted) + + txb, err := txf.BuildUnsignedTx(msgs...) + if err != nil { + return nil, err + } + if err := clienttx.Sign(b.s.Ctx, txf, fromName, txb, true); err != nil { + return nil, fmt.Errorf("sign: %w", err) + } + return s.Env.TxConfig.TxEncoder()(txb.GetTx()) +} + +// Broadcast signs and broadcasts msgs from fromName over gRPC, waits for block +// inclusion, and returns the final (DeliverTx) response. A non-zero result code +// (at CheckTx or DeliverTx) is returned as an error alongside the response so +// callers can assert on either. +func (b *Broadcaster) Broadcast(fromName string, msgs ...sdk.Msg) (*sdk.TxResponse, error) { + txf, err := b.txFactory(fromName) + if err != nil { + return nil, err + } + if err := b.requireLocallySignable(txf, fromName, msgs); err != nil { + return nil, err + } + + for _, m := range msgs { + b.s.Cov.recordMsg(sdk.MsgTypeURL(m)) + } + + bz, err := b.sign(txf, fromName, msgs) + if err != nil { + return nil, err + } + startHeight, err := b.latestHeight(b.s.Ctx) + if err != nil { + return nil, fmt.Errorf("latest height before broadcast: %w", err) + } + + res, err := b.txSvc.BroadcastTx(b.s.Ctx, &txtypes.BroadcastTxRequest{ + TxBytes: bz, + Mode: txtypes.BroadcastMode_BROADCAST_MODE_SYNC, + }) + if err != nil { + return nil, fmt.Errorf("gRPC BroadcastTx: %w", err) + } + check := res.TxResponse + if check.Code != 0 { + // Rejected at CheckTx (ante handler etc.) — never enters a block. + return check, abciErr(check) + } + + // Accepted into the mempool; wait for it to land in a block and read the + // DeliverTx result. + final, err := b.waitForTx(check.TxHash, bz, startHeight) + if err != nil { + return final, err + } + if final.Code != 0 { + return final, abciErr(final) + } + return final, nil +} + +func (b *Broadcaster) waitForTx(hash string, txBytes []byte, startHeight int64) (*sdk.TxResponse, error) { + ctx, cancel := context.WithTimeout(b.s.Ctx, 2*time.Minute) + defer cancel() + hash = strings.ToUpper(hash) + nextScanHeight := startHeight + 1 + var lastGetTxErr error + var lastBlockScanErr error + for { + gr, err := b.txSvc.GetTx(ctx, &txtypes.GetTxRequest{Hash: hash}) + if err == nil && gr.TxResponse != nil { + return gr.TxResponse, nil + } + if err != nil { + lastGetTxErr = err + } + + latest, err := b.latestHeight(ctx) + if err != nil { + lastBlockScanErr = err + } else { + for height := nextScanHeight; height <= latest; height++ { + found, err := b.txIncludedAtHeight(ctx, hash, txBytes, height) + if err != nil { + lastBlockScanErr = err + continue + } + if found { + if indexed, indexErr := b.waitForTxIndex(ctx, hash, 5*time.Second); indexErr == nil { + return indexed, nil + } + // The block scan proves the tx landed even when the tx indexer is + // lagging or not answering GetTx. Keep the response conservative: + // it has the hash and height, but no DeliverTx events. + b.s.logf("tx %s found in block %d before GetTx returned it (last GetTx err: %v)", hash, height, lastGetTxErr) + return &sdk.TxResponse{TxHash: hash, Height: height, Code: 0}, nil + } + } + nextScanHeight = latest + 1 + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf( + "waiting for tx %s inclusion from height %d to %d: %w (last GetTx err: %v; last block scan err: %v)", + hash, startHeight, nextScanHeight-1, ctx.Err(), lastGetTxErr, lastBlockScanErr, + ) + case <-time.After(500 * time.Millisecond): + } + } +} + +func (b *Broadcaster) latestHeight(ctx context.Context) (int64, error) { + resp, err := b.cmtSvc.GetLatestBlock(ctx, &cmtservice.GetLatestBlockRequest{}) + if err != nil { + return 0, err + } + if resp == nil { + return 0, fmt.Errorf("latest block response was empty") + } + if resp.SdkBlock != nil { + return resp.SdkBlock.Header.Height, nil + } + if resp.Block != nil { + return resp.Block.Header.Height, nil + } + return 0, fmt.Errorf("latest block response was empty") +} + +func (b *Broadcaster) waitForTxIndex(ctx context.Context, hash string, timeout time.Duration) (*sdk.TxResponse, error) { + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + gr, err := b.txSvc.GetTx(ctx, &txtypes.GetTxRequest{Hash: hash}) + if err == nil && gr.TxResponse != nil { + return gr.TxResponse, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + if err != nil { + return nil, err + } + return nil, fmt.Errorf("tx %s was not indexed within %s", hash, timeout) + case <-time.After(500 * time.Millisecond): + } + } +} + +func (b *Broadcaster) txIncludedAtHeight(ctx context.Context, hash string, txBytes []byte, height int64) (bool, error) { + resp, err := b.cmtSvc.GetBlockByHeight(ctx, &cmtservice.GetBlockByHeightRequest{Height: height}) + if err != nil { + return false, err + } + for _, tx := range blockTxs(resp) { + txHash := strings.ToUpper(hex.EncodeToString(cmttypes.Tx(tx).Hash())) + if txHash == hash || bytes.Equal(tx, txBytes) { + return true, nil + } + } + return false, nil +} + +func blockTxs(resp *cmtservice.GetBlockByHeightResponse) [][]byte { + if resp == nil { + return nil + } + if resp.SdkBlock != nil { + return resp.SdkBlock.Data.Txs + } + if resp.Block != nil { + return resp.Block.Data.Txs + } + return nil +} + +func abciErr(r *sdk.TxResponse) error { + return fmt.Errorf("tx %s failed: code=%d codespace=%s log=%q", r.TxHash, r.Code, r.Codespace, r.RawLog) +} + +// BroadcastOK broadcasts msgs from fromName and requires the tx to succeed. +func (s *Suite) BroadcastOK(fromName string, msgs ...sdk.Msg) *sdk.TxResponse { + s.T.Helper() + res, err := s.TX.Broadcast(fromName, msgs...) + require.NoErrorf(s.T, err, "broadcast from %s", fromName) + require.NotNil(s.T, res) + require.Equalf(s.T, uint32(0), res.Code, "tx failed: code=%d log=%s", res.Code, res.RawLog) + return res +} + +// BroadcastExpectErr broadcasts msgs expecting failure, and returns the response +// (which may be nil if the failure was pre-broadcast) and error for the caller to +// assert on (e.g. require.ErrorIs / a specific ABCI code). +func (s *Suite) BroadcastExpectErr(fromName string, msgs ...sdk.Msg) (*sdk.TxResponse, error) { + s.T.Helper() + res, err := s.TX.Broadcast(fromName, msgs...) + require.Error(s.T, err, "expected broadcast from %s to fail", fromName) + return res, err +} + +// BroadcastTolerant broadcasts msgs and accepts EITHER success OR a failure whose +// error contains one of okErrSubstrs. Use for messages whose acceptance depends on +// dynamic chain state that is hard to control deterministically in a test/forked +// network (e.g. the bme circuit breaker / collateral ratio): the message is still +// exercised (and counted for coverage) and the gRPC handler is proven wired, +// responding either by executing or by correctly rejecting. +func (s *Suite) BroadcastTolerant(fromName string, okErrSubstrs []string, msgs ...sdk.Msg) { + s.T.Helper() + _, err := s.TX.Broadcast(fromName, msgs...) + if err == nil { + return + } + for _, sub := range okErrSubstrs { + if strings.Contains(err.Error(), sub) { + s.logf("tolerated expected rejection from %s: %v", fromName, err) + return + } + } + require.NoErrorf(s.T, err, "broadcast from %s (not a tolerated rejection)", fromName) +} diff --git a/tests/upgrade/grpcsurface_worker_test.go b/tests/upgrade/grpcsurface_worker_test.go new file mode 100644 index 000000000..3b0abdd88 --- /dev/null +++ b/tests/upgrade/grpcsurface_worker_test.go @@ -0,0 +1,60 @@ +//go:build e2e.upgrade + +package upgrade + +import ( + "context" + "testing" + + sdkclient "github.com/cosmos/cosmos-sdk/client" + "github.com/stretchr/testify/require" + + "pkg.akt.dev/go/sdkutil" + + akash "pkg.akt.dev/node/v2/app" + "pkg.akt.dev/node/v2/tests/upgrade/grpcsuite" + uttypes "pkg.akt.dev/node/v2/tests/upgrade/types" +) + +// The exhaustive gRPC tx/query suite runs after EVERY upgrade as the universal +// post-upgrade worker, against the testnetify-forked, freshly-upgraded validator. +func init() { + uttypes.RegisterUniversalPostUpgradeWorker(&grpcSurfaceWorker{}) +} + +type grpcSurfaceWorker struct{} + +var _ uttypes.TestWorker = (*grpcSurfaceWorker)(nil) + +func (w *grpcSurfaceWorker) Run(ctx context.Context, t *testing.T, params uttypes.TestParams) { + encCfg := sdkutil.MakeEncodingConfig() + akash.ModuleBasics().RegisterInterfaces(encCfg.InterfaceRegistry) + + // Load the keyring shipped with the testnetify state (contains the funded, + // voting-power-holding `params.From` account, e.g. validator0). + cctx := sdkclient.Context{}. + WithCodec(encCfg.Codec). + WithKeyringDir(params.Home) + kr, err := sdkclient.NewKeyringFromBackend(cctx, params.KeyringBackend) + require.NoError(t, err) + + env := grpcsuite.Env{ + GRPCEndpoint: params.GRPC, + ChainID: params.ChainID, + RepoRoot: params.SourceDir, + Cdc: encCfg.Codec, + InterfaceReg: encCfg.InterfaceRegistry, + TxConfig: encCfg.TxConfig, + Amino: encCfg.Amino, + Keyring: kr, + Funder: params.From, + FunderAddr: params.FromAddress, + BondDenom: sdkutil.DenomUakt, + GasPrices: "0.025uakt", + // Enforce full coverage post-upgrade: fail if any in-scope tx or query + // was not exercised against the upgraded chain. + RequireFullCoverage: true, + } + + grpcsuite.Run(ctx, t, env) +} diff --git a/tests/upgrade/types/types.go b/tests/upgrade/types/types.go index 5c5741a05..c2313e307 100644 --- a/tests/upgrade/types/types.go +++ b/tests/upgrade/types/types.go @@ -11,6 +11,7 @@ import ( type TestParams struct { Home string Node string + GRPC string // host:port of the node's gRPC server (validator 0) SourceDir string ChainID string KeyringBackend string @@ -25,8 +26,27 @@ type TestWorker interface { var ( preUpgradeWorkers = map[string]TestWorker{} postUpgradeWorkers = map[string]TestWorker{} + + // universalPostUpgradeWorker, if set, runs after every upgrade regardless of + // the upgrade name — used by the exhaustive gRPC tx/query suite which must + // verify the API surface on every upgrade. + universalPostUpgradeWorker TestWorker ) +// RegisterUniversalPostUpgradeWorker registers a worker that runs after every +// upgrade, in addition to any name-specific worker. +func RegisterUniversalPostUpgradeWorker(worker TestWorker) { + if universalPostUpgradeWorker != nil { + panic("universal post-upgrade worker already registered") + } + universalPostUpgradeWorker = worker +} + +// GetUniversalPostUpgradeWorker returns the universal post-upgrade worker, if any. +func GetUniversalPostUpgradeWorker() TestWorker { + return universalPostUpgradeWorker +} + func RegisterPreUpgradeWorker(name string, worker TestWorker) { if _, exists := preUpgradeWorkers[name]; exists { panic(fmt.Sprintf("pre-upgrade worker for upgrade \"%s\" already exists", name)) diff --git a/tests/upgrade/upgrade_test.go b/tests/upgrade/upgrade_test.go index 89f0252fc..ec4abae2f 100644 --- a/tests/upgrade/upgrade_test.go +++ b/tests/upgrade/upgrade_test.go @@ -398,6 +398,7 @@ func TestUpgrade(t *testing.T) { postUpgradeParams.SourceDir = *sourcesdir postUpgradeParams.ChainID = cfg.ChainID postUpgradeParams.Node = "tcp://127.0.0.1:26657" + postUpgradeParams.GRPC = "127.0.0.1:9090" // validator 0 gRPC (9090 + 0*3) postUpgradeParams.KeyringBackend = "test" postUpgradeParams.From = cfg.Work.Key postUpgradeParams.FromAddress = addr @@ -596,15 +597,16 @@ loop: if stageCount == 0 { l.t.Log("all nodes performed upgrade") - postUpgradeWorker := uttypes.GetPostUpgradeWorker(l.upgradeName) - if postUpgradeWorker == nil { + namedWorker := uttypes.GetPostUpgradeWorker(l.upgradeName) + universalWorker := uttypes.GetUniversalPostUpgradeWorker() + if namedWorker == nil && universalWorker == nil { l.t.Log("no post upgrade handlers found. submitting shutdown") _ = bus.Publish(postUpgradeTestDone{}) break } - l.t.Log("running post upgrade test handler") + l.t.Log("running post upgrade test handler(s)") l.group.Go(func() error { defer func() { @@ -612,7 +614,12 @@ loop: }() result := l.t.Run(l.upgradeName, func(t *testing.T) { - postUpgradeWorker.Run(l.ctx, l.t, l.postUpgradeParams) + if namedWorker != nil { + namedWorker.Run(l.ctx, t, l.postUpgradeParams) + } + if universalWorker != nil { + universalWorker.Run(l.ctx, t, l.postUpgradeParams) + } }) if !result {