diff --git a/.github/workflows/go-hardened.yml b/.github/workflows/go-hardened.yml index 549bf21cd..9fcacbbe3 100644 --- a/.github/workflows/go-hardened.yml +++ b/.github/workflows/go-hardened.yml @@ -23,7 +23,7 @@ jobs: # surface as a confusing not-found failure for external # contributors. Internal PRs and push events always run. if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - uses: allora-network/ci-workflows-private/.github/workflows/go-install-hardened.yml@0d80a856ef798b01a39f65cb88a5cf7e74e4ebf9 + uses: allora-network/ci-workflows-private/.github/workflows/go-install-hardened.yml@v1 with: # Pinned to match the `toolchain` directive in go.mod. Keeps # the hardened install check deterministic across runs instead diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 79f5ad146..89870640e 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: 1.23.6 + go-version: 1.23.5 - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: @@ -34,7 +34,13 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: stable + # Pinned rather than `stable`: the custom analyzers link + # golang.org/x/tools, whose go/packages loader only understands + # export data up to the Go release it shipped with. A floating + # `stable` silently outran the pinned x/tools and broke package + # loading with an "imported without types" internal error. + # Keep in step with the `lint` job above and go.mod's toolchain. + go-version: 1.23.5 - name: check-defer-close run: go run ./linter/check-defer-close . timeout-minutes: 10 diff --git a/app/app.go b/app/app.go index 5a9448578..0802e218b 100644 --- a/app/app.go +++ b/app/app.go @@ -90,6 +90,12 @@ type AlloraApp struct { txConfig client.TxConfig interfaceRegistry codectypes.InterfaceRegistry + // Read-only decoding path for historical tx queries. Separate from the + // consensus registry/txConfig above so that decoding pre-upgrade payloads + // never widens what the consensus decoder accepts. See querytx.go. + queryInterfaceRegistry codectypes.InterfaceRegistry + queryTxConfig client.TxConfig + // simulation manager sm *module.SimulationManager @@ -107,20 +113,27 @@ func init() { DefaultNodeHome = filepath.Join(userHomeDir, ".allorad") } +// CustomModuleBasics returns the module basics the runtime cannot derive on its +// own. Hoisted out of AppConfig so the read-only query registry (querytx.go) can +// resolve genutil/gov exactly as SetupAppBuilder does. +func CustomModuleBasics() map[string]module.AppModuleBasic { + return map[string]module.AppModuleBasic{ + genutiltypes.ModuleName: genutil.NewAppModuleBasic(genutiltypes.DefaultMessageValidator), + govtypes.ModuleName: gov.NewAppModuleBasic( + []govclient.ProposalHandler{ + paramsclient.ProposalHandler, + }, + ), + } +} + // AppConfig returns the default app config. func AppConfig() depinject.Config { return depinject.Configs( appconfig.LoadYAML(AppConfigYAML), depinject.Supply( // supply custom module basics - map[string]module.AppModuleBasic{ - genutiltypes.ModuleName: genutil.NewAppModuleBasic(genutiltypes.DefaultMessageValidator), - govtypes.ModuleName: gov.NewAppModuleBasic( - []govclient.ProposalHandler{ - paramsclient.ProposalHandler, - }, - ), - }, + CustomModuleBasics(), ), ) } @@ -195,6 +208,13 @@ func NewAlloraApp( // Register feemarket module app.registerFeeMarketModule() + // Build the read-only decoding path now that ModuleManager holds every module + // (including the legacy/IBC/feemarket ones registered just above). This never + // touches app.interfaceRegistry, so the consensus decoder is unchanged. + if err := app.buildQueryDecodingPath(); err != nil { + return nil, err + } + // register streaming services if err := app.RegisterStreamingServices(appOpts, app.kvStoreKeys()); err != nil { return nil, err diff --git a/app/querytx.go b/app/querytx.go new file mode 100644 index 000000000..bfdb4520e --- /dev/null +++ b/app/querytx.go @@ -0,0 +1,269 @@ +package app + +import ( + "context" + "errors" + "fmt" + + txsigning "cosmossdk.io/x/tx/signing" + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/std" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/cosmos/cosmos-sdk/types/module" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" + gogoproto "github.com/cosmos/gogoproto/proto" + protov2 "google.golang.org/protobuf/proto" + + emissionsv7 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v7" + emissionsv8 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v8" + mintv2 "github.com/allora-network/allora-chain/x/mint/api/mint/v2" +) + +// Compile-time guards for the two exported contracts these types must satisfy. +// The read path also asserts intoAny (AsAny) and protoTxProvider (GetProtoTx) on +// historicalTx structurally; those are covered by TestHistoricalTxImplementsReadInterfaces. +var ( + _ sdk.Tx = historicalTx{} //nolint:exhaustruct // interface assertion only + _ txtypes.ServiceServer = compositeTxServer{} //nolint:exhaustruct // interface assertion only +) + +// The consensus tx decoder (app.txConfig) rejects pre-upgrade payloads: after the +// v10 proto bump, unknownproto can no longer resolve historical nested message +// names in the process-global gogo registry. Widening the consensus decoder to +// accept them would be consensus-breaking (a rejected tx and an accepted-then-failed +// tx write different state), so instead the historical-read RPCs get their own +// tolerant decoder over a separate registry. Nothing here touches consensus state. + +// historicalTx is the minimal sdk.Tx the tolerant decoder returns for a payload +// the strict decoder rejects. It exposes exactly the three shapes the read-only +// tx service asserts: sdk.Tx, intoAny (AsAny), and protoTxProvider (GetProtoTx). +type historicalTx struct { + tx *txtypes.Tx +} + +// GetMsgs returns the already-unpacked messages; the tolerant decoder unpacks the +// Anys during Unmarshal, so the cached values are present. +func (h historicalTx) GetMsgs() []sdk.Msg { return h.tx.GetMsgs() } + +// GetMsgsV2 is never called on the read path; fail loudly rather than return a +// half-built value if that ever changes. +func (h historicalTx) GetMsgsV2() ([]protov2.Message, error) { + return nil, fmt.Errorf("historicalTx: GetMsgsV2 is not supported on the read-only query path") +} + +// GetProtoTx satisfies protoTxProvider, used by GetBlockWithTxs. +func (h historicalTx) GetProtoTx() *txtypes.Tx { return h.tx } + +// AsAny satisfies intoAny, used by GetTx/GetTxsEvent. UnsafePackAny caches the +// concrete *txtypes.Tx that mkTxResult type-asserts, without an error return +// AsAny cannot surface. +func (h historicalTx) AsAny() *codectypes.Any { return codectypes.UnsafePackAny(h.tx) } + +// newHistoricalTxDecoder decodes committed txs for the query path. It tries the +// strict SDK decoder first, so every tx that decodes today keeps the identical +// code path and concrete type; only when that fails (a historical payload) does +// it fall back to a plain unmarshal that skips unknownproto's nested-field walk. +func newHistoricalTxDecoder(cdc codec.Codec) sdk.TxDecoder { + strict := authtx.DefaultTxDecoder(cdc) + return func(txBytes []byte) (sdk.Tx, error) { + // Current-format txs: unchanged behavior and unchanged return type. + tx, strictErr := strict(txBytes) + if strictErr == nil { + return tx, nil + } + + // Both decoders failing means the payload is malformed rather than + // historical, and the strict error is the one that carries the reason. + fail := func(err error) (sdk.Tx, error) { + return nil, sdkerrors.ErrTxDecode.Wrap(errors.Join(err, strictErr).Error()) + } + + // Historical payloads: unmarshal the envelope directly. Any resolution + // still goes through cdc's registry, so an unknown type URL still errors + // here; only the strict unknown-field rejection is skipped. + var raw txtypes.TxRaw + if err := cdc.Unmarshal(txBytes, &raw); err != nil { + return fail(err) + } + var body txtypes.TxBody + if err := cdc.Unmarshal(raw.BodyBytes, &body); err != nil { + return fail(err) + } + // An Any with an empty type URL unmarshals without error but caches no + // value, which makes GetMsgs panic; unknown non-empty URLs already fail above. + for _, msg := range body.Messages { + if msg == nil || msg.GetCachedValue() == nil { + return fail(errors.New("message Any has no resolvable type URL")) + } + } + var authInfo txtypes.AuthInfo + if err := cdc.Unmarshal(raw.AuthInfoBytes, &authInfo); err != nil { + return fail(err) + } + return historicalTx{tx: &txtypes.Tx{ + Body: &body, + AuthInfo: &authInfo, + Signatures: raw.Signatures, + }}, nil + } +} + +// buildQueryDecodingPath constructs the read-only registry and tx config and +// stores them on the app. Called once at construction, after every module is on +// ModuleManager. It never mutates app.interfaceRegistry or the gogo registry. +func (app *AlloraApp) buildQueryDecodingPath() error { + reg, err := app.buildQueryInterfaceRegistry() + if err != nil { + return err + } + app.queryInterfaceRegistry = reg + + // The tolerant decoder and the tx config share one codec so Any resolution is + // consistent across decode and (gRPC) response marshaling. + queryCodec := codec.NewProtoCodec(reg) + //nolint:exhaustruct // only the decoder and sign modes are overridden; the rest default + txConfig, err := authtx.NewTxConfigWithOptions(queryCodec, authtx.ConfigOptions{ + EnabledSignModes: authtx.DefaultSignModes, + ProtoDecoder: newHistoricalTxDecoder(queryCodec), + }) + if err != nil { + return err + } + app.queryTxConfig = txConfig + return nil +} + +// buildQueryInterfaceRegistry mirrors the app's registry (same address codecs, +// same modules) into a fresh registry, then adds the historical tx types that are +// not registered for consensus. Kept separate so these additions can never widen +// what the consensus decoder accepts. +func (app *AlloraApp) buildQueryInterfaceRegistry() (codectypes.InterfaceRegistry, error) { + // Reuse the app's exact address codecs so signer resolution matches consensus. + // Only the address codecs are copied, not any CustomGetSigners: none exist today + // (every Msg carries the (cosmos.msg.v1.signer) annotation). If one is ever added + // it must be mirrored here too, or Validate() below fails and the node won't start. + sc := app.interfaceRegistry.SigningContext() + reg, err := codectypes.NewInterfaceRegistryWithOptions(codectypes.InterfaceRegistryOptions{ + ProtoFiles: gogoproto.HybridResolver, + //nolint:exhaustruct // only address codecs are set; see comment above + SigningOptions: txsigning.Options{ + AddressCodec: sc.AddressCodec(), + ValidatorAddressCodec: sc.ValidatorAddressCodec(), + }, + }) + if err != nil { + return nil, err + } + + // std registers sdk.Msg/sdk.Tx/cryptotypes.PubKey as interfaces; without it + // RegisterImplementations below has nothing to attach to. + std.RegisterInterfaces(reg) + + // Re-run every module's RegisterInterfaces against the new registry. This + // covers auth/bank/gov/emissions v2-v9/mint v1beta1 and the IBC/feemarket + // modules added imperatively, exactly as SetupAppBuilder does for consensus. + module.NewBasicManagerFromManager(app.ModuleManager, CustomModuleBasics()).RegisterInterfaces(reg) + + // The historical extras. mint.v2 is the only tx-bearing version not registered + // anywhere for consensus (the mint tx lineage is v1beta1 -> v2 -> v5; v3 is + // events-only and v4 is query/genesis-only, so neither declares an sdk.Msg). + // This is a query-registry-only addition and does not touch consensus; verified + // by TestMintV2QueryRegistryDoesNotAffectConsensus. + mintv2.RegisterInterfaces(reg) + registerHistoricalWhitelistMsgs(reg) + + if err := reg.SigningContext().Validate(); err != nil { + return nil, err + } + return reg, nil +} + +// registerHistoricalWhitelistMsgs registers the v7/v8 whitelist txs their own +// codec.go omits, so the query path can decode them. +// The emissions module did not register these messages in the app registry, +// so they need to be registered on the query registry. They must not be added +// to codec.go, which the emissions module registers into the app registry: +// that would widen what the consensus decoder accepts. +// +//nolint:exhaustruct // type registration; the field values are never read +func registerHistoricalWhitelistMsgs(reg codectypes.InterfaceRegistry) { + reg.RegisterImplementations((*sdk.Msg)(nil), + &emissionsv7.AddToGlobalWorkerWhitelistRequest{}, + &emissionsv7.AddToGlobalReputerWhitelistRequest{}, + &emissionsv7.AddToGlobalAdminWhitelistRequest{}, + &emissionsv7.RemoveFromGlobalWorkerWhitelistRequest{}, + &emissionsv7.RemoveFromGlobalReputerWhitelistRequest{}, + &emissionsv7.RemoveFromGlobalAdminWhitelistRequest{}, + &emissionsv7.BulkAddToGlobalWorkerWhitelistRequest{}, + &emissionsv7.BulkAddToGlobalReputerWhitelistRequest{}, + &emissionsv7.BulkRemoveFromGlobalWorkerWhitelistRequest{}, + &emissionsv7.BulkRemoveFromGlobalReputerWhitelistRequest{}, + &emissionsv7.BulkAddToTopicWorkerWhitelistRequest{}, + &emissionsv7.BulkAddToTopicReputerWhitelistRequest{}, + &emissionsv7.BulkRemoveFromTopicWorkerWhitelistRequest{}, + &emissionsv7.BulkRemoveFromTopicReputerWhitelistRequest{}, + ) + reg.RegisterImplementations((*sdk.Msg)(nil), + &emissionsv8.AddToGlobalWorkerWhitelistRequest{}, + &emissionsv8.AddToGlobalReputerWhitelistRequest{}, + &emissionsv8.AddToGlobalAdminWhitelistRequest{}, + &emissionsv8.RemoveFromGlobalWorkerWhitelistRequest{}, + &emissionsv8.RemoveFromGlobalReputerWhitelistRequest{}, + &emissionsv8.RemoveFromGlobalAdminWhitelistRequest{}, + &emissionsv8.BulkAddToGlobalWorkerWhitelistRequest{}, + &emissionsv8.BulkAddToGlobalReputerWhitelistRequest{}, + &emissionsv8.BulkRemoveFromGlobalWorkerWhitelistRequest{}, + &emissionsv8.BulkRemoveFromGlobalReputerWhitelistRequest{}, + &emissionsv8.BulkAddToTopicWorkerWhitelistRequest{}, + &emissionsv8.BulkAddToTopicReputerWhitelistRequest{}, + &emissionsv8.BulkRemoveFromTopicWorkerWhitelistRequest{}, + &emissionsv8.BulkRemoveFromTopicReputerWhitelistRequest{}, + ) +} + +// compositeTxServer serves the tx service with two backends: the strict, embedded +// server handles every method by default (and any method a future SDK adds), while +// only the historical-read methods are delegated to the tolerant server. +type compositeTxServer struct { + txtypes.ServiceServer // strict; default for all methods + tolerant txtypes.ServiceServer // tolerant; historical reads only +} + +// GetTx reads a committed tx by hash; route to the tolerant server so pre-upgrade +// payloads decode. +func (s compositeTxServer) GetTx(ctx context.Context, req *txtypes.GetTxRequest) (*txtypes.GetTxResponse, error) { + return s.tolerant.GetTx(ctx, req) +} + +// GetTxsEvent searches committed txs; route to the tolerant server. +func (s compositeTxServer) GetTxsEvent(ctx context.Context, req *txtypes.GetTxsEventRequest) (*txtypes.GetTxsEventResponse, error) { + return s.tolerant.GetTxsEvent(ctx, req) +} + +// GetBlockWithTxs decodes every tx in a committed block; route to the tolerant server. +func (s compositeTxServer) GetBlockWithTxs(ctx context.Context, req *txtypes.GetBlockWithTxsRequest) (*txtypes.GetBlockWithTxsResponse, error) { + return s.tolerant.GetBlockWithTxs(ctx, req) +} + +// RegisterTxService overrides the promoted runtime.App method. It registers a +// composite tx service: strict for user-supplied and non-decoding methods +// (including TxDecode, which asserts the SDK's own concrete type), tolerant only +// for historical reads. It must NOT also call the embedded method, or the gRPC +// router panics on duplicate service registration. +func (app *AlloraApp) RegisterTxService(clientCtx client.Context) { + // Strict server: the app's own client context, unchanged consensus behavior. + strict := authtx.NewTxServer(clientCtx, app.Simulate, app.interfaceRegistry) + + // Tolerant server: same context but with the read-only tx config swapped in. + tolerantCtx := clientCtx.WithTxConfig(app.queryTxConfig) + tolerant := authtx.NewTxServer(tolerantCtx, app.Simulate, app.queryInterfaceRegistry) + + txtypes.RegisterServiceServer( + app.GRPCQueryRouter(), + compositeTxServer{ServiceServer: strict, tolerant: tolerant}, + ) +} diff --git a/app/querytx_gateway_test.go b/app/querytx_gateway_test.go new file mode 100644 index 000000000..d1e8dee10 --- /dev/null +++ b/app/querytx_gateway_test.go @@ -0,0 +1,60 @@ +package app + +import ( + "testing" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + gogojsonpb "github.com/cosmos/gogoproto/jsonpb" + "github.com/stretchr/testify/require" + + emissionsv7 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v7" + emissionsv8 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v8" + mintv2 "github.com/allora-network/allora-chain/x/mint/api/mint/v2" +) + +// rendersOverGateway reports whether a tx carrying msg would render as JSON over +// the REST gateway. The gateway marshals query responses with a jsonpb marshaler +// whose AnyResolver is the app interface registry, not the query registry +// (server/api New: gateway.JSONPb{AnyResolver: clientCtx.InterfaceRegistry}), and +// it resolves each message Any through that registry rather than any cached value. +// The Any is rebuilt from TypeUrl+Value to mirror what the gateway sees after the +// gRPC round-trip. +func rendersOverGateway(t *testing.T, a *AlloraApp, msg sdk.Msg) bool { + t.Helper() + packed, err := codectypes.NewAnyWithValue(msg) + require.NoError(t, err) + //nolint:exhaustruct // wire form: only TypeUrl+Value survive the gRPC boundary + wire := &codectypes.Any{TypeUrl: packed.TypeUrl, Value: packed.Value} + //nolint:exhaustruct // minimal response tx; only the message Any drives resolution + tx := &txtypes.Tx{Body: &txtypes.TxBody{Messages: []*codectypes.Any{wire}}} + //nolint:exhaustruct // only the marshaling options the gateway sets are relevant + m := &gogojsonpb.Marshaler{OrigName: true, EmitDefaults: true, AnyResolver: a.interfaceRegistry} + _, err = m.MarshalToString(tx) + return err == nil +} + +// The REST gateway resolves response messages through the app registry, so its +// visible surface is exactly the consensus-registered types. A historical payload +// registered for consensus (v9) renders; every query-only addition (mint.v2 and the +// v7/v8 whitelist txs) does not. The negative cases are the regression guard: if one +// starts rendering, it was added to the app registry, which widens what the +// consensus decoder accepts. The positive case guards that the read fix still +// reaches the REST surface that the original bug was reported on. +func TestGatewayRendersConsensusTypesOnly(t *testing.T) { + a := sharedApp(t) + + require.True(t, rendersOverGateway(t, a, historicalV9Worker()), + "a historical v9 tx must render over the REST gateway") + + //nolint:exhaustruct // only Sender is needed to resolve the type URL + require.False(t, rendersOverGateway(t, a, &mintv2.UpdateParamsRequest{Sender: "allo1sender"}), + "query-only mint.v2 must not render over the REST gateway") + //nolint:exhaustruct // only the signer fields are needed + require.False(t, rendersOverGateway(t, a, &emissionsv7.AddToGlobalWorkerWhitelistRequest{Sender: "allo1sender", Address: "allo1addr"}), + "query-only v7 whitelist tx must not render over the REST gateway") + //nolint:exhaustruct // only the signer fields are needed + require.False(t, rendersOverGateway(t, a, &emissionsv8.BulkRemoveFromTopicReputerWhitelistRequest{Sender: "allo1sender"}), + "query-only v8 whitelist tx must not render over the REST gateway") +} diff --git a/app/querytx_test.go b/app/querytx_test.go new file mode 100644 index 000000000..58c5a96f3 --- /dev/null +++ b/app/querytx_test.go @@ -0,0 +1,468 @@ +package app + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "cosmossdk.io/log" + dbm "github.com/cosmos/cosmos-db" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + "github.com/cosmos/cosmos-sdk/x/authz" + gogoproto "github.com/cosmos/gogoproto/proto" + "github.com/stretchr/testify/require" + + emissionsv2 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v2" + emissionsv3 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v3" + emissionsv4 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v4" + emissionsv5 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v5" + emissionsv6 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v6" + emissionsv7 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v7" + emissionsv8 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v8" + emissionsv9 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v9" + emissionstypes "github.com/allora-network/allora-chain/x/emissions/types" + mintv2 "github.com/allora-network/allora-chain/x/mint/api/mint/v2" +) + +const ( + mintV2TypeURL = "/mint.v2.UpdateParamsRequest" + mintV2RecalculateTypeURL = "/mint.v2.RecalculateTargetEmissionRequest" + v10WorkerURL = "/emissions.v10.InsertWorkerPayloadRequest" +) + +// One AlloraApp is enough for every read-only assertion here; build it once. +// The construction error is stored rather than asserted inside the Once: a failed +// require there exits the goroutine with the Once already marked done, leaving +// every later caller with a nil app. +var ( + sharedAppOnce sync.Once + sharedAppInst *AlloraApp + errSharedApp error +) + +func sharedApp(t *testing.T) *AlloraApp { + t.Helper() + sharedAppOnce.Do(func() { + sharedAppInst, errSharedApp = NewAlloraApp( + log.NewNopLogger(), dbm.NewMemDB(), nil, true, simtestutil.EmptyAppOptions{}, + ) + }) + require.NoError(t, errSharedApp) + return sharedAppInst +} + +// marshalCodec only serializes tx envelopes; the Anys inside are pre-built, so it +// needs no registered types. +func marshalCodec() codec.Codec { //nolint:ireturn // returns the SDK codec interface by design + return codec.NewProtoCodec(codectypes.NewInterfaceRegistry()) +} + +// buildTxBytes wraps msgs into a minimal signed-shaped TxRaw and returns the bytes. +func buildTxBytes(t *testing.T, msgs ...sdk.Msg) []byte { + t.Helper() + cdc := marshalCodec() + anys := make([]*codectypes.Any, 0, len(msgs)) + for _, m := range msgs { + a, err := codectypes.NewAnyWithValue(m) + require.NoError(t, err) + anys = append(anys, a) + } + //nolint:exhaustruct // minimal tx envelope; only these fields are read on decode + bodyBz, err := cdc.Marshal(&txtypes.TxBody{Messages: anys}) + require.NoError(t, err) + //nolint:exhaustruct // minimal tx envelope + aiBz, err := cdc.Marshal(&txtypes.AuthInfo{Fee: &txtypes.Fee{GasLimit: 200000}}) + require.NoError(t, err) + //nolint:exhaustruct // minimal tx envelope + rawBz, err := cdc.Marshal(&txtypes.TxRaw{BodyBytes: bodyBz, AuthInfoBytes: aiBz, Signatures: [][]byte{{0x01}}}) + require.NoError(t, err) + return rawBz +} + +// historicalV9Worker is a pre-v10 worker payload: the exact shape that stopped +// decoding after the v10 proto bump. +func historicalV9Worker() sdk.Msg { + //nolint:exhaustruct // only the fields exercised by the nested walk are set + return &emissionsv9.InsertWorkerPayloadRequest{ + Sender: "allo1sender", + WorkerDataBundle: &emissionsv9.InputWorkerDataBundle{ + Worker: "allo1worker", + Nonce: &emissionsv3.Nonce{BlockHeight: 1}, + TopicId: 1, + InferenceForecastsBundle: &emissionsv9.InputInferenceForecastBundle{ + Inference: &emissionsv9.InputInference{ + TopicId: 1, BlockHeight: 1, Inferer: "allo1worker", Value: "1.0", + }, + }, + InferencesForecastsBundleSignature: []byte{0x01}, + Pubkey: "pubkey", + }, + } +} + +type workerPayloadCase struct { + name string + msg sdk.Msg + wantFallback bool // true only where the strict decoder is known to reject it +} + +// historicalWorkerPayloads builds a worker-insert message for every pre-v10 +// emissions version. v2 and v3 carry their own WorkerDataBundle; v4-v8 reuse v3's; +// v9 uses InputWorkerDataBundle. This is the full historical tx surface the query +// path must keep decoding. +func historicalWorkerPayloads() []workerPayloadCase { + //nolint:exhaustruct // only the fields exercised by the nested walk are set + v2Bundle := &emissionsv2.WorkerDataBundle{ + Worker: "allo1worker", + Nonce: &emissionsv2.Nonce{BlockHeight: 1}, + TopicId: 1, + InferenceForecastsBundle: &emissionsv2.InferenceForecastBundle{ + Inference: &emissionsv2.Inference{TopicId: 1, BlockHeight: 1, Inferer: "allo1worker", Value: "1.0"}, + }, + InferencesForecastsBundleSignature: []byte{0x01}, + Pubkey: "pubkey", + } + //nolint:exhaustruct // v4-v8 nest this exact v3 bundle type + v3Bundle := &emissionsv3.WorkerDataBundle{ + Worker: "allo1worker", + Nonce: &emissionsv3.Nonce{BlockHeight: 1}, + TopicId: 1, + InferenceForecastsBundle: &emissionsv3.InferenceForecastBundle{ + Inference: &emissionsv3.Inference{TopicId: 1, BlockHeight: 1, Inferer: "allo1worker", Value: "1.0"}, + }, + InferencesForecastsBundleSignature: []byte{0x01}, + Pubkey: "pubkey", + } + //nolint:exhaustruct // only Sender and the bundle matter for the decode walk + return []workerPayloadCase{ + {"v2", &emissionsv2.MsgInsertWorkerPayload{Sender: "allo1sender", WorkerDataBundle: v2Bundle}, false}, + {"v3", &emissionsv3.MsgInsertWorkerPayload{Sender: "allo1sender", WorkerDataBundle: v3Bundle}, false}, + {"v4", &emissionsv4.InsertWorkerPayloadRequest{Sender: "allo1sender", WorkerDataBundle: v3Bundle}, false}, + {"v5", &emissionsv5.InsertWorkerPayloadRequest{Sender: "allo1sender", WorkerDataBundle: v3Bundle}, false}, + {"v6", &emissionsv6.InsertWorkerPayloadRequest{Sender: "allo1sender", WorkerDataBundle: v3Bundle}, false}, + {"v7", &emissionsv7.InsertWorkerPayloadRequest{Sender: "allo1sender", WorkerDataBundle: v3Bundle}, false}, + {"v8", &emissionsv8.InsertWorkerPayloadRequest{Sender: "allo1sender", WorkerDataBundle: v3Bundle}, false}, + {"v9", historicalV9Worker(), true}, + } +} + +// Every historical emissions worker payload (v2-v9) decodes on the query path, +// bare and wrapped in a routable authz.MsgExec, and yields the correct type URL. +// v9 additionally must take the tolerant fallback, since v10 reclaimed its nested +// message names; the rest need only decode. +func TestQueryDecoderDecodesHistoricalWorkerPayloads(t *testing.T) { + a := sharedApp(t) + dec := a.queryTxConfig.TxDecoder() + for _, tc := range historicalWorkerPayloads() { + t.Run(tc.name+"_bare", func(t *testing.T) { + tx, err := dec(buildTxBytes(t, tc.msg)) + require.NoError(t, err) + require.Equal(t, sdk.MsgTypeURL(tc.msg), sdk.MsgTypeURL(tx.GetMsgs()[0])) + if tc.wantFallback { + _, ok := tx.(historicalTx) + require.True(t, ok, "expected tolerant fallback for %s", tc.name) + } + }) + t.Run(tc.name+"_wrapped", func(t *testing.T) { + inner, err := codectypes.NewAnyWithValue(tc.msg) + require.NoError(t, err) + //nolint:exhaustruct // only Grantee and Msgs matter + exec := &authz.MsgExec{Grantee: "allo1grantee", Msgs: []*codectypes.Any{inner}} + tx, err := dec(buildTxBytes(t, exec)) + require.NoError(t, err) + require.Equal(t, "/cosmos.authz.v1beta1.MsgExec", sdk.MsgTypeURL(tx.GetMsgs()[0])) + + // The nested payload must be decoded, not left packed: resolving it is + // what the wrapped case exists to cover. + outer, ok := tx.GetMsgs()[0].(*authz.MsgExec) + require.True(t, ok) + require.Len(t, outer.Msgs, 1) + require.Equal(t, sdk.MsgTypeURL(tc.msg), outer.Msgs[0].TypeUrl) + require.NotNil(t, outer.Msgs[0].GetCachedValue()) + }) + } +} + +// The same bare v9 bytes are rejected by the consensus decoder: the guard that +// the query path does not widen what consensus accepts. +func TestConsensusDecoderRejectsBareHistoricalV9(t *testing.T) { + a := sharedApp(t) + _, err := a.txConfig.TxDecoder()(buildTxBytes(t, historicalV9Worker())) + require.Error(t, err, "consensus decoder must still reject historical payloads") +} + +// A v9 payload wrapped in a routable authz.MsgExec is rejected by the consensus +// decoder. Wrapping clears baseapp's top-level routing check, so this is the shape +// where a registry shared with consensus would change what consensus accepts. +func TestConsensusDecoderRejectsWrappedHistoricalV9(t *testing.T) { + a := sharedApp(t) + inner, err := codectypes.NewAnyWithValue(historicalV9Worker()) + require.NoError(t, err) + //nolint:exhaustruct // only Grantee and Msgs matter + exec := &authz.MsgExec{Grantee: "allo1grantee", Msgs: []*codectypes.Any{inner}} + _, err = a.txConfig.TxDecoder()(buildTxBytes(t, exec)) + require.Error(t, err, "consensus decoder must reject a wrapped historical payload") +} + +// A current-format (v10) emissions tx decodes on the query path via the strict +// branch, so it yields the SDK's own tx type, not the tolerant fallback. +func TestQueryDecoderKeepsStrictPathForCurrentTx(t *testing.T) { + a := sharedApp(t) + //nolint:exhaustruct // a valid current (v10) msg is all that is needed + msg := &emissionstypes.InsertWorkerPayloadRequest{Sender: "allo1sender"} + tx, err := a.queryTxConfig.TxDecoder()(buildTxBytes(t, msg)) + require.NoError(t, err) + _, isFallback := tx.(historicalTx) + require.False(t, isFallback, "current txs must take the strict path") + require.Equal(t, v10WorkerURL, sdk.MsgTypeURL(tx.GetMsgs()[0])) +} + +// The query registry resolves the mint.v2 messages while the consensus registry +// and the process-global gogo registry do not, keeping the addition off the +// consensus path. +func TestMintV2QueryRegistryDoesNotAffectConsensus(t *testing.T) { + a := sharedApp(t) + for _, typeURL := range []string{mintV2TypeURL, mintV2RecalculateTypeURL} { + _, errQuery := a.queryInterfaceRegistry.Resolve(typeURL) + require.NoErrorf(t, errQuery, "query registry should resolve %s", typeURL) + _, errConsensus := a.interfaceRegistry.Resolve(typeURL) + require.Errorf(t, errConsensus, "consensus registry must not resolve %s", typeURL) + require.Nilf(t, gogoproto.MessageType(strings.TrimPrefix(typeURL, "/")), + "%s must not leak into the gogo registry unknownproto reads", typeURL) + } + + // And a mint.v2 tx decodes on the query path but not on the consensus path. + //nolint:exhaustruct // only Sender is needed + bz := buildTxBytes(t, &mintv2.UpdateParamsRequest{Sender: "allo1sender"}) + _, errQ := a.queryTxConfig.TxDecoder()(bz) + require.NoError(t, errQ) + _, errC := a.txConfig.TxDecoder()(bz) + require.Error(t, errC) +} + +// A message Any with an empty type URL is rejected outright. It unmarshals +// cleanly but caches no value, so returning it would yield a tx whose GetMsgs +// panics. +func TestQueryDecoderRejectsUnresolvableMessageAny(t *testing.T) { + a := sharedApp(t) + cdc := marshalCodec() + //nolint:exhaustruct // the empty type URL is the point of the case + empty := &codectypes.Any{Value: []byte{0x0a, 0x02, 0x68, 0x69}} + //nolint:exhaustruct // minimal tx envelope + bodyBz, err := cdc.Marshal(&txtypes.TxBody{Messages: []*codectypes.Any{empty}}) + require.NoError(t, err) + //nolint:exhaustruct // minimal tx envelope + aiBz, err := cdc.Marshal(&txtypes.AuthInfo{Fee: &txtypes.Fee{GasLimit: 200000}}) + require.NoError(t, err) + //nolint:exhaustruct // minimal tx envelope + rawBz, err := cdc.Marshal(&txtypes.TxRaw{BodyBytes: bodyBz, AuthInfoBytes: aiBz, Signatures: [][]byte{{0x01}}}) + require.NoError(t, err) + + _, err = a.queryTxConfig.TxDecoder()(rawBz) + require.Error(t, err, "an unresolvable message Any must be rejected, not returned as a panicking tx") +} + +// The query registry is the app's registry plus historical extras. Freezing the +// superset relation catches a module registered for consensus but missed on the +// read path, whose txs would then fail to decode on the query endpoints. +func TestQueryRegistryIsSupersetOfConsensus(t *testing.T) { + a := sharedApp(t) + ifaces := a.interfaceRegistry.ListAllInterfaces() + require.NotEmpty(t, ifaces, "consensus registry must expose interfaces to compare against") + for _, iface := range ifaces { + for _, impl := range a.interfaceRegistry.ListImplementations(iface) { + _, err := a.queryInterfaceRegistry.Resolve(impl) + require.NoErrorf(t, err, "query registry must resolve %s", impl) + } + } +} + +// Every message a historical emissions Msg service accepts must resolve on the +// query registry: a version whose codec.go lists fewer types than its service +// declares leaves those txs undecodable on the read path. +func TestQueryRegistryResolvesAllHistoricalEmissionsMsgs(t *testing.T) { + a := sharedApp(t) + for _, version := range []string{"v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9"} { + fd, err := gogoproto.HybridResolver.FindFileByPath("emissions/" + version + "/tx.proto") + require.NoErrorf(t, err, "no tx.proto descriptor for %s", version) + services := fd.Services() + require.NotZerof(t, services.Len(), "%s declares no service", version) + for i := 0; i < services.Len(); i++ { + methods := services.Get(i).Methods() + for j := 0; j < methods.Len(); j++ { + typeURL := "/" + string(methods.Get(j).Input().FullName()) + _, err := a.queryInterfaceRegistry.Resolve(typeURL) + require.NoErrorf(t, err, "query registry must resolve %s", typeURL) + } + } + } +} + +// The v7/v8 whitelist messages their codec.go omits resolve on the query registry +// only, so the query path can decode them while consensus still rejects them. +func TestHistoricalWhitelistMsgsAreQueryOnly(t *testing.T) { + a := sharedApp(t) + for _, typeURL := range []string{ + "/emissions.v7.AddToGlobalWorkerWhitelistRequest", + "/emissions.v8.BulkRemoveFromTopicReputerWhitelistRequest", + } { + _, errQuery := a.queryInterfaceRegistry.Resolve(typeURL) + require.NoErrorf(t, errQuery, "query registry should resolve %s", typeURL) + _, errConsensus := a.interfaceRegistry.Resolve(typeURL) + require.Errorf(t, errConsensus, "consensus registry must not resolve %s", typeURL) + } + + //nolint:exhaustruct // only the signer fields matter for the decode walk + bz := buildTxBytes(t, &emissionsv7.AddToGlobalWorkerWhitelistRequest{ + Sender: "allo1sender", Address: "allo1addr", + }) + _, errQ := a.queryTxConfig.TxDecoder()(bz) + require.NoError(t, errQ, "query path must decode a historical whitelist tx") + _, errC := a.txConfig.TxDecoder()(bz) + require.Error(t, errC, "consensus decoder must still reject it") +} + +// The tolerant decoder is not "accept anything": garbage and a truncated envelope +// still error. +func TestQueryDecoderRejectsGarbage(t *testing.T) { + a := sharedApp(t) + dec := a.queryTxConfig.TxDecoder() + _, err := dec([]byte{0xff, 0xff, 0xff, 0xff}) + require.Error(t, err) + good := buildTxBytes(t, historicalV9Worker()) + _, err = dec(good[:len(good)/2]) + require.Error(t, err) +} + +// The tolerant fallback type satisfies the three interfaces the tx service asserts +// on decoded txs: sdk.Tx, intoAny, and protoTxProvider. +func TestHistoricalTxImplementsReadInterfaces(t *testing.T) { + //nolint:exhaustruct // empty envelope is enough for interface checks + var h any = historicalTx{tx: &txtypes.Tx{Body: &txtypes.TxBody{}, AuthInfo: &txtypes.AuthInfo{}}} + _, isTx := h.(sdk.Tx) + require.True(t, isTx) + _, isIntoAny := h.(interface{ AsAny() *codectypes.Any }) + require.True(t, isIntoAny) + _, isProtoTx := h.(interface{ GetProtoTx() *txtypes.Tx }) + require.True(t, isProtoTx) +} + +// AsAny caches the concrete *txtypes.Tx that mkTxResult (GetTx / GetTxsEvent) then +// type-asserts; a plain pack would leave the cache empty. +func TestHistoricalTxAsAnyCachesConcreteTx(t *testing.T) { + //nolint:exhaustruct // empty envelope is enough + h := historicalTx{tx: &txtypes.Tx{Body: &txtypes.TxBody{}, AuthInfo: &txtypes.AuthInfo{}}} + any := h.AsAny() + cached, ok := any.GetCachedValue().(*txtypes.Tx) + require.True(t, ok, "cached value must be *txtypes.Tx") + require.Same(t, h.tx, cached) +} + +// GetMsgsV2 is unsupported on the read path and returns an explicit error rather +// than a nil slice that a caller might treat as "no messages". +func TestHistoricalTxGetMsgsV2Errors(t *testing.T) { + //nolint:exhaustruct // empty envelope is enough + h := historicalTx{tx: &txtypes.Tx{Body: &txtypes.TxBody{}}} + msgs, err := h.GetMsgsV2() + require.Error(t, err) + require.Nil(t, msgs) +} + +// The composite routes only the three historical-read methods to the tolerant +// backend and every other method to the strict backend. +func TestCompositeTxServerRouting(t *testing.T) { + var hits []string + strict := &recordingTxServer{id: "strict", hits: &hits} + tolerant := &recordingTxServer{id: "tolerant", hits: &hits} + cts := compositeTxServer{ServiceServer: strict, tolerant: tolerant} + ctx := context.Background() + + // exhaustruct is not meaningful for empty request probes + //nolint:exhaustruct + _, _ = cts.GetTx(ctx, &txtypes.GetTxRequest{}) + //nolint:exhaustruct + _, _ = cts.GetTxsEvent(ctx, &txtypes.GetTxsEventRequest{}) + //nolint:exhaustruct + _, _ = cts.GetBlockWithTxs(ctx, &txtypes.GetBlockWithTxsRequest{}) + //nolint:exhaustruct + _, _ = cts.Simulate(ctx, &txtypes.SimulateRequest{}) + //nolint:exhaustruct + _, _ = cts.BroadcastTx(ctx, &txtypes.BroadcastTxRequest{}) + //nolint:exhaustruct + _, _ = cts.TxDecode(ctx, &txtypes.TxDecodeRequest{}) + //nolint:exhaustruct + _, _ = cts.TxEncode(ctx, &txtypes.TxEncodeRequest{}) + //nolint:exhaustruct + _, _ = cts.TxEncodeAmino(ctx, &txtypes.TxEncodeAminoRequest{}) + //nolint:exhaustruct + _, _ = cts.TxDecodeAmino(ctx, &txtypes.TxDecodeAminoRequest{}) + + require.Equal(t, []string{ + "tolerant.GetTx", + "tolerant.GetTxsEvent", + "tolerant.GetBlockWithTxs", + "strict.Simulate", + "strict.BroadcastTx", + "strict.TxDecode", + "strict.TxEncode", + "strict.TxEncodeAmino", + "strict.TxDecodeAmino", + }, hits) +} + +// errStub is returned by every recordingTxServer method so the stubs never return +// (nil, nil); callers in the routing test ignore it. +var errStub = errors.New("recording stub") + +// recordingTxServer records which backend and method were invoked. +type recordingTxServer struct { + id string + hits *[]string +} + +func (r *recordingTxServer) mark(method string) error { + *r.hits = append(*r.hits, r.id+"."+method) + return errStub +} + +func (r *recordingTxServer) Simulate(context.Context, *txtypes.SimulateRequest) (*txtypes.SimulateResponse, error) { + return nil, r.mark("Simulate") +} + +func (r *recordingTxServer) GetTx(context.Context, *txtypes.GetTxRequest) (*txtypes.GetTxResponse, error) { + return nil, r.mark("GetTx") +} + +func (r *recordingTxServer) BroadcastTx(context.Context, *txtypes.BroadcastTxRequest) (*txtypes.BroadcastTxResponse, error) { + return nil, r.mark("BroadcastTx") +} + +func (r *recordingTxServer) GetTxsEvent(context.Context, *txtypes.GetTxsEventRequest) (*txtypes.GetTxsEventResponse, error) { + return nil, r.mark("GetTxsEvent") +} + +func (r *recordingTxServer) GetBlockWithTxs(context.Context, *txtypes.GetBlockWithTxsRequest) (*txtypes.GetBlockWithTxsResponse, error) { + return nil, r.mark("GetBlockWithTxs") +} + +func (r *recordingTxServer) TxDecode(context.Context, *txtypes.TxDecodeRequest) (*txtypes.TxDecodeResponse, error) { + return nil, r.mark("TxDecode") +} + +func (r *recordingTxServer) TxEncode(context.Context, *txtypes.TxEncodeRequest) (*txtypes.TxEncodeResponse, error) { + return nil, r.mark("TxEncode") +} + +func (r *recordingTxServer) TxEncodeAmino(context.Context, *txtypes.TxEncodeAminoRequest) (*txtypes.TxEncodeAminoResponse, error) { + return nil, r.mark("TxEncodeAmino") +} + +func (r *recordingTxServer) TxDecodeAmino(context.Context, *txtypes.TxDecodeAminoRequest) (*txtypes.TxDecodeAminoResponse, error) { + return nil, r.mark("TxDecodeAmino") +} diff --git a/test/api/harness_control_test.go b/test/api/harness_control_test.go new file mode 100644 index 000000000..de567e82e --- /dev/null +++ b/test/api/harness_control_test.go @@ -0,0 +1,43 @@ +package api + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" +) + +// TestHarnessObservesExecutedTx is the positive control for the balance and +// sequence assertions in TestHistoricalTxDoesNotMoveConsensus: it proves those +// reads can observe a tx that executes, so an unchanged balance there is evidence +// rather than an artifact of the harness. FinalizeBlock writes the block's state +// transitions into the root multistore before returning, so chain.GetContext() +// sees them without a separate Commit. +func TestHarnessObservesExecutedTx(t *testing.T) { + chain, alloraApp := SetupChain(t) + sender := chain.SenderAccount.GetAddress() + recipient := sdk.AccAddress([]byte("recipient___________")) + + //nolint:exhaustruct // only these fields matter for the control + msg := &banktypes.MsgSend{ + FromAddress: sender.String(), + ToAddress: recipient.String(), + Amount: sdk.NewCoins(sdk.NewInt64Coin(sdk.DefaultBondDenom, 12345)), + } + + ctx := chain.GetContext() + balBefore := alloraApp.BankKeeper.GetBalance(ctx, sender, sdk.DefaultBondDenom) + seqBefore := alloraApp.AccountKeeper.GetAccount(ctx, sender).GetSequence() + + resp := SignAndDeliver(t, chain, msg) + require.Len(t, resp.TxResults, 1) + require.Zero(t, resp.TxResults[0].Code, "control tx must execute: %s", resp.TxResults[0].Log) + require.NotZero(t, resp.TxResults[0].GasUsed, "an executed tx must consume gas") + + ctxAfter := chain.GetContext() + balAfter := alloraApp.BankKeeper.GetBalance(ctxAfter, sender, sdk.DefaultBondDenom) + seqAfter := alloraApp.AccountKeeper.GetAccount(ctxAfter, sender).GetSequence() + require.True(t, balAfter.Amount.LT(balBefore.Amount), "an executed tx must move the balance") + require.Equal(t, seqBefore+1, seqAfter, "an executed tx must increment the sequence") +} diff --git a/test/api/harness_test.go b/test/api/harness_test.go new file mode 100644 index 000000000..c819c5e56 --- /dev/null +++ b/test/api/harness_test.go @@ -0,0 +1,96 @@ +package api + +// Shared harness for API regression tests. It boots a real AlloraApp on an +// in-memory db via the ibctesting scaffolding (funded sender, single validator) +// and delivers signed txs through a real FinalizeBlock, so tests exercise the +// whole ABCI path rather than a decoder in isolation. + +import ( + "encoding/json" + "fmt" + "math/rand" + "testing" + + "cosmossdk.io/log" + abci "github.com/cometbft/cometbft/abci/types" + dbm "github.com/cosmos/cosmos-db" + simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" + sdk "github.com/cosmos/cosmos-sdk/types" + ibctesting "github.com/cosmos/ibc-go/v8/testing" + "github.com/stretchr/testify/require" + + "github.com/allora-network/allora-chain/app" +) + +const ( + // LargeFeeAmount and LargeGasLimit are generous so a delivered tx is never + // rejected for fee/gas reasons; tests assert on decode/exec outcomes. + LargeFeeAmount = 1_000_000_000 + LargeGasLimit = simtestutil.DefaultGenTxGas * 10 +) + +// AppInitializer is the ibctesting hook that builds the AlloraApp under test. +func AppInitializer() (ibctesting.TestingApp, map[string]json.RawMessage) { + testApp, err := app.NewAlloraApp( + log.NewNopLogger(), + dbm.NewMemDB(), + nil, + true, + simtestutil.EmptyAppOptions{}, + ) + if err != nil { + // The hook signature cannot return an error, and a nil app surfaces as an + // unrelated nil dereference inside the coordinator. + panic(fmt.Errorf("initialize AlloraApp test fixture: %w", err)) + } + return testApp, testApp.DefaultGenesis() +} + +// SetupChain returns a single-validator chain running AlloraApp plus the concrete +// app handle for reading state (balances, sequences, the query tx config). +func SetupChain(t *testing.T) (*ibctesting.TestChain, *app.AlloraApp) { + t.Helper() + app.UseFeeMarketDecorator = true + ibctesting.DefaultTestingAppInit = AppInitializer + + coordinator := ibctesting.NewCoordinator(t, 1) + chain, ok := coordinator.Chains[ibctesting.GetChainID(1)] + require.True(t, ok, "chain not found") + chain.CurrentHeader.ProposerAddress = sdk.ConsAddress(chain.Vals.Validators[0].Address) + + alloraApp, ok := chain.App.(*app.AlloraApp) + require.True(t, ok, "expected App to be AlloraApp") + return chain, alloraApp +} + +// SignAndDeliver signs msgs from the chain's default sender and runs one block. +// It asserts only that the block itself was produced; per-tx success or failure +// is left for the caller to read from the returned TxResults. +func SignAndDeliver(t *testing.T, chain *ibctesting.TestChain, msgs ...sdk.Msg) *abci.ResponseFinalizeBlock { + t.Helper() + tx, err := simtestutil.GenSignedMockTx( + rand.New(rand.NewSource(1)), // fixed seed: deterministic tx bytes + chain.TxConfig, + msgs, + sdk.Coins{sdk.NewInt64Coin(sdk.DefaultBondDenom, LargeFeeAmount)}, + LargeGasLimit, + chain.ChainID, + []uint64{chain.SenderAccount.GetAccountNumber()}, + []uint64{chain.SenderAccount.GetSequence()}, + chain.SenderPrivKey, + ) + require.NoError(t, err) + + txBytes, err := chain.TxConfig.TxEncoder()(tx) + require.NoError(t, err) + + //nolint:exhaustruct // only these block fields are needed to deliver one tx + resp, err := chain.App.GetBaseApp().FinalizeBlock(&abci.RequestFinalizeBlock{ + Height: chain.App.GetBaseApp().LastBlockHeight() + 1, + Time: chain.CurrentHeader.GetTime(), + NextValidatorsHash: chain.NextVals.Hash(), + Txs: [][]byte{txBytes}, + }) + require.NoError(t, err, "block-level failure; per-tx result is in resp.TxResults") + return resp +} diff --git a/test/api/historical_tx_decode_test.go b/test/api/historical_tx_decode_test.go new file mode 100644 index 000000000..62241bf54 --- /dev/null +++ b/test/api/historical_tx_decode_test.go @@ -0,0 +1,74 @@ +package api + +import ( + "testing" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/cosmos/cosmos-sdk/x/authz" + "github.com/stretchr/testify/require" + + emissionsv3 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v3" + emissionsv9 "github.com/allora-network/allora-chain/x/emissions/api/emissions/v9" +) + +// historicalV9WorkerPayload is a pre-v10 worker payload: the shape that stopped +// decoding after the v10 proto bump. +func historicalV9WorkerPayload(sender string) sdk.Msg { + //nolint:exhaustruct // only the fields exercised by the nested walk are set + return &emissionsv9.InsertWorkerPayloadRequest{ + Sender: sender, + WorkerDataBundle: &emissionsv9.InputWorkerDataBundle{ + Worker: sender, + Nonce: &emissionsv3.Nonce{BlockHeight: 1}, + TopicId: 1, + InferenceForecastsBundle: &emissionsv9.InputInferenceForecastBundle{ + Inference: &emissionsv9.InputInference{ + TopicId: 1, BlockHeight: 1, Inferer: sender, Value: "1.0", + }, + }, + InferencesForecastsBundleSignature: []byte{0x01}, + Pubkey: "pubkey", + }, + } +} + +// TestHistoricalTxDoesNotMoveConsensus delivers a historical v9 payload wrapped in +// a routable authz.MsgExec through a real AlloraApp FinalizeBlock and asserts +// consensus state is untouched: rejected at decode, with no gas, no fee deduction +// and no sequence increment. A decoder that accepted the nested payload would run +// the ante handler and move all four. TestHarnessObservesExecutedTx is the positive +// control showing those reads can observe a tx that does execute. +func TestHistoricalTxDoesNotMoveConsensus(t *testing.T) { + chain, alloraApp := SetupChain(t) + sender := chain.SenderAccount.GetAddress() + + // Wrap the historical payload in a message that IS routable today, so it clears + // baseapp's pre-ante routing check (which only inspects top-level messages). + inner, err := codectypes.NewAnyWithValue(historicalV9WorkerPayload(sender.String())) + require.NoError(t, err) + //nolint:exhaustruct // only Grantee and Msgs matter + wrapped := &authz.MsgExec{Grantee: sender.String(), Msgs: []*codectypes.Any{inner}} + + ctx := chain.GetContext() + balBefore := alloraApp.BankKeeper.GetBalance(ctx, sender, sdk.DefaultBondDenom) + seqBefore := alloraApp.AccountKeeper.GetAccount(ctx, sender).GetSequence() + + resp := SignAndDeliver(t, chain, wrapped) + require.Len(t, resp.TxResults, 1) + + // Pinning the decode error is what distinguishes this from an ante-handler + // rejection, which is the outcome a widened decoder would produce. + require.Equal(t, sdkerrors.ErrTxDecode.ABCICode(), resp.TxResults[0].Code, + "wrapped historical tx must be rejected at decode") + require.Equal(t, sdkerrors.ErrTxDecode.Codespace(), resp.TxResults[0].Codespace) + require.Zero(t, resp.TxResults[0].GasUsed, "a rejected-at-decode tx must not consume gas") + + // State a widened decoder would have moved must be unchanged. + ctxAfter := chain.GetContext() + balAfter := alloraApp.BankKeeper.GetBalance(ctxAfter, sender, sdk.DefaultBondDenom) + seqAfter := alloraApp.AccountKeeper.GetAccount(ctxAfter, sender).GetSequence() + require.Equal(t, balBefore.Amount.String(), balAfter.Amount.String(), "no fee may be deducted") + require.Equal(t, seqBefore, seqAfter, "no sequence increment may occur") +}