From 7a4b8c3097f288866f51abdd77e491c70d64c652 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 27 Aug 2026 12:55:24 +0200 Subject: [PATCH 1/3] Add upgrade tests for the modules v6.7 retires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the v6.7 removal of capability, feegrant, ibc and transfer, plus the oracle handler deprecation, at levels that trade control for realism. The existing tests call ApplyUpgrade directly, which never writes upgrade-info.json and never reloads the stores, so App.SetStoreUpgradeHandlers — the code deciding whether a store is dropped at an upgrade height — was reached by nothing. * Assert that transactions aimed at retired modules are rejected identically on both sides of the upgrade, that a rejected oracle transaction still pays its fee and occupies gas, and that retained store state survives the upgrade while being absent from an exported genesis. * Guard the whole module version map rather than the names v6.7 drops, so a later removal that forgets DeleteModuleVersion fails there instead of leaving an entry on chain for the life of the chain. Require every mounted store to be owned by a registered module or declared retained with a reason. * Drive a real governance upgrade on one throwaway node, reaching the halt, upgrade-info.json and store-reload path in seconds without docker. * Run the upgrade across two real binaries, seeding a spendable fee allowance and tallied oracle rates with v6.6.2 where those modules are still alive. This is the only layer whose pre-upgrade state is real, because the current binary can no longer create it. * Record how a live network answers before an upgrade and verify it after, as two phases with an artifact carried between them. * Add the four-node docker suite and its CI matrix row, and check every YAML suite parses without needing a cluster. processblock gains RunBlockDetailed and SignWithFeeGranter. Both are additive; existing callers are unchanged. Co-authored-by: Cursor --- .../workflows/integration-test-matrix.json | 7 + .github/workflows/upgrade_tests.yml | 112 ++ Makefile | 19 + app/upgrade_orphan_test.go | 113 ++ app/upgrade_v67_test.go | 355 ++++ integration_test/runner/runner_test.go | 7 + .../runner/yamlparse_check_test.go | 62 + .../retired_modules_upgrade_test.yaml | 145 ++ .../scripts/probe_retired_modules.sh | 133 ++ integration_test/upgrade_tests/.gitignore | 2 + .../upgrade_tests/.mocharc.record.json | 6 + .../upgrade_tests/.mocharc.selftest.json | 6 + .../upgrade_tests/.mocharc.verify.json | 6 + integration_test/upgrade_tests/README.md | 104 + .../upgrade_tests/config/targets.ts | 98 + .../upgrade_tests/package-lock.json | 1697 +++++++++++++++++ integration_test/upgrade_tests/package.json | 25 + .../upgrade_tests/record/record.spec.ts | 110 ++ .../upgrade_tests/selftest/harness.spec.ts | 322 ++++ integration_test/upgrade_tests/tsconfig.json | 18 + .../upgrade_tests/utils/artifact.ts | 132 ++ integration_test/upgrade_tests/utils/chain.ts | 100 + .../upgrade_tests/utils/cosmos.ts | 136 ++ .../upgrade_tests/utils/probes.ts | 288 +++ .../upgrade_tests/utils/txProbes.ts | 89 + .../upgrade_tests/verify/verify.spec.ts | 251 +++ scripts/cross_version_upgrade_test.sh | 478 +++++ scripts/local_upgrade_test.sh | 317 +++ testutil/processblock/common.go | 9 +- testutil/processblock/tx.go | 14 + 30 files changed, 5160 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/upgrade_tests.yml create mode 100644 app/upgrade_orphan_test.go create mode 100644 app/upgrade_v67_test.go create mode 100644 integration_test/runner/yamlparse_check_test.go create mode 100644 integration_test/upgrade_module/retired_modules_upgrade_test.yaml create mode 100755 integration_test/upgrade_module/scripts/probe_retired_modules.sh create mode 100644 integration_test/upgrade_tests/.gitignore create mode 100644 integration_test/upgrade_tests/.mocharc.record.json create mode 100644 integration_test/upgrade_tests/.mocharc.selftest.json create mode 100644 integration_test/upgrade_tests/.mocharc.verify.json create mode 100644 integration_test/upgrade_tests/README.md create mode 100644 integration_test/upgrade_tests/config/targets.ts create mode 100644 integration_test/upgrade_tests/package-lock.json create mode 100644 integration_test/upgrade_tests/package.json create mode 100644 integration_test/upgrade_tests/record/record.spec.ts create mode 100644 integration_test/upgrade_tests/selftest/harness.spec.ts create mode 100644 integration_test/upgrade_tests/tsconfig.json create mode 100644 integration_test/upgrade_tests/utils/artifact.ts create mode 100644 integration_test/upgrade_tests/utils/chain.ts create mode 100644 integration_test/upgrade_tests/utils/cosmos.ts create mode 100644 integration_test/upgrade_tests/utils/probes.ts create mode 100644 integration_test/upgrade_tests/utils/txProbes.ts create mode 100644 integration_test/upgrade_tests/verify/verify.spec.ts create mode 100755 scripts/cross_version_upgrade_test.sh create mode 100755 scripts/local_upgrade_test.sh diff --git a/.github/workflows/integration-test-matrix.json b/.github/workflows/integration-test-matrix.json index bc44a7ad94..c9a6e4b7ac 100644 --- a/.github/workflows/integration-test-matrix.json +++ b/.github/workflows/integration-test-matrix.json @@ -80,6 +80,13 @@ "go test -tags yaml_integration -v -timeout $JOB_TIMEOUT ./integration_test/runner/... -run TestUpgradeMinor" ] }, + { + "name": "Upgrade Module (Retired Modules)", + "env": "GIGA_STORAGE=true UPGRADE_VERSION_LIST=v1.0.0", + "scripts": [ + "go test -tags yaml_integration -v -timeout $JOB_TIMEOUT ./integration_test/runner/... -run TestUpgradeRetiredModules" + ] + }, { "name": "SeiDB State Store", "scripts": [ diff --git a/.github/workflows/upgrade_tests.yml b/.github/workflows/upgrade_tests.yml new file mode 100644 index 0000000000..499f662706 --- /dev/null +++ b/.github/workflows/upgrade_tests.yml @@ -0,0 +1,112 @@ +name: Upgrade Tests + +# Two phases separated by a real network upgrade, so they are dispatched +# separately rather than run back to back. The record phase uploads its artifact +# and the verify phase downloads it, which is what lets the two runs be days +# apart. +on: + workflow_dispatch: + inputs: + phase: + description: Which phase to run + required: true + type: choice + options: [record, verify] + network: + description: Target network + required: true + default: arctic-1 + type: choice + options: [arctic-1, atlantic-2] + plan_name: + description: Governance plan name to record or verify + required: true + default: v6.7 + type: string + record_run_id: + description: >- + Required for verify: the run id of the record run holding the + pre-upgrade artifact. Find it in this workflow's run history. + required: false + type: string + # The self-checks need no chain, so they gate changes to the suite itself. + pull_request: + paths: + - integration_test/upgrade_tests/** + - .github/workflows/upgrade_tests.yml + +jobs: + selftest: + name: Self-checks + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + defaults: + run: + working-directory: integration_test/upgrade_tests + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: npm run typecheck + - run: npm run selftest + + run-phase: + name: ${{ inputs.phase }} on ${{ inputs.network }} + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + environment: + name: ${{ inputs.network == 'arctic-1' && 'devnet' || 'testnet' }} + # Reading an artifact from an earlier run needs more than the default + # contents scope. + permissions: + contents: read + actions: read + defaults: + run: + working-directory: integration_test/upgrade_tests + env: + UPGRADE_TEST_NETWORK: ${{ inputs.network }} + UPGRADE_TEST_PLAN_NAME: ${{ inputs.plan_name }} + # Optional. Without it the suite runs read-only probes and skips the + # committed-history checks, saying so rather than passing quietly. + UPGRADE_TEST_MNEMONIC: ${{ secrets.DAPP_TESTS_MNEMONIC }} + steps: + # Fail on the missing input rather than on a confusing "artifact not + # found" from the download step, which is what a blank run id produces: + # it defaults to the current run, where no artifact was ever uploaded. + - name: Require a record run id to verify against + if: inputs.phase == 'verify' && inputs.record_run_id == '' + run: | + echo "::error::verify needs record_run_id — the run id of the record phase" \ + "that produced the pre-upgrade artifact. Check this workflow's run history." + exit 1 + + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + + - name: Download the pre-upgrade artifact + if: inputs.phase == 'verify' + uses: actions/download-artifact@v4 + with: + name: pre-upgrade-${{ inputs.network }}-${{ inputs.plan_name }} + path: integration_test/upgrade_tests/artifacts + run-id: ${{ inputs.record_run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - run: npm run ${{ inputs.phase }} + + - name: Upload the pre-upgrade artifact + if: inputs.phase == 'record' + uses: actions/upload-artifact@v4 + with: + name: pre-upgrade-${{ inputs.network }}-${{ inputs.plan_name }} + path: integration_test/upgrade_tests/artifacts/pre-upgrade.json + # The gap between the two phases is however long the upgrade takes to + # ship, so the artifact has to outlive the default retention. + retention-days: 90 + if-no-files-found: error diff --git a/Makefile b/Makefile index 9ded00c1bf..bbdfb43b0c 100644 --- a/Makefile +++ b/Makefile @@ -200,6 +200,25 @@ build: go build $(BUILD_FLAGS) -o ./build/seid ./cmd/seid .PHONY: build +# Drive a real governance software upgrade on a single throwaway node and compare +# how the chain answers for the retired modules on either side of it. Reaches the +# halt / upgrade-info.json / store-reload path that the in-process Go tests never +# take, without needing the docker cluster. Uses an isolated home; never touches +# ~/.sei. Roughly 30s once seid is built. +local-upgrade-test: build + ./scripts/local_upgrade_test.sh --skip-build +.PHONY: local-upgrade-test + +# Upgrade a throwaway local chain across two real binaries: seed state with the +# modules that are alive on the previous release, then swap this binary in and +# check what the upgrade did to it. The only place the pre-upgrade state is real, +# since this binary can no longer create a feegrant allowance or store an oracle +# vote. Builds the old release into a git worktree on first run (a few minutes); +# about 30s after that. Uses an isolated home and never touches ~/.sei. +cross-upgrade-test: build + ./scripts/cross_version_upgrade_test.sh --skip-build +.PHONY: cross-upgrade-test + build-frozen-rpc-router: mkdir -p ./build go build -o ./build/frozen-rpc-router ./cmd/frozen-rpc-router diff --git a/app/upgrade_orphan_test.go b/app/upgrade_orphan_test.go new file mode 100644 index 0000000000..034237e411 --- /dev/null +++ b/app/upgrade_orphan_test.go @@ -0,0 +1,113 @@ +package app + +import ( + "testing" + + upgradetypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade/types" + storekeys "github.com/sei-protocol/sei-chain/sei-db/common/keys" + tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" + "github.com/stretchr/testify/require" +) + +// retainedStores are mounted KV stores that no registered module owns. A store +// stays on this list when its history must remain readable at the store level +// after its module is gone; dropping it instead is application-hash breaking +// and needs a StoreUpgrades{Deleted} entry at a specific upgrade height. +var retainedStores = map[string]string{ + feegrantModuleName: "module removed in v6.7; allowances kept for historical state access", + capabilityModuleName: "module removed in v6.7; capabilities kept for freeze-mode historical state access", + transferModuleName: "module removed in v6.7; transfer state kept for historical state access", + storekeys.IBCStoreKey: "module removed in v6.7; client, connection and channel state kept for " + + "historical state access", +} + +// storeKeyOwners names the owning module for the KV stores whose key differs +// from the module name. Every other mounted store is keyed by its own module. +var storeKeyOwners = map[string]string{ + "acc": "auth", +} + +func owningModuleName(storeKey string) string { + if owner, ok := storeKeyOwners[storeKey]; ok { + return owner + } + return storeKey +} + +// Removing a module from the manager does not remove it from the stored module +// version map: SetModuleVersionMap only writes the keys it is given and never +// deletes the ones it is not, so a departing module's entry survives every +// later upgrade unless a handler calls DeleteModuleVersion for it. This asserts +// the whole map rather than the names v6.7 happens to drop, so the next module +// removal that forgets the call fails here instead of leaving a version entry +// on chain forever. +func TestLatestUpgradeLeavesNoOrphanedModuleVersions(t *testing.T) { + t.Setenv("UPGRADE_VERSION_LIST", LatestUpgrade) + testApp := Setup(t, false, false, false) + testApp.RegisterUpgradeHandlers() + ctx := testApp.NewContext(false, tmproto.Header{}) + + registered := make(map[string]struct{}, len(testApp.mm.Modules)) + for name := range testApp.mm.Modules { + registered[name] = struct{}{} + } + + // Model a chain that carried every retained store's module version across + // earlier upgrades, which is what a real node upgrading into this release + // has in state. + versionMap := testApp.UpgradeKeeper.GetModuleVersionMap(ctx) + for name := range retainedStores { + versionMap[name] = 1 + } + testApp.UpgradeKeeper.SetModuleVersionMap(ctx, versionMap) + + testApp.UpgradeKeeper.ApplyUpgrade(ctx, upgradetypes.Plan{ + Name: LatestUpgrade, + Height: ctx.BlockHeight(), + }) + + for name := range testApp.UpgradeKeeper.GetModuleVersionMap(ctx) { + require.Contains(t, registered, name, + "module version map still carries %q, which no registered module owns; "+ + "the upgrade handler needs a DeleteModuleVersion call for it", name) + } +} + +// Every mounted KV store should be owned by a registered module or be named on +// the retained list. An unowned store that nobody declared is state the chain +// keeps paying for with no code able to read it, which is how a module removal +// half lands: the manager entry goes, the store stays, and nothing says whether +// that was the intent. +func TestMountedStoresAreOwnedOrExplicitlyRetained(t *testing.T) { + testApp := Setup(t, false, false, false) + + var unowned []string + for _, storeKey := range kvStoreKeyNames { + if _, ok := testApp.mm.Modules[owningModuleName(storeKey)]; ok { + continue + } + if _, ok := retainedStores[storeKey]; ok { + continue + } + unowned = append(unowned, storeKey) + } + + require.Empty(t, unowned, + "these KV stores are mounted but no registered module owns them; for each, either "+ + "delete it at an upgrade height, add it to retainedStores with the reason, or "+ + "record its owning module in storeKeyOwners") +} + +// A retained store is only worth retaining if it can still be read. This fails +// if a later change drops one from the mount list while leaving it declared +// retained, which would make the declaration a comment rather than a fact. +func TestRetainedStoresRemainMounted(t *testing.T) { + testApp := Setup(t, false, false, false) + + for storeKey, reason := range retainedStores { + require.Contains(t, kvStoreKeyNames, storeKey, + "%q is declared retained (%s) but is not mounted", storeKey, reason) + require.NotNil(t, testApp.GetKey(storeKey), + "%q is declared retained (%s) but has no store key", storeKey, reason) + } +} diff --git a/app/upgrade_v67_test.go b/app/upgrade_v67_test.go new file mode 100644 index 0000000000..4e46ef7b16 --- /dev/null +++ b/app/upgrade_v67_test.go @@ -0,0 +1,355 @@ +package app_test + +import ( + "encoding/json" + "testing" + + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" + "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/signing" + upgradetypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade/types" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" + abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" + "github.com/sei-protocol/sei-chain/testutil/processblock" + "github.com/sei-protocol/sei-chain/testutil/processblock/msgs" + oracletypes "github.com/sei-protocol/sei-chain/x/oracle/types" + "github.com/stretchr/testify/require" +) + +// v6.7 retires the feegrant, capability, ibc and transfer modules, and +// deprecates the oracle handlers. The tests below drive a chain through the +// shape an operator sees on upgrade day: state already written by a retired +// module, transactions still arriving for it, then the upgrade, then the same +// transactions again. + +const ( + v67UpgradeName = "v6.7" + txFee = 200000 +) + +// retiredModuleTx is a transaction aimed at a module surface v6.7 retires, +// paired with the rejection every node must produce for it. The same case runs +// on both sides of the upgrade: retiring a module may not change how its +// transactions are rejected, or nodes mid-upgrade would disagree on a block. +type retiredModuleTx struct { + name string + // sign builds the transaction. Each case signs from an account of its own so + // that a rejection which consumes no sequence number cannot disturb the + // other cases sharing the block. + sign func(a *processblock.App, signer, granter sdk.AccAddress) signing.Tx + // code and codespace are the ABCI rejection the transaction must produce. + code uint32 + codespace string + // logContains is a distinguishing fragment of the rejection message. + logContains string + // chargesFee records whether the sender still pays for the failed + // transaction. Rejections raised by the message handler run after the fee + // deduction; rejections raised by ValidateBasic run before it. + chargesFee bool +} + +func retiredModuleTxCases() []retiredModuleTx { + return []retiredModuleTx{ + { + name: "oracle aggregate exchange rate vote", + sign: func(a *processblock.App, signer, _ sdk.AccAddress) signing.Tx { + return a.Sign(signer, txFee, oracletypes.NewMsgAggregateExchangeRateVote( + "1.5uatom", signer, sdk.ValAddress(signer))) + }, + code: uint32(oracletypes.ErrOracleDeprecated.ABCICode()), + codespace: oracletypes.ErrOracleDeprecated.Codespace(), + logContains: "oracle module is deprecated", + chargesFee: true, + }, + { + name: "oracle delegate feed consent", + sign: func(a *processblock.App, signer, _ sdk.AccAddress) signing.Tx { + return a.Sign(signer, txFee, oracletypes.NewMsgDelegateFeedConsent( + sdk.ValAddress(signer), signer)) + }, + code: uint32(oracletypes.ErrOracleDeprecated.ABCICode()), + codespace: oracletypes.ErrOracleDeprecated.Codespace(), + logContains: "oracle module is deprecated", + chargesFee: true, + }, + { + name: "transaction nominating a distinct fee granter", + sign: func(a *processblock.App, signer, granter sdk.AccAddress) signing.Tx { + return a.SignWithFeeGranter(signer, granter, txFee, + oracletypes.NewMsgDelegateFeedConsent(sdk.ValAddress(signer), signer)) + }, + code: uint32(sdkerrors.ErrInvalidRequest.ABCICode()), + codespace: sdkerrors.ErrInvalidRequest.Codespace(), + logContains: "fee grants are not enabled", + chargesFee: false, + }, + { + // The granter field stays on the wire even though nothing grants + // any more, so a client that always populates it with its own + // address keeps working. Only a granter that differs from the payer + // is refused. + name: "transaction nominating itself as fee granter", + sign: func(a *processblock.App, signer, _ sdk.AccAddress) signing.Tx { + return a.SignWithFeeGranter(signer, signer, txFee, + msgs.Send(signer, signer, 1)) + }, + code: abci.CodeTypeOK, + codespace: "", + chargesFee: true, + }, + } +} + +// retiredModuleTxAccounts holds one signer and one fee granter per case. +type retiredModuleTxAccounts struct { + signers []sdk.AccAddress + granters []sdk.AccAddress +} + +// fundRetiredModuleTxAccounts creates and funds the accounts one phase needs. +// +// Every phase's accounts must be created before the first block runs. The +// process block harness hands out the deliver context of the block it just +// committed, so an account created after that point is written into a cache +// that has already been flushed and never reaches committed state. +func fundRetiredModuleTxAccounts(a *processblock.App, phase string) retiredModuleTxAccounts { + cases := retiredModuleTxCases() + accounts := retiredModuleTxAccounts{ + signers: make([]sdk.AccAddress, len(cases)), + granters: make([]sdk.AccAddress, len(cases)), + } + for i, c := range cases { + accounts.signers[i] = a.NewSignableAccount(phase + "/signer/" + c.name) + accounts.granters[i] = a.NewSignableAccount(phase + "/granter/" + c.name) + a.FundAccount(accounts.signers[i], 1000000000) + a.FundAccount(accounts.granters[i], 1000000000) + } + return accounts +} + +// newV67Chain returns a chain whose only registered upgrade is v6.7, so that +// applying it exercises the handler under test rather than an earlier one. The +// common preset supplies the bonded validators a block needs a proposer from. +func newV67Chain(t *testing.T) *processblock.App { + t.Helper() + t.Setenv("UPGRADE_VERSION_LIST", v67UpgradeName) + a := processblock.NewTestApp(t) + processblock.CommonPreset(a) + a.RegisterUpgradeHandlers() + return a +} + +func applyV67(t *testing.T, a *processblock.App) { + t.Helper() + a.UpgradeKeeper.ApplyUpgrade(a.Ctx(), upgradetypes.Plan{ + Name: v67UpgradeName, + Height: a.Ctx().BlockHeight(), + }) +} + +// runRetiredModuleTxs submits one transaction per case in a single block and +// asserts the rejection and fee outcome each case declares. +func runRetiredModuleTxs(t *testing.T, a *processblock.App, phase string, accounts retiredModuleTxAccounts) { + t.Helper() + cases := retiredModuleTxCases() + + balancesBefore := make([]sdk.Coin, len(cases)) + txs := make([]signing.Tx, len(cases)) + for i, c := range cases { + balancesBefore[i] = a.BankKeeper.GetBalance(a.Ctx(), accounts.signers[i], "usei") + txs[i] = c.sign(a, accounts.signers[i], accounts.granters[i]) + } + + results := a.RunBlockDetailed(txs) + require.Len(t, results, len(cases)) + + for i, c := range cases { + t.Run(phase+"/"+c.name, func(t *testing.T) { + res := results[i] + require.Equal(t, c.code, res.Code, "log was %q", res.Log) + require.Equal(t, c.codespace, res.Codespace) + require.Contains(t, res.Log, c.logContains) + + spent := balancesBefore[i].Sub(a.BankKeeper.GetBalance(a.Ctx(), accounts.signers[i], "usei")) + if c.chargesFee { + require.Equal(t, sdk.NewInt64Coin("usei", txFee), spent, + "a transaction rejected by the message handler still pays its fee") + } else { + require.True(t, spent.IsZero(), + "a transaction rejected before fee deduction must cost nothing, spent %s", spent) + } + }) + } +} + +// Transactions aimed at retired modules must be rejected identically on both +// sides of the upgrade. A node that has applied v6.7 and one that has not are +// briefly both live during a rollout, and a rejection that changed shape at the +// upgrade height would put them on different application hashes. +func TestV67LeavesRetiredModuleTxRejectionUnchanged(t *testing.T) { + a := newV67Chain(t) + beforeAccounts := fundRetiredModuleTxAccounts(a, "before-upgrade") + afterAccounts := fundRetiredModuleTxAccounts(a, "after-upgrade") + + runRetiredModuleTxs(t, a, "before-upgrade", beforeAccounts) + applyV67(t, a) + runRetiredModuleTxs(t, a, "after-upgrade", afterAccounts) +} + +// Spamming a retired module is not free. Every oracle transaction is rejected, +// yet it still pays its fee, consumes a sequence number, and occupies block +// gas. This is what stops a retired handler from becoming a free denial of +// service, so it is asserted rather than left as an implementation detail. +func TestRetiredOracleTxsAreRejectedButStillCharged(t *testing.T) { + a := newV67Chain(t) + applyV67(t, a) + + const spamCount = 25 + spammer := a.NewSignableAccount("oracle-spammer") + a.FundAccount(spammer, 1000000000) + + balanceBefore := a.BankKeeper.GetBalance(a.Ctx(), spammer, "usei") + sequenceBefore := a.AccountKeeper.GetAccount(a.Ctx(), spammer).GetSequence() + + txs := make([]signing.Tx, spamCount) + for i := range txs { + txs[i] = a.Sign(spammer, txFee, oracletypes.NewMsgAggregateExchangeRateVote( + "1.5uatom", spammer, sdk.ValAddress(spammer))) + } + + for i, res := range a.RunBlockDetailed(txs) { + require.Equal(t, uint32(oracletypes.ErrOracleDeprecated.ABCICode()), res.Code, + "spam transaction %d: %s", i, res.Log) + require.Positive(t, res.GasUsed, "spam transaction %d consumed no gas", i) + } + + spent := balanceBefore.Sub(a.BankKeeper.GetBalance(a.Ctx(), spammer, "usei")) + require.Equal(t, sdk.NewInt64Coin("usei", txFee*spamCount), spent) + require.Equal(t, sequenceBefore+spamCount, + a.AccountKeeper.GetAccount(a.Ctx(), spammer).GetSequence()) + + // Nothing the spam carried reached oracle state. + _, err := a.OracleKeeper.GetAggregateExchangeRateVote(a.Ctx(), sdk.ValAddress(spammer)) + require.ErrorIs(t, err, oracletypes.ErrNoAggregateVote, + "a rejected vote must not be recorded") +} + +// v6.7 drops the retired modules from the version map but deliberately leaves +// their stores mounted, so state written before the upgrade stays on disk. +// Pinning that here means adding a StoreUpgrades{Deleted} entry for one of them +// later cannot pass silently: deleting a store is a defensible choice, but it is +// an application-hash-breaking one that has to be made on purpose. +func TestV67RetainsRetiredModuleStateWrittenBeforeUpgrade(t *testing.T) { + a := newV67Chain(t) + + // A chain reaching this upgrade carries both each module's version entry and + // the state it wrote, so seed both. Without the version entry the version + // map assertions below would hold on a chain that never ran these modules. + seeded := map[string][]byte{} + versionMap := a.UpgradeKeeper.GetModuleVersionMap(a.Ctx()) + for _, store := range retiredStoreKeys { + key := a.GetKey(store) + require.NotNil(t, key, "%s store is no longer mounted", store) + value := []byte("pre-upgrade/" + store) + a.Ctx().KVStore(key).Set([]byte("seeded"), value) + seeded[store] = value + versionMap[store] = 1 + } + a.UpgradeKeeper.SetModuleVersionMap(a.Ctx(), versionMap) + + for i := 0; i < 3; i++ { + a.RunBlock([]signing.Tx{}) + } + for _, store := range retiredStoreKeys { + require.Contains(t, a.UpgradeKeeper.GetModuleVersionMap(a.Ctx()), store, + "seeded %s module version did not survive to the upgrade height", store) + require.Equal(t, seeded[store], a.Ctx().KVStore(a.GetKey(store)).Get([]byte("seeded")), + "seeded %s state did not survive to the upgrade height", store) + } + + applyV67(t, a) + + for _, store := range retiredStoreKeys { + require.NotContains(t, a.UpgradeKeeper.GetModuleVersionMap(a.Ctx()), store, + "v6.7 must drop %s from the module version map", store) + require.Equal(t, seeded[store], a.Ctx().KVStore(a.GetKey(store)).Get([]byte("seeded")), + "v6.7 retains the %s store; deleting it is application-hash breaking", store) + } + + a.RunBlock([]signing.Tx{}) + for _, store := range retiredStoreKeys { + require.Equal(t, seeded[store], a.Ctx().KVStore(a.GetKey(store)).Get([]byte("seeded")), + "retained %s state disappeared once the chain continued past the upgrade", store) + } +} + +// Deprecating the oracle handlers stopped transactions from reaching oracle +// state, but the module is still in the manager and its mid blocker still runs +// every vote period. With no votes to tally it marks every bonded validator +// absent, so the store keeps being written after the upgrade even though no +// client can put anything in it. +// +// This is characterization, not a defect: it records the write that a later +// oracle removal has to stop before it can drop the store, because a module +// still writing at the height its store is deleted is how an upgrade halts a +// chain. If oracle stops writing, this test should be deleted along with the +// blocker, not adjusted to keep passing. +func TestOracleKeepsWritingStateAfterV67(t *testing.T) { + a := newV67Chain(t) + applyV67(t, a) + + validator := a.GetAllValidators()[0] + operator, err := sdk.ValAddressFromBech32(validator.OperatorAddress) + require.NoError(t, err) + + require.Zero(t, a.OracleKeeper.GetVotePenaltyCounter(a.Ctx(), operator).AbstainCount) + + // A vote period is two blocks by default, so this spans several of them. + for i := 0; i < 8; i++ { + a.RunBlock([]signing.Tx{}) + } + + require.Positive(t, a.OracleKeeper.GetVotePenaltyCounter(a.Ctx(), operator).AbstainCount, + "oracle no longer records abstentions after v6.7; if its blocker was removed, "+ + "remove this test with it") + _, err = a.OracleKeeper.GetAggregateExchangeRateVote(a.Ctx(), operator) + require.ErrorIs(t, err, oracletypes.ErrNoAggregateVote, + "the abstentions must come from the blocker, not from a vote that got through") +} + +// The retired stores survive the upgrade, but no module claims them, so genesis +// export cannot emit them. A chain restarted from its own exported genesis +// therefore starts with those stores empty while the chain it was exported from +// still carries the state. Retained state is reachable by direct store access +// only, and does not survive an export and import cycle. +func TestV67RetainedStateIsAbsentFromExportedGenesis(t *testing.T) { + a := newV67Chain(t) + for _, store := range retiredStoreKeys { + a.Ctx().KVStore(a.GetKey(store)).Set([]byte("seeded"), []byte("pre-upgrade")) + } + a.RunBlock([]signing.Tx{}) + + applyV67(t, a) + + exported, err := a.ExportAppStateAndValidators(false, nil) + require.NoError(t, err) + + var genesis map[string]json.RawMessage + require.NoError(t, json.Unmarshal(exported.AppState, &genesis)) + for _, store := range retiredStoreKeys { + require.Equal(t, []byte("pre-upgrade"), a.Ctx().KVStore(a.GetKey(store)).Get([]byte("seeded")), + "the %s state must still be in the store, or its absence from the export proves nothing", store) + require.NotContains(t, genesis, store, + "an exported genesis with a %s section would need a module to import it", store) + } +} + +// retiredStoreKeys are the stores whose modules v6.7 removes while keeping the +// store mounted. Declared here in terms of the sei-db key constants so this +// external test package does not need the unexported names in package app. +var retiredStoreKeys = []string{ + keys.FeegrantStoreKey, + keys.CapabilityStoreKey, + keys.IBCStoreKey, + keys.IBCTransferStoreKey, +} diff --git a/integration_test/runner/runner_test.go b/integration_test/runner/runner_test.go index d5220f325d..fedca203e3 100644 --- a/integration_test/runner/runner_test.go +++ b/integration_test/runner/runner_test.go @@ -125,3 +125,10 @@ func TestUpgradeMajor(t *testing.T) { func TestUpgradeMinor(t *testing.T) { runner.RunFile(t, "../upgrade_module/minor_upgrade_test.yaml") } + +// TestUpgradeRetiredModules runs a real v6.7 governance upgrade against a cluster +// that is being spammed with transactions for the modules v6.7 retires, and +// requires the retired surfaces to answer identically on both sides of it. +func TestUpgradeRetiredModules(t *testing.T) { + runner.RunFile(t, "../upgrade_module/retired_modules_upgrade_test.yaml") +} diff --git a/integration_test/runner/yamlparse_check_test.go b/integration_test/runner/yamlparse_check_test.go new file mode 100644 index 0000000000..3c84245ada --- /dev/null +++ b/integration_test/runner/yamlparse_check_test.go @@ -0,0 +1,62 @@ +package runner_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// Every YAML suite must parse into the runner's schema and name a verifier type +// the runner implements. Without this, a malformed suite only surfaces on a +// cluster run, which is the slowest place to find a typo. +func TestYAMLSuitesParse(t *testing.T) { + files, err := filepath.Glob("../*/*.yaml") + require.NoError(t, err) + require.NotEmpty(t, files) + + type input struct { + Cmd string `yaml:"cmd"` + Env string `yaml:"env,omitempty"` + Node string `yaml:"node,omitempty"` + } + type verifier struct { + Type string `yaml:"type"` + Expr string `yaml:"expr"` + Result string `yaml:"result,omitempty"` + } + type testCase struct { + Name string `yaml:"name"` + Inputs []input `yaml:"inputs"` + Verifiers []verifier `yaml:"verifiers"` + } + + for _, file := range files { + t.Run(filepath.Base(file), func(t *testing.T) { + raw, err := os.ReadFile(file) + require.NoError(t, err) + + var cases []testCase + require.NoError(t, yaml.Unmarshal(raw, &cases)) + require.NotEmpty(t, cases) + + for _, tc := range cases { + require.NotEmpty(t, tc.Name) + for i, in := range tc.Inputs { + require.NotEmpty(t, in.Cmd, "input %d has no cmd", i) + } + for i, v := range tc.Verifiers { + require.Contains(t, []string{"eval", "regex"}, v.Type, + "verifier %d has unknown type", i) + require.NotEmpty(t, v.Expr, "verifier %d has no expr", i) + if v.Type == "regex" { + require.NotEmpty(t, v.Result, + "regex verifier %d needs the env var to match against", i) + } + } + } + }) + } +} diff --git a/integration_test/upgrade_module/retired_modules_upgrade_test.yaml b/integration_test/upgrade_module/retired_modules_upgrade_test.yaml new file mode 100644 index 0000000000..4b99d5600c --- /dev/null +++ b/integration_test/upgrade_module/retired_modules_upgrade_test.yaml @@ -0,0 +1,145 @@ +# Retired Module Upgrade Scenario: +# +# The v6.7 upgrade retires module surfaces (feegrant, capability, ibc and +# transfer are removed; the oracle message and query handlers are deprecated). +# This drives a live four node cluster through the shape an operator sees on +# upgrade day and asks one question: does the chain answer clients the same way +# once the modules are gone? +# +# 1. Before the upgrade, probe every retired surface and spam the retired oracle +# handler with real broadcasts. Record a fingerprint of the answers. +# +# 2. Run a real governance software upgrade to v6.7. Every node lacks v6.7 in +# its version list, so all four panic at the upgrade height and are restarted +# with it, which is what makes the actual v6.7 handler run. +# +# 3. Confirm the cluster keeps producing blocks afterwards. Four nodes that +# agree on blocks past the upgrade height is the end-to-end statement that +# the handler is deterministic; a node that computed a different application +# hash would stall instead. +# +# 4. Probe again and require an identical fingerprint. Retiring a module changes +# what a node stores, never how it answers a client that has not noticed yet. +# A fingerprint that shifted at the upgrade height would mean two nodes +# mid-rollout disagree about whether a block is valid. + +- name: Test Retired Module Behavior Across the v6.7 Upgrade + inputs: + - cmd: echo "v6.7" + env: VERSION + + # A fee granter that is not the fee payer. Only a granter distinct from the + # payer is refused, so the probe needs a second address to be meaningful. + - cmd: printf "12345678\n" | seid keys show admin -a + env: DISTINCT_GRANTER + node: sei-node-0 + + # PRE-UPGRADE BEHAVIOR + - cmd: probe_retired_modules.sh $DISTINCT_GRANTER 5 + env: PRE_UPGRADE_PROBE + node: sei-node-0 + + # PASS PROPOSAL FOR THE v6.7 UPGRADE + - cmd: proposal_target_height.sh 60 + env: TARGET_HEIGHT + node: sei-node-0 + - cmd: proposal_submit.sh $TARGET_HEIGHT major $VERSION + env: PROPOSAL_ID + node: sei-node-0 + - cmd: proposal_vote.sh $PROPOSAL_ID + node: sei-node-0 + - cmd: proposal_vote.sh $PROPOSAL_ID + node: sei-node-1 + - cmd: proposal_vote.sh $PROPOSAL_ID + node: sei-node-2 + - cmd: proposal_vote.sh $PROPOSAL_ID + node: sei-node-3 + - cmd: proposal_wait_for_pass.sh $PROPOSAL_ID node_admin + node: sei-node-0 + + # No node knows v6.7 yet, so each one panics on reaching the upgrade height. + - cmd: wait_for_height.sh $TARGET_HEIGHT node_admin + node: sei-node-1 + - cmd: wait_for_height.sh $TARGET_HEIGHT node_admin + node: sei-node-2 + - cmd: wait_for_height.sh $TARGET_HEIGHT node_admin + node: sei-node-3 + - cmd: verify_upgrade_needed_log.sh $TARGET_HEIGHT $VERSION + node: sei-node-1 + env: LOG_UPGRADE_NEEDED_NODE_1 + - cmd: verify_upgrade_needed_log.sh $TARGET_HEIGHT $VERSION + node: sei-node-2 + env: LOG_UPGRADE_NEEDED_NODE_2 + - cmd: verify_upgrade_needed_log.sh $TARGET_HEIGHT $VERSION + node: sei-node-3 + env: LOG_UPGRADE_NEEDED_NODE_3 + + # Restarting with v6.7 in the version list is what registers and runs the + # real upgrade handler. + - cmd: seid_upgrade.sh $VERSION + node: sei-node-0 + - cmd: seid_upgrade.sh $VERSION + node: sei-node-1 + - cmd: seid_upgrade.sh $VERSION + node: sei-node-2 + - cmd: seid_upgrade.sh $VERSION + node: sei-node-3 + - cmd: verify_running.sh + node: sei-node-0 + env: RUNNING_UPGRADED_NODE_0 + - cmd: verify_running.sh + node: sei-node-1 + env: RUNNING_UPGRADED_NODE_1 + - cmd: verify_running.sh + node: sei-node-2 + env: RUNNING_UPGRADED_NODE_2 + - cmd: verify_running.sh + node: sei-node-3 + env: RUNNING_UPGRADED_NODE_3 + + # Blocks past the upgrade height mean all four nodes agreed on the state the + # handler produced. The runner records a non-zero script exit without + # failing the step, so capture an explicit result. + - cmd: wait_for_height.sh $((TARGET_HEIGHT + 5)) node_admin; if [ $? -eq 0 ]; then echo ADVANCED_PAST_UPGRADE; else echo STALLED_AT_UPGRADE; fi + env: CHAIN_ADVANCED_AFTER_UPGRADE + node: sei-node-0 + + # POST-UPGRADE BEHAVIOR + - cmd: probe_retired_modules.sh $DISTINCT_GRANTER 5 + env: POST_UPGRADE_PROBE + node: sei-node-0 + + verifiers: + # Every node reached the upgrade height and asked for v6.7. + - type: eval + expr: LOG_UPGRADE_NEEDED_NODE_1 == "PASS" or LOG_UPGRADE_NEEDED_NODE_2 == "PASS" or LOG_UPGRADE_NEEDED_NODE_3 == "PASS" + + # Every node came back up on the upgraded version. + - type: eval + expr: RUNNING_UPGRADED_NODE_0 == "PASS" + - type: eval + expr: RUNNING_UPGRADED_NODE_1 == "PASS" + - type: eval + expr: RUNNING_UPGRADED_NODE_2 == "PASS" + - type: eval + expr: RUNNING_UPGRADED_NODE_3 == "PASS" + + # The cluster kept producing blocks, so the four nodes agreed on the + # application hash the upgrade handler produced. + - type: regex + result: CHAIN_ADVANCED_AFTER_UPGRADE + expr: '(?m)^ADVANCED_PAST_UPGRADE$' + + # Every retired surface answered as expected before the upgrade. Asserting + # this separately from the comparison below distinguishes "the upgrade + # changed behavior" from "the probe was already failing on both sides". + - type: regex + result: PRE_UPGRADE_PROBE + expr: '(?m)^PROBE_RESULT=ALL_MATCH$' + - type: regex + result: POST_UPGRADE_PROBE + expr: '(?m)^PROBE_RESULT=ALL_MATCH$' + + # ...and answered identically afterwards. + - type: eval + expr: PRE_UPGRADE_PROBE == POST_UPGRADE_PROBE diff --git a/integration_test/upgrade_module/scripts/probe_retired_modules.sh b/integration_test/upgrade_module/scripts/probe_retired_modules.sh new file mode 100755 index 0000000000..e71aa58be7 --- /dev/null +++ b/integration_test/upgrade_module/scripts/probe_retired_modules.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# +# Probe the module surfaces the v6.7 upgrade retires and print a fingerprint of +# how the chain answered each one. The caller runs this on both sides of the +# upgrade and compares the two fingerprints: retiring a module changes what a +# node stores, never how it answers a client that has not noticed yet. A +# fingerprint that shifts at the upgrade height means two nodes mid-rollout +# would disagree about a block. +# +# Each probe reports a fixed token rather than raw output, so that incidental +# differences between two runs (transaction hashes, gas figures, heights) cannot +# make equivalent answers look different. A probe that did not get the expected +# answer reports the output it did get, which both fails the run and says why. +# +# The last line is PROBE_RESULT, a single verdict over all probes, so a caller +# can assert the whole run with one match. +# +# The chain identity and signing key are read from the environment so that one +# probe definition serves both the four-node docker cluster and a single-node +# local chain. Defaults are the docker cluster's, so the YAML suite that calls +# this needs no arguments. +# +# CHAIN_ID chain id to sign against (default sei) +# ADMIN_KEY key name to sign and query with (default node_admin) +# +# Usage: probe_retired_modules.sh [spam_count] + +set -uo pipefail + +FEE_GRANTER=${1:-} +SPAM_COUNT=${2:-5} + +if [ -z "$FEE_GRANTER" ]; then + echo "Usage: $0 [spam_count]" >&2 + exit 1 +fi + +seidbin=seid +chainid=${CHAIN_ID:-sei} +adminkey=${ADMIN_KEY:-node_admin} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../utils/_tx_helpers.sh +source "$SCRIPT_DIR/../../utils/_tx_helpers.sh" + +ADMIN_ADDR=$(_get_key_address "$adminkey") +ADMIN_VAL_ADDR=$(printf "12345678\n" | $seidbin keys show "$adminkey" --bech val -a 2>/dev/null) + +if [ -z "$ADMIN_ADDR" ] || [ -z "$ADMIN_VAL_ADDR" ]; then + echo "could not resolve key '$adminkey'; set ADMIN_KEY to a key in this node's keyring" >&2 + exit 1 +fi + +MISSED="" + +# report prints one fingerprint line and records whether the probe matched. +report() { + echo "$1=$2" + case "$2" in MATCH) ;; *) MISSED="$MISSED $1" ;; esac +} + +# token collapses an output to MATCH when it carries the expected text, or to a +# truncated single-line echo of what came back instead. +token() { + case "$1" in + *"$2"*) echo "MATCH" ;; + *) echo "MISS[$(echo "$1" | tr '\n' ' ' | cut -c1-160)]" ;; + esac +} + +# Retired oracle message handlers, reached through simulation. --dry-run does +# not open the keyring, so --from has to be the address rather than the key name. +report ORACLE_VOTE_SIM "$(token "$(printf '12345678\n' | $seidbin tx oracle aggregate-vote \ + 1.5ueth "$ADMIN_VAL_ADDR" --from "$ADMIN_ADDR" --chain-id "$chainid" --dry-run 2>&1)" \ + 'oracle module is deprecated')" +report ORACLE_FEEDER_SIM "$(token "$(printf '12345678\n' | $seidbin tx oracle set-feeder \ + "$ADMIN_ADDR" --from "$ADMIN_ADDR" --chain-id "$chainid" --dry-run 2>&1)" \ + 'oracle module is deprecated')" + +# Retired oracle query handlers. +report ORACLE_PARAMS_QUERY "$(token "$($seidbin q oracle params --output json 2>&1)" \ + 'oracle module is deprecated')" +report ORACLE_TWAPS_QUERY "$(token "$($seidbin q oracle twaps 3600 --output json 2>&1)" \ + 'oracle module is deprecated')" + +# A transaction nominating a fee granter other than its signer. The feegrant +# module is gone, so the chain refuses it, and refuses it in ValidateBasic +# before any fee is taken. +report FEE_GRANTER_DISTINCT "$(token "$(printf '12345678\n' | $seidbin tx bank send \ + "$adminkey" "$ADMIN_ADDR" 1usei --fee-account "$FEE_GRANTER" --from "$adminkey" \ + --chain-id "$chainid" --fees 2000usei -b sync -y --output json 2>&1)" \ + 'fee grants are not enabled')" + +# The granter field itself stays on the wire: a transaction naming its own +# signer as granter is still accepted, which is what keeps older clients +# working. CheckTx code 0 is the accepted answer. +report FEE_GRANTER_SELF "$(token "$(printf '12345678\n' | $seidbin tx bank send "$adminkey" \ + "$ADMIN_ADDR" 1usei --fee-account "$ADMIN_ADDR" --from "$adminkey" --chain-id "$chainid" \ + --fees 2000usei -b sync -y --output json 2>&1 | jq -r '.code // "unparsable"')" '0')" + +# Spam the retired oracle handler with real broadcasts. Each one is rejected but +# still committed, so the sender's sequence advances and its balance drops: a +# retired handler must not become a free way to fill blocks. +balance_before=$($seidbin q bank balances "$ADMIN_ADDR" --denom usei --output json 2>/dev/null | jq -r '.amount // 0') +seq_before=$(_get_account_sequence "$ADMIN_ADDR") + +for ((i = 0; i < SPAM_COUNT; i++)); do + printf '12345678\n' | $seidbin tx oracle aggregate-vote "1.${i}ueth" "$ADMIN_VAL_ADDR" \ + --from "$adminkey" --chain-id "$chainid" --fees 2000usei -b sync -y --output json >/dev/null 2>&1 + _wait_until "$adminkey sequence > $((seq_before + i))" \ + "[ \$(_get_account_sequence $ADMIN_ADDR) -gt $((seq_before + i)) ]" >/dev/null 2>&1 +done + +seq_after=$(_get_account_sequence "$ADMIN_ADDR") +balance_after=$($seidbin q bank balances "$ADMIN_ADDR" --denom usei --output json 2>/dev/null | jq -r '.amount // 0') + +if [ "$seq_after" -gt "$seq_before" ]; then + report ORACLE_SPAM_COMMITTED "MATCH" +else + report ORACLE_SPAM_COMMITTED "MISS[sequence stayed at $seq_before]" +fi + +if [ "$balance_after" -lt "$balance_before" ]; then + report ORACLE_SPAM_CHARGED "MATCH" +else + report ORACLE_SPAM_CHARGED "MISS[balance $balance_before to $balance_after]" +fi + +if [ -z "$MISSED" ]; then + echo "PROBE_RESULT=ALL_MATCH" +else + echo "PROBE_RESULT=MISSED[$(echo "$MISSED" | xargs)]" +fi diff --git a/integration_test/upgrade_tests/.gitignore b/integration_test/upgrade_tests/.gitignore new file mode 100644 index 0000000000..fd3807ca80 --- /dev/null +++ b/integration_test/upgrade_tests/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +artifacts/ diff --git a/integration_test/upgrade_tests/.mocharc.record.json b/integration_test/upgrade_tests/.mocharc.record.json new file mode 100644 index 0000000000..eb0843e304 --- /dev/null +++ b/integration_test/upgrade_tests/.mocharc.record.json @@ -0,0 +1,6 @@ +{ + "require": ["tsx"], + "timeout": 300000, + "exit": true, + "spec": ["record/*.spec.ts"] +} diff --git a/integration_test/upgrade_tests/.mocharc.selftest.json b/integration_test/upgrade_tests/.mocharc.selftest.json new file mode 100644 index 0000000000..f26d7cccdd --- /dev/null +++ b/integration_test/upgrade_tests/.mocharc.selftest.json @@ -0,0 +1,6 @@ +{ + "require": ["tsx"], + "timeout": 30000, + "exit": true, + "spec": ["selftest/*.spec.ts"] +} diff --git a/integration_test/upgrade_tests/.mocharc.verify.json b/integration_test/upgrade_tests/.mocharc.verify.json new file mode 100644 index 0000000000..fcaeb8b0d4 --- /dev/null +++ b/integration_test/upgrade_tests/.mocharc.verify.json @@ -0,0 +1,6 @@ +{ + "require": ["tsx"], + "timeout": 300000, + "exit": true, + "spec": ["verify/*.spec.ts"] +} diff --git a/integration_test/upgrade_tests/README.md b/integration_test/upgrade_tests/README.md new file mode 100644 index 0000000000..5504573adb --- /dev/null +++ b/integration_test/upgrade_tests/README.md @@ -0,0 +1,104 @@ +# Upgrade tests + +Records how a live network answers for the modules an upgrade retires, then +verifies the answers once the upgrade has landed. + +Because the upgrade happens when the network upgrades, this is **two separately +invoked phases** with an artifact carried between them. + +``` + record ──── artifact ────► verify + (before upgrade) (after upgrade) +``` + +## Running it + +```bash +npm install + +# Before the upgrade. Writes artifacts/pre-upgrade.json. Keep that file. +UPGRADE_TEST_NETWORK=arctic-1 npm run record + +# After the upgrade has landed on that network. +UPGRADE_TEST_NETWORK=arctic-1 npm run verify + +# Checks on the suite itself. No chain needed; runs in CI on every PR. +npm run selftest +npm run typecheck +``` + +The read-only probes need no key and no funding, so `npm run record` works +against a public endpoint as-is. Set `UPGRADE_TEST_MNEMONIC` to also send +transaction probes, which is what lets the verify phase check whether committed +history survived. Without it that check is skipped and says so. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `UPGRADE_TEST_NETWORK` | `arctic-1` | `local`, `arctic-1`, or `atlantic-2` | +| `UPGRADE_TEST_PLAN_NAME` | `v6.7` | Governance plan name, the key `applied_plan` is stored under | +| `UPGRADE_TEST_REMOVED_MODULES` | `capability feegrant ibc transfer` | Modules the plan deletes from the version map | +| `UPGRADE_TEST_ARTIFACT` | `artifacts/pre-upgrade.json` | Where the artifact is written and read | +| `UPGRADE_TEST_EVM_RPC` | per network | Override, e.g. an archive node | +| `UPGRADE_TEST_REST` | per network | Cosmos REST override | +| `UPGRADE_TEST_MNEMONIC` | unset | Funded key for the transaction probes; falls back to `DAPP_TESTS_MNEMONIC` | + +## What it asserts + +**The gate.** The verify phase refuses to run unless `applied_plan/` is +non-zero *and* greater than the height the artifact was recorded at. Without +both halves a run against a not-yet-upgraded chain would report a clean pass on +everything that must not change and a confusing failure on everything that +must — the opposite of a useful signal. The gate is a pure function +(`upgradeGateRefusal`) so it is unit tested rather than only exercised live. + +**Invariants** must answer identically on both sides, and a difference fails. +These are surfaces the upgrade does not claim to touch, so a failure is +unambiguous. + +**Transitions** are expected to change. They are recorded on both sides and +printed as a before/after diff. The specific changes the upgrade is specified to +make get their own named assertions; the rest are reported for a human to read. + +**History** committed before the upgrade must still be served afterwards: +`eth_call` at the recorded heights must return the same answer, receipts must +still resolve with the same status and block, and a transaction that failed must +still fail for the same reason. + +## Two things worth knowing + +**A live network runs a released binary, not this checkout.** arctic-1 is on +v6.6.1, where the oracle query handlers still serve data and the upgrade +precompile at `0x…1015` is not registered at all. So the oracle surfaces are +classified as transitions here rather than invariants: from that network's point +of view the oracle deprecation arrives *with* v6.7. Classifying head-of-tree +behaviour as an invariant would fail the run for the wrong reason. + +**The upgrade module is read over Cosmos REST, not the upgrade precompile.** +The one fact the verify phase cannot work without is whether the upgrade ran, +and a precompile that is absent on the binary being tested cannot answer it. +`applied_plan` and `module_versions` therefore come from REST, which is proven +against a real past upgrade: arctic-1 reports v6.3.0 applied at height +138093082. + +## Where this sits + +Five layers test the same upgrade, trading control for realism: + +| Layer | Upgrade is | Pre-upgrade state | Runtime | +| --- | --- | --- | --- | +| `app/upgrade_v67_test.go` | `ApplyUpgrade` in process | synthetic store writes | seconds | +| `make local-upgrade-test` | real gov proposal, one node | empty | ~30s | +| `make cross-upgrade-test` | real gov proposal, **two binaries** | **real** | ~30s + old build | +| `integration_test/upgrade_module/retired_modules_upgrade_test.yaml` | real gov proposal, four nodes | empty | minutes, needs docker | +| this suite | whenever the network upgrades | whatever the network has | two runs, days apart | + +The in-process Go tests call `ApplyUpgrade` directly, which never writes +`upgrade-info.json` and never reloads the stores, so `App.SetStoreUpgradeHandlers` +— the code that decides whether a store is dropped at an upgrade height — is not +reached by any of them. `make local-upgrade-test` is the cheapest layer that +does reach it, and `make cross-upgrade-test` is the only one where the +pre-upgrade state was written by modules that were actually alive. + +`app/upgrade_orphan_test.go` guards against a removed module leaving a stale +version entry behind. arctic-1 carries `dex` and `accesscontrol` entries from +removals that predate the guard. diff --git a/integration_test/upgrade_tests/config/targets.ts b/integration_test/upgrade_tests/config/targets.ts new file mode 100644 index 0000000000..782e2eabf5 --- /dev/null +++ b/integration_test/upgrade_tests/config/targets.ts @@ -0,0 +1,98 @@ +/** + * Which chain the suite talks to, and the guard that it is the intended one. + * + * This suite runs against long-lived shared networks, and its two phases run + * hours or days apart. Recording against one network and verifying against + * another would produce a confident, meaningless diff, so the chain identifier + * is checked against the target table before anything else runs. + */ + +export type TargetName = 'local' | 'arctic-1' | 'atlantic-2'; + +export interface Target { + name: TargetName; + evmChainId: bigint; + cosmosChainId: string; + evmRpcUrl: string; + /** + * Cosmos REST (LCD). The upgrade module's applied plan and module version + * map are read from here rather than from the upgrade precompile: the + * precompile is not registered on every released binary (arctic-1 on v6.6.1 + * answers 0x at 0x…1015), and the one fact the verify phase cannot work + * without is whether the upgrade ran. + */ + restUrl: string; +} + +/** + * Endpoints match integration_test/dapp_tests/hardhat.config.js (the public + * sei-apis.com hosts) rather than the platform wave nodes the load generator + * uses, so the suite works without access to internal infrastructure. Override + * with UPGRADE_TEST_EVM_RPC / UPGRADE_TEST_REST to point at a wave node or a + * private archive. + */ +const TARGETS: Record> = { + local: { + evmChainId: 713714n, + cosmosChainId: 'sei', + evmRpcUrl: 'http://localhost:8545', + restUrl: 'http://localhost:1317', + }, + 'arctic-1': { + evmChainId: 713715n, + cosmosChainId: 'arctic-1', + evmRpcUrl: 'https://evm-rpc-arctic-1.sei-apis.com', + restUrl: 'https://rest-arctic-1.sei-apis.com', + }, + 'atlantic-2': { + evmChainId: 1328n, + cosmosChainId: 'atlantic-2', + evmRpcUrl: 'https://evm-rpc-testnet.sei-apis.com', + restUrl: 'https://rest-testnet.sei-apis.com', + }, +}; + +function isTargetName(value: string): value is TargetName { + return value in TARGETS; +} + +export function resolveTarget(env: NodeJS.ProcessEnv = process.env): Target { + const name = env.UPGRADE_TEST_NETWORK ?? 'arctic-1'; + if (!isTargetName(name)) { + throw new Error( + `UPGRADE_TEST_NETWORK=${name} is not one of ${Object.keys(TARGETS).join(', ')}`, + ); + } + const target = TARGETS[name]; + return { + name, + ...target, + evmRpcUrl: env.UPGRADE_TEST_EVM_RPC ?? target.evmRpcUrl, + restUrl: env.UPGRADE_TEST_REST ?? target.restUrl, + }; +} + +/** + * The upgrade whose effects this suite records and verifies. The name has to + * match the governance plan name exactly, because that string is the key + * appliedPlan() is stored under. + */ +export function upgradeName(env: NodeJS.ProcessEnv = process.env): string { + return env.UPGRADE_TEST_PLAN_NAME ?? 'v6.7'; +} + +/** The mnemonic used for the transaction probes, or '' when none was given. */ +export function adminMnemonic(env: NodeJS.ProcessEnv = process.env): string { + return env.UPGRADE_TEST_MNEMONIC ?? env.DAPP_TESTS_MNEMONIC ?? ''; +} + +/** + * The modules the upgrade removes from the module version map. Anything else + * disappearing is a regression, and a name here that survives means the handler + * is missing a DeleteModuleVersion call for it. + */ +export function expectedRemovedModules(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.UPGRADE_TEST_REMOVED_MODULES; + const names = raw ? raw.split(/[\s,]+/).filter(Boolean) : ['capability', 'feegrant', 'ibc', 'transfer']; + return [...names].sort(); +} diff --git a/integration_test/upgrade_tests/package-lock.json b/integration_test/upgrade_tests/package-lock.json new file mode 100644 index 0000000000..d58dc221dc --- /dev/null +++ b/integration_test/upgrade_tests/package-lock.json @@ -0,0 +1,1697 @@ +{ + "name": "sei-upgrade-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sei-upgrade-tests", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "ethers": "^6.14.0" + }, + "devDependencies": { + "@types/chai": "^4.3.16", + "@types/mocha": "^10.0.7", + "@types/node": "^22.5.0", + "chai": "^4.4.1", + "mocha": "^10.7.3", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/chai": { + "version": "4.3.20", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", + "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ethers": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.11.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/integration_test/upgrade_tests/package.json b/integration_test/upgrade_tests/package.json new file mode 100644 index 0000000000..7c147cadaf --- /dev/null +++ b/integration_test/upgrade_tests/package.json @@ -0,0 +1,25 @@ +{ + "name": "sei-upgrade-tests", + "version": "1.0.0", + "private": true, + "description": "Two-phase suite that records how a live Sei network answers for the modules an upgrade retires, then verifies the answers after the upgrade lands.", + "license": "MIT", + "scripts": { + "record": "mocha --config .mocharc.record.json", + "verify": "mocha --config .mocharc.verify.json", + "selftest": "mocha --config .mocharc.selftest.json", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "ethers": "^6.14.0" + }, + "devDependencies": { + "@types/chai": "^4.3.16", + "@types/mocha": "^10.0.7", + "@types/node": "^22.5.0", + "chai": "^4.4.1", + "mocha": "^10.7.3", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + } +} diff --git a/integration_test/upgrade_tests/record/record.spec.ts b/integration_test/upgrade_tests/record/record.spec.ts new file mode 100644 index 0000000000..720d7cb49d --- /dev/null +++ b/integration_test/upgrade_tests/record/record.spec.ts @@ -0,0 +1,110 @@ +/** + * Phase one: run before the upgrade. + * + * Probes every module surface the upgrade retires, sends transactions at the + * retired ones, and writes it all to an artifact. Nothing here asserts the + * upgrade's effects — that is the verify phase's job. What it does assert is + * that the chain is in a state worth recording: if the upgrade has already run, + * an artifact taken now would make the comparison vacuous. + */ +import { expect } from 'chai'; +import { + adminMnemonic, + expectedRemovedModules, + resolveTarget, + upgradeName, +} from '../config/targets'; +import { assertExpectedChain } from '../utils/chain'; +import { writeArtifact, type Artifact, type RecordedTx } from '../utils/artifact'; +import { + appliedPlanHeight, + applicationVersion, + currentPlan, + latestCosmosHeight, + moduleVersions, +} from '../utils/cosmos'; +import { currentHeight, runProbes, takeArchiveReads } from '../utils/probes'; +import { fundedAddress, sendProbeTransactions } from '../utils/txProbes'; + +describe('record pre-upgrade behaviour', function () { + this.timeout(300 * 1000); + + const target = resolveTarget(); + const planName = upgradeName(); + const mnemonic = adminMnemonic(); + + before(async () => { + await assertExpectedChain(); + }); + + it(`is on a chain that has not applied ${planName} yet`, async () => { + const applied = await appliedPlanHeight(planName); + expect( + applied, + `${target.name} already applied ${planName} at height ${applied}. Recording now ` + + 'would capture post-upgrade behaviour and leave the verify phase comparing it ' + + 'against itself. Set UPGRADE_TEST_PLAN_NAME to the next upgrade instead.', + ).to.equal(0); + }); + + it('writes an artifact of every retired surface', async () => { + const blockNumber = await currentHeight(); + const cosmosHeight = await latestCosmosHeight(); + const seidVersion = await applicationVersion(); + const probes = await runProbes(planName); + const archiveReads = await takeArchiveReads(blockNumber); + const modules = await moduleVersions(); + + let transactions: RecordedTx[] = []; + if (mnemonic) { + const funded = await fundedAddress(mnemonic); + expect( + funded.balance > 0n, + `${funded.address} has no balance, so the transaction probes cannot be sent`, + ).to.equal(true); + transactions = await sendProbeTransactions(mnemonic); + } + + const artifact: Artifact = { + schema: 1, + meta: { + network: target.name, + evmChainId: target.evmChainId.toString(), + planName, + blockNumber, + cosmosHeight, + seidVersion, + recordedAt: new Date().toISOString(), + }, + probes, + archiveReads, + transactions, + moduleVersions: modules, + }; + + const written = writeArtifact(artifact); + + // Each module the upgrade is meant to remove must still be present, or + // there is nothing for the upgrade to remove and the verify phase would + // pass without proving anything. + const names = modules.map(m => m.name); + for (const module of expectedRemovedModules()) { + expect(names, `${module} must still hold a module version before the upgrade`).to.include( + module, + ); + } + + const scheduled = await currentPlan(); + console.log(` network ${target.name} on seid ${seidVersion}, EVM height ${blockNumber}`); + console.log(` modules ${modules.length} with a version entry`); + console.log( + ` scheduled ${scheduled ? `${scheduled.name} at height ${scheduled.height}` : 'no upgrade scheduled yet'}`, + ); + console.log(` transactions ${transactions.length}${mnemonic ? '' : ' (no UPGRADE_TEST_MNEMONIC; read-only probes only)'}`); + console.log(` artifact ${written}`); + console.log(' keep the artifact; the verify phase needs it once the upgrade lands.'); + for (const probe of probes) { + console.log(` ${probe.kind === 'invariant' ? '=' : '~'} ${probe.name} = ${probe.value}`); + } + }); +}); diff --git a/integration_test/upgrade_tests/selftest/harness.spec.ts b/integration_test/upgrade_tests/selftest/harness.spec.ts new file mode 100644 index 0000000000..99c1cf2e6e --- /dev/null +++ b/integration_test/upgrade_tests/selftest/harness.spec.ts @@ -0,0 +1,322 @@ +/** + * Checks on the suite itself, runnable with no chain. + * + * The record and verify phases only run against a live network, days apart, and + * a mistake in the comparison logic would surface as a false pass at the worst + * possible moment. These cover the parts that can be exercised offline: the + * ABIs the probes encode against, the artifact round trip, the gate, and the + * normalisation both phases share. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { expect } from 'chai'; +import { precompileInterface, ADDRESSES } from '../utils/chain'; +import { + assertSameChain, + probeValue, + readArtifact, + upgradeGateRefusal, + writeArtifact, + type Artifact, +} from '../utils/artifact'; +import { + archiveReadLabels, + diffProbes, + isHistoryUnavailable, + knowsArchiveRead, + probes, + reReadAtHeight, +} from '../utils/probes'; +import { expectedRemovedModules, resolveTarget, upgradeName } from '../config/targets'; + +const sampleArtifact = (): Artifact => ({ + schema: 1, + meta: { + network: 'arctic-1', + evmChainId: '713715', + planName: 'v6.7', + blockNumber: 1234, + cosmosHeight: 1234, + seidVersion: 'v6.6.1', + recordedAt: '2026-01-01T00:00:00.000Z', + }, + probes: [ + { + name: 'cosmos.moduleVersion.feegrant', + kind: 'transition', + describes: 'x', + value: 'v1', + }, + { + name: 'cosmos.moduleVersion.bank', + kind: 'invariant', + describes: 'x', + value: 'v2', + }, + ], + archiveReads: [], + transactions: [], + moduleVersions: [ + { name: 'bank', version: '2' }, + { name: 'feegrant', version: '1' }, + { name: 'oracle', version: '6' }, + ], +}); + +describe('suite self-checks', () => { + describe('precompile ABIs the probes encode against', () => { + it('the oracle interface still has the methods the probes call', () => { + const iface = precompileInterface('oracle'); + expect(iface.encodeFunctionData('getExchangeRates', [])).to.match(/^0x[0-9a-f]{8}$/); + expect(iface.encodeFunctionData('getOracleTwaps', [3600n])).to.match(/^0x[0-9a-f]+$/); + }); + + it('the upgrade interface still has appliedPlan and moduleVersions', () => { + const iface = precompileInterface('upgrade'); + expect(iface.encodeFunctionData('appliedPlan', ['v6.7'])).to.match(/^0x[0-9a-f]+$/); + expect(iface.encodeFunctionData('moduleVersions', [''])).to.match(/^0x[0-9a-f]+$/); + }); + + it('pins the addresses the suite probes', () => { + expect(ADDRESSES.oracle).to.equal('0x0000000000000000000000000000000000001008'); + expect(ADDRESSES.feegrant).to.equal('0x0000000000000000000000000000000000001010'); + expect(ADDRESSES.upgrade).to.equal('0x0000000000000000000000000000000000001015'); + }); + }); + + describe('the probe table', () => { + const table = probes('v6.7'); + + it('names every probe uniquely, so artifact lookups cannot collide', () => { + const names = table.map(p => p.name); + expect(new Set(names).size, `duplicate probe name in ${names.join(', ')}`).to.equal( + names.length, + ); + }); + + it('explains every probe, since the artifact outlives whoever ran it', () => { + for (const probe of table) { + expect(probe.describes, `${probe.name} has no description`).to.not.equal(''); + } + }); + + it('covers both what must hold and what must change', () => { + expect(table.some(p => p.kind === 'invariant')).to.equal(true); + expect(table.some(p => p.kind === 'transition')).to.equal(true); + }); + + it('probes every module the upgrade removes, plus the applied plan', () => { + const names = table.map(p => p.name); + for (const module of expectedRemovedModules()) { + expect(names, `no probe for the removal of ${module}`).to.include( + `cosmos.moduleVersion.${module}`, + ); + } + expect(names).to.include('cosmos.appliedPlan.v6.7'); + }); + + it('expects the four modules v6.7 retires', () => { + expect(expectedRemovedModules()).to.deep.equal([ + 'capability', + 'feegrant', + 'ibc', + 'transfer', + ]); + }); + }); + + describe('the probe diff the verify phase reports', () => { + it('lists only the probes whose answer moved', () => { + const before = sampleArtifact().probes; + const after = before.map(p => + p.name === 'cosmos.moduleVersion.feegrant' ? { ...p, value: 'absent' } : p, + ); + expect(diffProbes(before, after)).to.deep.equal([ + { + name: 'cosmos.moduleVersion.feegrant', + kind: 'transition', + from: 'v1', + to: 'absent', + }, + ]); + }); + + it('is empty when nothing moved', () => { + const probeList = sampleArtifact().probes; + expect(diffProbes(probeList, probeList)).to.deep.equal([]); + }); + + it('ignores a probe the artifact never recorded, which the invariant check reports instead', () => { + const after = [ + ...sampleArtifact().probes, + { name: 'brand.new', kind: 'invariant' as const, describes: 'x', value: 'ok' }, + ]; + expect(diffProbes(sampleArtifact().probes, after)).to.deep.equal([]); + }); + }); + + describe('the artifact', () => { + let dir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sei-upgrade-tests-')); + env = { UPGRADE_TEST_ARTIFACT: path.join(dir, 'pre-upgrade.json') }; + }); + + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('round trips without losing anything', () => { + const original = sampleArtifact(); + writeArtifact(original, env); + expect(readArtifact(env)).to.deep.equal(original); + }); + + it('creates the directory it writes into', () => { + const nested = { UPGRADE_TEST_ARTIFACT: path.join(dir, 'a', 'b', 'pre.json') }; + writeArtifact(sampleArtifact(), nested); + expect(fs.existsSync(nested.UPGRADE_TEST_ARTIFACT!)).to.equal(true); + }); + + it('says how to produce one when it is missing', () => { + expect(() => readArtifact(env)).to.throw(/Run the record phase before the upgrade/); + }); + + it('refuses an artifact from a different chain', () => { + const artifact = sampleArtifact(); + expect(() => assertSameChain(artifact, 'atlantic-2', 1328n)).to.throw( + /recorded against EVM chain 713715/, + ); + expect(() => assertSameChain(artifact, 'arctic-1', 713715n)).to.not.throw(); + }); + + it('refuses a schema it does not understand', () => { + const artifact = { ...sampleArtifact(), schema: 99 }; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(env.UPGRADE_TEST_ARTIFACT!, JSON.stringify(artifact)); + expect(() => readArtifact(env)).to.throw(/unsupported artifact schema 99/); + }); + + it('looks probes up by name', () => { + const artifact = sampleArtifact(); + expect(probeValue(artifact, 'cosmos.moduleVersion.feegrant')).to.equal('v1'); + expect(probeValue(artifact, 'nope')).to.equal(undefined); + }); + }); + + /** + * The gate decides whether anything the verify phase reports means + * anything, and its failure mode is a false pass. It is pure so that it can + * be pinned here rather than only exercised against a live chain. + */ + describe('the gate on the verify phase', () => { + const gate = (appliedHeight: number, artifactCosmosHeight = 1000) => + upgradeGateRefusal({ + planName: 'v6.7', + network: 'arctic-1', + appliedHeight, + artifactCosmosHeight, + context: 'height 1200, seid v6.6.1', + }); + + it('refuses when the upgrade has not been applied', () => { + expect(gate(0)).to.match(/has not applied v6\.7 yet/); + }); + + it('refuses when the artifact was recorded after the upgrade', () => { + expect(gate(900, 1000)).to.match(/at or before the artifact was recorded/); + }); + + it('refuses when the upgrade landed exactly at the recorded height', () => { + expect(gate(1000, 1000)).to.match(/at or before the artifact was recorded/); + }); + + it('allows a run where the upgrade landed after the artifact', () => { + expect(gate(1100, 1000)).to.equal(undefined); + }); + + it('names the network and the plan, so the message is actionable', () => { + expect(gate(0)).to.contain('arctic-1').and.to.contain('v6.7'); + }); + }); + + /** + * The record phase and the verify phase have to reduce the same answer to + * the same token. When they did not, a re-read of an unchanged block + * reported `revert:oracle-retired` becoming + * `revert:raw(execution reverted: oracle precompile is retired…)` — the same + * answer, called a regression. Both phases now go through one lookup table, + * and this pins that they stay in step. + */ + describe('archive reads normalise identically in both phases', () => { + it('re-reads every label the record phase can produce', () => { + for (const label of archiveReadLabels()) { + expect(knowsArchiveRead(label), `${label} cannot be re-read`).to.equal(true); + } + }); + + it('takes at least one archive read, or the history check proves nothing', () => { + expect(archiveReadLabels().length).to.be.greaterThan(0); + }); + + it('reports rather than compares a label it no longer defines', async () => { + const outcome = await reReadAtHeight({ + label: 'retired.probe.from.an.older.artifact', + blockNumber: 1, + value: 'ok:0x', + }); + expect(outcome).to.deep.equal({ unknownLabel: true }); + }); + }); + + describe('pruned history is not a changed answer', () => { + it('recognises the ways a node says it no longer has a height', () => { + for (const value of [ + 'revert:raw(height 100 is not available, lowest height is 900)', + 'revert:raw(failed to load state at height 5)', + 'revert:raw(-32000: header not found)', + ]) { + expect(isHistoryUnavailable(value), value).to.equal(true); + } + }); + + it('does not mistake a real answer for a pruned one', () => { + expect(isHistoryUnavailable('ok:0x')).to.equal(false); + expect(isHistoryUnavailable('revert:oracle-retired')).to.equal(false); + expect(isHistoryUnavailable('absent')).to.equal(false); + }); + }); + + describe('target resolution', () => { + it('defaults to arctic-1', () => { + const target = resolveTarget({}); + expect(target.name).to.equal('arctic-1'); + expect(target.evmChainId).to.equal(713715n); + }); + + it('honours an endpoint override without losing the expected chain id', () => { + const target = resolveTarget({ UPGRADE_TEST_EVM_RPC: 'http://archive.example:8545' }); + expect(target.evmRpcUrl).to.equal('http://archive.example:8545'); + expect(target.evmChainId).to.equal(713715n); + }); + + it('rejects a network it has no chain id for', () => { + expect(() => resolveTarget({ UPGRADE_TEST_NETWORK: 'mainnet' })).to.throw( + /is not one of/, + ); + }); + + it('defaults the plan name to the upgrade under test', () => { + expect(upgradeName({})).to.equal('v6.7'); + expect(upgradeName({ UPGRADE_TEST_PLAN_NAME: 'v6.8' })).to.equal('v6.8'); + }); + + it('lets the removed-module set be overridden, and sorts it', () => { + expect(expectedRemovedModules({ UPGRADE_TEST_REMOVED_MODULES: 'zeta,alpha' })).to.deep.equal([ + 'alpha', + 'zeta', + ]); + }); + }); +}); diff --git a/integration_test/upgrade_tests/tsconfig.json b/integration_test/upgrade_tests/tsconfig.json new file mode 100644 index 0000000000..1f37f05596 --- /dev/null +++ b/integration_test/upgrade_tests/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "forceConsistentCasingInFileNames": true, + "types": ["node", "mocha", "chai"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "artifacts"] +} diff --git a/integration_test/upgrade_tests/utils/artifact.ts b/integration_test/upgrade_tests/utils/artifact.ts new file mode 100644 index 0000000000..e33fa8c8e0 --- /dev/null +++ b/integration_test/upgrade_tests/utils/artifact.ts @@ -0,0 +1,132 @@ +/** + * The file that carries one run's observations to the next. + * + * The two phases are separated by a real governance upgrade, so they are + * separate processes minutes or days apart, possibly on different machines. + * Everything the verify phase needs has to survive in this file, and it records + * which chain and height it came from so a mismatched pair fails loudly instead + * of comparing unrelated observations. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import type { ModuleVersion } from './cosmos'; +import type { ArchiveRead, ProbeResult } from './probes'; + +/** A transaction the record phase put on chain, to be re-read afterwards. */ +export interface RecordedTx { + label: string; + hash: string; + blockNumber: number; + /** 0 for a failed transaction, 1 for a successful one. */ + status: number; + /** eth_getVMError for a failed transaction, when the node served one. */ + vmError?: string; +} + +export interface Artifact { + schema: 1; + meta: { + network: string; + evmChainId: string; + planName: string; + /** EVM block height the observations were taken at. */ + blockNumber: number; + /** + * Cosmos block height at record time. Kept separately from the EVM + * height because applied_plan reports a Cosmos height, and the gate + * compares the two. + */ + cosmosHeight: number; + /** The seid version the endpoint reported, which is what was observed. */ + seidVersion: string; + recordedAt: string; + }; + probes: ProbeResult[]; + archiveReads: ArchiveRead[]; + transactions: RecordedTx[]; + /** The chain's whole module version map at record time, sorted by name. */ + moduleVersions: ModuleVersion[]; +} + +const DEFAULT_PATH = 'artifacts/pre-upgrade.json'; + +export function artifactPath(env: NodeJS.ProcessEnv = process.env): string { + return path.resolve(env.UPGRADE_TEST_ARTIFACT ?? DEFAULT_PATH); +} + +export function writeArtifact(artifact: Artifact, env: NodeJS.ProcessEnv = process.env): string { + const target = artifactPath(env); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, `${JSON.stringify(artifact, null, 2)}\n`); + return target; +} + +export function readArtifact(env: NodeJS.ProcessEnv = process.env): Artifact { + const source = artifactPath(env); + if (!fs.existsSync(source)) { + throw new Error( + `No pre-upgrade artifact at ${source}. Run the record phase before the upgrade ` + + '(npm run record), keep the file, and point UPGRADE_TEST_ARTIFACT at it afterwards.', + ); + } + const artifact = JSON.parse(fs.readFileSync(source, 'utf-8')) as Artifact; + if (artifact.schema !== 1) { + throw new Error(`${source}: unsupported artifact schema ${artifact.schema}`); + } + return artifact; +} + +/** + * Refuse an artifact recorded against a different chain. Comparing observations + * across networks would produce a confident diff about nothing. + */ +export function assertSameChain(artifact: Artifact, network: string, evmChainId: bigint): void { + if (artifact.meta.evmChainId !== evmChainId.toString()) { + throw new Error( + `artifact was recorded against EVM chain ${artifact.meta.evmChainId} (${artifact.meta.network}) ` + + `but this run targets ${evmChainId} (${network})`, + ); + } +} + +export function probeValue(artifact: Artifact, name: string): string | undefined { + return artifact.probes.find(p => p.name === name)?.value; +} + +/** + * The gate on the verify phase: the upgrade must have run, and it must have run + * after the artifact was taken. + * + * Both halves matter. Without the first, a run against a chain that has not + * upgraded yet reports a clean pass on everything that must not change and a + * baffling failure on everything that must — the opposite of a useful signal. + * Without the second, an artifact recorded after the upgrade would be compared + * against itself and pass while proving nothing. + * + * Returns the message explaining the refusal, or undefined when the run may + * proceed. Pure so it can be tested without a chain. + */ +export function upgradeGateRefusal(args: { + planName: string; + network: string; + appliedHeight: number; + artifactCosmosHeight: number; + /** Rendered into the message so the reader can see how far off the chain is. */ + context: string; +}): string | undefined { + const { planName, network, appliedHeight, artifactCosmosHeight, context } = args; + if (appliedHeight === 0) { + return ( + `${network} has not applied ${planName} yet (applied_plan reports 0 at ${context}). ` + + 'Wait for the upgrade to land on this network, then re-run.' + ); + } + if (appliedHeight <= artifactCosmosHeight) { + return ( + `${planName} was applied at height ${appliedHeight}, at or before the artifact was ` + + `recorded at height ${artifactCosmosHeight}. The artifact already describes an ` + + 'upgraded chain, so there is no pre-upgrade state to compare against.' + ); + } + return undefined; +} diff --git a/integration_test/upgrade_tests/utils/chain.ts b/integration_test/upgrade_tests/utils/chain.ts new file mode 100644 index 0000000000..75171a4d44 --- /dev/null +++ b/integration_test/upgrade_tests/utils/chain.ts @@ -0,0 +1,100 @@ +/** + * Transport helpers. Mirrors integration_test/precompile_tests/utils/chainUtils.ts, + * kept local so this suite has no cross-package import. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { ethers } from 'ethers'; +import { resolveTarget } from '../config/targets'; + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +export interface JsonRpcEnvelope { + result?: T; + error?: JsonRpcError; +} + +let provider: ethers.JsonRpcProvider | undefined; + +export function seiRpc(): ethers.JsonRpcProvider { + if (!provider) { + provider = new ethers.JsonRpcProvider(resolveTarget().evmRpcUrl, undefined, { + batchMaxCount: 1, + staticNetwork: true, + }); + } + return provider; +} + +/** + * Raw JSON-RPC POST. Precompile reverts are asserted through the raw envelope + * because ethers rewrites the node's error message on the way out, and the exact + * message is the thing being compared across the upgrade. + */ +export async function rawSei( + method: string, + params: unknown, +): Promise> { + const res = await fetch(resolveTarget().evmRpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + return res.json() as Promise>; +} + +/** + * Fail before any probe runs if the endpoint is not the network the caller named. + * Both phases call this, which is what stops a record/verify pair from spanning + * two different chains. + */ +export async function assertExpectedChain(): Promise { + const target = resolveTarget(); + const envelope = await rawSei('eth_chainId', []); + if (envelope.error || !envelope.result) { + throw new Error( + `${target.evmRpcUrl} did not answer eth_chainId: ${envelope.error?.message ?? 'empty result'}`, + ); + } + const actual = BigInt(envelope.result); + if (actual !== target.evmChainId) { + throw new Error( + `${target.evmRpcUrl} reports EVM chain id ${actual}, but ${target.name} is ${target.evmChainId}. ` + + 'Set UPGRADE_TEST_NETWORK or UPGRADE_TEST_EVM_RPC so they agree.', + ); + } +} + +/** Repo-root precompiles/ dir (this module lives at integration_test/upgrade_tests/utils). */ +const PRECOMPILES_ROOT = path.resolve(__dirname, '..', '..', '..', 'precompiles'); + +const abiCache = new Map(); + +/** + * A precompile's interface, loaded from the repo's own precompiles//abi.json + * — the same file the chain binary embeds, so the suite cannot drift from the + * deployed interface. + */ +export function precompileInterface(name: string): ethers.Interface { + const cached = abiCache.get(name); + if (cached) return cached; + const abiPath = path.join(PRECOMPILES_ROOT, name, 'abi.json'); + if (!fs.existsSync(abiPath)) { + throw new Error(`precompileInterface(${name}): ${abiPath} not found`); + } + const iface = new ethers.Interface(JSON.parse(fs.readFileSync(abiPath, 'utf-8'))); + abiCache.set(name, iface); + return iface; +} + +/** Canonical precompile addresses, mirroring precompiles//.go. */ +export const ADDRESSES = { + oracle: '0x0000000000000000000000000000000000001008', + /** Removed in v6.7; there is no ABI in the tree for it any more. */ + feegrant: '0x0000000000000000000000000000000000001010', + upgrade: '0x0000000000000000000000000000000000001015', +} as const; diff --git a/integration_test/upgrade_tests/utils/cosmos.ts b/integration_test/upgrade_tests/utils/cosmos.ts new file mode 100644 index 0000000000..4a2da16e38 --- /dev/null +++ b/integration_test/upgrade_tests/utils/cosmos.ts @@ -0,0 +1,136 @@ +/** + * Cosmos REST reads. + * + * The two facts the verify phase cannot work without — whether the upgrade ran, + * and which modules the chain thinks exist — come from here rather than from the + * upgrade precompile. A released binary does not necessarily register that + * precompile (arctic-1 on v6.6.1 answers `0x` at 0x…1015), and a gate that + * silently cannot tell whether the upgrade happened is worse than no gate. + */ +import { resolveTarget } from '../config/targets'; + +export interface RestResult { + ok: boolean; + status: number; + body: T | undefined; + /** The error message a non-2xx response carried, when it had one. */ + message?: string; +} + +async function restGet(pathAndQuery: string): Promise> { + const url = `${resolveTarget().restUrl}${pathAndQuery}`; + const res = await fetch(url, { headers: { accept: 'application/json' } }); + const text = await res.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + if (!res.ok) { + const message = + (parsed as { message?: string } | undefined)?.message ?? text.slice(0, 200); + return { ok: false, status: res.status, body: undefined, message }; + } + return { ok: true, status: res.status, body: parsed as T }; +} + +export interface ModuleVersion { + name: string; + version: string; +} + +/** + * The height the named upgrade was applied at, or 0 if it has not been applied. + * Proven against a real past upgrade: arctic-1 reports v6.3.0 at 138093082. + */ +export async function appliedPlanHeight(planName: string): Promise { + const result = await restGet<{ height: string }>( + `/cosmos/upgrade/v1beta1/applied_plan/${encodeURIComponent(planName)}`, + ); + if (!result.ok || !result.body) { + throw new Error( + `applied_plan/${planName} failed (HTTP ${result.status}): ${result.message ?? 'no body'}. ` + + 'Set UPGRADE_TEST_REST to a Cosmos REST endpoint for this network.', + ); + } + return Number(result.body.height ?? '0'); +} + +/** The currently scheduled upgrade plan, or undefined when none is scheduled. */ +export async function currentPlan(): Promise<{ name: string; height: string } | undefined> { + const result = await restGet<{ plan: { name: string; height: string } | null }>( + '/cosmos/upgrade/v1beta1/current_plan', + ); + if (!result.ok || !result.body) return undefined; + return result.body.plan ?? undefined; +} + +/** Every module version the chain has stored, sorted by name. */ +export async function moduleVersions(): Promise { + const result = await restGet<{ module_versions: ModuleVersion[] }>( + '/cosmos/upgrade/v1beta1/module_versions', + ); + if (!result.ok || !result.body) { + throw new Error( + `module_versions failed (HTTP ${result.status}): ${result.message ?? 'no body'}`, + ); + } + return [...(result.body.module_versions ?? [])].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * A single module's version as a token: `v` when present, `absent` + * once the upgrade has deleted it. The upgrade keeper answers a named lookup + * for a missing module with a not-found error rather than an empty list, so an + * error here is the expected post-removal answer, not a transport failure. + */ +export async function moduleVersionToken(moduleName: string): Promise { + const result = await restGet<{ module_versions: ModuleVersion[] }>( + `/cosmos/upgrade/v1beta1/module_versions?module_name=${encodeURIComponent(moduleName)}`, + ); + if (result.ok && result.body) { + const found = (result.body.module_versions ?? []).find(m => m.name === moduleName); + return found ? `v${found.version}` : 'absent'; + } + if (/not found/i.test(result.message ?? '')) return 'absent'; + return `error(HTTP ${result.status}: ${(result.message ?? '').slice(0, 120)})`; +} + +/** + * Whether a Cosmos query still serves data or answers with the deprecation + * error. Returns `ok` or `deprecated` or a truncated description of anything + * else, so the value is stable enough to compare across runs. + */ +export async function queryToken(pathAndQuery: string): Promise { + const result = await restGet(pathAndQuery); + if (result.ok) return 'ok'; + const message = result.message ?? ''; + if (/is deprecated/i.test(message)) return 'deprecated'; + return `error(HTTP ${result.status}: ${message.slice(0, 120)})`; +} + +/** + * The chain's latest Cosmos block height. Recorded alongside the EVM height + * because applied_plan reports a Cosmos height, and the two must be compared on + * the same clock. + */ +export async function latestCosmosHeight(): Promise { + const result = await restGet<{ block: { header: { height: string } } }>( + '/cosmos/base/tendermint/v1beta1/blocks/latest', + ); + if (!result.ok || !result.body) { + throw new Error( + `blocks/latest failed (HTTP ${result.status}): ${result.message ?? 'no body'}`, + ); + } + return Number(result.body.block.header.height); +} + +/** The seid version the endpoint reports, for the artifact's provenance. */ +export async function applicationVersion(): Promise { + const result = await restGet<{ application_version?: { version?: string } }>( + '/cosmos/base/tendermint/v1beta1/node_info', + ); + return result.body?.application_version?.version ?? 'unknown'; +} diff --git a/integration_test/upgrade_tests/utils/probes.ts b/integration_test/upgrade_tests/utils/probes.ts new file mode 100644 index 0000000000..33713d1dcf --- /dev/null +++ b/integration_test/upgrade_tests/utils/probes.ts @@ -0,0 +1,288 @@ +/** + * The observations both phases make, defined once so the recording run and the + * verifying run cannot drift apart. + * + * Every probe collapses its answer to a short token rather than raw output. + * Incidental variation — heights, gas figures, hashes inside error strings — + * would otherwise make two equivalent answers compare unequal. An unexpected + * answer records the raw text instead, so a mismatch both fails and says what + * came back. + * + * Probes are split by what the upgrade is allowed to do to them: + * + * invariant the answer must be identical before and after, and a difference + * fails the run. Reserved for surfaces the upgrade does not claim + * to touch, so that a failure is unambiguous. + * + * transition the answer is expected to change. Recorded on both sides and + * printed as a before/after diff. The changes the upgrade is + * specified to make get their own named assertions in the verify + * phase; the rest are reported so a human can see them. + * + * On classification: a live network runs a released binary, not this checkout. + * arctic-1 is on v6.6.1, where the oracle handlers still serve data and the + * upgrade precompile is not registered. So the oracle surfaces are transitions + * here, not invariants — the deprecation lands *with* the upgrade from that + * network's point of view. Asserting head-of-tree behaviour as an invariant + * would fail the run for the wrong reason. + */ +import { ethers } from 'ethers'; +import { expectedRemovedModules } from '../config/targets'; +import { ADDRESSES, precompileInterface, rawSei } from './chain'; +import { appliedPlanHeight, moduleVersionToken, queryToken } from './cosmos'; + +export type ProbeKind = 'invariant' | 'transition'; + +export interface Probe { + name: string; + kind: ProbeKind; + /** Why this probe exists, carried into the artifact for whoever reads it later. */ + describes: string; + run: () => Promise; +} + +const BANK_PRECOMPILE = '0x0000000000000000000000000000000000001001'; + +const truncate = (text: string, max = 160): string => + text.length <= max ? text : `${text.slice(0, max)}…`; + +/** + * Revert reasons the suite recognises, collapsed to a stable token. + * + * Applied by revertToken to every call the suite makes, rather than passed in at + * each call site. When the record phase and the verify phase normalise + * differently the run reports a change that did not happen: recording + * `revert:oracle-retired` and re-reading + * `revert:raw(execution reverted: oracle precompile is retired…)` is the same + * answer twice. One table every path goes through is what makes that + * impossible rather than merely unlikely. + */ +const KNOWN_REVERTS: Record = { + 'oracle-retired': /oracle precompile is retired/, +}; + +/** A JSON-RPC error message reduced to a recognised token, or its raw text. */ +function revertToken(message: string): string { + for (const [token, pattern] of Object.entries(KNOWN_REVERTS)) { + if (pattern.test(message)) return `revert:${token}`; + } + return `revert:raw(${truncate(message)})`; +} + +/** An eth_call at a block tag, reduced to `ok:` or `revert:`. */ +async function callToken(to: string, data: string, blockTag = 'latest'): Promise { + const envelope = await rawSei('eth_call', [{ to, data }, blockTag]); + return envelope.error ? revertToken(envelope.error.message) : `ok:${envelope.result ?? ''}`; +} + +/** + * Whether an address is a registered precompile. A registered one rejects an + * unknown selector; an address with no code answers a call with empty data. + * Verified live on arctic-1's v6.6.1: bank (0x…1001) reverts, while 0x…1010 and + * 0x…1015 both return 0x. + */ +async function precompileRegistrationToken(address: string, blockTag = 'latest'): Promise { + const value = await callToken(address, '0xdeadbeef', blockTag); + return value === 'ok:0x' || value === 'ok:' ? 'unregistered' : 'registered'; +} + +const oracleIface = () => precompileInterface('oracle'); + +export function probes(planName: string): Probe[] { + const removed = expectedRemovedModules(); + return [ + // --- surfaces the upgrade does not claim to touch --- + { + name: 'bank.precompileRegistered', + kind: 'invariant', + describes: + 'a precompile the upgrade does not touch, so a failure here means the probe ' + + 'broke rather than the chain changing', + run: () => precompileRegistrationToken(BANK_PRECOMPILE), + }, + { + name: 'cosmos.moduleVersion.bank', + kind: 'invariant', + describes: 'bank keeps its module version across the upgrade', + run: () => moduleVersionToken('bank'), + }, + { + name: 'cosmos.moduleVersion.oracle', + kind: 'invariant', + describes: + 'oracle is deprecated but not removed, so it must keep its module version; ' + + 'losing one would mean the deprecation went further than intended', + run: () => moduleVersionToken('oracle'), + }, + + // --- what the upgrade is for: one probe per module it removes --- + ...removed.map( + (module): Probe => ({ + name: `cosmos.moduleVersion.${module}`, + kind: 'transition', + describes: `${module} holds a module version until the upgrade deletes it`, + run: () => moduleVersionToken(module), + }), + ), + { + name: `cosmos.appliedPlan.${planName}`, + kind: 'transition', + describes: 'zero until the upgrade runs, then the height it ran at', + run: async () => String(await appliedPlanHeight(planName)), + }, + + // --- oracle deprecation, which reaches a released network with this upgrade --- + { + name: 'cosmos.query.oracleParams', + kind: 'transition', + describes: + 'the oracle query handlers serve data until they are deprecated, after which ' + + 'they answer "oracle module is deprecated"', + run: () => queryToken('/sei-protocol/sei-chain/oracle/params'), + }, + { + name: 'cosmos.query.oracleExchangeRates', + kind: 'transition', + describes: 'the same, for the exchange rate query', + run: () => queryToken('/sei-protocol/sei-chain/oracle/denoms/exchange_rates'), + }, + { + name: 'oracle.precompile.getExchangeRates', + kind: 'transition', + describes: + 'the oracle precompile’s revert reason. Recorded rather than asserted because ' + + 'public endpoints do not always forward revert data', + run: () => + callToken(ADDRESSES.oracle, oracleIface().encodeFunctionData('getExchangeRates', [])), + }, + { + name: 'feegrant.precompileRegistered', + kind: 'transition', + describes: + 'the feegrant precompile is removed, so this must read unregistered afterwards', + run: () => precompileRegistrationToken(ADDRESSES.feegrant), + }, + { + name: 'upgrade.precompileRegistered', + kind: 'transition', + describes: + 'whether the upgrade precompile (0x…1015) is mounted. Informational: the suite ' + + 'reads the upgrade module over Cosmos REST precisely because this is not ' + + 'registered on every released binary', + run: () => precompileRegistrationToken(ADDRESSES.upgrade), + }, + ]; +} + +export interface ProbeResult { + name: string; + kind: ProbeKind; + describes: string; + value: string; +} + +export async function runProbes(planName: string): Promise { + const results: ProbeResult[] = []; + for (const probe of probes(planName)) { + results.push({ + name: probe.name, + kind: probe.kind, + describes: probe.describes, + value: await probe.run(), + }); + } + return results; +} + +/** The EVM block height the observation was taken at. */ +export async function currentHeight(): Promise { + const envelope = await rawSei('eth_blockNumber', []); + if (envelope.error || !envelope.result) { + throw new Error(`eth_blockNumber failed: ${envelope.error?.message ?? 'empty'}`); + } + return Number(BigInt(envelope.result)); +} + +/** + * A read pinned to the height it was made at, so the verify phase can ask the + * same question of the same block after the upgrade. If removing state broke + * historical reads, this is where it shows. + */ +export interface ArchiveRead { + label: string; + blockNumber: number; + /** `ok:`, `revert:`, or a registration token. */ + value: string; +} + +/** + * The archive reads the suite takes, keyed by label. + * + * The verify phase re-runs a recorded read by looking its label up here rather + * than replaying calldata out of the artifact, so both phases encode and + * normalise through the same code. An artifact naming a label this version no + * longer defines is reported instead of compared. + */ +const ARCHIVE_READS: Record Promise> = { + 'oracle.precompile.getExchangeRates': blockTag => + callToken(ADDRESSES.oracle, oracleIface().encodeFunctionData('getExchangeRates', []), blockTag), + 'bank.precompileRegistered': blockTag => precompileRegistrationToken(BANK_PRECOMPILE, blockTag), +}; + +/** The labels takeArchiveReads produces, which reReadAtHeight must all know. */ +export function archiveReadLabels(): string[] { + return Object.keys(ARCHIVE_READS); +} + +export function knowsArchiveRead(label: string): boolean { + return label in ARCHIVE_READS; +} + +export async function takeArchiveReads(blockNumber: number): Promise { + const blockTag = ethers.toQuantity(blockNumber); + const reads: ArchiveRead[] = []; + for (const [label, read] of Object.entries(ARCHIVE_READS)) { + reads.push({ label, blockNumber, value: await read(blockTag) }); + } + return reads; +} + +export type ReReadOutcome = { value: string } | { unknownLabel: true }; + +export async function reReadAtHeight(recorded: ArchiveRead): Promise { + const read = ARCHIVE_READS[recorded.label]; + if (!read) return { unknownLabel: true }; + return { value: await read(ethers.toQuantity(recorded.blockNumber)) }; +} + +/** + * A pruned or unavailable historical block, which is a different outcome from a + * changed answer. Shared endpoints prune, and treating that as a failure would + * make the suite unusable against them. + */ +export function isHistoryUnavailable(value: string): boolean { + return /pruned|not available|failed to load state|is not available|header not found/i.test( + value, + ); +} + +/** A before/after line for every probe whose answer moved. */ +export function diffProbes( + before: ProbeResult[], + after: ProbeResult[], +): Array<{ name: string; kind: ProbeKind; from: string; to: string }> { + const byName = new Map(before.map(p => [p.name, p])); + const changed: Array<{ name: string; kind: ProbeKind; from: string; to: string }> = []; + for (const probe of after) { + const previous = byName.get(probe.name); + if (previous && previous.value !== probe.value) { + changed.push({ + name: probe.name, + kind: probe.kind, + from: previous.value, + to: probe.value, + }); + } + } + return changed; +} diff --git a/integration_test/upgrade_tests/utils/txProbes.ts b/integration_test/upgrade_tests/utils/txProbes.ts new file mode 100644 index 0000000000..749fb385cb --- /dev/null +++ b/integration_test/upgrade_tests/utils/txProbes.ts @@ -0,0 +1,89 @@ +/** + * Transactions the record phase puts on chain so the verify phase has real + * pre-upgrade history to re-read. + * + * The read-only probes answer "does the chain still behave the same". These + * answer the harder question: after state is removed, can the chain still serve + * what it already committed? A receipt that stops resolving, or a mined failure + * whose reason changes, is the shape a bad state migration takes. + * + * These need a funded key. Without one the record phase skips them and the + * verify phase has nothing to re-read, which keeps the read-only suite usable + * against any endpoint with no setup. + */ +import { ethers } from 'ethers'; +import { ADDRESSES, precompileInterface, rawSei, seiRpc } from './chain'; +import type { RecordedTx } from './artifact'; + +/** Sei's HD path, matching integration_test/dapp_tests/hardhat.config.js. */ +const SEI_HD_PATH = "m/44'/118'/0'/0/0"; + +export function walletFromMnemonic(mnemonic: string): ethers.HDNodeWallet { + return ethers.HDNodeWallet.fromPhrase(mnemonic, undefined, SEI_HD_PATH).connect(seiRpc()); +} + +/** + * eth_getVMError is the only channel carrying a precompile's exact error for a + * mined, failed transaction: eth_call rewrites most executor errors to a bare + * "execution reverted". Returns undefined when the node does not serve one. + */ +export async function vmError(txHash: string): Promise { + const envelope = await rawSei('eth_getVMError', [txHash]); + return envelope.result && envelope.result.length > 0 ? envelope.result : undefined; +} + +async function record( + label: string, + sent: Promise, +): Promise { + const tx = await sent; + // A failing transaction rejects in wait(); its receipt is on the error. + const receipt: ethers.TransactionReceipt | undefined = await tx + .wait() + .then(r => r ?? undefined) + .catch((e: { receipt?: ethers.TransactionReceipt }) => e.receipt); + if (!receipt) { + throw new Error(`${label}: transaction ${tx.hash} never produced a receipt`); + } + return { + label, + hash: receipt.hash, + blockNumber: receipt.blockNumber, + status: receipt.status ?? 0, + vmError: receipt.status === 0 ? await vmError(receipt.hash) : undefined, + }; +} + +/** + * Two transactions: one aimed at a retired precompile, which must mine as a + * failure, and one ordinary transfer that must mine as a success. Recording both + * means the verify phase can tell "history is gone" from "failed history is + * gone", which are different bugs. + */ +export async function sendProbeTransactions(mnemonic: string): Promise { + const wallet = walletFromMnemonic(mnemonic); + const oracleIface = precompileInterface('oracle'); + + const retired = await record( + 'oracle.getExchangeRates', + // An explicit gasLimit is required: estimateGas on a reverting call + // throws client-side, so the transaction would never be broadcast. + wallet.sendTransaction({ + to: ADDRESSES.oracle, + data: oracleIface.encodeFunctionData('getExchangeRates', []), + gasLimit: 200_000, + }), + ); + + const transfer = await record( + 'selfTransfer', + wallet.sendTransaction({ to: wallet.address, value: 1n }), + ); + + return [retired, transfer]; +} + +export async function fundedAddress(mnemonic: string): Promise<{ address: string; balance: bigint }> { + const wallet = walletFromMnemonic(mnemonic); + return { address: wallet.address, balance: await seiRpc().getBalance(wallet.address) }; +} diff --git a/integration_test/upgrade_tests/verify/verify.spec.ts b/integration_test/upgrade_tests/verify/verify.spec.ts new file mode 100644 index 0000000000..8d432525e1 --- /dev/null +++ b/integration_test/upgrade_tests/verify/verify.spec.ts @@ -0,0 +1,251 @@ +/** + * Phase two: run after the upgrade has landed on the network. + * + * Reads the pre-upgrade artifact and asks three questions of the live chain: + * did the surfaces the upgrade does not touch keep answering identically, did + * the module removal actually happen, and can the chain still serve the history + * it committed before its state changed. + */ +import { expect } from 'chai'; +import { expectedRemovedModules, resolveTarget, upgradeName } from '../config/targets'; +import { assertExpectedChain, rawSei } from '../utils/chain'; +import { + assertSameChain, + probeValue, + readArtifact, + upgradeGateRefusal, + type Artifact, +} from '../utils/artifact'; +import { + appliedPlanHeight, + applicationVersion, + latestCosmosHeight, + moduleVersions, +} from '../utils/cosmos'; +import { + diffProbes, + isHistoryUnavailable, + reReadAtHeight, + runProbes, + type ProbeResult, +} from '../utils/probes'; +import { vmError } from '../utils/txProbes'; + +const EXPECTED_REMOVED_MODULES = expectedRemovedModules(); + +describe('verify post-upgrade behaviour', function () { + this.timeout(300 * 1000); + + const target = resolveTarget(); + const planName = upgradeName(); + + let artifact: Artifact; + let current: ProbeResult[]; + let appliedHeight: number; + + /** + * The gate. Every assertion below is about the difference the upgrade made, + * so a run against a chain that has not upgraded yet would report a clean + * pass on the invariants and a confusing failure on the transitions. + * Throwing from a hook stops the suite with one message saying what to do. + */ + before(async () => { + await assertExpectedChain(); + artifact = readArtifact(); + assertSameChain(artifact, target.name, target.evmChainId); + + appliedHeight = await appliedPlanHeight(planName); + const refusal = upgradeGateRefusal({ + planName, + network: target.name, + appliedHeight, + artifactCosmosHeight: artifact.meta.cosmosHeight, + context: `height ${await latestCosmosHeight()}, seid ${await applicationVersion()}`, + }); + if (refusal) throw new Error(refusal); + + current = await runProbes(planName); + + const changed = diffProbes(artifact.probes, current); + console.log( + ` ${planName} applied at height ${appliedHeight}; artifact recorded at EVM height ` + + `${artifact.meta.blockNumber} on seid ${artifact.meta.seidVersion}, now ${await applicationVersion()}`, + ); + console.log(` ${changed.length} probe(s) changed:`); + for (const entry of changed) { + console.log(` ~ ${entry.name}: ${entry.from} -> ${entry.to}`); + } + }); + + describe('surfaces the upgrade does not touch', () => { + it('every invariant probe answers exactly as it did before', () => { + const mismatches: string[] = []; + for (const probe of current.filter(p => p.kind === 'invariant')) { + const before = probeValue(artifact, probe.name); + if (before === undefined) { + mismatches.push(`${probe.name}: absent from the artifact`); + } else if (before !== probe.value) { + mismatches.push(`${probe.name}: was ${before}, now ${probe.value}`); + } + } + expect( + mismatches, + 'a surface the upgrade does not claim to touch answered differently', + ).to.deep.equal([]); + }); + }); + + describe('the module removal the upgrade is for', () => { + for (const removed of EXPECTED_REMOVED_MODULES) { + it(`${removed} no longer holds a module version`, () => { + const probe = `cosmos.moduleVersion.${removed}`; + expect( + probeValue(artifact, probe), + `${removed} must have held a version before the upgrade`, + ).to.match(/^v\d+$/); + expect( + current.find(p => p.name === probe)?.value, + `the upgrade handler calls DeleteModuleVersion for ${removed}, so the ` + + 'lookup must now answer not-found', + ).to.equal('absent'); + }); + } + + it('removes exactly the modules it was supposed to and no others', async () => { + const after = (await moduleVersions()).map(m => m.name); + const before = artifact.moduleVersions.map(m => m.name); + const removed = before.filter(n => !after.includes(n)).sort(); + const added = after.filter(n => !before.includes(n)).sort(); + + expect(removed, 'unexpected module(s) lost their version entry').to.deep.equal( + EXPECTED_REMOVED_MODULES, + ); + expect(added, 'the upgrade added a module version this test did not expect').to.deep.equal( + [], + ); + }); + + it('the feegrant precompile address is not a registered precompile', () => { + expect( + current.find(p => p.name === 'feegrant.precompileRegistered')?.value, + 'a revert would mean a precompile is still mounted at 0x…1010', + ).to.equal('unregistered'); + }); + + /** + * Module versions are only ever written, never cleared, so a module + * dropped from the manager without a DeleteModuleVersion call keeps its + * entry for the life of the chain. arctic-1 carries dex and accesscontrol + * entries whose stores were deleted at v5.8.0 and v6.3.0. This reports + * them rather than failing: they are pre-existing, and the assertion that + * this upgrade does not add to the pile is the one above. + */ + it('reports module versions with no module behind them', async () => { + const known = ['dex', 'accesscontrol', 'crisis', ...EXPECTED_REMOVED_MODULES]; + const orphans = (await moduleVersions()).map(m => m.name).filter(n => known.includes(n)); + if (orphans.length > 0) { + console.log(` orphaned module version entries still on chain: ${orphans.join(', ')}`); + } + for (const removed of EXPECTED_REMOVED_MODULES) { + expect(orphans, `${planName} must not leave ${removed} on the orphan pile`).to.not.include( + removed, + ); + } + }); + }); + + describe('history committed before the upgrade', () => { + it('still answers the same at the heights it was read at', async () => { + const mismatches: string[] = []; + const skipped: string[] = []; + + for (const before of artifact.archiveReads) { + const outcome = await reReadAtHeight(before); + if ('unknownLabel' in outcome) { + skipped.push(`${before.label}: this suite version no longer defines that read`); + continue; + } + if (isHistoryUnavailable(outcome.value)) { + skipped.push(`${before.label}@${before.blockNumber}: ${outcome.value}`); + continue; + } + if (outcome.value !== before.value) { + mismatches.push( + `${before.label}@${before.blockNumber}: was ${before.value}, now ${outcome.value}`, + ); + } + } + + if (skipped.length > 0) { + // Pruning is a property of the endpoint, not of the upgrade. Say + // so rather than passing quietly or failing on it. + console.log(` ${skipped.length} historical read(s) not compared:`); + for (const entry of skipped) console.log(` ${entry}`); + } + expect( + mismatches, + 'a historical read changed answer; removing state must not rewrite what ' + + 'already-committed blocks return', + ).to.deep.equal([]); + }); + + it('still serves the receipts of transactions mined before it', async () => { + if (artifact.transactions.length === 0) { + console.log(' artifact has no transactions (recorded without a funded key)'); + return; + } + + const mismatches: string[] = []; + for (const before of artifact.transactions) { + const envelope = await rawSei<{ status: string; blockNumber: string }>( + 'eth_getTransactionReceipt', + [before.hash], + ); + if (envelope.error || !envelope.result) { + mismatches.push( + `${before.label} (${before.hash}): receipt no longer resolves: ` + + `${envelope.error?.message ?? 'null result'}`, + ); + continue; + } + const status = Number(BigInt(envelope.result.status)); + const blockNumber = Number(BigInt(envelope.result.blockNumber)); + if (status !== before.status) { + mismatches.push(`${before.label}: status was ${before.status}, now ${status}`); + } + if (blockNumber !== before.blockNumber) { + mismatches.push( + `${before.label}: block was ${before.blockNumber}, now ${blockNumber}`, + ); + } + } + expect(mismatches, 'pre-upgrade receipts must survive the upgrade unchanged').to.deep.equal( + [], + ); + }); + + it('still reports the same failure reason for transactions that failed before it', async () => { + const withReason = artifact.transactions.filter(t => t.vmError); + if (withReason.length === 0) { + console.log(' artifact recorded no failed transaction with a VM error'); + return; + } + + const mismatches: string[] = []; + for (const before of withReason) { + const now = await vmError(before.hash); + if (now === undefined) { + mismatches.push(`${before.label}: no VM error served any more`); + } else if (now !== before.vmError) { + mismatches.push( + `${before.label}: was ${JSON.stringify(before.vmError)}, now ${JSON.stringify(now)}`, + ); + } + } + expect( + mismatches, + 'the reason a pre-upgrade transaction failed must not change after the upgrade', + ).to.deep.equal([]); + }); + }); +}); diff --git a/scripts/cross_version_upgrade_test.sh b/scripts/cross_version_upgrade_test.sh new file mode 100755 index 0000000000..6e3bd1814d --- /dev/null +++ b/scripts/cross_version_upgrade_test.sh @@ -0,0 +1,478 @@ +#!/usr/bin/env bash +# +# Upgrade a local chain across two real binaries: seed state using modules that +# are alive on the old release, then swap in the new one and see what the +# upgrade does to it. +# +# Every other layer of upgrade testing in this repo runs one binary, and that +# binary is always the new one. So the "before" state is fake: head-of-tree +# cannot create a feegrant allowance or store an oracle vote, because the code +# that did is gone. The v6.7 handler has therefore only ever been run against +# empty feegrant and capability stores, which is precisely the condition under +# which a migration that mishandles their contents looks fine. +# +# This runs the old release for real: +# +# v6.6.2 (feegrant, capability and oracle all live) +# grant a fee allowance, spend it, submit oracle votes, let them tally +# -> pass a v6.7 software-upgrade proposal +# -> the old binary has no v6.7 handler, so it halts and writes upgrade-info.json +# swap the binary +# head (v6.7: feegrant and capability removed, oracle handlers deprecated) +# -> applies the upgrade against state those modules actually wrote +# +# Then it asks what changed: does the chain still produce blocks, are the module +# versions gone, is the seeded state still on disk, and does a transaction that +# succeeded before the upgrade now fail? +# +# Uses an isolated home under build/. Never touches ~/.sei. +# +# Usage: +# scripts/cross_version_upgrade_test.sh [--old-version v6.6.2] [--plan v6.7] [--keep] +# +# --old-version git tag to build the pre-upgrade binary from (default v6.6.2) +# --plan governance plan name to upgrade to (default v6.7) +# --keep leave the chain running and the home dir in place +# --skip-build reuse both binaries if already present + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +OLD_VERSION="v6.6.2" +PLAN="v6.7" +KEEP=0 +SKIP_BUILD=0 +while [ $# -gt 0 ]; do + case "$1" in + --old-version) OLD_VERSION="$2"; shift 2 ;; + --plan) PLAN="$2"; shift 2 ;; + --keep) KEEP=1; shift ;; + --skip-build) SKIP_BUILD=1; shift ;; + -h|--help) sed -n '2,38p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 64 ;; + esac +done + +# The module versions $PLAN deletes, as a space-separated set. Kept explicit so +# that a release which removes another module fails here until someone states +# the new expectation, rather than the test quietly accepting whatever it finds. +EXPECTED_REMOVED=${CROSS_UPGRADE_EXPECTED_REMOVED:-"capability feegrant ibc transfer"} + +WORK="$REPO_ROOT/build/cross-upgrade" +CHAIN_HOME="$WORK/home" +SHIM_DIR="$WORK/bin" +LOG="$WORK/seid.log" +OUT="$WORK/out" +CHAIN_ID="sei-cross-upgrade" +OLD_BIN="$REPO_ROOT/build/old/seid-$OLD_VERSION" +NEW_BIN="$REPO_ROOT/build/seid" + +# The old release is checked out *outside* the repository. A second Go source +# tree anywhere under the repo root gets picked up by `golangci-lint fmt`, whose +# formatters take no path exclusions, so an old release's files would fail +# `make fmtcheck` for everyone. The compiled binary can stay in build/ because +# the formatters only read source. +OLD_SRC_ROOT=${CROSS_UPGRADE_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/sei-chain/oldsrc} +OLD_SRC="$OLD_SRC_ROOT/$OLD_VERSION" + +# Which binary the shim currently points at. Every helper below calls a bare +# `seid`, so the swap is a single file rewrite rather than a variable every call +# site has to remember to use. +ACTIVE_BIN="" + +step() { printf '\n\033[1m==> %s\033[0m\n' "$1"; } +note() { printf ' %s\n' "$1"; } +fail() { printf '\033[31mFAIL: %s\033[0m\n' "$1" >&2; exit 1; } + +use_binary() { + ACTIVE_BIN="$1" + mkdir -p "$SHIM_DIR" + cat > "$SHIM_DIR/seid" </dev/null + for _ in $(seq 1 30); do + [ -z "$(node_pid)" ] && return 0 + sleep 1 + done + [ -n "$(node_pid)" ] && kill -9 $(node_pid) 2>/dev/null + sleep 1 + return 0 +} + +cleanup() { + if [ "$KEEP" = 1 ]; then + note "--keep: node left running against $CHAIN_HOME" + return + fi + stop_node +} +trap cleanup EXIT + +start_node() { + "$ACTIVE_BIN" start --home "$CHAIN_HOME" --chain-id "$CHAIN_ID" >> "$LOG" 2>&1 & + # Detach from job control so the expected kill at the upgrade height does not + # print a "Terminated" line into the middle of the report. + disown + for _ in $(seq 1 90); do + if seid status 2>/dev/null | jq -e '.SyncInfo.latest_block_height' >/dev/null 2>&1; then + return 0 + fi + [ -z "$(node_pid)" ] && return 1 + sleep 1 + done + return 1 +} + +height() { seid status 2>/dev/null | jq -r '.SyncInfo.latest_block_height' 2>/dev/null; } + +# Returns 0 when the target height is reached, 2 when the node died first. +wait_for_height() { + local target="$1" deadline=$((SECONDS + ${2:-180})) + while [ "$SECONDS" -lt "$deadline" ]; do + [ -z "$(node_pid)" ] && return 2 + local h; h=$(height) + if [[ "$h" =~ ^[0-9]+$ ]] && [ "$h" -ge "$target" ]; then return 0; fi + sleep 1 + done + return 1 +} + +wait_blocks() { local h; h=$(height); wait_for_height $((h + ${1:-1})) 60; } + +tx() { printf '12345678\n' | seid tx "$@" --keyring-backend test --chain-id "$CHAIN_ID" \ + --fees 200000usei --gas 2000000 -b sync -y --output json 2>&1; } + +# `seid export` writes startup logs to stdout ahead of the genesis document, so +# the JSON has to be cut out of the stream rather than read from line one. +export_genesis() { # export_genesis + local bin="$1" out="$2" + "$bin" export --home "$CHAIN_HOME" --chain-id "$CHAIN_ID" 2>"$out.err" \ + | sed -n '/^{/,$p' > "$out" + jq -e . "$out" >/dev/null 2>&1 +} + +# --------------------------------------------------------------------------- +step "Building both binaries" +# --------------------------------------------------------------------------- +mkdir -p "$WORK" "$OUT" "$(dirname "$OLD_BIN")" + +if [ "$SKIP_BUILD" = 1 ] && [ -x "$NEW_BIN" ]; then + note "reusing $NEW_BIN" +else + make build >/dev/null || fail "could not build the current binary" +fi +[ -x "$NEW_BIN" ] || fail "no binary at $NEW_BIN" + +# A binary built here links libwasmvm by an rpath into the source tree it was +# built from, so moving or deleting that tree leaves the binary unable to start. +# Treat an unrunnable binary as absent rather than reusing it, or the failure +# surfaces later as a confusing "this release cannot grant fee allowances". +if [ -x "$OLD_BIN" ] && ! "$OLD_BIN" version >/dev/null 2>&1; then + note "$OLD_BIN cannot start (its source tree moved); rebuilding" + rm -f "$OLD_BIN" +fi + +if [ -x "$OLD_BIN" ] && [ "$SKIP_BUILD" = 1 ]; then + note "reusing $OLD_BIN" +else + if [ ! -d "$OLD_SRC" ]; then + note "checking out $OLD_VERSION into $OLD_SRC" + mkdir -p "$OLD_SRC_ROOT" + git worktree add "$OLD_SRC" "$OLD_VERSION" >/dev/null 2>&1 \ + || fail "could not create a worktree at $OLD_VERSION" + fi + note "building $OLD_VERSION (a few minutes the first time)" + ( cd "$OLD_SRC" && go build -o "$OLD_BIN" ./cmd/seid ) >/dev/null 2>&1 \ + || fail "could not build $OLD_VERSION" +fi +[ -x "$OLD_BIN" ] || fail "no binary at $OLD_BIN" + +# The whole test rests on the two binaries differing in the right way: the old +# one must be able to grant a fee allowance and the new one must not. +"$OLD_BIN" version >/dev/null 2>&1 \ + || fail "$OLD_BIN will not start: $("$OLD_BIN" version 2>&1 | head -2 | tr '\n' ' ')" +"$OLD_BIN" tx feegrant --help 2>&1 | grep -q "Grant Fee allowance" \ + || fail "$OLD_VERSION cannot grant fee allowances, so it cannot seed the state this test needs" +"$NEW_BIN" tx feegrant --help 2>&1 | grep -q "Grant Fee allowance" \ + && fail "the current binary still has the feegrant CLI; there is nothing removed to test" +note "old binary grants fee allowances, current binary does not" + +# --------------------------------------------------------------------------- +step "Initialising a chain with $OLD_VERSION" +# --------------------------------------------------------------------------- +stop_node +rm -rf "$CHAIN_HOME" +mkdir -p "$CHAIN_HOME" +: > "$LOG" +use_binary "$OLD_BIN" +export PATH="$SHIM_DIR:$PATH" + +seid init cross-upgrade --chain-id "$CHAIN_ID" --overwrite >/dev/null 2>&1 +seid config keyring-backend test >/dev/null 2>&1 +seid config chain-id "$CHAIN_ID" >/dev/null 2>&1 + +for key in admin granter grantee; do + seid keys add "$key" --keyring-backend test >/dev/null 2>&1 || fail "could not create key $key" +done +ADMIN=$(seid keys show admin -a --keyring-backend test) +GRANTER=$(seid keys show granter -a --keyring-backend test) +GRANTEE=$(seid keys show grantee -a --keyring-backend test) +VALOPER=$(seid keys show admin --bech val -a --keyring-backend test) + +seid add-genesis-account "$ADMIN" 1000000000000000000usei --keyring-backend test >/dev/null +seid add-genesis-account "$GRANTER" 1000000000000000usei --keyring-backend test >/dev/null +# Deliberately small: the grantee must not be able to pay its own fees, so a +# transaction that only works because of the allowance is unambiguous. +seid add-genesis-account "$GRANTEE" 100000usei --keyring-backend test >/dev/null +seid gentx admin 70000000000000usei --chain-id "$CHAIN_ID" --keyring-backend test >/dev/null 2>&1 + +GENESIS="$CHAIN_HOME/config/genesis.json" +VAL_KEY=$(jq '.pub_key' "$CHAIN_HOME/config/priv_validator_key.json" -c) +jq --argjson k "$VAL_KEY" '.validators = [{"power":"70000000","pub_key":$k}]' "$GENESIS" > "$GENESIS.t" && mv "$GENESIS.t" "$GENESIS" +seid collect-gentxs >/dev/null 2>&1 + +jq ' + .app_state.gov.deposit_params.max_deposit_period = "60s" + | .app_state.gov.voting_params.voting_period = "12s" + | .app_state.gov.voting_params.expedited_voting_period = "6s" + | .app_state.gov.tally_params.quorum = "0.100000000000000000" + | .app_state.gov.tally_params.expedited_quorum = "0.100000000000000000" + | .app_state.oracle.params.vote_period = "2" + | .app_state.oracle.params.whitelist = [{"name":"uatom"},{"name":"ueth"}] + | .app_state.distribution.params.community_tax = "0.000000000000000000" + | .consensus_params.block.max_gas = "35000000" + | .consensus_params.block.max_gas_wanted = "50000000" + | .consensus_params.timeout.commit = "500000000" +' "$GENESIS" > "$GENESIS.t" && mv "$GENESIS.t" "$GENESIS" + +APP_TOML="$CHAIN_HOME/config/app.toml" +sed -i.bak -e 's/^sc-enable = .*/sc-enable = true/' -e 's/^ss-enable = .*/ss-enable = true/' "$APP_TOML" +CONFIG_TOML="$CHAIN_HOME/config/config.toml" +sed -i.bak -e 's/^mode = "full"/mode = "validator"/' -e 's/^indexer = \["null"\]/indexer = ["kv"]/' "$CONFIG_TOML" +grep -q '^mode = "validator"' "$CONFIG_TOML" || fail "could not switch the node to validator mode" + +# Genesis written by the old binary must carry the sections for the modules the +# upgrade removes, or there is nothing to seed into. +for section in feegrant capability oracle; do + jq -e --arg s "$section" '.app_state[$s]' "$GENESIS" >/dev/null \ + || fail "$OLD_VERSION genesis has no $section section" +done +note "genesis carries feegrant, capability and oracle sections" + +start_node || fail "the $OLD_VERSION node did not start; see $LOG" +wait_for_height 3 90 >/dev/null || fail "no blocks from $OLD_VERSION; see $LOG" +note "running $OLD_VERSION at height $(height)" + +# --------------------------------------------------------------------------- +step "Seeding state with modules that are alive on $OLD_VERSION" +# --------------------------------------------------------------------------- + +# --- feegrant: a real allowance, then actually spend it --- +RESP=$(tx feegrant grant "$GRANTER" "$GRANTEE" --spend-limit 100000000usei --from granter) +[ "$(echo "$RESP" | jq -r '.code // 1')" = "0" ] \ + || fail "could not grant a fee allowance: $(echo "$RESP" | jq -r '.raw_log // .')" +wait_blocks 2 >/dev/null + +GRANT=$(seid q feegrant grant "$GRANTER" "$GRANTEE" -o json 2>&1) +echo "$GRANT" | jq -e '.allowance' >/dev/null 2>&1 \ + || fail "the allowance is not in state after granting: $GRANT" +note "feegrant allowance $GRANTER -> $GRANTEE is in state" + +# The grantee has almost no balance, so this can only pay its fee through the +# allowance. Succeeding here is what proves the grant is live, and it is the +# behaviour the upgrade takes away. +GRANTEE_BAL_BEFORE=$(seid q bank balances "$GRANTEE" --denom usei -o json 2>/dev/null | jq -r '.amount') +RESP=$(tx bank send "$GRANTEE" "$ADMIN" 1usei --fee-account "$GRANTER" --from grantee) +[ "$(echo "$RESP" | jq -r '.code // 1')" = "0" ] \ + || fail "a fee-granted send was rejected on $OLD_VERSION: $(echo "$RESP" | jq -r '.raw_log // .')" +wait_blocks 2 >/dev/null +GRANTEE_BAL_AFTER=$(seid q bank balances "$GRANTEE" --denom usei -o json 2>/dev/null | jq -r '.amount') +note "fee-granted send accepted on $OLD_VERSION (grantee balance $GRANTEE_BAL_BEFORE -> $GRANTEE_BAL_AFTER)" + +# --- oracle: real votes, tallied into exchange rates --- +for i in 1 2 3; do + tx oracle aggregate-vote "1.${i}uatom,2.${i}ueth" "$VALOPER" --from admin >/dev/null + wait_blocks 2 >/dev/null +done +RATES=$(seid q oracle exchange-rates -o json 2>&1) +echo "$RATES" | jq -e '.denom_oracle_exchange_rate_pairs | length > 0' >/dev/null 2>&1 \ + || fail "oracle votes did not tally into exchange rates on $OLD_VERSION: $RATES" +note "oracle exchange rates present: $(echo "$RATES" | jq -c '[.denom_oracle_exchange_rate_pairs[].denom]')" + +# --------------------------------------------------------------------------- +step "Recording what $OLD_VERSION holds" +# --------------------------------------------------------------------------- +seid q feegrant grant "$GRANTER" "$GRANTEE" -o json > "$OUT/old-grant.json" 2>&1 +seid q oracle exchange-rates -o json > "$OUT/old-rates.json" 2>&1 +OLD_MODULE_VERSIONS=$(seid q upgrade module_versions -o json 2>/dev/null | jq -r '[.module_versions[].name] | sort | join(" ")') +echo "$OLD_MODULE_VERSIONS" > "$OUT/old-modules.txt" +note "module versions: $OLD_MODULE_VERSIONS" +for m in feegrant capability oracle; do + echo "$OLD_MODULE_VERSIONS" | grep -qw "$m" || fail "$m has no module version on $OLD_VERSION" +done + +# --------------------------------------------------------------------------- +step "Passing a software-upgrade proposal for $PLAN" +# --------------------------------------------------------------------------- +TARGET=$(( $(height) + 25 )) +RESP=$(tx gov submit-proposal software-upgrade "$PLAN" --title "$PLAN" \ + --description "cross version upgrade test" --upgrade-height "$TARGET" \ + --deposit 20000000usei --is-expedited --from admin) +[ "$(echo "$RESP" | jq -r '.code // 1')" = "0" ] \ + || fail "could not submit the proposal: $(echo "$RESP" | jq -r '.raw_log // .')" +sleep 3 +PROPOSAL=$(seid q gov proposals --reverse --limit 1 -o json 2>/dev/null | jq -r '.proposals[0].proposal_id // .proposals[0].id') +[ -n "$PROPOSAL" ] && [ "$PROPOSAL" != "null" ] || fail "no proposal in gov state" +tx gov vote "$PROPOSAL" yes --from admin >/dev/null + +STATUS="" +for _ in $(seq 1 40); do + STATUS=$(seid q gov proposal "$PROPOSAL" -o json 2>/dev/null | jq -r '.status // ""') + [ "$STATUS" = "PROPOSAL_STATUS_PASSED" ] && break + case "$STATUS" in + PROPOSAL_STATUS_REJECTED|PROPOSAL_STATUS_FAILED) fail "proposal reached $STATUS" ;; + esac + sleep 1 +done +[ "$STATUS" = "PROPOSAL_STATUS_PASSED" ] || fail "proposal did not pass (last status ${STATUS:-unknown})" +note "proposal $PROPOSAL passed, targeting height $TARGET" + +# --------------------------------------------------------------------------- +step "Letting $OLD_VERSION halt at $TARGET" +# --------------------------------------------------------------------------- +# The old binary has no handler for $PLAN, so it must stop rather than produce +# the block. This is the halt a validator on the old release sees. +wait_for_height "$TARGET" 240 +case $? in + 2) note "halted, as a node without the $PLAN handler must" ;; + 0) fail "$OLD_VERSION produced block $TARGET without a $PLAN handler" ;; + *) fail "$OLD_VERSION neither halted nor reached $TARGET; see $LOG" ;; +esac +grep -q "UPGRADE \"$PLAN\" NEEDED" "$LOG" || fail "the halt was not an upgrade panic; see $LOG" +[ -f "$CHAIN_HOME/data/upgrade-info.json" ] || fail "no upgrade-info.json was written" +note "upgrade-info.json: $(cat "$CHAIN_HOME/data/upgrade-info.json")" + +# With the node down, export the state the old binary understands. This is the +# only chance to capture it with a binary that still has these modules. +step "Exporting state with $OLD_VERSION while it is stopped" +export_genesis "$OLD_BIN" "$OUT/pre-upgrade-by-old.json" \ + || fail "could not export with $OLD_VERSION: $(head -1 "$OUT/pre-upgrade-by-old.json.err")" +jq -e '.app_state.feegrant.allowances | length > 0' "$OUT/pre-upgrade-by-old.json" >/dev/null \ + || fail "the pre-upgrade export carries no feegrant allowance, so nothing was seeded" +note "feegrant: $(jq -c '.app_state.feegrant.allowances[0].allowance.spend_limit' "$OUT/pre-upgrade-by-old.json")" +note "capability: $(jq -c '.app_state.capability | {index, owners: (.owners | length)}' "$OUT/pre-upgrade-by-old.json")" +note "oracle rates: $(jq -c '[.app_state.oracle.exchange_rates[] | .denom + "=" + .exchange_rate]' "$OUT/pre-upgrade-by-old.json")" + +# --------------------------------------------------------------------------- +step "Swapping in the current binary and restarting" +# --------------------------------------------------------------------------- +use_binary "$NEW_BIN" +start_node || fail "the new binary did not start; see $LOG" +wait_for_height $((TARGET + 3)) 180 || fail "the new binary did not advance past $TARGET; see $LOG" +note "advanced to height $(height) on the current binary" +grep -q "BINARY UPDATED BEFORE TRIGGER" "$LOG" \ + && fail "the new binary refused the chain state; see $LOG" + +APPLIED=$(seid q upgrade applied "$PLAN" 2>/dev/null | jq -r '.header.height // 0') +[[ "$APPLIED" =~ ^[0-9]+$ ]] && [ "$APPLIED" -gt 0 ] \ + || fail "the chain does not report $PLAN as applied, so the handler never ran" +note "$PLAN applied at height $APPLIED" + +# --------------------------------------------------------------------------- +step "What the upgrade did to the seeded state" +# --------------------------------------------------------------------------- +FAILURES=0 +check() { # check + if [ "$2" = 0 ]; then printf ' \033[32mok\033[0m %s\n' "$1" + else printf ' \033[31mBAD\033[0m %s\n' "$1"; FAILURES=$((FAILURES + 1)); fi +} + +NEW_MODULE_VERSIONS=$(seid q upgrade module_versions -o json 2>/dev/null | jq -r '[.module_versions[].name] | sort | join(" ")') +note "module versions now: $NEW_MODULE_VERSIONS" + +for m in $EXPECTED_REMOVED; do + echo "$NEW_MODULE_VERSIONS" | grep -qw "$m" + [ $? = 1 ]; check "$m module version removed" $? +done +echo "$NEW_MODULE_VERSIONS" | grep -qw oracle +check "oracle keeps its module version (deprecated, not removed)" $? + +REMOVED=$(comm -23 <(tr ' ' '\n' <<<"$OLD_MODULE_VERSIONS" | sort -u) <(tr ' ' '\n' <<<"$NEW_MODULE_VERSIONS" | sort -u) | tr '\n' ' ' | xargs) +WANT=$(tr ' ' '\n' <<<"$EXPECTED_REMOVED" | sort -u | tr '\n' ' ' | xargs) +[ "$REMOVED" = "$WANT" ] +check "exactly the expected modules were removed (want: $WANT / got: ${REMOVED:-none})" $? + +# The behaviour change a user sees: the same fee-granted send that worked before +# the upgrade must now be refused, and refused for a stated reason. +RESP=$(tx bank send "$GRANTEE" "$ADMIN" 1usei --fee-account "$GRANTER" --from grantee) +echo "$RESP" | grep -q "fee grants are not enabled" +check "the fee-granted send that worked before is now refused: fee grants are not enabled" $? + +# Oracle rates seeded on the old binary are no longer queryable. +RATES_NOW=$(seid q oracle exchange-rates -o json 2>&1) +echo "$RATES_NOW" | grep -q "oracle module is deprecated" +check "oracle exchange rates are no longer queryable (handler deprecated)" $? + +# ...and the chain is still producing blocks with all of that behind it. +wait_blocks 3 >/dev/null +check "chain still producing blocks after the upgrade" $? + +step "Where the seeded state ended up" +stop_node + +# Three exports answer the question the release notes raise. The new binary +# cannot emit a section for a module it no longer registers, so its export +# alone cannot distinguish "kept on disk but unreachable" from "deleted". The +# old binary still has the feegrant and capability modules, so pointing it at +# the upgraded state reads whatever the upgrade left behind. +export_genesis "$NEW_BIN" "$OUT/post-upgrade-by-new.json" \ + || fail "could not export with the current binary: $(head -1 "$OUT/post-upgrade-by-new.json.err")" +export_genesis "$OLD_BIN" "$OUT/post-upgrade-by-old.json" \ + || note "the old binary could not read the upgraded state: $(head -1 "$OUT/post-upgrade-by-old.json.err")" + +for section in feegrant capability; do + jq -e --arg s "$section" '.app_state[$s]' "$OUT/post-upgrade-by-new.json" >/dev/null 2>&1 + [ $? = 1 ]; check "$section is absent from the export the current binary produces" $? +done + +# Oracle is deprecated rather than removed, so it is still a registered module +# and its state still exports. That contrast is the point: deprecating a module +# and removing one leave state in very different places. +jq -e '.app_state.oracle.exchange_rates | length > 0' "$OUT/post-upgrade-by-new.json" >/dev/null 2>&1 +check "oracle rates seeded on $OLD_VERSION still export after the upgrade" $? + +if jq -e . "$OUT/post-upgrade-by-old.json" >/dev/null 2>&1; then + RETAINED=$(jq -r '.app_state.feegrant.allowances | length' "$OUT/post-upgrade-by-old.json" 2>/dev/null) + if [ "${RETAINED:-0}" -gt 0 ]; then + check "the allowance survives on disk: $OLD_VERSION reads it back out of the upgraded state" 0 + note "spend limit before: $(jq -c '.app_state.feegrant.allowances[0].allowance.spend_limit' "$OUT/pre-upgrade-by-old.json")" + note "spend limit after: $(jq -c '.app_state.feegrant.allowances[0].allowance.spend_limit' "$OUT/post-upgrade-by-old.json")" + note "no code on the current binary can reach it, and no export carries it forward" + else + check "the upgrade dropped the feegrant allowance from the store" 1 + note "the stores are documented as retained for historical access, so an empty" + note " read here contradicts that and is application-hash relevant" + fi +fi + +# --------------------------------------------------------------------------- +step "Result" +# --------------------------------------------------------------------------- +note "artefacts in $OUT" +if [ "$FAILURES" -gt 0 ]; then + fail "$FAILURES check(s) did not hold across the $OLD_VERSION -> $PLAN upgrade" +fi +printf '\n\033[32mPASS\033[0m %s -> %s applied at height %s against state the removed modules actually wrote.\n' \ + "$OLD_VERSION" "$PLAN" "$APPLIED" diff --git a/scripts/local_upgrade_test.sh b/scripts/local_upgrade_test.sh new file mode 100755 index 0000000000..3e2fabb71c --- /dev/null +++ b/scripts/local_upgrade_test.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# +# Drive a real governance software upgrade on a single-node local chain, and +# compare how the chain answers for the modules the upgrade retires on either +# side of it. +# +# This is the cheapest layer that reaches the real upgrade machinery. The +# in-process Go tests call ApplyUpgrade directly, which never writes +# upgrade-info.json, never panics, and never reloads the stores, so the code +# that decides whether a store is dropped at an upgrade height +# (App.SetStoreUpgradeHandlers) is not reached by any of them. The docker suite +# does reach it but needs a cluster. This reaches it with one node on a laptop: +# +# init -> start on a version list without the plan -> probe -> pass a +# software-upgrade proposal -> node panics at the height -> restart with the +# plan in the list, which is what runs SetStoreUpgradeHandlers and the upgrade +# handler -> probe -> require both probes to agree. +# +# Note what this does NOT do: both processes are this binary, so the modules the +# upgrade retires are already gone before the chain starts and the pre-upgrade +# state is empty. Use scripts/cross_version_upgrade_test.sh when the point is to +# seed state with modules that were alive on the previous release. +# +# Everything lives under an isolated home directory. It never touches ~/.sei. +# +# Usage: +# scripts/local_upgrade_test.sh [--version v6.7] [--keep] [--skip-build] + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +VERSION="v6.7" +KEEP=0 +SKIP_BUILD=0 +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --keep) KEEP=1; shift ;; + --skip-build) SKIP_BUILD=1; shift ;; + -h|--help) sed -n '2,27p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 64 ;; + esac +done + +# The version list the node starts on. It deliberately excludes $VERSION so the +# first process does not know the upgrade: that is what makes it panic at the +# height and write upgrade-info.json, exactly as a validator on an old release +# does. Overridable so the halt guard itself can be exercised — starting with +# $VERSION already present must be reported, not accepted. +BASE_VERSION_LIST=${LOCAL_UPGRADE_BASE_LIST:-v1.0.0} + +# Modules $VERSION removes from the module version map. Each must answer +# not-found afterwards; one that survives means the handler is missing a +# DeleteModuleVersion call for it. +RETIRED_MODULES=${LOCAL_UPGRADE_RETIRED_MODULES:-"capability feegrant ibc transfer"} + +WORK_DIR="$REPO_ROOT/build/generated/local-upgrade" +CHAIN_HOME="$WORK_DIR/home" +SHIM_DIR="$WORK_DIR/bin" +LOG="$WORK_DIR/seid.log" +CHAIN_ID="sei-local-upgrade" +ADMIN_KEY="admin" +GRANTER_KEY="granter" +SEID="$REPO_ROOT/build/seid" + +step() { printf '\n\033[1m==> %s\033[0m\n' "$1"; } +note() { printf ' %s\n' "$1"; } +fail() { printf '\033[31mFAIL: %s\033[0m\n' "$1" >&2; exit 1; } + +node_pid() { pgrep -f "seid start --home $CHAIN_HOME" || true; } + +stop_node() { + local pid + pid=$(node_pid) + [ -n "$pid" ] && kill $pid 2>/dev/null + for _ in $(seq 1 30); do + [ -z "$(node_pid)" ] && return 0 + sleep 1 + done + [ -n "$(node_pid)" ] && kill -9 $(node_pid) 2>/dev/null + return 0 +} + +cleanup() { + if [ "$KEEP" = 1 ]; then + note "--keep: node left running against $CHAIN_HOME" + note " stop it with: pkill -f 'seid start --home $CHAIN_HOME'" + return + fi + stop_node +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +step "Building seid" +# --------------------------------------------------------------------------- +if [ "$SKIP_BUILD" = 1 ] && [ -x "$SEID" ]; then + note "reusing $SEID" +else + make build >/dev/null || fail "make build" +fi +[ -x "$SEID" ] || fail "no binary at $SEID" + +# A shim on PATH that pins --home, so the probe script and the helpers it +# sources can call a bare `seid` without knowing about this chain's home. This +# is the same indirection the in-process YAML runner uses. +mkdir -p "$SHIM_DIR" +cat > "$SHIM_DIR/seid" < "$LOG" + +seid init local-upgrade --chain-id "$CHAIN_ID" --overwrite >/dev/null 2>&1 +seid config keyring-backend test >/dev/null 2>&1 +seid config chain-id "$CHAIN_ID" >/dev/null 2>&1 + +for key in "$ADMIN_KEY" "$GRANTER_KEY"; do + seid keys add "$key" --keyring-backend test >/dev/null 2>&1 \ + || fail "could not create key $key" +done +ADMIN_ADDR=$(seid keys show "$ADMIN_KEY" -a --keyring-backend test) +GRANTER_ADDR=$(seid keys show "$GRANTER_KEY" -a --keyring-backend test) + +seid add-genesis-account "$ADMIN_ADDR" 1000000000000000000usei --keyring-backend test >/dev/null +seid add-genesis-account "$GRANTER_ADDR" 1000000000000usei --keyring-backend test >/dev/null +seid gentx "$ADMIN_KEY" 70000000000000usei --chain-id "$CHAIN_ID" --keyring-backend test >/dev/null 2>&1 + +# Pin the single validator into genesis, matching scripts/initialize_local_chain.sh. +GENESIS="$CHAIN_HOME/config/genesis.json" +VAL_KEY=$(jq '.pub_key' "$CHAIN_HOME/config/priv_validator_key.json" -c) +jq --argjson k "$VAL_KEY" '.validators = [{"power":"70000000","pub_key":$k}]' "$GENESIS" > "$GENESIS.tmp" && mv "$GENESIS.tmp" "$GENESIS" +seid collect-gentxs >/dev/null 2>&1 + +# Governance has to resolve in seconds, not days, for this to be a local test. +jq ' + .app_state.gov.deposit_params.max_deposit_period = "60s" + | .app_state.gov.voting_params.voting_period = "12s" + | .app_state.gov.voting_params.expedited_voting_period = "6s" + | .app_state.gov.tally_params.quorum = "0.100000000000000000" + | .app_state.gov.tally_params.expedited_quorum = "0.100000000000000000" + | .app_state.distribution.params.community_tax = "0.000000000000000000" + | .consensus_params.block.max_gas = "35000000" + | .consensus_params.block.max_gas_wanted = "50000000" + | .consensus_params.timeout.commit = "500000000" +' "$GENESIS" > "$GENESIS.tmp" && mv "$GENESIS.tmp" "$GENESIS" + +APP_TOML="$CHAIN_HOME/config/app.toml" +sed -i.bak -e 's/^sc-enable = .*/sc-enable = true/' -e 's/^ss-enable = .*/ss-enable = true/' "$APP_TOML" +sed -i.bak -e 's/^occ-enabled = .*/occ-enabled = true/' "$APP_TOML" + +# A lone validator has no peers to block-sync from, so in the default "full" +# mode it waits for a peer height that never arrives and never produces a block. +CONFIG_TOML="$CHAIN_HOME/config/config.toml" +sed -i.bak -e 's/^mode = "full"/mode = "validator"/' "$CONFIG_TOML" +sed -i.bak -e 's/^indexer = \["null"\]/indexer = ["kv"]/' "$CONFIG_TOML" +grep -q '^mode = "validator"' "$CONFIG_TOML" || fail "could not switch the node to validator mode" + +start_node() { + local version_list="$1" + UPGRADE_VERSION_LIST="$version_list" \ + "$SEID" start --home "$CHAIN_HOME" --chain-id "$CHAIN_ID" >> "$LOG" 2>&1 & + disown + for _ in $(seq 1 60); do + if seid status 2>/dev/null | jq -e '.SyncInfo.latest_block_height' >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + +height() { seid status 2>/dev/null | jq -r '.SyncInfo.latest_block_height' 2>/dev/null; } + +wait_for_height() { + local target="$1" deadline=$((SECONDS + ${2:-120})) + while [ "$SECONDS" -lt "$deadline" ]; do + local h; h=$(height) + [ -z "$(node_pid)" ] && return 2 # the node died, which may be the point + if [[ "$h" =~ ^[0-9]+$ ]] && [ "$h" -ge "$target" ]; then return 0; fi + sleep 1 + done + return 1 +} + +# --------------------------------------------------------------------------- +step "Starting the node without $VERSION in its version list" +# --------------------------------------------------------------------------- +start_node "$BASE_VERSION_LIST" || fail "node did not start; see $LOG" +wait_for_height 3 60 || fail "node did not reach height 3; see $LOG" +note "running at height $(height) on UPGRADE_VERSION_LIST=$BASE_VERSION_LIST" + +# --------------------------------------------------------------------------- +step "Probing retired modules BEFORE the upgrade" +# --------------------------------------------------------------------------- +PROBE=integration_test/upgrade_module/scripts/probe_retired_modules.sh +BEFORE=$(CHAIN_ID="$CHAIN_ID" ADMIN_KEY="$ADMIN_KEY" bash "$PROBE" "$GRANTER_ADDR" 3) +echo "$BEFORE" +echo "$BEFORE" | grep -q '^PROBE_RESULT=ALL_MATCH$' \ + || fail "the pre-upgrade probe did not get the answers it expected, so the comparison below would be meaningless" + +# --------------------------------------------------------------------------- +step "Passing a software-upgrade proposal for $VERSION" +# --------------------------------------------------------------------------- +TARGET_HEIGHT=$(( $(height) + 25 )) +seid tx gov submit-proposal software-upgrade "$VERSION" \ + --title "$VERSION" --description "local upgrade test" \ + --upgrade-height "$TARGET_HEIGHT" --deposit 20000000usei --is-expedited \ + --from "$ADMIN_KEY" --keyring-backend test --chain-id "$CHAIN_ID" \ + --fees 200000usei -b sync -y --output json >/dev/null 2>&1 \ + || fail "could not submit the upgrade proposal" + +sleep 3 +PROPOSAL_ID=$(seid q gov proposals --reverse --limit 1 -o json 2>/dev/null | jq -r '.proposals[0].proposal_id // .proposals[0].id') +[ -n "$PROPOSAL_ID" ] && [ "$PROPOSAL_ID" != "null" ] || fail "no proposal appeared in gov state" +note "proposal $PROPOSAL_ID targets height $TARGET_HEIGHT" + +seid tx gov vote "$PROPOSAL_ID" yes --from "$ADMIN_KEY" --keyring-backend test \ + --chain-id "$CHAIN_ID" --fees 200000usei -b sync -y --output json >/dev/null 2>&1 \ + || fail "could not vote" + +STATUS="" +for _ in $(seq 1 40); do + STATUS=$(seid q gov proposal "$PROPOSAL_ID" -o json 2>/dev/null | jq -r '.status // ""') + [ "$STATUS" = "PROPOSAL_STATUS_PASSED" ] && break + case "$STATUS" in + PROPOSAL_STATUS_REJECTED|PROPOSAL_STATUS_FAILED) fail "proposal reached $STATUS" ;; + esac + sleep 1 +done +[ "$STATUS" = "PROPOSAL_STATUS_PASSED" ] || fail "proposal did not pass (last status ${STATUS:-unknown})" +note "proposal passed" + +# --------------------------------------------------------------------------- +step "Letting the node reach $TARGET_HEIGHT without the upgrade" +# --------------------------------------------------------------------------- +# A node that does not know the plan name must halt here rather than produce a +# block. Continuing past it would mean the upgrade was silently skipped. +wait_for_height "$TARGET_HEIGHT" 180 +case $? in + 2) note "node halted as expected" ;; + 0) fail "node produced block $TARGET_HEIGHT without knowing $VERSION; the upgrade was skipped" ;; + *) fail "node neither halted nor reached $TARGET_HEIGHT; see $LOG" ;; +esac +grep -q "UPGRADE \"$VERSION\" NEEDED" "$LOG" \ + || fail "the halt was not an upgrade panic; see $LOG" +# A node that already knows the plan refuses to start at all, with "BINARY +# UPDATED BEFORE TRIGGER". Reaching that here would mean the version list was +# wrong and this run proved nothing about the upgrade. +grep -q "BINARY UPDATED BEFORE TRIGGER" "$LOG" \ + && fail "the node knew $VERSION before the chain executed it; start it on a version list without $VERSION" +[ -f "$CHAIN_HOME/data/upgrade-info.json" ] \ + || fail "the halted node did not write upgrade-info.json, so a restart cannot apply store upgrades" +note "upgrade-info.json: $(cat "$CHAIN_HOME/data/upgrade-info.json")" + +# --------------------------------------------------------------------------- +step "Restarting with $VERSION, which runs the handler and the store upgrades" +# --------------------------------------------------------------------------- +start_node "$BASE_VERSION_LIST,$VERSION" || fail "node did not come back up; see $LOG" +wait_for_height $((TARGET_HEIGHT + 3)) 120 \ + || fail "node did not advance past the upgrade height; see $LOG" +note "advanced to height $(height)" + +# `q upgrade applied` prints the block header of the height the plan ran at, so +# the height is nested. A zero or unreadable answer means the handler never ran, +# which would make every comparison below a comparison against itself. +APPLIED=$(seid q upgrade applied "$VERSION" 2>/dev/null | jq -r '.header.height // 0') +[[ "$APPLIED" =~ ^[0-9]+$ ]] && [ "$APPLIED" -gt 0 ] \ + || fail "the chain does not report $VERSION as applied, so the upgrade handler never ran" +note "applied plan $VERSION at height $APPLIED" + +# --------------------------------------------------------------------------- +step "Probing retired modules AFTER the upgrade" +# --------------------------------------------------------------------------- +AFTER=$(CHAIN_ID="$CHAIN_ID" ADMIN_KEY="$ADMIN_KEY" bash "$PROBE" "$GRANTER_ADDR" 3) +echo "$AFTER" + +# --------------------------------------------------------------------------- +step "Result" +# --------------------------------------------------------------------------- +echo "$AFTER" | grep -q '^PROBE_RESULT=ALL_MATCH$' \ + || fail "a retired surface stopped answering as expected after the upgrade" + +if [ "$BEFORE" != "$AFTER" ]; then + printf '\033[31mthe chain answered differently after the upgrade:\033[0m\n' + diff <(echo "$BEFORE") <(echo "$AFTER") || true + fail "retiring a module changed how the chain answers a client" +fi + +# The modules this upgrade retires must be gone from the module version map. The +# keeper answers a named lookup for a missing module with a not-found error, so +# that error is the expected post-removal answer. +for module in $RETIRED_MODULES; do + ANSWER=$(seid q upgrade module_versions "$module" -o json 2>&1 || true) + case "$ANSWER" in + *"not found"*) note "$module module version removed" ;; + *) fail "$module still holds a module version after $VERSION: $ANSWER" ;; + esac +done + +printf '\n\033[32mPASS\033[0m %s applied at height %s; every retired surface answered identically before and after.\n' \ + "$VERSION" "$APPLIED" diff --git a/testutil/processblock/common.go b/testutil/processblock/common.go index 2bf1943211..2dd572fe81 100644 --- a/testutil/processblock/common.go +++ b/testutil/processblock/common.go @@ -70,6 +70,13 @@ func (a *App) Ctx() sdk.Context { // Assumes all validators voted with equal weight, and there are no byzantine validators. // Proposer is rotated among all validators round-robin. func (a *App) RunBlock(txs []signing.Tx) (resultCodes []uint32) { + return utils.Map(a.RunBlockDetailed(txs), func(r *types.ExecTxResult) uint32 { return r.Code }) +} + +// RunBlockDetailed processes and commits a block of transactions the same way +// RunBlock does, returning the full per-transaction results. Callers that need +// the error codespace, log, or gas of a rejected transaction use this. +func (a *App) RunBlockDetailed(txs []signing.Tx) []*types.ExecTxResult { defer func() { a.lastCtx = a.GetContextForDeliverTx([]byte{}) // Commit will set deliver tx ctx to nil so we need to cache it here for testing queries before the next block is FinalizeBlock'ed (which will set deliver tx ctx) _, err := a.Commit(context.Background()) @@ -105,7 +112,7 @@ func (a *App) RunBlock(txs []signing.Tx) (resultCodes []uint32) { if err != nil { panic(err) } - return utils.Map(res.TxResults, func(r *types.ExecTxResult) uint32 { return r.Code }) + return res.TxResults } func (a *App) GetVotes() []types.VoteInfo { diff --git a/testutil/processblock/tx.go b/testutil/processblock/tx.go index 1cdcfa5f80..cc5177548e 100644 --- a/testutil/processblock/tx.go +++ b/testutil/processblock/tx.go @@ -16,6 +16,17 @@ var Marshaler = codec.NewProtoCodec(InterfaceReg) var TxConfig = tx.NewTxConfig(Marshaler, tx.DefaultSignModes) func (a *App) Sign(account sdk.AccAddress, fee int64, msgs ...sdk.Msg) xauthsigning.Tx { + return a.sign(account, nil, fee, msgs...) +} + +// SignWithFeeGranter signs a transaction that nominates feeGranter to pay its +// fee. Passing a feeGranter other than account produces the wire shape a +// feegrant-using client sent before the module was removed. +func (a *App) SignWithFeeGranter(account, feeGranter sdk.AccAddress, fee int64, msgs ...sdk.Msg) xauthsigning.Tx { + return a.sign(account, feeGranter, fee, msgs...) +} + +func (a *App) sign(account, feeGranter sdk.AccAddress, fee int64, msgs ...sdk.Msg) xauthsigning.Tx { txBuilder := TxConfig.NewTxBuilder() if err := txBuilder.SetMsgs(msgs...); err != nil { panic(err) @@ -24,6 +35,9 @@ func (a *App) Sign(account sdk.AccAddress, fee int64, msgs ...sdk.Msg) xauthsign txBuilder.SetFeeAmount([]sdk.Coin{ sdk.NewCoin("usei", sdk.NewInt(fee)), }) + if feeGranter != nil { + txBuilder.SetFeeGranter(feeGranter) + } acc := a.AccountKeeper.GetAccount(a.Ctx(), account) seqNum := acc.GetSequence() From 63d9b4e74f35fe225b9302f2313b85b33d031aa4 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 27 Aug 2026 18:01:34 +0200 Subject: [PATCH 2/3] Consolidate v6.7 coverage in the release upgrade test Extend the four-validator two-binary runner with real feegrant and oracle state plus v6.7 retirement assertions. Remove the overlapping local, YAML, and live-network harnesses. Co-authored-by: Cursor --- .github/scripts/release-upgrade-test.sh | 529 +++-- .../workflows/integration-test-matrix.json | 7 - .github/workflows/release-upgrade-test.yml | 24 +- .github/workflows/upgrade_tests.yml | 112 -- Makefile | 20 +- docker/docker-compose.yml | 4 + docker/localnode/scripts/step2_genesis.sh | 3 +- integration_test/runner/runner_test.go | 7 - .../retired_modules_upgrade_test.yaml | 145 -- .../scripts/probe_retired_modules.sh | 133 -- integration_test/upgrade_tests/.gitignore | 2 - .../upgrade_tests/.mocharc.record.json | 6 - .../upgrade_tests/.mocharc.selftest.json | 6 - .../upgrade_tests/.mocharc.verify.json | 6 - integration_test/upgrade_tests/README.md | 104 - .../upgrade_tests/config/targets.ts | 98 - .../upgrade_tests/package-lock.json | 1697 ----------------- integration_test/upgrade_tests/package.json | 25 - .../upgrade_tests/record/record.spec.ts | 110 -- .../upgrade_tests/selftest/harness.spec.ts | 322 ---- integration_test/upgrade_tests/tsconfig.json | 18 - .../upgrade_tests/utils/artifact.ts | 132 -- integration_test/upgrade_tests/utils/chain.ts | 100 - .../upgrade_tests/utils/cosmos.ts | 136 -- .../upgrade_tests/utils/probes.ts | 288 --- .../upgrade_tests/utils/txProbes.ts | 89 - .../upgrade_tests/verify/verify.spec.ts | 251 --- scripts/cross_version_upgrade_test.sh | 478 ----- scripts/local_upgrade_test.sh | 317 --- 29 files changed, 435 insertions(+), 4734 deletions(-) delete mode 100644 .github/workflows/upgrade_tests.yml delete mode 100644 integration_test/upgrade_module/retired_modules_upgrade_test.yaml delete mode 100755 integration_test/upgrade_module/scripts/probe_retired_modules.sh delete mode 100644 integration_test/upgrade_tests/.gitignore delete mode 100644 integration_test/upgrade_tests/.mocharc.record.json delete mode 100644 integration_test/upgrade_tests/.mocharc.selftest.json delete mode 100644 integration_test/upgrade_tests/.mocharc.verify.json delete mode 100644 integration_test/upgrade_tests/README.md delete mode 100644 integration_test/upgrade_tests/config/targets.ts delete mode 100644 integration_test/upgrade_tests/package-lock.json delete mode 100644 integration_test/upgrade_tests/package.json delete mode 100644 integration_test/upgrade_tests/record/record.spec.ts delete mode 100644 integration_test/upgrade_tests/selftest/harness.spec.ts delete mode 100644 integration_test/upgrade_tests/tsconfig.json delete mode 100644 integration_test/upgrade_tests/utils/artifact.ts delete mode 100644 integration_test/upgrade_tests/utils/chain.ts delete mode 100644 integration_test/upgrade_tests/utils/cosmos.ts delete mode 100644 integration_test/upgrade_tests/utils/probes.ts delete mode 100644 integration_test/upgrade_tests/utils/txProbes.ts delete mode 100644 integration_test/upgrade_tests/verify/verify.spec.ts delete mode 100755 scripts/cross_version_upgrade_test.sh delete mode 100755 scripts/local_upgrade_test.sh diff --git a/.github/scripts/release-upgrade-test.sh b/.github/scripts/release-upgrade-test.sh index f6559c60e7..b0c8613e81 100755 --- a/.github/scripts/release-upgrade-test.sh +++ b/.github/scripts/release-upgrade-test.sh @@ -7,14 +7,21 @@ readonly RUN_ROOT="${RUNNER_TEMP:-/tmp}/sei-release-upgrade-${GITHUB_RUN_ID:-$$} readonly MAIN_WORKTREE="$RUN_ROOT/main" readonly RELEASE_WORKTREE="$RUN_ROOT/release" readonly BUILD_ROOT="$RUN_ROOT/bin" -readonly ARTIFACT_ROOT="$REPO_ROOT/artifacts/release-upgrade" +readonly ARTIFACT_ROOT="$RUN_ROOT/artifacts" readonly NODE_COUNT=4 +readonly ORACLE_VOTE_PERIOD=20 +readonly UPGRADE_NAME="v6.7" +readonly -a EXPECTED_REMOVED_MODULES=(capability feegrant ibc transfer) -RELEASE_BRANCH="${RELEASE_BRANCH:-}" +RELEASE_BRANCH="${RELEASE_BRANCH:-release/v6.6}" +MAIN_REF="${MAIN_REF:-${GITHUB_SHA:-HEAD}}" UPGRADE_LEAD_SECONDS="${UPGRADE_LEAD_SECONDS:-60}" POST_UPGRADE_BLOCKS="${POST_UPGRADE_BLOCKS:-10}" CLUSTER_STARTED=false MAIN_BINARY_HASH= +RELEASE_MODULE_VERSIONS= +FEE_GRANTER_ADDRESS= +FEE_GRANTEE_ADDRESS= log() { printf '\n[%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*" @@ -36,45 +43,9 @@ validate_inputs() { ((POST_UPGRADE_BLOCKS >= 2 && POST_UPGRADE_BLOCKS <= 100)) || die "post_upgrade_blocks must be between 2 and 100" - if [[ -n "$RELEASE_BRANCH" ]]; then - [[ "$RELEASE_BRANCH" =~ ^release/v[0-9]+\.[0-9]+(\.[0-9]+)?(-branch)?$ ]] || - die "release_branch must look like release/v6.6 or release/v6.2.0-branch" - fi -} - -resolve_latest_release_branch() { - local heads_file="$RUN_ROOT/release-heads" - git ls-remote --heads origin 'release/v*' >"$heads_file" - - python3 - "$heads_file" <<'PY' -import re -import sys - -patterns = ( - (re.compile(r"^refs/heads/(release/v(\d+)\.(\d+))$"), 2), - (re.compile(r"^refs/heads/(release/v(\d+)\.(\d+)\.(\d+))$"), 1), - (re.compile(r"^refs/heads/(release/v(\d+)\.(\d+)\.(\d+)-branch)$"), 0), -) - -candidates = [] -with open(sys.argv[1], encoding="utf-8") as heads: - for line in heads: - _, ref = line.split() - for pattern, preference in patterns: - match = pattern.fullmatch(ref) - if not match: - continue - branch = match.group(1) - numbers = tuple(int(part) for part in match.groups()[1:]) - version = numbers if len(numbers) == 3 else (*numbers, 0) - candidates.append((version, preference, branch)) - break - -if not candidates: - raise SystemExit("no official release/v* branch found") - -print(max(candidates)[2]) -PY + [[ "$RELEASE_BRANCH" =~ ^release/v[0-9]+\.[0-9]+(\.[0-9]+)?(-branch)?$ ]] || + die "release_branch must look like release/v6.6 or release/v6.2.0-branch" + [[ -n "$MAIN_REF" ]] || die "main_ref must not be empty" } latest_upgrade_tag() { @@ -97,46 +68,17 @@ print(max(versions)[1]) PY } -version_greater_than() { - python3 - "$1" "$2" <<'PY' -import re -import sys - -def parse(value): - match = re.fullmatch(r"v(\d+)\.(\d+)(?:\.(\d+))?", value) - if not match: - raise SystemExit(f"invalid upgrade version: {value}") - return tuple(int(part or 0) for part in match.groups()) - -raise SystemExit(0 if parse(sys.argv[1]) > parse(sys.argv[2]) else 1) -PY -} - -next_minor_version() { - python3 - "$1" "$2" <<'PY' -import re -import sys - -def parse(value): - match = re.fullmatch(r"v(\d+)\.(\d+)(?:\.(\d+))?", value) - if not match: - raise SystemExit(f"invalid upgrade version: {value}") - return tuple(int(part or 0) for part in match.groups()) - -highest = max(parse(sys.argv[1]), parse(sys.argv[2])) -print(f"v{highest[0]}.{highest[1] + 1}") -PY +has_upgrade_tag() { + local source_dir="$1" + local upgrade_name="$2" + grep -Fxq "$upgrade_name" "$source_dir/app/tags" } prepare_worktrees() { - if [[ -z "$RELEASE_BRANCH" ]]; then - RELEASE_BRANCH="$(resolve_latest_release_branch)" - fi - - log "Pinning origin/main and $RELEASE_BRANCH" - git fetch --no-tags origin main + log "Pinning main $MAIN_REF and release $RELEASE_BRANCH" local main_sha - main_sha="$(git rev-parse FETCH_HEAD)" + main_sha="$(git rev-parse "$MAIN_REF^{commit}")" || + die "unable to resolve main ref $MAIN_REF" git fetch --no-tags origin "$RELEASE_BRANCH" local release_sha @@ -146,6 +88,7 @@ prepare_worktrees() { git worktree add --detach "$RELEASE_WORKTREE" "$release_sha" { + printf 'main_ref=%s\n' "$MAIN_REF" printf 'main_sha=%s\n' "$main_sha" printf 'release_branch=%s\n' "$RELEASE_BRANCH" printf 'release_sha=%s\n' "$release_sha" @@ -158,30 +101,15 @@ prepare_upgrade_name() { release_upgrade="$(latest_upgrade_tag "$RELEASE_WORKTREE")" main_upgrade="$(latest_upgrade_tag "$MAIN_WORKTREE")" - if version_greater_than "$main_upgrade" "$release_upgrade"; then - UPGRADE_NAME="$main_upgrade" - log "Using main upgrade name $UPGRADE_NAME" - else - UPGRADE_NAME="$(next_minor_version "$main_upgrade" "$release_upgrade")" - log "Generating synthetic main upgrade name $UPGRADE_NAME" - python3 - "$MAIN_WORKTREE/app/tags" "$UPGRADE_NAME" <<'PY' -import pathlib -import sys - -tags_path = pathlib.Path(sys.argv[1]) -content = tags_path.read_bytes() -separator = b"" if not content or content.endswith(b"\n") else b"\n" -tags_path.write_bytes(content + separator + sys.argv[2].encode() + b"\n") -PY - ( - cd "$MAIN_WORKTREE" - go run ./scripts/bump_version - ) 2>&1 | tee "$ARTIFACT_ROOT/precompile-generation.log" - fi + ! has_upgrade_tag "$RELEASE_WORKTREE" "$UPGRADE_NAME" || + die "$RELEASE_BRANCH already knows $UPGRADE_NAME; it cannot exercise that upgrade" + has_upgrade_tag "$MAIN_WORKTREE" "$UPGRADE_NAME" || + die "main $MAIN_REF does not register $UPGRADE_NAME" + log "Testing $RELEASE_BRANCH ($release_upgrade) -> main with $UPGRADE_NAME" { printf 'release_upgrade=%s\n' "$release_upgrade" - printf 'main_upgrade_before_generation=%s\n' "$main_upgrade" + printf 'main_latest_upgrade=%s\n' "$main_upgrade" printf 'test_upgrade=%s\n' "$UPGRADE_NAME" } | tee -a "$ARTIFACT_ROOT/revisions.txt" } @@ -198,8 +126,12 @@ build_binary() { local source_dir="$1" local output_path="$2" local label="$3" + local source_commit + local source_version local go_mod_cache local go_build_cache + source_commit="$(git -C "$source_dir" rev-parse HEAD)" + source_version="$(git -C "$source_dir" describe --tags --always)" go_mod_cache="$(go env GOMODCACHE)" go_build_cache="$(go env GOCACHE)" mkdir -p "$go_mod_cache" "$go_build_cache" @@ -213,8 +145,11 @@ build_binary() { -v "$go_build_cache:/root/.cache/go-build:Z" \ -w /sei-protocol/sei-chain \ -e LEDGER_ENABLED=false \ + -e GOFLAGS=-buildvcs=false \ + -e "BUILD_COMMIT=$source_commit" \ + -e "BUILD_VERSION=$source_version" \ sei-chain/localnode \ - bash -c 'export PATH=/usr/local/go/bin:$PATH && make clean && make build-linux' + bash -c 'export PATH=/usr/local/go/bin:$PATH && make clean && make VERSION="$BUILD_VERSION" COMMIT="$BUILD_COMMIT" build-linux' install -m 0755 "$source_dir/build/seid" "$output_path" sha256sum "$output_path" | tee "$ARTIFACT_ROOT/$label.sha256" @@ -259,6 +194,184 @@ wait_for_node_height() { die "$node did not reach height $target within $timeout_seconds seconds" } +wait_for_blocks() { + local node="$1" + local blocks="$2" + local start + start="$(height "$node")" || die "unable to query $node before waiting for blocks" + wait_for_node_height "$node" "$((start + blocks))" 180 +} + +node_key_address() { + local node="$1" + local key="$2" + printf '12345678\n' | + docker exec -i "$node" seid keys show "$key" -a 2>/dev/null +} + +node_validator_address() { + local node="$1" + printf '12345678\n' | + docker exec -i "$node" seid keys show node_admin --bech val -a 2>/dev/null +} + +require_check_tx_success() { + local label="$1" + local output="$2" + local code + code="$(jq -r '.code // 0' <<<"$output" 2>/dev/null)" || + die "$label did not return JSON: $output" + [[ "$code" == "0" ]] || + die "$label was rejected: $(jq -r '.raw_log // .' <<<"$output")" +} + +feegrant_spend_limit() { + jq -er ' + [ + .. | objects | select(has("spend_limit")) | + .spend_limit[] | select(.denom == "usei") | .amount | tonumber + ][0] + ' +} + +query_module_versions() { + local node="$1" + docker exec "$node" seid q upgrade module_versions --output json | + jq -er '.module_versions | map(.name) | sort | .[]' +} + +seed_release_oracle_rates() { + log "Seeding oracle exchange rates with release validator votes" + local configured_vote_period + configured_vote_period="$( + docker exec sei-node-0 seid q oracle params --output json | + jq -er '.params.vote_period | tonumber' + )" || die "unable to query the release oracle vote period" + ((configured_vote_period == ORACLE_VOTE_PERIOD)) || + die "release oracle vote period is $configured_vote_period, expected $ORACLE_VOTE_PERIOD" + + local attempt + local code + local current + local start + local tally_height + local node + local output_file + local validator + local rates= + + for ((attempt = 1; attempt <= 3; attempt++)); do + current="$(height sei-node-0)" + start="$((current + ORACLE_VOTE_PERIOD - (current % ORACLE_VOTE_PERIOD) + 1))" + wait_for_node_height sei-node-0 "$start" 180 + + for ((i = 0; i < NODE_COUNT; i++)); do + node="sei-node-$i" + output_file="$ARTIFACT_ROOT/oracle-vote-$attempt-$i.json" + validator="$(node_validator_address "$node")" || + die "unable to resolve the oracle validator in $node" + [[ -n "$validator" ]] || die "oracle validator in $node is empty" + if ! ( + printf '12345678\n' | + docker exec -i "$node" seid tx oracle aggregate-vote \ + "1.${attempt}uatom,2.${attempt}ueth" "$validator" \ + --from node_admin --chain-id sei --fees 200000usei --gas 2000000 \ + --broadcast-mode sync --yes --output json + ) >"$output_file" 2>"$output_file.stderr"; then + die "oracle vote failed in $node; see $output_file.stderr" + fi + code="$(jq -r '.code // 0' "$output_file" 2>/dev/null)" || + die "oracle vote in $node did not return JSON; see $output_file" + [[ "$code" == "0" ]] || + die "oracle vote in $node was rejected; see $output_file" + done + + current="$(height sei-node-0)" + tally_height="$((current + ORACLE_VOTE_PERIOD - (current % ORACLE_VOTE_PERIOD) + 2))" + wait_for_node_height sei-node-0 "$tally_height" 180 + if rates="$(docker exec sei-node-0 seid q oracle exchange-rates --output json 2>&1)" && + jq -e '.denom_oracle_exchange_rate_pairs | length > 0' <<<"$rates" >/dev/null; then + printf '%s\n' "$rates" >"$ARTIFACT_ROOT/release-oracle-rates.json" + return + fi + done + + die "release validators did not produce oracle exchange rates; see oracle-vote artifacts" +} + +seed_release_state() { + log "Seeding state through modules available on the release binary" + FEE_GRANTER_ADDRESS="$(node_key_address sei-node-0 admin)" || + die "unable to resolve the release fee granter" + FEE_GRANTEE_ADDRESS="$(node_key_address sei-node-0 node_admin)" || + die "unable to resolve the release fee grantee" + [[ -n "$FEE_GRANTER_ADDRESS" && -n "$FEE_GRANTEE_ADDRESS" ]] || + die "release feegrant accounts are empty" + + local grant_output + if ! grant_output="$( + printf '12345678\n' | + docker exec -i sei-node-0 seid tx feegrant grant \ + "$FEE_GRANTER_ADDRESS" "$FEE_GRANTEE_ADDRESS" \ + --spend-limit 100000000usei --from admin --chain-id sei \ + --fees 200000usei --gas 2000000 --broadcast-mode sync --yes --output json \ + 2>"$ARTIFACT_ROOT/release-feegrant-grant.stderr" + )"; then + die "release binary could not grant a fee allowance; see release-feegrant-grant.stderr" + fi + require_check_tx_success "fee allowance grant" "$grant_output" + wait_for_blocks sei-node-0 3 + + local allowance + allowance="$(docker exec sei-node-0 seid q feegrant grant \ + "$FEE_GRANTER_ADDRESS" "$FEE_GRANTEE_ADDRESS" --output json 2>&1)" || + die "release binary could not query the seeded fee allowance: $allowance" + jq -e '.allowance' <<<"$allowance" >/dev/null || + die "release fee allowance was not stored: $allowance" + printf '%s\n' "$allowance" >"$ARTIFACT_ROOT/release-feegrant.json" + local allowance_before + allowance_before="$(feegrant_spend_limit <<<"$allowance")" || + die "release fee allowance has no usei spend limit: $allowance" + + local spend_output + if ! spend_output="$( + printf '12345678\n' | + docker exec -i sei-node-0 seid tx bank send node_admin \ + "$FEE_GRANTER_ADDRESS" 1usei --fee-account "$FEE_GRANTER_ADDRESS" \ + --from node_admin --chain-id sei --fees 200000usei --gas 2000000 \ + --broadcast-mode sync --yes --output json \ + 2>"$ARTIFACT_ROOT/release-feegrant-spend.stderr" + )"; then + die "release binary could not spend the fee allowance; see release-feegrant-spend.stderr" + fi + require_check_tx_success "fee-granted bank send" "$spend_output" + wait_for_blocks sei-node-0 3 + + local allowance_after_output + allowance_after_output="$(docker exec sei-node-0 seid q feegrant grant \ + "$FEE_GRANTER_ADDRESS" "$FEE_GRANTEE_ADDRESS" --output json 2>&1)" || + die "release binary could not re-query the spent fee allowance: $allowance_after_output" + local allowance_after + allowance_after="$(feegrant_spend_limit <<<"$allowance_after_output")" || + die "spent release fee allowance has no usei spend limit: $allowance_after_output" + ((allowance_after < allowance_before)) || + die "fee-granted send did not consume the allowance ($allowance_before -> $allowance_after)" + printf '%s\n' "$allowance_after_output" \ + >"$ARTIFACT_ROOT/release-feegrant-after-spend.json" + + seed_release_oracle_rates + + RELEASE_MODULE_VERSIONS="$(query_module_versions sei-node-0)" || + die "unable to query release module versions" + printf '%s\n' "$RELEASE_MODULE_VERSIONS" >"$ARTIFACT_ROOT/release-module-versions.txt" + + local module + for module in "${EXPECTED_REMOVED_MODULES[@]}" oracle; do + grep -Fxq "$module" <<<"$RELEASE_MODULE_VERSIONS" || + die "release module version map does not contain $module" + done +} + assert_all_nodes_progress() { local blocks="$1" local label="$2" @@ -297,7 +410,8 @@ start_release_cluster() { DOCKER_DETACH=true \ DOCKER_PLATFORM=linux/amd64 \ INVARIANT_CHECK_INTERVAL=10 \ - UPGRADE_VERSION_LIST= \ + ORACLE_VOTE_PERIOD="$ORACLE_VOTE_PERIOD" \ + UPGRADE_VERSION_LIST='' \ make docker-cluster-start-skipbuild ) CLUSTER_STARTED=true @@ -353,6 +467,9 @@ stage_main_binary() { local node local actual_hash + docker exec --user root sei-node-0 \ + sh -c 'cp /root/go/bin/seid /tmp/seid.release && chmod 0755 /tmp/seid.release' + for ((i = 0; i < NODE_COUNT; i++)); do node="sei-node-$i" docker cp "$BUILD_ROOT/main-seid" "$node:/tmp/seid.next" @@ -377,16 +494,19 @@ seid_process_state() { local node="$1" local state if ! state="$(docker exec "$node" sh -c ' - if pgrep -x seid >/dev/null 2>&1; then - printf running - else - status=$? - if [ "$status" -eq 1 ]; then - printf stopped - else - exit "$status" + for comm in /proc/[0-9]*/comm; do + [ -r "$comm" ] || continue + read -r name <"$comm" || continue + if [ "$name" = seid ]; then + process_dir="${comm%/comm}" + read -r stat_pid stat_comm process_state stat_rest <"$process_dir/stat" || + continue + [ "$process_state" = Z ] && continue + printf running + exit fi - fi + done + printf stopped ')"; then die "unable to inspect the seid process in $node" fi @@ -400,11 +520,42 @@ seid_process_state() { esac } +stop_node_process() { + local node="$1" + docker exec "$node" sh -c ' + for comm in /proc/[0-9]*/comm; do + [ -r "$comm" ] || continue + read -r name <"$comm" || continue + if [ "$name" = seid ]; then + process_dir="${comm%/comm}" + read -r stat_pid stat_comm process_state stat_rest <"$process_dir/stat" || + continue + [ "$process_state" = Z ] && continue + pid="${comm#/proc/}" + pid="${pid%/comm}" + kill -TERM "$pid" + fi + done + ' || die "unable to stop seid in $node" + + local deadline=$((SECONDS + 60)) + while ((SECONDS < deadline)); do + if [[ "$(seid_process_state "$node")" == stopped ]]; then + return + fi + sleep 1 + done + die "seid in $node did not stop within 60 seconds" +} + install_main_binary() { local node_id="$1" local node="sei-node-$node_id" local actual_hash + docker exec "$node" test -f /root/.sei/data/upgrade-info.json || + die "$node halted without writing upgrade-info.json" + log "Installing main on $node" mkdir -p "$ARTIFACT_ROOT/pre-upgrade-logs" cp "$REPO_ROOT/build/generated/logs/seid-$node_id.log" \ @@ -428,7 +579,8 @@ start_main_binary() { upgrade_nodes_as_they_halt() { log "Supervising each validator through the upgrade at height $TARGET_HEIGHT" - local deadline=$((SECONDS + 360)) + local timeout_seconds=$((UPGRADE_LEAD_SECONDS + 360)) + local deadline=$((SECONDS + timeout_seconds)) local upgraded_count=0 local ready_count=0 local current @@ -501,9 +653,9 @@ upgrade_nodes_as_they_halt() { done ((upgraded_count == NODE_COUNT)) || - die "only $upgraded_count of $NODE_COUNT validators halted and upgraded within 360 seconds" + die "only $upgraded_count of $NODE_COUNT validators halted and upgraded within $timeout_seconds seconds" ((ready_count == NODE_COUNT)) || - die "only $ready_count of $NODE_COUNT upgraded validators became ready within 360 seconds" + die "only $ready_count of $NODE_COUNT upgraded validators became ready within $timeout_seconds seconds" for ((i = 0; i < NODE_COUNT; i++)); do state="$(seid_process_state "sei-node-$i")" @@ -513,6 +665,143 @@ upgrade_nodes_as_they_halt() { verify_running_binary_hash "$MAIN_BINARY_HASH" main } +assert_v67_upgrade() { + log "Asserting the v6.7 module retirement" + local applied_output + applied_output="$(docker exec sei-node-0 \ + seid q upgrade applied "$UPGRADE_NAME" --output json 2>&1)" || + die "unable to query applied plan $UPGRADE_NAME: $applied_output" + + local applied_height + applied_height="$(jq -er '.header.height | tonumber' <<<"$applied_output")" || + die "applied plan $UPGRADE_NAME has no height: $applied_output" + ((applied_height == TARGET_HEIGHT)) || + die "$UPGRADE_NAME applied at height $applied_height, expected $TARGET_HEIGHT" + printf 'applied_height=%s\n' "$applied_height" | + tee -a "$ARTIFACT_ROOT/revisions.txt" + + local main_module_versions + main_module_versions="$(query_module_versions sei-node-0)" || + die "unable to query main module versions" + printf '%s\n' "$main_module_versions" \ + >"$ARTIFACT_ROOT/main-module-versions.txt" + + local removed_modules + local expected_removed + removed_modules="$( + comm -23 \ + <(printf '%s\n' "$RELEASE_MODULE_VERSIONS") \ + <(printf '%s\n' "$main_module_versions") + )" + expected_removed="$(printf '%s\n' "${EXPECTED_REMOVED_MODULES[@]}" | sort)" + [[ "$removed_modules" == "$expected_removed" ]] || + die "unexpected removed module versions; expected [$expected_removed], got [$removed_modules]" + + grep -Fxq oracle <<<"$main_module_versions" || + die "main module version map no longer contains oracle" + + local feegrant_output + feegrant_output="$( + printf '12345678\n' | + docker exec -i sei-node-0 seid tx bank send node_admin \ + "$FEE_GRANTER_ADDRESS" 1usei --fee-account "$FEE_GRANTER_ADDRESS" \ + --from node_admin --chain-id sei --fees 200000usei --gas 2000000 \ + --broadcast-mode sync --yes --output json 2>&1 || true + )" + printf '%s\n' "$feegrant_output" >"$ARTIFACT_ROOT/main-feegrant-response.txt" + grep -Fq "fee grants are not enabled" <<<"$feegrant_output" || + die "main did not reject the previously valid fee-granted send: $feegrant_output" + + local oracle_output + oracle_output="$( + docker exec sei-node-0 seid q oracle exchange-rates --output json 2>&1 || true + )" + printf '%s\n' "$oracle_output" >"$ARTIFACT_ROOT/main-oracle-response.txt" + grep -Fq "oracle module is deprecated" <<<"$oracle_output" || + die "main did not deprecate the release oracle query: $oracle_output" +} + +export_node_state() { + local binary_path="$1" + local label="$2" + local raw_output="$ARTIFACT_ROOT/$label.raw" + local json_output="$ARTIFACT_ROOT/$label.json" + local error_output="$ARTIFACT_ROOT/$label.stderr" + local exported=false + local attempt + + for ((attempt = 1; attempt <= 10; attempt++)); do + if docker exec sei-node-0 "$binary_path" export --home /root/.sei --chain-id sei \ + >"$raw_output" 2>"$error_output"; then + exported=true + break + fi + sleep 2 + done + [[ "$exported" == true ]] || return 1 + + if ! python3 - "$raw_output" "$json_output" <<'PY' +import json +import pathlib +import sys + +raw_path = pathlib.Path(sys.argv[1]) +output_path = pathlib.Path(sys.argv[2]) +text = raw_path.read_text(encoding="utf-8", errors="replace") +decoder = json.JSONDecoder() +offset = 0 + +for line in text.splitlines(keepends=True): + stripped = line.lstrip() + if stripped.startswith("{"): + start = offset + len(line) - len(stripped) + try: + value, _ = decoder.raw_decode(text[start:]) + except json.JSONDecodeError: + pass + else: + if isinstance(value, dict) and "app_state" in value: + output_path.write_text( + json.dumps(value, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + break + offset += len(line) +else: + raise SystemExit("no genesis document found") +PY + then + return 1 + fi +} + +verify_retained_state() { + log "Verifying retired state through both binaries" + stop_node_process sei-node-0 + + export_node_state /root/go/bin/seid main-export || + die "main export failed; see main-export.stderr" + jq -e ' + (.app_state | has("feegrant") | not) and + (.app_state | has("capability") | not) and + (.app_state | has("ibc") | not) and + (.app_state | has("transfer") | not) and + (.app_state.oracle.exchange_rates | length > 0) + ' "$ARTIFACT_ROOT/main-export.json" >/dev/null || + die "main export did not omit retired modules while retaining oracle rates" + + if export_node_state /tmp/seid.release release-export-after-upgrade; then + jq -e ' + (.app_state.feegrant.allowances | length > 0) and + (.app_state | has("capability")) and + (.app_state.oracle.exchange_rates | length > 0) + ' "$ARTIFACT_ROOT/release-export-after-upgrade.json" >/dev/null || + die "release export lost retained feegrant, capability, or oracle state" + else + log "Release binary could not export upgraded state; keeping its diagnostics" + fi +} + verify_post_upgrade() { local required_height=$((TARGET_HEIGHT + POST_UPGRADE_BLOCKS)) log "Requiring every main node to reach height $required_height" @@ -525,21 +814,15 @@ verify_post_upgrade() { assert_all_nodes_progress 3 "post-upgrade" - local minimum= - local maximum=0 local current for ((i = 0; i < NODE_COUNT; i++)); do current="$(height "sei-node-$i")" - ((current > maximum)) && maximum="$current" - if [[ -z "$minimum" ]] || ((current < minimum)); then - minimum="$current" - fi + printf 'post_upgrade_node_%s_height=%s\n' "$i" "$current" | + tee -a "$ARTIFACT_ROOT/revisions.txt" done - ((maximum - minimum <= 3)) || - die "validators are not synchronized after upgrade (min=$minimum max=$maximum)" - printf 'post_upgrade_min_height=%s\n' "$minimum" | - tee -a "$ARTIFACT_ROOT/revisions.txt" + assert_v67_upgrade + verify_retained_state log "Upgrade $UPGRADE_NAME succeeded" } @@ -573,6 +856,7 @@ cleanup() { fi git -C "$REPO_ROOT" worktree remove --force "$MAIN_WORKTREE" 2>/dev/null git -C "$REPO_ROOT" worktree remove --force "$RELEASE_WORKTREE" 2>/dev/null + git -C "$REPO_ROOT" worktree prune exit "$exit_code" } @@ -587,6 +871,7 @@ main() { build_binary "$RELEASE_WORKTREE" "$BUILD_ROOT/release-seid" release build_binary "$MAIN_WORKTREE" "$BUILD_ROOT/main-seid" main start_release_cluster + seed_release_state stage_main_binary submit_upgrade upgrade_nodes_as_they_halt diff --git a/.github/workflows/integration-test-matrix.json b/.github/workflows/integration-test-matrix.json index c9a6e4b7ac..bc44a7ad94 100644 --- a/.github/workflows/integration-test-matrix.json +++ b/.github/workflows/integration-test-matrix.json @@ -80,13 +80,6 @@ "go test -tags yaml_integration -v -timeout $JOB_TIMEOUT ./integration_test/runner/... -run TestUpgradeMinor" ] }, - { - "name": "Upgrade Module (Retired Modules)", - "env": "GIGA_STORAGE=true UPGRADE_VERSION_LIST=v1.0.0", - "scripts": [ - "go test -tags yaml_integration -v -timeout $JOB_TIMEOUT ./integration_test/runner/... -run TestUpgradeRetiredModules" - ] - }, { "name": "SeiDB State Store", "scripts": [ diff --git a/.github/workflows/release-upgrade-test.yml b/.github/workflows/release-upgrade-test.yml index de7f73374c..cab9ca6c26 100644 --- a/.github/workflows/release-upgrade-test.yml +++ b/.github/workflows/release-upgrade-test.yml @@ -7,13 +7,21 @@ on: paths: - .github/workflows/release-upgrade-test.yml - .github/scripts/release-upgrade-test.sh + - Makefile + - app/app.go + - app/tags + - app/upgrades.go + - docker/** + - integration_test/upgrade_module/scripts/** + - integration_test/utils/_tx_helpers.sh schedule: - cron: '17 7 * * *' workflow_dispatch: inputs: release_branch: - description: Official release branch to test (empty selects the latest) + description: v6.6 release branch to upgrade from required: false + default: release/v6.6 type: string upgrade_lead_seconds: description: Seconds between proposal submission and upgrade @@ -43,7 +51,9 @@ jobs: runs-on: ubuntu-large timeout-minutes: 90 env: - RELEASE_BRANCH: ${{ inputs.release_branch || '' }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + MAIN_REF: ${{ github.sha }} + RELEASE_BRANCH: ${{ inputs.release_branch || 'release/v6.6' }} UPGRADE_LEAD_SECONDS: ${{ inputs.upgrade_lead_seconds || '60' }} POST_UPGRADE_BLOCKS: ${{ inputs.post_upgrade_blocks || '10' }} steps: @@ -66,6 +76,14 @@ jobs: with: go-version: '1.25.6' + - name: Login to Docker Hub + # See: https://github.com/docker/login-action/releases/tag/v4.2.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + if: env.DOCKERHUB_USERNAME != '' + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Run coordinated release upgrade run: bash .github/scripts/release-upgrade-test.sh @@ -75,6 +93,6 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: release-upgrade-${{ github.run_id }} - path: artifacts/release-upgrade + path: ${{ runner.temp }}/sei-release-upgrade-${{ github.run_id }}/artifacts if-no-files-found: warn retention-days: 7 diff --git a/.github/workflows/upgrade_tests.yml b/.github/workflows/upgrade_tests.yml deleted file mode 100644 index 499f662706..0000000000 --- a/.github/workflows/upgrade_tests.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Upgrade Tests - -# Two phases separated by a real network upgrade, so they are dispatched -# separately rather than run back to back. The record phase uploads its artifact -# and the verify phase downloads it, which is what lets the two runs be days -# apart. -on: - workflow_dispatch: - inputs: - phase: - description: Which phase to run - required: true - type: choice - options: [record, verify] - network: - description: Target network - required: true - default: arctic-1 - type: choice - options: [arctic-1, atlantic-2] - plan_name: - description: Governance plan name to record or verify - required: true - default: v6.7 - type: string - record_run_id: - description: >- - Required for verify: the run id of the record run holding the - pre-upgrade artifact. Find it in this workflow's run history. - required: false - type: string - # The self-checks need no chain, so they gate changes to the suite itself. - pull_request: - paths: - - integration_test/upgrade_tests/** - - .github/workflows/upgrade_tests.yml - -jobs: - selftest: - name: Self-checks - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - defaults: - run: - working-directory: integration_test/upgrade_tests - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - run: npm ci - - run: npm run typecheck - - run: npm run selftest - - run-phase: - name: ${{ inputs.phase }} on ${{ inputs.network }} - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - environment: - name: ${{ inputs.network == 'arctic-1' && 'devnet' || 'testnet' }} - # Reading an artifact from an earlier run needs more than the default - # contents scope. - permissions: - contents: read - actions: read - defaults: - run: - working-directory: integration_test/upgrade_tests - env: - UPGRADE_TEST_NETWORK: ${{ inputs.network }} - UPGRADE_TEST_PLAN_NAME: ${{ inputs.plan_name }} - # Optional. Without it the suite runs read-only probes and skips the - # committed-history checks, saying so rather than passing quietly. - UPGRADE_TEST_MNEMONIC: ${{ secrets.DAPP_TESTS_MNEMONIC }} - steps: - # Fail on the missing input rather than on a confusing "artifact not - # found" from the download step, which is what a blank run id produces: - # it defaults to the current run, where no artifact was ever uploaded. - - name: Require a record run id to verify against - if: inputs.phase == 'verify' && inputs.record_run_id == '' - run: | - echo "::error::verify needs record_run_id — the run id of the record phase" \ - "that produced the pre-upgrade artifact. Check this workflow's run history." - exit 1 - - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - run: npm ci - - - name: Download the pre-upgrade artifact - if: inputs.phase == 'verify' - uses: actions/download-artifact@v4 - with: - name: pre-upgrade-${{ inputs.network }}-${{ inputs.plan_name }} - path: integration_test/upgrade_tests/artifacts - run-id: ${{ inputs.record_run_id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - run: npm run ${{ inputs.phase }} - - - name: Upload the pre-upgrade artifact - if: inputs.phase == 'record' - uses: actions/upload-artifact@v4 - with: - name: pre-upgrade-${{ inputs.network }}-${{ inputs.plan_name }} - path: integration_test/upgrade_tests/artifacts/pre-upgrade.json - # The gap between the two phases is however long the upgrade takes to - # ship, so the artifact has to outlive the default retention. - retention-days: 90 - if-no-files-found: error diff --git a/Makefile b/Makefile index bbdfb43b0c..3b2ebabf8d 100644 --- a/Makefile +++ b/Makefile @@ -200,25 +200,6 @@ build: go build $(BUILD_FLAGS) -o ./build/seid ./cmd/seid .PHONY: build -# Drive a real governance software upgrade on a single throwaway node and compare -# how the chain answers for the retired modules on either side of it. Reaches the -# halt / upgrade-info.json / store-reload path that the in-process Go tests never -# take, without needing the docker cluster. Uses an isolated home; never touches -# ~/.sei. Roughly 30s once seid is built. -local-upgrade-test: build - ./scripts/local_upgrade_test.sh --skip-build -.PHONY: local-upgrade-test - -# Upgrade a throwaway local chain across two real binaries: seed state with the -# modules that are alive on the previous release, then swap this binary in and -# check what the upgrade did to it. The only place the pre-upgrade state is real, -# since this binary can no longer create a feegrant allowance or store an oracle -# vote. Builds the old release into a git worktree on first run (a few minutes); -# about 30s after that. Uses an isolated home and never touches ~/.sei. -cross-upgrade-test: build - ./scripts/cross_version_upgrade_test.sh --skip-build -.PHONY: cross-upgrade-test - build-frozen-rpc-router: mkdir -p ./build go build -o ./build/frozen-rpc-router ./cmd/frozen-rpc-router @@ -412,6 +393,7 @@ kill-rpc-node: CLUSTER_ENV_VARS = DOCKER_PLATFORM=$(DOCKER_PLATFORM) USERID=$(shell id -u) GROUPID=$(shell id -g) \ GOCACHE=$(shell go env GOCACHE) NUM_ACCOUNTS=10 \ INVARIANT_CHECK_INTERVAL=$(INVARIANT_CHECK_INTERVAL) \ + ORACLE_VOTE_PERIOD=$(ORACLE_VOTE_PERIOD) \ UPGRADE_VERSION_LIST=$(UPGRADE_VERSION_LIST) \ MOCK_BALANCES=$(MOCK_BALANCES) \ GIGA_EXECUTOR=$(GIGA_EXECUTOR) \ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b0db261842..47398e521b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,7 @@ services: - NUM_ACCOUNTS - SKIP_BUILD - INVARIANT_CHECK_INTERVAL + - ORACLE_VOTE_PERIOD - UPGRADE_VERSION_LIST - MOCK_BALANCES - GIGA_EXECUTOR @@ -50,6 +51,7 @@ services: - NUM_ACCOUNTS - SKIP_BUILD - INVARIANT_CHECK_INTERVAL + - ORACLE_VOTE_PERIOD - UPGRADE_VERSION_LIST - GIGA_EXECUTOR - GIGA_OCC @@ -81,6 +83,7 @@ services: - NUM_ACCOUNTS - SKIP_BUILD - INVARIANT_CHECK_INTERVAL + - ORACLE_VOTE_PERIOD - UPGRADE_VERSION_LIST - GIGA_EXECUTOR - GIGA_OCC @@ -116,6 +119,7 @@ services: - NUM_ACCOUNTS - SKIP_BUILD - INVARIANT_CHECK_INTERVAL + - ORACLE_VOTE_PERIOD - UPGRADE_VERSION_LIST - GIGA_EXECUTOR - GIGA_OCC diff --git a/docker/localnode/scripts/step2_genesis.sh b/docker/localnode/scripts/step2_genesis.sh index 9373ac6745..42bd1c161c 100755 --- a/docker/localnode/scripts/step2_genesis.sh +++ b/docker/localnode/scripts/step2_genesis.sh @@ -2,6 +2,7 @@ # Input parameters NODE_ID=${ID:-0} +ORACLE_VOTE_PERIOD=${ORACLE_VOTE_PERIOD:-2} echo "Preparing genesis file" @@ -15,7 +16,7 @@ override_genesis() { override_genesis '.app_state["mint"]["params"]["mint_denom"]="usei"' override_genesis '.app_state["staking"]["params"]["bond_denom"]="usei"' -override_genesis '.app_state["oracle"]["params"]["vote_period"]="2"' +override_genesis ".app_state[\"oracle\"][\"params\"][\"vote_period\"]=\"$ORACLE_VOTE_PERIOD\"" override_genesis '.app_state["slashing"]["params"]["signed_blocks_window"]="10000"' override_genesis '.app_state["slashing"]["params"]["min_signed_per_window"]="0.050000000000000000"' override_genesis '.app_state["staking"]["params"]["max_validators"]="50"' diff --git a/integration_test/runner/runner_test.go b/integration_test/runner/runner_test.go index fedca203e3..d5220f325d 100644 --- a/integration_test/runner/runner_test.go +++ b/integration_test/runner/runner_test.go @@ -125,10 +125,3 @@ func TestUpgradeMajor(t *testing.T) { func TestUpgradeMinor(t *testing.T) { runner.RunFile(t, "../upgrade_module/minor_upgrade_test.yaml") } - -// TestUpgradeRetiredModules runs a real v6.7 governance upgrade against a cluster -// that is being spammed with transactions for the modules v6.7 retires, and -// requires the retired surfaces to answer identically on both sides of it. -func TestUpgradeRetiredModules(t *testing.T) { - runner.RunFile(t, "../upgrade_module/retired_modules_upgrade_test.yaml") -} diff --git a/integration_test/upgrade_module/retired_modules_upgrade_test.yaml b/integration_test/upgrade_module/retired_modules_upgrade_test.yaml deleted file mode 100644 index 4b99d5600c..0000000000 --- a/integration_test/upgrade_module/retired_modules_upgrade_test.yaml +++ /dev/null @@ -1,145 +0,0 @@ -# Retired Module Upgrade Scenario: -# -# The v6.7 upgrade retires module surfaces (feegrant, capability, ibc and -# transfer are removed; the oracle message and query handlers are deprecated). -# This drives a live four node cluster through the shape an operator sees on -# upgrade day and asks one question: does the chain answer clients the same way -# once the modules are gone? -# -# 1. Before the upgrade, probe every retired surface and spam the retired oracle -# handler with real broadcasts. Record a fingerprint of the answers. -# -# 2. Run a real governance software upgrade to v6.7. Every node lacks v6.7 in -# its version list, so all four panic at the upgrade height and are restarted -# with it, which is what makes the actual v6.7 handler run. -# -# 3. Confirm the cluster keeps producing blocks afterwards. Four nodes that -# agree on blocks past the upgrade height is the end-to-end statement that -# the handler is deterministic; a node that computed a different application -# hash would stall instead. -# -# 4. Probe again and require an identical fingerprint. Retiring a module changes -# what a node stores, never how it answers a client that has not noticed yet. -# A fingerprint that shifted at the upgrade height would mean two nodes -# mid-rollout disagree about whether a block is valid. - -- name: Test Retired Module Behavior Across the v6.7 Upgrade - inputs: - - cmd: echo "v6.7" - env: VERSION - - # A fee granter that is not the fee payer. Only a granter distinct from the - # payer is refused, so the probe needs a second address to be meaningful. - - cmd: printf "12345678\n" | seid keys show admin -a - env: DISTINCT_GRANTER - node: sei-node-0 - - # PRE-UPGRADE BEHAVIOR - - cmd: probe_retired_modules.sh $DISTINCT_GRANTER 5 - env: PRE_UPGRADE_PROBE - node: sei-node-0 - - # PASS PROPOSAL FOR THE v6.7 UPGRADE - - cmd: proposal_target_height.sh 60 - env: TARGET_HEIGHT - node: sei-node-0 - - cmd: proposal_submit.sh $TARGET_HEIGHT major $VERSION - env: PROPOSAL_ID - node: sei-node-0 - - cmd: proposal_vote.sh $PROPOSAL_ID - node: sei-node-0 - - cmd: proposal_vote.sh $PROPOSAL_ID - node: sei-node-1 - - cmd: proposal_vote.sh $PROPOSAL_ID - node: sei-node-2 - - cmd: proposal_vote.sh $PROPOSAL_ID - node: sei-node-3 - - cmd: proposal_wait_for_pass.sh $PROPOSAL_ID node_admin - node: sei-node-0 - - # No node knows v6.7 yet, so each one panics on reaching the upgrade height. - - cmd: wait_for_height.sh $TARGET_HEIGHT node_admin - node: sei-node-1 - - cmd: wait_for_height.sh $TARGET_HEIGHT node_admin - node: sei-node-2 - - cmd: wait_for_height.sh $TARGET_HEIGHT node_admin - node: sei-node-3 - - cmd: verify_upgrade_needed_log.sh $TARGET_HEIGHT $VERSION - node: sei-node-1 - env: LOG_UPGRADE_NEEDED_NODE_1 - - cmd: verify_upgrade_needed_log.sh $TARGET_HEIGHT $VERSION - node: sei-node-2 - env: LOG_UPGRADE_NEEDED_NODE_2 - - cmd: verify_upgrade_needed_log.sh $TARGET_HEIGHT $VERSION - node: sei-node-3 - env: LOG_UPGRADE_NEEDED_NODE_3 - - # Restarting with v6.7 in the version list is what registers and runs the - # real upgrade handler. - - cmd: seid_upgrade.sh $VERSION - node: sei-node-0 - - cmd: seid_upgrade.sh $VERSION - node: sei-node-1 - - cmd: seid_upgrade.sh $VERSION - node: sei-node-2 - - cmd: seid_upgrade.sh $VERSION - node: sei-node-3 - - cmd: verify_running.sh - node: sei-node-0 - env: RUNNING_UPGRADED_NODE_0 - - cmd: verify_running.sh - node: sei-node-1 - env: RUNNING_UPGRADED_NODE_1 - - cmd: verify_running.sh - node: sei-node-2 - env: RUNNING_UPGRADED_NODE_2 - - cmd: verify_running.sh - node: sei-node-3 - env: RUNNING_UPGRADED_NODE_3 - - # Blocks past the upgrade height mean all four nodes agreed on the state the - # handler produced. The runner records a non-zero script exit without - # failing the step, so capture an explicit result. - - cmd: wait_for_height.sh $((TARGET_HEIGHT + 5)) node_admin; if [ $? -eq 0 ]; then echo ADVANCED_PAST_UPGRADE; else echo STALLED_AT_UPGRADE; fi - env: CHAIN_ADVANCED_AFTER_UPGRADE - node: sei-node-0 - - # POST-UPGRADE BEHAVIOR - - cmd: probe_retired_modules.sh $DISTINCT_GRANTER 5 - env: POST_UPGRADE_PROBE - node: sei-node-0 - - verifiers: - # Every node reached the upgrade height and asked for v6.7. - - type: eval - expr: LOG_UPGRADE_NEEDED_NODE_1 == "PASS" or LOG_UPGRADE_NEEDED_NODE_2 == "PASS" or LOG_UPGRADE_NEEDED_NODE_3 == "PASS" - - # Every node came back up on the upgraded version. - - type: eval - expr: RUNNING_UPGRADED_NODE_0 == "PASS" - - type: eval - expr: RUNNING_UPGRADED_NODE_1 == "PASS" - - type: eval - expr: RUNNING_UPGRADED_NODE_2 == "PASS" - - type: eval - expr: RUNNING_UPGRADED_NODE_3 == "PASS" - - # The cluster kept producing blocks, so the four nodes agreed on the - # application hash the upgrade handler produced. - - type: regex - result: CHAIN_ADVANCED_AFTER_UPGRADE - expr: '(?m)^ADVANCED_PAST_UPGRADE$' - - # Every retired surface answered as expected before the upgrade. Asserting - # this separately from the comparison below distinguishes "the upgrade - # changed behavior" from "the probe was already failing on both sides". - - type: regex - result: PRE_UPGRADE_PROBE - expr: '(?m)^PROBE_RESULT=ALL_MATCH$' - - type: regex - result: POST_UPGRADE_PROBE - expr: '(?m)^PROBE_RESULT=ALL_MATCH$' - - # ...and answered identically afterwards. - - type: eval - expr: PRE_UPGRADE_PROBE == POST_UPGRADE_PROBE diff --git a/integration_test/upgrade_module/scripts/probe_retired_modules.sh b/integration_test/upgrade_module/scripts/probe_retired_modules.sh deleted file mode 100755 index e71aa58be7..0000000000 --- a/integration_test/upgrade_module/scripts/probe_retired_modules.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/bin/bash -# -# Probe the module surfaces the v6.7 upgrade retires and print a fingerprint of -# how the chain answered each one. The caller runs this on both sides of the -# upgrade and compares the two fingerprints: retiring a module changes what a -# node stores, never how it answers a client that has not noticed yet. A -# fingerprint that shifts at the upgrade height means two nodes mid-rollout -# would disagree about a block. -# -# Each probe reports a fixed token rather than raw output, so that incidental -# differences between two runs (transaction hashes, gas figures, heights) cannot -# make equivalent answers look different. A probe that did not get the expected -# answer reports the output it did get, which both fails the run and says why. -# -# The last line is PROBE_RESULT, a single verdict over all probes, so a caller -# can assert the whole run with one match. -# -# The chain identity and signing key are read from the environment so that one -# probe definition serves both the four-node docker cluster and a single-node -# local chain. Defaults are the docker cluster's, so the YAML suite that calls -# this needs no arguments. -# -# CHAIN_ID chain id to sign against (default sei) -# ADMIN_KEY key name to sign and query with (default node_admin) -# -# Usage: probe_retired_modules.sh [spam_count] - -set -uo pipefail - -FEE_GRANTER=${1:-} -SPAM_COUNT=${2:-5} - -if [ -z "$FEE_GRANTER" ]; then - echo "Usage: $0 [spam_count]" >&2 - exit 1 -fi - -seidbin=seid -chainid=${CHAIN_ID:-sei} -adminkey=${ADMIN_KEY:-node_admin} - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../../utils/_tx_helpers.sh -source "$SCRIPT_DIR/../../utils/_tx_helpers.sh" - -ADMIN_ADDR=$(_get_key_address "$adminkey") -ADMIN_VAL_ADDR=$(printf "12345678\n" | $seidbin keys show "$adminkey" --bech val -a 2>/dev/null) - -if [ -z "$ADMIN_ADDR" ] || [ -z "$ADMIN_VAL_ADDR" ]; then - echo "could not resolve key '$adminkey'; set ADMIN_KEY to a key in this node's keyring" >&2 - exit 1 -fi - -MISSED="" - -# report prints one fingerprint line and records whether the probe matched. -report() { - echo "$1=$2" - case "$2" in MATCH) ;; *) MISSED="$MISSED $1" ;; esac -} - -# token collapses an output to MATCH when it carries the expected text, or to a -# truncated single-line echo of what came back instead. -token() { - case "$1" in - *"$2"*) echo "MATCH" ;; - *) echo "MISS[$(echo "$1" | tr '\n' ' ' | cut -c1-160)]" ;; - esac -} - -# Retired oracle message handlers, reached through simulation. --dry-run does -# not open the keyring, so --from has to be the address rather than the key name. -report ORACLE_VOTE_SIM "$(token "$(printf '12345678\n' | $seidbin tx oracle aggregate-vote \ - 1.5ueth "$ADMIN_VAL_ADDR" --from "$ADMIN_ADDR" --chain-id "$chainid" --dry-run 2>&1)" \ - 'oracle module is deprecated')" -report ORACLE_FEEDER_SIM "$(token "$(printf '12345678\n' | $seidbin tx oracle set-feeder \ - "$ADMIN_ADDR" --from "$ADMIN_ADDR" --chain-id "$chainid" --dry-run 2>&1)" \ - 'oracle module is deprecated')" - -# Retired oracle query handlers. -report ORACLE_PARAMS_QUERY "$(token "$($seidbin q oracle params --output json 2>&1)" \ - 'oracle module is deprecated')" -report ORACLE_TWAPS_QUERY "$(token "$($seidbin q oracle twaps 3600 --output json 2>&1)" \ - 'oracle module is deprecated')" - -# A transaction nominating a fee granter other than its signer. The feegrant -# module is gone, so the chain refuses it, and refuses it in ValidateBasic -# before any fee is taken. -report FEE_GRANTER_DISTINCT "$(token "$(printf '12345678\n' | $seidbin tx bank send \ - "$adminkey" "$ADMIN_ADDR" 1usei --fee-account "$FEE_GRANTER" --from "$adminkey" \ - --chain-id "$chainid" --fees 2000usei -b sync -y --output json 2>&1)" \ - 'fee grants are not enabled')" - -# The granter field itself stays on the wire: a transaction naming its own -# signer as granter is still accepted, which is what keeps older clients -# working. CheckTx code 0 is the accepted answer. -report FEE_GRANTER_SELF "$(token "$(printf '12345678\n' | $seidbin tx bank send "$adminkey" \ - "$ADMIN_ADDR" 1usei --fee-account "$ADMIN_ADDR" --from "$adminkey" --chain-id "$chainid" \ - --fees 2000usei -b sync -y --output json 2>&1 | jq -r '.code // "unparsable"')" '0')" - -# Spam the retired oracle handler with real broadcasts. Each one is rejected but -# still committed, so the sender's sequence advances and its balance drops: a -# retired handler must not become a free way to fill blocks. -balance_before=$($seidbin q bank balances "$ADMIN_ADDR" --denom usei --output json 2>/dev/null | jq -r '.amount // 0') -seq_before=$(_get_account_sequence "$ADMIN_ADDR") - -for ((i = 0; i < SPAM_COUNT; i++)); do - printf '12345678\n' | $seidbin tx oracle aggregate-vote "1.${i}ueth" "$ADMIN_VAL_ADDR" \ - --from "$adminkey" --chain-id "$chainid" --fees 2000usei -b sync -y --output json >/dev/null 2>&1 - _wait_until "$adminkey sequence > $((seq_before + i))" \ - "[ \$(_get_account_sequence $ADMIN_ADDR) -gt $((seq_before + i)) ]" >/dev/null 2>&1 -done - -seq_after=$(_get_account_sequence "$ADMIN_ADDR") -balance_after=$($seidbin q bank balances "$ADMIN_ADDR" --denom usei --output json 2>/dev/null | jq -r '.amount // 0') - -if [ "$seq_after" -gt "$seq_before" ]; then - report ORACLE_SPAM_COMMITTED "MATCH" -else - report ORACLE_SPAM_COMMITTED "MISS[sequence stayed at $seq_before]" -fi - -if [ "$balance_after" -lt "$balance_before" ]; then - report ORACLE_SPAM_CHARGED "MATCH" -else - report ORACLE_SPAM_CHARGED "MISS[balance $balance_before to $balance_after]" -fi - -if [ -z "$MISSED" ]; then - echo "PROBE_RESULT=ALL_MATCH" -else - echo "PROBE_RESULT=MISSED[$(echo "$MISSED" | xargs)]" -fi diff --git a/integration_test/upgrade_tests/.gitignore b/integration_test/upgrade_tests/.gitignore deleted file mode 100644 index fd3807ca80..0000000000 --- a/integration_test/upgrade_tests/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -artifacts/ diff --git a/integration_test/upgrade_tests/.mocharc.record.json b/integration_test/upgrade_tests/.mocharc.record.json deleted file mode 100644 index eb0843e304..0000000000 --- a/integration_test/upgrade_tests/.mocharc.record.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "require": ["tsx"], - "timeout": 300000, - "exit": true, - "spec": ["record/*.spec.ts"] -} diff --git a/integration_test/upgrade_tests/.mocharc.selftest.json b/integration_test/upgrade_tests/.mocharc.selftest.json deleted file mode 100644 index f26d7cccdd..0000000000 --- a/integration_test/upgrade_tests/.mocharc.selftest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "require": ["tsx"], - "timeout": 30000, - "exit": true, - "spec": ["selftest/*.spec.ts"] -} diff --git a/integration_test/upgrade_tests/.mocharc.verify.json b/integration_test/upgrade_tests/.mocharc.verify.json deleted file mode 100644 index fcaeb8b0d4..0000000000 --- a/integration_test/upgrade_tests/.mocharc.verify.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "require": ["tsx"], - "timeout": 300000, - "exit": true, - "spec": ["verify/*.spec.ts"] -} diff --git a/integration_test/upgrade_tests/README.md b/integration_test/upgrade_tests/README.md deleted file mode 100644 index 5504573adb..0000000000 --- a/integration_test/upgrade_tests/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# Upgrade tests - -Records how a live network answers for the modules an upgrade retires, then -verifies the answers once the upgrade has landed. - -Because the upgrade happens when the network upgrades, this is **two separately -invoked phases** with an artifact carried between them. - -``` - record ──── artifact ────► verify - (before upgrade) (after upgrade) -``` - -## Running it - -```bash -npm install - -# Before the upgrade. Writes artifacts/pre-upgrade.json. Keep that file. -UPGRADE_TEST_NETWORK=arctic-1 npm run record - -# After the upgrade has landed on that network. -UPGRADE_TEST_NETWORK=arctic-1 npm run verify - -# Checks on the suite itself. No chain needed; runs in CI on every PR. -npm run selftest -npm run typecheck -``` - -The read-only probes need no key and no funding, so `npm run record` works -against a public endpoint as-is. Set `UPGRADE_TEST_MNEMONIC` to also send -transaction probes, which is what lets the verify phase check whether committed -history survived. Without it that check is skipped and says so. - -| Variable | Default | Purpose | -| --- | --- | --- | -| `UPGRADE_TEST_NETWORK` | `arctic-1` | `local`, `arctic-1`, or `atlantic-2` | -| `UPGRADE_TEST_PLAN_NAME` | `v6.7` | Governance plan name, the key `applied_plan` is stored under | -| `UPGRADE_TEST_REMOVED_MODULES` | `capability feegrant ibc transfer` | Modules the plan deletes from the version map | -| `UPGRADE_TEST_ARTIFACT` | `artifacts/pre-upgrade.json` | Where the artifact is written and read | -| `UPGRADE_TEST_EVM_RPC` | per network | Override, e.g. an archive node | -| `UPGRADE_TEST_REST` | per network | Cosmos REST override | -| `UPGRADE_TEST_MNEMONIC` | unset | Funded key for the transaction probes; falls back to `DAPP_TESTS_MNEMONIC` | - -## What it asserts - -**The gate.** The verify phase refuses to run unless `applied_plan/` is -non-zero *and* greater than the height the artifact was recorded at. Without -both halves a run against a not-yet-upgraded chain would report a clean pass on -everything that must not change and a confusing failure on everything that -must — the opposite of a useful signal. The gate is a pure function -(`upgradeGateRefusal`) so it is unit tested rather than only exercised live. - -**Invariants** must answer identically on both sides, and a difference fails. -These are surfaces the upgrade does not claim to touch, so a failure is -unambiguous. - -**Transitions** are expected to change. They are recorded on both sides and -printed as a before/after diff. The specific changes the upgrade is specified to -make get their own named assertions; the rest are reported for a human to read. - -**History** committed before the upgrade must still be served afterwards: -`eth_call` at the recorded heights must return the same answer, receipts must -still resolve with the same status and block, and a transaction that failed must -still fail for the same reason. - -## Two things worth knowing - -**A live network runs a released binary, not this checkout.** arctic-1 is on -v6.6.1, where the oracle query handlers still serve data and the upgrade -precompile at `0x…1015` is not registered at all. So the oracle surfaces are -classified as transitions here rather than invariants: from that network's point -of view the oracle deprecation arrives *with* v6.7. Classifying head-of-tree -behaviour as an invariant would fail the run for the wrong reason. - -**The upgrade module is read over Cosmos REST, not the upgrade precompile.** -The one fact the verify phase cannot work without is whether the upgrade ran, -and a precompile that is absent on the binary being tested cannot answer it. -`applied_plan` and `module_versions` therefore come from REST, which is proven -against a real past upgrade: arctic-1 reports v6.3.0 applied at height -138093082. - -## Where this sits - -Five layers test the same upgrade, trading control for realism: - -| Layer | Upgrade is | Pre-upgrade state | Runtime | -| --- | --- | --- | --- | -| `app/upgrade_v67_test.go` | `ApplyUpgrade` in process | synthetic store writes | seconds | -| `make local-upgrade-test` | real gov proposal, one node | empty | ~30s | -| `make cross-upgrade-test` | real gov proposal, **two binaries** | **real** | ~30s + old build | -| `integration_test/upgrade_module/retired_modules_upgrade_test.yaml` | real gov proposal, four nodes | empty | minutes, needs docker | -| this suite | whenever the network upgrades | whatever the network has | two runs, days apart | - -The in-process Go tests call `ApplyUpgrade` directly, which never writes -`upgrade-info.json` and never reloads the stores, so `App.SetStoreUpgradeHandlers` -— the code that decides whether a store is dropped at an upgrade height — is not -reached by any of them. `make local-upgrade-test` is the cheapest layer that -does reach it, and `make cross-upgrade-test` is the only one where the -pre-upgrade state was written by modules that were actually alive. - -`app/upgrade_orphan_test.go` guards against a removed module leaving a stale -version entry behind. arctic-1 carries `dex` and `accesscontrol` entries from -removals that predate the guard. diff --git a/integration_test/upgrade_tests/config/targets.ts b/integration_test/upgrade_tests/config/targets.ts deleted file mode 100644 index 782e2eabf5..0000000000 --- a/integration_test/upgrade_tests/config/targets.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Which chain the suite talks to, and the guard that it is the intended one. - * - * This suite runs against long-lived shared networks, and its two phases run - * hours or days apart. Recording against one network and verifying against - * another would produce a confident, meaningless diff, so the chain identifier - * is checked against the target table before anything else runs. - */ - -export type TargetName = 'local' | 'arctic-1' | 'atlantic-2'; - -export interface Target { - name: TargetName; - evmChainId: bigint; - cosmosChainId: string; - evmRpcUrl: string; - /** - * Cosmos REST (LCD). The upgrade module's applied plan and module version - * map are read from here rather than from the upgrade precompile: the - * precompile is not registered on every released binary (arctic-1 on v6.6.1 - * answers 0x at 0x…1015), and the one fact the verify phase cannot work - * without is whether the upgrade ran. - */ - restUrl: string; -} - -/** - * Endpoints match integration_test/dapp_tests/hardhat.config.js (the public - * sei-apis.com hosts) rather than the platform wave nodes the load generator - * uses, so the suite works without access to internal infrastructure. Override - * with UPGRADE_TEST_EVM_RPC / UPGRADE_TEST_REST to point at a wave node or a - * private archive. - */ -const TARGETS: Record> = { - local: { - evmChainId: 713714n, - cosmosChainId: 'sei', - evmRpcUrl: 'http://localhost:8545', - restUrl: 'http://localhost:1317', - }, - 'arctic-1': { - evmChainId: 713715n, - cosmosChainId: 'arctic-1', - evmRpcUrl: 'https://evm-rpc-arctic-1.sei-apis.com', - restUrl: 'https://rest-arctic-1.sei-apis.com', - }, - 'atlantic-2': { - evmChainId: 1328n, - cosmosChainId: 'atlantic-2', - evmRpcUrl: 'https://evm-rpc-testnet.sei-apis.com', - restUrl: 'https://rest-testnet.sei-apis.com', - }, -}; - -function isTargetName(value: string): value is TargetName { - return value in TARGETS; -} - -export function resolveTarget(env: NodeJS.ProcessEnv = process.env): Target { - const name = env.UPGRADE_TEST_NETWORK ?? 'arctic-1'; - if (!isTargetName(name)) { - throw new Error( - `UPGRADE_TEST_NETWORK=${name} is not one of ${Object.keys(TARGETS).join(', ')}`, - ); - } - const target = TARGETS[name]; - return { - name, - ...target, - evmRpcUrl: env.UPGRADE_TEST_EVM_RPC ?? target.evmRpcUrl, - restUrl: env.UPGRADE_TEST_REST ?? target.restUrl, - }; -} - -/** - * The upgrade whose effects this suite records and verifies. The name has to - * match the governance plan name exactly, because that string is the key - * appliedPlan() is stored under. - */ -export function upgradeName(env: NodeJS.ProcessEnv = process.env): string { - return env.UPGRADE_TEST_PLAN_NAME ?? 'v6.7'; -} - -/** The mnemonic used for the transaction probes, or '' when none was given. */ -export function adminMnemonic(env: NodeJS.ProcessEnv = process.env): string { - return env.UPGRADE_TEST_MNEMONIC ?? env.DAPP_TESTS_MNEMONIC ?? ''; -} - -/** - * The modules the upgrade removes from the module version map. Anything else - * disappearing is a regression, and a name here that survives means the handler - * is missing a DeleteModuleVersion call for it. - */ -export function expectedRemovedModules(env: NodeJS.ProcessEnv = process.env): string[] { - const raw = env.UPGRADE_TEST_REMOVED_MODULES; - const names = raw ? raw.split(/[\s,]+/).filter(Boolean) : ['capability', 'feegrant', 'ibc', 'transfer']; - return [...names].sort(); -} diff --git a/integration_test/upgrade_tests/package-lock.json b/integration_test/upgrade_tests/package-lock.json deleted file mode 100644 index d58dc221dc..0000000000 --- a/integration_test/upgrade_tests/package-lock.json +++ /dev/null @@ -1,1697 +0,0 @@ -{ - "name": "sei-upgrade-tests", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "sei-upgrade-tests", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "ethers": "^6.14.0" - }, - "devDependencies": { - "@types/chai": "^4.3.16", - "@types/mocha": "^10.0.7", - "@types/node": "^22.5.0", - "chai": "^4.4.1", - "mocha": "^10.7.3", - "tsx": "^4.19.0", - "typescript": "^5.5.4" - } - }, - "node_modules/@adraffy/ens-normalize": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", - "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@types/chai": { - "version": "4.3.20", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", - "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mocha": { - "version": "10.0.10", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", - "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/aes-js": { - "version": "4.0.0-beta.5", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", - "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "license": "MIT" - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true, - "license": "ISC" - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/diff": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", - "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ethers": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", - "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "1.11.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.21.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ethers/node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/ethers/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mocha": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", - "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.3", - "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", - "debug": "^4.3.5", - "diff": "^5.2.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^8.1.0", - "he": "^1.2.0", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", - "ms": "^2.1.3", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.23.12", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", - "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", - "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/integration_test/upgrade_tests/package.json b/integration_test/upgrade_tests/package.json deleted file mode 100644 index 7c147cadaf..0000000000 --- a/integration_test/upgrade_tests/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "sei-upgrade-tests", - "version": "1.0.0", - "private": true, - "description": "Two-phase suite that records how a live Sei network answers for the modules an upgrade retires, then verifies the answers after the upgrade lands.", - "license": "MIT", - "scripts": { - "record": "mocha --config .mocharc.record.json", - "verify": "mocha --config .mocharc.verify.json", - "selftest": "mocha --config .mocharc.selftest.json", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "ethers": "^6.14.0" - }, - "devDependencies": { - "@types/chai": "^4.3.16", - "@types/mocha": "^10.0.7", - "@types/node": "^22.5.0", - "chai": "^4.4.1", - "mocha": "^10.7.3", - "tsx": "^4.19.0", - "typescript": "^5.5.4" - } -} diff --git a/integration_test/upgrade_tests/record/record.spec.ts b/integration_test/upgrade_tests/record/record.spec.ts deleted file mode 100644 index 720d7cb49d..0000000000 --- a/integration_test/upgrade_tests/record/record.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Phase one: run before the upgrade. - * - * Probes every module surface the upgrade retires, sends transactions at the - * retired ones, and writes it all to an artifact. Nothing here asserts the - * upgrade's effects — that is the verify phase's job. What it does assert is - * that the chain is in a state worth recording: if the upgrade has already run, - * an artifact taken now would make the comparison vacuous. - */ -import { expect } from 'chai'; -import { - adminMnemonic, - expectedRemovedModules, - resolveTarget, - upgradeName, -} from '../config/targets'; -import { assertExpectedChain } from '../utils/chain'; -import { writeArtifact, type Artifact, type RecordedTx } from '../utils/artifact'; -import { - appliedPlanHeight, - applicationVersion, - currentPlan, - latestCosmosHeight, - moduleVersions, -} from '../utils/cosmos'; -import { currentHeight, runProbes, takeArchiveReads } from '../utils/probes'; -import { fundedAddress, sendProbeTransactions } from '../utils/txProbes'; - -describe('record pre-upgrade behaviour', function () { - this.timeout(300 * 1000); - - const target = resolveTarget(); - const planName = upgradeName(); - const mnemonic = adminMnemonic(); - - before(async () => { - await assertExpectedChain(); - }); - - it(`is on a chain that has not applied ${planName} yet`, async () => { - const applied = await appliedPlanHeight(planName); - expect( - applied, - `${target.name} already applied ${planName} at height ${applied}. Recording now ` + - 'would capture post-upgrade behaviour and leave the verify phase comparing it ' + - 'against itself. Set UPGRADE_TEST_PLAN_NAME to the next upgrade instead.', - ).to.equal(0); - }); - - it('writes an artifact of every retired surface', async () => { - const blockNumber = await currentHeight(); - const cosmosHeight = await latestCosmosHeight(); - const seidVersion = await applicationVersion(); - const probes = await runProbes(planName); - const archiveReads = await takeArchiveReads(blockNumber); - const modules = await moduleVersions(); - - let transactions: RecordedTx[] = []; - if (mnemonic) { - const funded = await fundedAddress(mnemonic); - expect( - funded.balance > 0n, - `${funded.address} has no balance, so the transaction probes cannot be sent`, - ).to.equal(true); - transactions = await sendProbeTransactions(mnemonic); - } - - const artifact: Artifact = { - schema: 1, - meta: { - network: target.name, - evmChainId: target.evmChainId.toString(), - planName, - blockNumber, - cosmosHeight, - seidVersion, - recordedAt: new Date().toISOString(), - }, - probes, - archiveReads, - transactions, - moduleVersions: modules, - }; - - const written = writeArtifact(artifact); - - // Each module the upgrade is meant to remove must still be present, or - // there is nothing for the upgrade to remove and the verify phase would - // pass without proving anything. - const names = modules.map(m => m.name); - for (const module of expectedRemovedModules()) { - expect(names, `${module} must still hold a module version before the upgrade`).to.include( - module, - ); - } - - const scheduled = await currentPlan(); - console.log(` network ${target.name} on seid ${seidVersion}, EVM height ${blockNumber}`); - console.log(` modules ${modules.length} with a version entry`); - console.log( - ` scheduled ${scheduled ? `${scheduled.name} at height ${scheduled.height}` : 'no upgrade scheduled yet'}`, - ); - console.log(` transactions ${transactions.length}${mnemonic ? '' : ' (no UPGRADE_TEST_MNEMONIC; read-only probes only)'}`); - console.log(` artifact ${written}`); - console.log(' keep the artifact; the verify phase needs it once the upgrade lands.'); - for (const probe of probes) { - console.log(` ${probe.kind === 'invariant' ? '=' : '~'} ${probe.name} = ${probe.value}`); - } - }); -}); diff --git a/integration_test/upgrade_tests/selftest/harness.spec.ts b/integration_test/upgrade_tests/selftest/harness.spec.ts deleted file mode 100644 index 99c1cf2e6e..0000000000 --- a/integration_test/upgrade_tests/selftest/harness.spec.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * Checks on the suite itself, runnable with no chain. - * - * The record and verify phases only run against a live network, days apart, and - * a mistake in the comparison logic would surface as a false pass at the worst - * possible moment. These cover the parts that can be exercised offline: the - * ABIs the probes encode against, the artifact round trip, the gate, and the - * normalisation both phases share. - */ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { expect } from 'chai'; -import { precompileInterface, ADDRESSES } from '../utils/chain'; -import { - assertSameChain, - probeValue, - readArtifact, - upgradeGateRefusal, - writeArtifact, - type Artifact, -} from '../utils/artifact'; -import { - archiveReadLabels, - diffProbes, - isHistoryUnavailable, - knowsArchiveRead, - probes, - reReadAtHeight, -} from '../utils/probes'; -import { expectedRemovedModules, resolveTarget, upgradeName } from '../config/targets'; - -const sampleArtifact = (): Artifact => ({ - schema: 1, - meta: { - network: 'arctic-1', - evmChainId: '713715', - planName: 'v6.7', - blockNumber: 1234, - cosmosHeight: 1234, - seidVersion: 'v6.6.1', - recordedAt: '2026-01-01T00:00:00.000Z', - }, - probes: [ - { - name: 'cosmos.moduleVersion.feegrant', - kind: 'transition', - describes: 'x', - value: 'v1', - }, - { - name: 'cosmos.moduleVersion.bank', - kind: 'invariant', - describes: 'x', - value: 'v2', - }, - ], - archiveReads: [], - transactions: [], - moduleVersions: [ - { name: 'bank', version: '2' }, - { name: 'feegrant', version: '1' }, - { name: 'oracle', version: '6' }, - ], -}); - -describe('suite self-checks', () => { - describe('precompile ABIs the probes encode against', () => { - it('the oracle interface still has the methods the probes call', () => { - const iface = precompileInterface('oracle'); - expect(iface.encodeFunctionData('getExchangeRates', [])).to.match(/^0x[0-9a-f]{8}$/); - expect(iface.encodeFunctionData('getOracleTwaps', [3600n])).to.match(/^0x[0-9a-f]+$/); - }); - - it('the upgrade interface still has appliedPlan and moduleVersions', () => { - const iface = precompileInterface('upgrade'); - expect(iface.encodeFunctionData('appliedPlan', ['v6.7'])).to.match(/^0x[0-9a-f]+$/); - expect(iface.encodeFunctionData('moduleVersions', [''])).to.match(/^0x[0-9a-f]+$/); - }); - - it('pins the addresses the suite probes', () => { - expect(ADDRESSES.oracle).to.equal('0x0000000000000000000000000000000000001008'); - expect(ADDRESSES.feegrant).to.equal('0x0000000000000000000000000000000000001010'); - expect(ADDRESSES.upgrade).to.equal('0x0000000000000000000000000000000000001015'); - }); - }); - - describe('the probe table', () => { - const table = probes('v6.7'); - - it('names every probe uniquely, so artifact lookups cannot collide', () => { - const names = table.map(p => p.name); - expect(new Set(names).size, `duplicate probe name in ${names.join(', ')}`).to.equal( - names.length, - ); - }); - - it('explains every probe, since the artifact outlives whoever ran it', () => { - for (const probe of table) { - expect(probe.describes, `${probe.name} has no description`).to.not.equal(''); - } - }); - - it('covers both what must hold and what must change', () => { - expect(table.some(p => p.kind === 'invariant')).to.equal(true); - expect(table.some(p => p.kind === 'transition')).to.equal(true); - }); - - it('probes every module the upgrade removes, plus the applied plan', () => { - const names = table.map(p => p.name); - for (const module of expectedRemovedModules()) { - expect(names, `no probe for the removal of ${module}`).to.include( - `cosmos.moduleVersion.${module}`, - ); - } - expect(names).to.include('cosmos.appliedPlan.v6.7'); - }); - - it('expects the four modules v6.7 retires', () => { - expect(expectedRemovedModules()).to.deep.equal([ - 'capability', - 'feegrant', - 'ibc', - 'transfer', - ]); - }); - }); - - describe('the probe diff the verify phase reports', () => { - it('lists only the probes whose answer moved', () => { - const before = sampleArtifact().probes; - const after = before.map(p => - p.name === 'cosmos.moduleVersion.feegrant' ? { ...p, value: 'absent' } : p, - ); - expect(diffProbes(before, after)).to.deep.equal([ - { - name: 'cosmos.moduleVersion.feegrant', - kind: 'transition', - from: 'v1', - to: 'absent', - }, - ]); - }); - - it('is empty when nothing moved', () => { - const probeList = sampleArtifact().probes; - expect(diffProbes(probeList, probeList)).to.deep.equal([]); - }); - - it('ignores a probe the artifact never recorded, which the invariant check reports instead', () => { - const after = [ - ...sampleArtifact().probes, - { name: 'brand.new', kind: 'invariant' as const, describes: 'x', value: 'ok' }, - ]; - expect(diffProbes(sampleArtifact().probes, after)).to.deep.equal([]); - }); - }); - - describe('the artifact', () => { - let dir: string; - let env: NodeJS.ProcessEnv; - - beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sei-upgrade-tests-')); - env = { UPGRADE_TEST_ARTIFACT: path.join(dir, 'pre-upgrade.json') }; - }); - - afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); - - it('round trips without losing anything', () => { - const original = sampleArtifact(); - writeArtifact(original, env); - expect(readArtifact(env)).to.deep.equal(original); - }); - - it('creates the directory it writes into', () => { - const nested = { UPGRADE_TEST_ARTIFACT: path.join(dir, 'a', 'b', 'pre.json') }; - writeArtifact(sampleArtifact(), nested); - expect(fs.existsSync(nested.UPGRADE_TEST_ARTIFACT!)).to.equal(true); - }); - - it('says how to produce one when it is missing', () => { - expect(() => readArtifact(env)).to.throw(/Run the record phase before the upgrade/); - }); - - it('refuses an artifact from a different chain', () => { - const artifact = sampleArtifact(); - expect(() => assertSameChain(artifact, 'atlantic-2', 1328n)).to.throw( - /recorded against EVM chain 713715/, - ); - expect(() => assertSameChain(artifact, 'arctic-1', 713715n)).to.not.throw(); - }); - - it('refuses a schema it does not understand', () => { - const artifact = { ...sampleArtifact(), schema: 99 }; - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(env.UPGRADE_TEST_ARTIFACT!, JSON.stringify(artifact)); - expect(() => readArtifact(env)).to.throw(/unsupported artifact schema 99/); - }); - - it('looks probes up by name', () => { - const artifact = sampleArtifact(); - expect(probeValue(artifact, 'cosmos.moduleVersion.feegrant')).to.equal('v1'); - expect(probeValue(artifact, 'nope')).to.equal(undefined); - }); - }); - - /** - * The gate decides whether anything the verify phase reports means - * anything, and its failure mode is a false pass. It is pure so that it can - * be pinned here rather than only exercised against a live chain. - */ - describe('the gate on the verify phase', () => { - const gate = (appliedHeight: number, artifactCosmosHeight = 1000) => - upgradeGateRefusal({ - planName: 'v6.7', - network: 'arctic-1', - appliedHeight, - artifactCosmosHeight, - context: 'height 1200, seid v6.6.1', - }); - - it('refuses when the upgrade has not been applied', () => { - expect(gate(0)).to.match(/has not applied v6\.7 yet/); - }); - - it('refuses when the artifact was recorded after the upgrade', () => { - expect(gate(900, 1000)).to.match(/at or before the artifact was recorded/); - }); - - it('refuses when the upgrade landed exactly at the recorded height', () => { - expect(gate(1000, 1000)).to.match(/at or before the artifact was recorded/); - }); - - it('allows a run where the upgrade landed after the artifact', () => { - expect(gate(1100, 1000)).to.equal(undefined); - }); - - it('names the network and the plan, so the message is actionable', () => { - expect(gate(0)).to.contain('arctic-1').and.to.contain('v6.7'); - }); - }); - - /** - * The record phase and the verify phase have to reduce the same answer to - * the same token. When they did not, a re-read of an unchanged block - * reported `revert:oracle-retired` becoming - * `revert:raw(execution reverted: oracle precompile is retired…)` — the same - * answer, called a regression. Both phases now go through one lookup table, - * and this pins that they stay in step. - */ - describe('archive reads normalise identically in both phases', () => { - it('re-reads every label the record phase can produce', () => { - for (const label of archiveReadLabels()) { - expect(knowsArchiveRead(label), `${label} cannot be re-read`).to.equal(true); - } - }); - - it('takes at least one archive read, or the history check proves nothing', () => { - expect(archiveReadLabels().length).to.be.greaterThan(0); - }); - - it('reports rather than compares a label it no longer defines', async () => { - const outcome = await reReadAtHeight({ - label: 'retired.probe.from.an.older.artifact', - blockNumber: 1, - value: 'ok:0x', - }); - expect(outcome).to.deep.equal({ unknownLabel: true }); - }); - }); - - describe('pruned history is not a changed answer', () => { - it('recognises the ways a node says it no longer has a height', () => { - for (const value of [ - 'revert:raw(height 100 is not available, lowest height is 900)', - 'revert:raw(failed to load state at height 5)', - 'revert:raw(-32000: header not found)', - ]) { - expect(isHistoryUnavailable(value), value).to.equal(true); - } - }); - - it('does not mistake a real answer for a pruned one', () => { - expect(isHistoryUnavailable('ok:0x')).to.equal(false); - expect(isHistoryUnavailable('revert:oracle-retired')).to.equal(false); - expect(isHistoryUnavailable('absent')).to.equal(false); - }); - }); - - describe('target resolution', () => { - it('defaults to arctic-1', () => { - const target = resolveTarget({}); - expect(target.name).to.equal('arctic-1'); - expect(target.evmChainId).to.equal(713715n); - }); - - it('honours an endpoint override without losing the expected chain id', () => { - const target = resolveTarget({ UPGRADE_TEST_EVM_RPC: 'http://archive.example:8545' }); - expect(target.evmRpcUrl).to.equal('http://archive.example:8545'); - expect(target.evmChainId).to.equal(713715n); - }); - - it('rejects a network it has no chain id for', () => { - expect(() => resolveTarget({ UPGRADE_TEST_NETWORK: 'mainnet' })).to.throw( - /is not one of/, - ); - }); - - it('defaults the plan name to the upgrade under test', () => { - expect(upgradeName({})).to.equal('v6.7'); - expect(upgradeName({ UPGRADE_TEST_PLAN_NAME: 'v6.8' })).to.equal('v6.8'); - }); - - it('lets the removed-module set be overridden, and sorts it', () => { - expect(expectedRemovedModules({ UPGRADE_TEST_REMOVED_MODULES: 'zeta,alpha' })).to.deep.equal([ - 'alpha', - 'zeta', - ]); - }); - }); -}); diff --git a/integration_test/upgrade_tests/tsconfig.json b/integration_test/upgrade_tests/tsconfig.json deleted file mode 100644 index 1f37f05596..0000000000 --- a/integration_test/upgrade_tests/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "Node", - "lib": ["ES2022"], - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "forceConsistentCasingInFileNames": true, - "types": ["node", "mocha", "chai"] - }, - "include": ["**/*.ts"], - "exclude": ["node_modules", "artifacts"] -} diff --git a/integration_test/upgrade_tests/utils/artifact.ts b/integration_test/upgrade_tests/utils/artifact.ts deleted file mode 100644 index e33fa8c8e0..0000000000 --- a/integration_test/upgrade_tests/utils/artifact.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * The file that carries one run's observations to the next. - * - * The two phases are separated by a real governance upgrade, so they are - * separate processes minutes or days apart, possibly on different machines. - * Everything the verify phase needs has to survive in this file, and it records - * which chain and height it came from so a mismatched pair fails loudly instead - * of comparing unrelated observations. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import type { ModuleVersion } from './cosmos'; -import type { ArchiveRead, ProbeResult } from './probes'; - -/** A transaction the record phase put on chain, to be re-read afterwards. */ -export interface RecordedTx { - label: string; - hash: string; - blockNumber: number; - /** 0 for a failed transaction, 1 for a successful one. */ - status: number; - /** eth_getVMError for a failed transaction, when the node served one. */ - vmError?: string; -} - -export interface Artifact { - schema: 1; - meta: { - network: string; - evmChainId: string; - planName: string; - /** EVM block height the observations were taken at. */ - blockNumber: number; - /** - * Cosmos block height at record time. Kept separately from the EVM - * height because applied_plan reports a Cosmos height, and the gate - * compares the two. - */ - cosmosHeight: number; - /** The seid version the endpoint reported, which is what was observed. */ - seidVersion: string; - recordedAt: string; - }; - probes: ProbeResult[]; - archiveReads: ArchiveRead[]; - transactions: RecordedTx[]; - /** The chain's whole module version map at record time, sorted by name. */ - moduleVersions: ModuleVersion[]; -} - -const DEFAULT_PATH = 'artifacts/pre-upgrade.json'; - -export function artifactPath(env: NodeJS.ProcessEnv = process.env): string { - return path.resolve(env.UPGRADE_TEST_ARTIFACT ?? DEFAULT_PATH); -} - -export function writeArtifact(artifact: Artifact, env: NodeJS.ProcessEnv = process.env): string { - const target = artifactPath(env); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, `${JSON.stringify(artifact, null, 2)}\n`); - return target; -} - -export function readArtifact(env: NodeJS.ProcessEnv = process.env): Artifact { - const source = artifactPath(env); - if (!fs.existsSync(source)) { - throw new Error( - `No pre-upgrade artifact at ${source}. Run the record phase before the upgrade ` + - '(npm run record), keep the file, and point UPGRADE_TEST_ARTIFACT at it afterwards.', - ); - } - const artifact = JSON.parse(fs.readFileSync(source, 'utf-8')) as Artifact; - if (artifact.schema !== 1) { - throw new Error(`${source}: unsupported artifact schema ${artifact.schema}`); - } - return artifact; -} - -/** - * Refuse an artifact recorded against a different chain. Comparing observations - * across networks would produce a confident diff about nothing. - */ -export function assertSameChain(artifact: Artifact, network: string, evmChainId: bigint): void { - if (artifact.meta.evmChainId !== evmChainId.toString()) { - throw new Error( - `artifact was recorded against EVM chain ${artifact.meta.evmChainId} (${artifact.meta.network}) ` + - `but this run targets ${evmChainId} (${network})`, - ); - } -} - -export function probeValue(artifact: Artifact, name: string): string | undefined { - return artifact.probes.find(p => p.name === name)?.value; -} - -/** - * The gate on the verify phase: the upgrade must have run, and it must have run - * after the artifact was taken. - * - * Both halves matter. Without the first, a run against a chain that has not - * upgraded yet reports a clean pass on everything that must not change and a - * baffling failure on everything that must — the opposite of a useful signal. - * Without the second, an artifact recorded after the upgrade would be compared - * against itself and pass while proving nothing. - * - * Returns the message explaining the refusal, or undefined when the run may - * proceed. Pure so it can be tested without a chain. - */ -export function upgradeGateRefusal(args: { - planName: string; - network: string; - appliedHeight: number; - artifactCosmosHeight: number; - /** Rendered into the message so the reader can see how far off the chain is. */ - context: string; -}): string | undefined { - const { planName, network, appliedHeight, artifactCosmosHeight, context } = args; - if (appliedHeight === 0) { - return ( - `${network} has not applied ${planName} yet (applied_plan reports 0 at ${context}). ` + - 'Wait for the upgrade to land on this network, then re-run.' - ); - } - if (appliedHeight <= artifactCosmosHeight) { - return ( - `${planName} was applied at height ${appliedHeight}, at or before the artifact was ` + - `recorded at height ${artifactCosmosHeight}. The artifact already describes an ` + - 'upgraded chain, so there is no pre-upgrade state to compare against.' - ); - } - return undefined; -} diff --git a/integration_test/upgrade_tests/utils/chain.ts b/integration_test/upgrade_tests/utils/chain.ts deleted file mode 100644 index 75171a4d44..0000000000 --- a/integration_test/upgrade_tests/utils/chain.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Transport helpers. Mirrors integration_test/precompile_tests/utils/chainUtils.ts, - * kept local so this suite has no cross-package import. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { ethers } from 'ethers'; -import { resolveTarget } from '../config/targets'; - -export interface JsonRpcError { - code: number; - message: string; - data?: unknown; -} - -export interface JsonRpcEnvelope { - result?: T; - error?: JsonRpcError; -} - -let provider: ethers.JsonRpcProvider | undefined; - -export function seiRpc(): ethers.JsonRpcProvider { - if (!provider) { - provider = new ethers.JsonRpcProvider(resolveTarget().evmRpcUrl, undefined, { - batchMaxCount: 1, - staticNetwork: true, - }); - } - return provider; -} - -/** - * Raw JSON-RPC POST. Precompile reverts are asserted through the raw envelope - * because ethers rewrites the node's error message on the way out, and the exact - * message is the thing being compared across the upgrade. - */ -export async function rawSei( - method: string, - params: unknown, -): Promise> { - const res = await fetch(resolveTarget().evmRpcUrl, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - }); - return res.json() as Promise>; -} - -/** - * Fail before any probe runs if the endpoint is not the network the caller named. - * Both phases call this, which is what stops a record/verify pair from spanning - * two different chains. - */ -export async function assertExpectedChain(): Promise { - const target = resolveTarget(); - const envelope = await rawSei('eth_chainId', []); - if (envelope.error || !envelope.result) { - throw new Error( - `${target.evmRpcUrl} did not answer eth_chainId: ${envelope.error?.message ?? 'empty result'}`, - ); - } - const actual = BigInt(envelope.result); - if (actual !== target.evmChainId) { - throw new Error( - `${target.evmRpcUrl} reports EVM chain id ${actual}, but ${target.name} is ${target.evmChainId}. ` + - 'Set UPGRADE_TEST_NETWORK or UPGRADE_TEST_EVM_RPC so they agree.', - ); - } -} - -/** Repo-root precompiles/ dir (this module lives at integration_test/upgrade_tests/utils). */ -const PRECOMPILES_ROOT = path.resolve(__dirname, '..', '..', '..', 'precompiles'); - -const abiCache = new Map(); - -/** - * A precompile's interface, loaded from the repo's own precompiles//abi.json - * — the same file the chain binary embeds, so the suite cannot drift from the - * deployed interface. - */ -export function precompileInterface(name: string): ethers.Interface { - const cached = abiCache.get(name); - if (cached) return cached; - const abiPath = path.join(PRECOMPILES_ROOT, name, 'abi.json'); - if (!fs.existsSync(abiPath)) { - throw new Error(`precompileInterface(${name}): ${abiPath} not found`); - } - const iface = new ethers.Interface(JSON.parse(fs.readFileSync(abiPath, 'utf-8'))); - abiCache.set(name, iface); - return iface; -} - -/** Canonical precompile addresses, mirroring precompiles//.go. */ -export const ADDRESSES = { - oracle: '0x0000000000000000000000000000000000001008', - /** Removed in v6.7; there is no ABI in the tree for it any more. */ - feegrant: '0x0000000000000000000000000000000000001010', - upgrade: '0x0000000000000000000000000000000000001015', -} as const; diff --git a/integration_test/upgrade_tests/utils/cosmos.ts b/integration_test/upgrade_tests/utils/cosmos.ts deleted file mode 100644 index 4a2da16e38..0000000000 --- a/integration_test/upgrade_tests/utils/cosmos.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Cosmos REST reads. - * - * The two facts the verify phase cannot work without — whether the upgrade ran, - * and which modules the chain thinks exist — come from here rather than from the - * upgrade precompile. A released binary does not necessarily register that - * precompile (arctic-1 on v6.6.1 answers `0x` at 0x…1015), and a gate that - * silently cannot tell whether the upgrade happened is worse than no gate. - */ -import { resolveTarget } from '../config/targets'; - -export interface RestResult { - ok: boolean; - status: number; - body: T | undefined; - /** The error message a non-2xx response carried, when it had one. */ - message?: string; -} - -async function restGet(pathAndQuery: string): Promise> { - const url = `${resolveTarget().restUrl}${pathAndQuery}`; - const res = await fetch(url, { headers: { accept: 'application/json' } }); - const text = await res.text(); - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - parsed = undefined; - } - if (!res.ok) { - const message = - (parsed as { message?: string } | undefined)?.message ?? text.slice(0, 200); - return { ok: false, status: res.status, body: undefined, message }; - } - return { ok: true, status: res.status, body: parsed as T }; -} - -export interface ModuleVersion { - name: string; - version: string; -} - -/** - * The height the named upgrade was applied at, or 0 if it has not been applied. - * Proven against a real past upgrade: arctic-1 reports v6.3.0 at 138093082. - */ -export async function appliedPlanHeight(planName: string): Promise { - const result = await restGet<{ height: string }>( - `/cosmos/upgrade/v1beta1/applied_plan/${encodeURIComponent(planName)}`, - ); - if (!result.ok || !result.body) { - throw new Error( - `applied_plan/${planName} failed (HTTP ${result.status}): ${result.message ?? 'no body'}. ` + - 'Set UPGRADE_TEST_REST to a Cosmos REST endpoint for this network.', - ); - } - return Number(result.body.height ?? '0'); -} - -/** The currently scheduled upgrade plan, or undefined when none is scheduled. */ -export async function currentPlan(): Promise<{ name: string; height: string } | undefined> { - const result = await restGet<{ plan: { name: string; height: string } | null }>( - '/cosmos/upgrade/v1beta1/current_plan', - ); - if (!result.ok || !result.body) return undefined; - return result.body.plan ?? undefined; -} - -/** Every module version the chain has stored, sorted by name. */ -export async function moduleVersions(): Promise { - const result = await restGet<{ module_versions: ModuleVersion[] }>( - '/cosmos/upgrade/v1beta1/module_versions', - ); - if (!result.ok || !result.body) { - throw new Error( - `module_versions failed (HTTP ${result.status}): ${result.message ?? 'no body'}`, - ); - } - return [...(result.body.module_versions ?? [])].sort((a, b) => a.name.localeCompare(b.name)); -} - -/** - * A single module's version as a token: `v` when present, `absent` - * once the upgrade has deleted it. The upgrade keeper answers a named lookup - * for a missing module with a not-found error rather than an empty list, so an - * error here is the expected post-removal answer, not a transport failure. - */ -export async function moduleVersionToken(moduleName: string): Promise { - const result = await restGet<{ module_versions: ModuleVersion[] }>( - `/cosmos/upgrade/v1beta1/module_versions?module_name=${encodeURIComponent(moduleName)}`, - ); - if (result.ok && result.body) { - const found = (result.body.module_versions ?? []).find(m => m.name === moduleName); - return found ? `v${found.version}` : 'absent'; - } - if (/not found/i.test(result.message ?? '')) return 'absent'; - return `error(HTTP ${result.status}: ${(result.message ?? '').slice(0, 120)})`; -} - -/** - * Whether a Cosmos query still serves data or answers with the deprecation - * error. Returns `ok` or `deprecated` or a truncated description of anything - * else, so the value is stable enough to compare across runs. - */ -export async function queryToken(pathAndQuery: string): Promise { - const result = await restGet(pathAndQuery); - if (result.ok) return 'ok'; - const message = result.message ?? ''; - if (/is deprecated/i.test(message)) return 'deprecated'; - return `error(HTTP ${result.status}: ${message.slice(0, 120)})`; -} - -/** - * The chain's latest Cosmos block height. Recorded alongside the EVM height - * because applied_plan reports a Cosmos height, and the two must be compared on - * the same clock. - */ -export async function latestCosmosHeight(): Promise { - const result = await restGet<{ block: { header: { height: string } } }>( - '/cosmos/base/tendermint/v1beta1/blocks/latest', - ); - if (!result.ok || !result.body) { - throw new Error( - `blocks/latest failed (HTTP ${result.status}): ${result.message ?? 'no body'}`, - ); - } - return Number(result.body.block.header.height); -} - -/** The seid version the endpoint reports, for the artifact's provenance. */ -export async function applicationVersion(): Promise { - const result = await restGet<{ application_version?: { version?: string } }>( - '/cosmos/base/tendermint/v1beta1/node_info', - ); - return result.body?.application_version?.version ?? 'unknown'; -} diff --git a/integration_test/upgrade_tests/utils/probes.ts b/integration_test/upgrade_tests/utils/probes.ts deleted file mode 100644 index 33713d1dcf..0000000000 --- a/integration_test/upgrade_tests/utils/probes.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * The observations both phases make, defined once so the recording run and the - * verifying run cannot drift apart. - * - * Every probe collapses its answer to a short token rather than raw output. - * Incidental variation — heights, gas figures, hashes inside error strings — - * would otherwise make two equivalent answers compare unequal. An unexpected - * answer records the raw text instead, so a mismatch both fails and says what - * came back. - * - * Probes are split by what the upgrade is allowed to do to them: - * - * invariant the answer must be identical before and after, and a difference - * fails the run. Reserved for surfaces the upgrade does not claim - * to touch, so that a failure is unambiguous. - * - * transition the answer is expected to change. Recorded on both sides and - * printed as a before/after diff. The changes the upgrade is - * specified to make get their own named assertions in the verify - * phase; the rest are reported so a human can see them. - * - * On classification: a live network runs a released binary, not this checkout. - * arctic-1 is on v6.6.1, where the oracle handlers still serve data and the - * upgrade precompile is not registered. So the oracle surfaces are transitions - * here, not invariants — the deprecation lands *with* the upgrade from that - * network's point of view. Asserting head-of-tree behaviour as an invariant - * would fail the run for the wrong reason. - */ -import { ethers } from 'ethers'; -import { expectedRemovedModules } from '../config/targets'; -import { ADDRESSES, precompileInterface, rawSei } from './chain'; -import { appliedPlanHeight, moduleVersionToken, queryToken } from './cosmos'; - -export type ProbeKind = 'invariant' | 'transition'; - -export interface Probe { - name: string; - kind: ProbeKind; - /** Why this probe exists, carried into the artifact for whoever reads it later. */ - describes: string; - run: () => Promise; -} - -const BANK_PRECOMPILE = '0x0000000000000000000000000000000000001001'; - -const truncate = (text: string, max = 160): string => - text.length <= max ? text : `${text.slice(0, max)}…`; - -/** - * Revert reasons the suite recognises, collapsed to a stable token. - * - * Applied by revertToken to every call the suite makes, rather than passed in at - * each call site. When the record phase and the verify phase normalise - * differently the run reports a change that did not happen: recording - * `revert:oracle-retired` and re-reading - * `revert:raw(execution reverted: oracle precompile is retired…)` is the same - * answer twice. One table every path goes through is what makes that - * impossible rather than merely unlikely. - */ -const KNOWN_REVERTS: Record = { - 'oracle-retired': /oracle precompile is retired/, -}; - -/** A JSON-RPC error message reduced to a recognised token, or its raw text. */ -function revertToken(message: string): string { - for (const [token, pattern] of Object.entries(KNOWN_REVERTS)) { - if (pattern.test(message)) return `revert:${token}`; - } - return `revert:raw(${truncate(message)})`; -} - -/** An eth_call at a block tag, reduced to `ok:` or `revert:`. */ -async function callToken(to: string, data: string, blockTag = 'latest'): Promise { - const envelope = await rawSei('eth_call', [{ to, data }, blockTag]); - return envelope.error ? revertToken(envelope.error.message) : `ok:${envelope.result ?? ''}`; -} - -/** - * Whether an address is a registered precompile. A registered one rejects an - * unknown selector; an address with no code answers a call with empty data. - * Verified live on arctic-1's v6.6.1: bank (0x…1001) reverts, while 0x…1010 and - * 0x…1015 both return 0x. - */ -async function precompileRegistrationToken(address: string, blockTag = 'latest'): Promise { - const value = await callToken(address, '0xdeadbeef', blockTag); - return value === 'ok:0x' || value === 'ok:' ? 'unregistered' : 'registered'; -} - -const oracleIface = () => precompileInterface('oracle'); - -export function probes(planName: string): Probe[] { - const removed = expectedRemovedModules(); - return [ - // --- surfaces the upgrade does not claim to touch --- - { - name: 'bank.precompileRegistered', - kind: 'invariant', - describes: - 'a precompile the upgrade does not touch, so a failure here means the probe ' + - 'broke rather than the chain changing', - run: () => precompileRegistrationToken(BANK_PRECOMPILE), - }, - { - name: 'cosmos.moduleVersion.bank', - kind: 'invariant', - describes: 'bank keeps its module version across the upgrade', - run: () => moduleVersionToken('bank'), - }, - { - name: 'cosmos.moduleVersion.oracle', - kind: 'invariant', - describes: - 'oracle is deprecated but not removed, so it must keep its module version; ' + - 'losing one would mean the deprecation went further than intended', - run: () => moduleVersionToken('oracle'), - }, - - // --- what the upgrade is for: one probe per module it removes --- - ...removed.map( - (module): Probe => ({ - name: `cosmos.moduleVersion.${module}`, - kind: 'transition', - describes: `${module} holds a module version until the upgrade deletes it`, - run: () => moduleVersionToken(module), - }), - ), - { - name: `cosmos.appliedPlan.${planName}`, - kind: 'transition', - describes: 'zero until the upgrade runs, then the height it ran at', - run: async () => String(await appliedPlanHeight(planName)), - }, - - // --- oracle deprecation, which reaches a released network with this upgrade --- - { - name: 'cosmos.query.oracleParams', - kind: 'transition', - describes: - 'the oracle query handlers serve data until they are deprecated, after which ' + - 'they answer "oracle module is deprecated"', - run: () => queryToken('/sei-protocol/sei-chain/oracle/params'), - }, - { - name: 'cosmos.query.oracleExchangeRates', - kind: 'transition', - describes: 'the same, for the exchange rate query', - run: () => queryToken('/sei-protocol/sei-chain/oracle/denoms/exchange_rates'), - }, - { - name: 'oracle.precompile.getExchangeRates', - kind: 'transition', - describes: - 'the oracle precompile’s revert reason. Recorded rather than asserted because ' + - 'public endpoints do not always forward revert data', - run: () => - callToken(ADDRESSES.oracle, oracleIface().encodeFunctionData('getExchangeRates', [])), - }, - { - name: 'feegrant.precompileRegistered', - kind: 'transition', - describes: - 'the feegrant precompile is removed, so this must read unregistered afterwards', - run: () => precompileRegistrationToken(ADDRESSES.feegrant), - }, - { - name: 'upgrade.precompileRegistered', - kind: 'transition', - describes: - 'whether the upgrade precompile (0x…1015) is mounted. Informational: the suite ' + - 'reads the upgrade module over Cosmos REST precisely because this is not ' + - 'registered on every released binary', - run: () => precompileRegistrationToken(ADDRESSES.upgrade), - }, - ]; -} - -export interface ProbeResult { - name: string; - kind: ProbeKind; - describes: string; - value: string; -} - -export async function runProbes(planName: string): Promise { - const results: ProbeResult[] = []; - for (const probe of probes(planName)) { - results.push({ - name: probe.name, - kind: probe.kind, - describes: probe.describes, - value: await probe.run(), - }); - } - return results; -} - -/** The EVM block height the observation was taken at. */ -export async function currentHeight(): Promise { - const envelope = await rawSei('eth_blockNumber', []); - if (envelope.error || !envelope.result) { - throw new Error(`eth_blockNumber failed: ${envelope.error?.message ?? 'empty'}`); - } - return Number(BigInt(envelope.result)); -} - -/** - * A read pinned to the height it was made at, so the verify phase can ask the - * same question of the same block after the upgrade. If removing state broke - * historical reads, this is where it shows. - */ -export interface ArchiveRead { - label: string; - blockNumber: number; - /** `ok:`, `revert:`, or a registration token. */ - value: string; -} - -/** - * The archive reads the suite takes, keyed by label. - * - * The verify phase re-runs a recorded read by looking its label up here rather - * than replaying calldata out of the artifact, so both phases encode and - * normalise through the same code. An artifact naming a label this version no - * longer defines is reported instead of compared. - */ -const ARCHIVE_READS: Record Promise> = { - 'oracle.precompile.getExchangeRates': blockTag => - callToken(ADDRESSES.oracle, oracleIface().encodeFunctionData('getExchangeRates', []), blockTag), - 'bank.precompileRegistered': blockTag => precompileRegistrationToken(BANK_PRECOMPILE, blockTag), -}; - -/** The labels takeArchiveReads produces, which reReadAtHeight must all know. */ -export function archiveReadLabels(): string[] { - return Object.keys(ARCHIVE_READS); -} - -export function knowsArchiveRead(label: string): boolean { - return label in ARCHIVE_READS; -} - -export async function takeArchiveReads(blockNumber: number): Promise { - const blockTag = ethers.toQuantity(blockNumber); - const reads: ArchiveRead[] = []; - for (const [label, read] of Object.entries(ARCHIVE_READS)) { - reads.push({ label, blockNumber, value: await read(blockTag) }); - } - return reads; -} - -export type ReReadOutcome = { value: string } | { unknownLabel: true }; - -export async function reReadAtHeight(recorded: ArchiveRead): Promise { - const read = ARCHIVE_READS[recorded.label]; - if (!read) return { unknownLabel: true }; - return { value: await read(ethers.toQuantity(recorded.blockNumber)) }; -} - -/** - * A pruned or unavailable historical block, which is a different outcome from a - * changed answer. Shared endpoints prune, and treating that as a failure would - * make the suite unusable against them. - */ -export function isHistoryUnavailable(value: string): boolean { - return /pruned|not available|failed to load state|is not available|header not found/i.test( - value, - ); -} - -/** A before/after line for every probe whose answer moved. */ -export function diffProbes( - before: ProbeResult[], - after: ProbeResult[], -): Array<{ name: string; kind: ProbeKind; from: string; to: string }> { - const byName = new Map(before.map(p => [p.name, p])); - const changed: Array<{ name: string; kind: ProbeKind; from: string; to: string }> = []; - for (const probe of after) { - const previous = byName.get(probe.name); - if (previous && previous.value !== probe.value) { - changed.push({ - name: probe.name, - kind: probe.kind, - from: previous.value, - to: probe.value, - }); - } - } - return changed; -} diff --git a/integration_test/upgrade_tests/utils/txProbes.ts b/integration_test/upgrade_tests/utils/txProbes.ts deleted file mode 100644 index 749fb385cb..0000000000 --- a/integration_test/upgrade_tests/utils/txProbes.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Transactions the record phase puts on chain so the verify phase has real - * pre-upgrade history to re-read. - * - * The read-only probes answer "does the chain still behave the same". These - * answer the harder question: after state is removed, can the chain still serve - * what it already committed? A receipt that stops resolving, or a mined failure - * whose reason changes, is the shape a bad state migration takes. - * - * These need a funded key. Without one the record phase skips them and the - * verify phase has nothing to re-read, which keeps the read-only suite usable - * against any endpoint with no setup. - */ -import { ethers } from 'ethers'; -import { ADDRESSES, precompileInterface, rawSei, seiRpc } from './chain'; -import type { RecordedTx } from './artifact'; - -/** Sei's HD path, matching integration_test/dapp_tests/hardhat.config.js. */ -const SEI_HD_PATH = "m/44'/118'/0'/0/0"; - -export function walletFromMnemonic(mnemonic: string): ethers.HDNodeWallet { - return ethers.HDNodeWallet.fromPhrase(mnemonic, undefined, SEI_HD_PATH).connect(seiRpc()); -} - -/** - * eth_getVMError is the only channel carrying a precompile's exact error for a - * mined, failed transaction: eth_call rewrites most executor errors to a bare - * "execution reverted". Returns undefined when the node does not serve one. - */ -export async function vmError(txHash: string): Promise { - const envelope = await rawSei('eth_getVMError', [txHash]); - return envelope.result && envelope.result.length > 0 ? envelope.result : undefined; -} - -async function record( - label: string, - sent: Promise, -): Promise { - const tx = await sent; - // A failing transaction rejects in wait(); its receipt is on the error. - const receipt: ethers.TransactionReceipt | undefined = await tx - .wait() - .then(r => r ?? undefined) - .catch((e: { receipt?: ethers.TransactionReceipt }) => e.receipt); - if (!receipt) { - throw new Error(`${label}: transaction ${tx.hash} never produced a receipt`); - } - return { - label, - hash: receipt.hash, - blockNumber: receipt.blockNumber, - status: receipt.status ?? 0, - vmError: receipt.status === 0 ? await vmError(receipt.hash) : undefined, - }; -} - -/** - * Two transactions: one aimed at a retired precompile, which must mine as a - * failure, and one ordinary transfer that must mine as a success. Recording both - * means the verify phase can tell "history is gone" from "failed history is - * gone", which are different bugs. - */ -export async function sendProbeTransactions(mnemonic: string): Promise { - const wallet = walletFromMnemonic(mnemonic); - const oracleIface = precompileInterface('oracle'); - - const retired = await record( - 'oracle.getExchangeRates', - // An explicit gasLimit is required: estimateGas on a reverting call - // throws client-side, so the transaction would never be broadcast. - wallet.sendTransaction({ - to: ADDRESSES.oracle, - data: oracleIface.encodeFunctionData('getExchangeRates', []), - gasLimit: 200_000, - }), - ); - - const transfer = await record( - 'selfTransfer', - wallet.sendTransaction({ to: wallet.address, value: 1n }), - ); - - return [retired, transfer]; -} - -export async function fundedAddress(mnemonic: string): Promise<{ address: string; balance: bigint }> { - const wallet = walletFromMnemonic(mnemonic); - return { address: wallet.address, balance: await seiRpc().getBalance(wallet.address) }; -} diff --git a/integration_test/upgrade_tests/verify/verify.spec.ts b/integration_test/upgrade_tests/verify/verify.spec.ts deleted file mode 100644 index 8d432525e1..0000000000 --- a/integration_test/upgrade_tests/verify/verify.spec.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Phase two: run after the upgrade has landed on the network. - * - * Reads the pre-upgrade artifact and asks three questions of the live chain: - * did the surfaces the upgrade does not touch keep answering identically, did - * the module removal actually happen, and can the chain still serve the history - * it committed before its state changed. - */ -import { expect } from 'chai'; -import { expectedRemovedModules, resolveTarget, upgradeName } from '../config/targets'; -import { assertExpectedChain, rawSei } from '../utils/chain'; -import { - assertSameChain, - probeValue, - readArtifact, - upgradeGateRefusal, - type Artifact, -} from '../utils/artifact'; -import { - appliedPlanHeight, - applicationVersion, - latestCosmosHeight, - moduleVersions, -} from '../utils/cosmos'; -import { - diffProbes, - isHistoryUnavailable, - reReadAtHeight, - runProbes, - type ProbeResult, -} from '../utils/probes'; -import { vmError } from '../utils/txProbes'; - -const EXPECTED_REMOVED_MODULES = expectedRemovedModules(); - -describe('verify post-upgrade behaviour', function () { - this.timeout(300 * 1000); - - const target = resolveTarget(); - const planName = upgradeName(); - - let artifact: Artifact; - let current: ProbeResult[]; - let appliedHeight: number; - - /** - * The gate. Every assertion below is about the difference the upgrade made, - * so a run against a chain that has not upgraded yet would report a clean - * pass on the invariants and a confusing failure on the transitions. - * Throwing from a hook stops the suite with one message saying what to do. - */ - before(async () => { - await assertExpectedChain(); - artifact = readArtifact(); - assertSameChain(artifact, target.name, target.evmChainId); - - appliedHeight = await appliedPlanHeight(planName); - const refusal = upgradeGateRefusal({ - planName, - network: target.name, - appliedHeight, - artifactCosmosHeight: artifact.meta.cosmosHeight, - context: `height ${await latestCosmosHeight()}, seid ${await applicationVersion()}`, - }); - if (refusal) throw new Error(refusal); - - current = await runProbes(planName); - - const changed = diffProbes(artifact.probes, current); - console.log( - ` ${planName} applied at height ${appliedHeight}; artifact recorded at EVM height ` + - `${artifact.meta.blockNumber} on seid ${artifact.meta.seidVersion}, now ${await applicationVersion()}`, - ); - console.log(` ${changed.length} probe(s) changed:`); - for (const entry of changed) { - console.log(` ~ ${entry.name}: ${entry.from} -> ${entry.to}`); - } - }); - - describe('surfaces the upgrade does not touch', () => { - it('every invariant probe answers exactly as it did before', () => { - const mismatches: string[] = []; - for (const probe of current.filter(p => p.kind === 'invariant')) { - const before = probeValue(artifact, probe.name); - if (before === undefined) { - mismatches.push(`${probe.name}: absent from the artifact`); - } else if (before !== probe.value) { - mismatches.push(`${probe.name}: was ${before}, now ${probe.value}`); - } - } - expect( - mismatches, - 'a surface the upgrade does not claim to touch answered differently', - ).to.deep.equal([]); - }); - }); - - describe('the module removal the upgrade is for', () => { - for (const removed of EXPECTED_REMOVED_MODULES) { - it(`${removed} no longer holds a module version`, () => { - const probe = `cosmos.moduleVersion.${removed}`; - expect( - probeValue(artifact, probe), - `${removed} must have held a version before the upgrade`, - ).to.match(/^v\d+$/); - expect( - current.find(p => p.name === probe)?.value, - `the upgrade handler calls DeleteModuleVersion for ${removed}, so the ` + - 'lookup must now answer not-found', - ).to.equal('absent'); - }); - } - - it('removes exactly the modules it was supposed to and no others', async () => { - const after = (await moduleVersions()).map(m => m.name); - const before = artifact.moduleVersions.map(m => m.name); - const removed = before.filter(n => !after.includes(n)).sort(); - const added = after.filter(n => !before.includes(n)).sort(); - - expect(removed, 'unexpected module(s) lost their version entry').to.deep.equal( - EXPECTED_REMOVED_MODULES, - ); - expect(added, 'the upgrade added a module version this test did not expect').to.deep.equal( - [], - ); - }); - - it('the feegrant precompile address is not a registered precompile', () => { - expect( - current.find(p => p.name === 'feegrant.precompileRegistered')?.value, - 'a revert would mean a precompile is still mounted at 0x…1010', - ).to.equal('unregistered'); - }); - - /** - * Module versions are only ever written, never cleared, so a module - * dropped from the manager without a DeleteModuleVersion call keeps its - * entry for the life of the chain. arctic-1 carries dex and accesscontrol - * entries whose stores were deleted at v5.8.0 and v6.3.0. This reports - * them rather than failing: they are pre-existing, and the assertion that - * this upgrade does not add to the pile is the one above. - */ - it('reports module versions with no module behind them', async () => { - const known = ['dex', 'accesscontrol', 'crisis', ...EXPECTED_REMOVED_MODULES]; - const orphans = (await moduleVersions()).map(m => m.name).filter(n => known.includes(n)); - if (orphans.length > 0) { - console.log(` orphaned module version entries still on chain: ${orphans.join(', ')}`); - } - for (const removed of EXPECTED_REMOVED_MODULES) { - expect(orphans, `${planName} must not leave ${removed} on the orphan pile`).to.not.include( - removed, - ); - } - }); - }); - - describe('history committed before the upgrade', () => { - it('still answers the same at the heights it was read at', async () => { - const mismatches: string[] = []; - const skipped: string[] = []; - - for (const before of artifact.archiveReads) { - const outcome = await reReadAtHeight(before); - if ('unknownLabel' in outcome) { - skipped.push(`${before.label}: this suite version no longer defines that read`); - continue; - } - if (isHistoryUnavailable(outcome.value)) { - skipped.push(`${before.label}@${before.blockNumber}: ${outcome.value}`); - continue; - } - if (outcome.value !== before.value) { - mismatches.push( - `${before.label}@${before.blockNumber}: was ${before.value}, now ${outcome.value}`, - ); - } - } - - if (skipped.length > 0) { - // Pruning is a property of the endpoint, not of the upgrade. Say - // so rather than passing quietly or failing on it. - console.log(` ${skipped.length} historical read(s) not compared:`); - for (const entry of skipped) console.log(` ${entry}`); - } - expect( - mismatches, - 'a historical read changed answer; removing state must not rewrite what ' + - 'already-committed blocks return', - ).to.deep.equal([]); - }); - - it('still serves the receipts of transactions mined before it', async () => { - if (artifact.transactions.length === 0) { - console.log(' artifact has no transactions (recorded without a funded key)'); - return; - } - - const mismatches: string[] = []; - for (const before of artifact.transactions) { - const envelope = await rawSei<{ status: string; blockNumber: string }>( - 'eth_getTransactionReceipt', - [before.hash], - ); - if (envelope.error || !envelope.result) { - mismatches.push( - `${before.label} (${before.hash}): receipt no longer resolves: ` + - `${envelope.error?.message ?? 'null result'}`, - ); - continue; - } - const status = Number(BigInt(envelope.result.status)); - const blockNumber = Number(BigInt(envelope.result.blockNumber)); - if (status !== before.status) { - mismatches.push(`${before.label}: status was ${before.status}, now ${status}`); - } - if (blockNumber !== before.blockNumber) { - mismatches.push( - `${before.label}: block was ${before.blockNumber}, now ${blockNumber}`, - ); - } - } - expect(mismatches, 'pre-upgrade receipts must survive the upgrade unchanged').to.deep.equal( - [], - ); - }); - - it('still reports the same failure reason for transactions that failed before it', async () => { - const withReason = artifact.transactions.filter(t => t.vmError); - if (withReason.length === 0) { - console.log(' artifact recorded no failed transaction with a VM error'); - return; - } - - const mismatches: string[] = []; - for (const before of withReason) { - const now = await vmError(before.hash); - if (now === undefined) { - mismatches.push(`${before.label}: no VM error served any more`); - } else if (now !== before.vmError) { - mismatches.push( - `${before.label}: was ${JSON.stringify(before.vmError)}, now ${JSON.stringify(now)}`, - ); - } - } - expect( - mismatches, - 'the reason a pre-upgrade transaction failed must not change after the upgrade', - ).to.deep.equal([]); - }); - }); -}); diff --git a/scripts/cross_version_upgrade_test.sh b/scripts/cross_version_upgrade_test.sh deleted file mode 100755 index 6e3bd1814d..0000000000 --- a/scripts/cross_version_upgrade_test.sh +++ /dev/null @@ -1,478 +0,0 @@ -#!/usr/bin/env bash -# -# Upgrade a local chain across two real binaries: seed state using modules that -# are alive on the old release, then swap in the new one and see what the -# upgrade does to it. -# -# Every other layer of upgrade testing in this repo runs one binary, and that -# binary is always the new one. So the "before" state is fake: head-of-tree -# cannot create a feegrant allowance or store an oracle vote, because the code -# that did is gone. The v6.7 handler has therefore only ever been run against -# empty feegrant and capability stores, which is precisely the condition under -# which a migration that mishandles their contents looks fine. -# -# This runs the old release for real: -# -# v6.6.2 (feegrant, capability and oracle all live) -# grant a fee allowance, spend it, submit oracle votes, let them tally -# -> pass a v6.7 software-upgrade proposal -# -> the old binary has no v6.7 handler, so it halts and writes upgrade-info.json -# swap the binary -# head (v6.7: feegrant and capability removed, oracle handlers deprecated) -# -> applies the upgrade against state those modules actually wrote -# -# Then it asks what changed: does the chain still produce blocks, are the module -# versions gone, is the seeded state still on disk, and does a transaction that -# succeeded before the upgrade now fail? -# -# Uses an isolated home under build/. Never touches ~/.sei. -# -# Usage: -# scripts/cross_version_upgrade_test.sh [--old-version v6.6.2] [--plan v6.7] [--keep] -# -# --old-version git tag to build the pre-upgrade binary from (default v6.6.2) -# --plan governance plan name to upgrade to (default v6.7) -# --keep leave the chain running and the home dir in place -# --skip-build reuse both binaries if already present - -set -uo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -OLD_VERSION="v6.6.2" -PLAN="v6.7" -KEEP=0 -SKIP_BUILD=0 -while [ $# -gt 0 ]; do - case "$1" in - --old-version) OLD_VERSION="$2"; shift 2 ;; - --plan) PLAN="$2"; shift 2 ;; - --keep) KEEP=1; shift ;; - --skip-build) SKIP_BUILD=1; shift ;; - -h|--help) sed -n '2,38p' "$0"; exit 0 ;; - *) echo "unknown argument: $1" >&2; exit 64 ;; - esac -done - -# The module versions $PLAN deletes, as a space-separated set. Kept explicit so -# that a release which removes another module fails here until someone states -# the new expectation, rather than the test quietly accepting whatever it finds. -EXPECTED_REMOVED=${CROSS_UPGRADE_EXPECTED_REMOVED:-"capability feegrant ibc transfer"} - -WORK="$REPO_ROOT/build/cross-upgrade" -CHAIN_HOME="$WORK/home" -SHIM_DIR="$WORK/bin" -LOG="$WORK/seid.log" -OUT="$WORK/out" -CHAIN_ID="sei-cross-upgrade" -OLD_BIN="$REPO_ROOT/build/old/seid-$OLD_VERSION" -NEW_BIN="$REPO_ROOT/build/seid" - -# The old release is checked out *outside* the repository. A second Go source -# tree anywhere under the repo root gets picked up by `golangci-lint fmt`, whose -# formatters take no path exclusions, so an old release's files would fail -# `make fmtcheck` for everyone. The compiled binary can stay in build/ because -# the formatters only read source. -OLD_SRC_ROOT=${CROSS_UPGRADE_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/sei-chain/oldsrc} -OLD_SRC="$OLD_SRC_ROOT/$OLD_VERSION" - -# Which binary the shim currently points at. Every helper below calls a bare -# `seid`, so the swap is a single file rewrite rather than a variable every call -# site has to remember to use. -ACTIVE_BIN="" - -step() { printf '\n\033[1m==> %s\033[0m\n' "$1"; } -note() { printf ' %s\n' "$1"; } -fail() { printf '\033[31mFAIL: %s\033[0m\n' "$1" >&2; exit 1; } - -use_binary() { - ACTIVE_BIN="$1" - mkdir -p "$SHIM_DIR" - cat > "$SHIM_DIR/seid" </dev/null - for _ in $(seq 1 30); do - [ -z "$(node_pid)" ] && return 0 - sleep 1 - done - [ -n "$(node_pid)" ] && kill -9 $(node_pid) 2>/dev/null - sleep 1 - return 0 -} - -cleanup() { - if [ "$KEEP" = 1 ]; then - note "--keep: node left running against $CHAIN_HOME" - return - fi - stop_node -} -trap cleanup EXIT - -start_node() { - "$ACTIVE_BIN" start --home "$CHAIN_HOME" --chain-id "$CHAIN_ID" >> "$LOG" 2>&1 & - # Detach from job control so the expected kill at the upgrade height does not - # print a "Terminated" line into the middle of the report. - disown - for _ in $(seq 1 90); do - if seid status 2>/dev/null | jq -e '.SyncInfo.latest_block_height' >/dev/null 2>&1; then - return 0 - fi - [ -z "$(node_pid)" ] && return 1 - sleep 1 - done - return 1 -} - -height() { seid status 2>/dev/null | jq -r '.SyncInfo.latest_block_height' 2>/dev/null; } - -# Returns 0 when the target height is reached, 2 when the node died first. -wait_for_height() { - local target="$1" deadline=$((SECONDS + ${2:-180})) - while [ "$SECONDS" -lt "$deadline" ]; do - [ -z "$(node_pid)" ] && return 2 - local h; h=$(height) - if [[ "$h" =~ ^[0-9]+$ ]] && [ "$h" -ge "$target" ]; then return 0; fi - sleep 1 - done - return 1 -} - -wait_blocks() { local h; h=$(height); wait_for_height $((h + ${1:-1})) 60; } - -tx() { printf '12345678\n' | seid tx "$@" --keyring-backend test --chain-id "$CHAIN_ID" \ - --fees 200000usei --gas 2000000 -b sync -y --output json 2>&1; } - -# `seid export` writes startup logs to stdout ahead of the genesis document, so -# the JSON has to be cut out of the stream rather than read from line one. -export_genesis() { # export_genesis - local bin="$1" out="$2" - "$bin" export --home "$CHAIN_HOME" --chain-id "$CHAIN_ID" 2>"$out.err" \ - | sed -n '/^{/,$p' > "$out" - jq -e . "$out" >/dev/null 2>&1 -} - -# --------------------------------------------------------------------------- -step "Building both binaries" -# --------------------------------------------------------------------------- -mkdir -p "$WORK" "$OUT" "$(dirname "$OLD_BIN")" - -if [ "$SKIP_BUILD" = 1 ] && [ -x "$NEW_BIN" ]; then - note "reusing $NEW_BIN" -else - make build >/dev/null || fail "could not build the current binary" -fi -[ -x "$NEW_BIN" ] || fail "no binary at $NEW_BIN" - -# A binary built here links libwasmvm by an rpath into the source tree it was -# built from, so moving or deleting that tree leaves the binary unable to start. -# Treat an unrunnable binary as absent rather than reusing it, or the failure -# surfaces later as a confusing "this release cannot grant fee allowances". -if [ -x "$OLD_BIN" ] && ! "$OLD_BIN" version >/dev/null 2>&1; then - note "$OLD_BIN cannot start (its source tree moved); rebuilding" - rm -f "$OLD_BIN" -fi - -if [ -x "$OLD_BIN" ] && [ "$SKIP_BUILD" = 1 ]; then - note "reusing $OLD_BIN" -else - if [ ! -d "$OLD_SRC" ]; then - note "checking out $OLD_VERSION into $OLD_SRC" - mkdir -p "$OLD_SRC_ROOT" - git worktree add "$OLD_SRC" "$OLD_VERSION" >/dev/null 2>&1 \ - || fail "could not create a worktree at $OLD_VERSION" - fi - note "building $OLD_VERSION (a few minutes the first time)" - ( cd "$OLD_SRC" && go build -o "$OLD_BIN" ./cmd/seid ) >/dev/null 2>&1 \ - || fail "could not build $OLD_VERSION" -fi -[ -x "$OLD_BIN" ] || fail "no binary at $OLD_BIN" - -# The whole test rests on the two binaries differing in the right way: the old -# one must be able to grant a fee allowance and the new one must not. -"$OLD_BIN" version >/dev/null 2>&1 \ - || fail "$OLD_BIN will not start: $("$OLD_BIN" version 2>&1 | head -2 | tr '\n' ' ')" -"$OLD_BIN" tx feegrant --help 2>&1 | grep -q "Grant Fee allowance" \ - || fail "$OLD_VERSION cannot grant fee allowances, so it cannot seed the state this test needs" -"$NEW_BIN" tx feegrant --help 2>&1 | grep -q "Grant Fee allowance" \ - && fail "the current binary still has the feegrant CLI; there is nothing removed to test" -note "old binary grants fee allowances, current binary does not" - -# --------------------------------------------------------------------------- -step "Initialising a chain with $OLD_VERSION" -# --------------------------------------------------------------------------- -stop_node -rm -rf "$CHAIN_HOME" -mkdir -p "$CHAIN_HOME" -: > "$LOG" -use_binary "$OLD_BIN" -export PATH="$SHIM_DIR:$PATH" - -seid init cross-upgrade --chain-id "$CHAIN_ID" --overwrite >/dev/null 2>&1 -seid config keyring-backend test >/dev/null 2>&1 -seid config chain-id "$CHAIN_ID" >/dev/null 2>&1 - -for key in admin granter grantee; do - seid keys add "$key" --keyring-backend test >/dev/null 2>&1 || fail "could not create key $key" -done -ADMIN=$(seid keys show admin -a --keyring-backend test) -GRANTER=$(seid keys show granter -a --keyring-backend test) -GRANTEE=$(seid keys show grantee -a --keyring-backend test) -VALOPER=$(seid keys show admin --bech val -a --keyring-backend test) - -seid add-genesis-account "$ADMIN" 1000000000000000000usei --keyring-backend test >/dev/null -seid add-genesis-account "$GRANTER" 1000000000000000usei --keyring-backend test >/dev/null -# Deliberately small: the grantee must not be able to pay its own fees, so a -# transaction that only works because of the allowance is unambiguous. -seid add-genesis-account "$GRANTEE" 100000usei --keyring-backend test >/dev/null -seid gentx admin 70000000000000usei --chain-id "$CHAIN_ID" --keyring-backend test >/dev/null 2>&1 - -GENESIS="$CHAIN_HOME/config/genesis.json" -VAL_KEY=$(jq '.pub_key' "$CHAIN_HOME/config/priv_validator_key.json" -c) -jq --argjson k "$VAL_KEY" '.validators = [{"power":"70000000","pub_key":$k}]' "$GENESIS" > "$GENESIS.t" && mv "$GENESIS.t" "$GENESIS" -seid collect-gentxs >/dev/null 2>&1 - -jq ' - .app_state.gov.deposit_params.max_deposit_period = "60s" - | .app_state.gov.voting_params.voting_period = "12s" - | .app_state.gov.voting_params.expedited_voting_period = "6s" - | .app_state.gov.tally_params.quorum = "0.100000000000000000" - | .app_state.gov.tally_params.expedited_quorum = "0.100000000000000000" - | .app_state.oracle.params.vote_period = "2" - | .app_state.oracle.params.whitelist = [{"name":"uatom"},{"name":"ueth"}] - | .app_state.distribution.params.community_tax = "0.000000000000000000" - | .consensus_params.block.max_gas = "35000000" - | .consensus_params.block.max_gas_wanted = "50000000" - | .consensus_params.timeout.commit = "500000000" -' "$GENESIS" > "$GENESIS.t" && mv "$GENESIS.t" "$GENESIS" - -APP_TOML="$CHAIN_HOME/config/app.toml" -sed -i.bak -e 's/^sc-enable = .*/sc-enable = true/' -e 's/^ss-enable = .*/ss-enable = true/' "$APP_TOML" -CONFIG_TOML="$CHAIN_HOME/config/config.toml" -sed -i.bak -e 's/^mode = "full"/mode = "validator"/' -e 's/^indexer = \["null"\]/indexer = ["kv"]/' "$CONFIG_TOML" -grep -q '^mode = "validator"' "$CONFIG_TOML" || fail "could not switch the node to validator mode" - -# Genesis written by the old binary must carry the sections for the modules the -# upgrade removes, or there is nothing to seed into. -for section in feegrant capability oracle; do - jq -e --arg s "$section" '.app_state[$s]' "$GENESIS" >/dev/null \ - || fail "$OLD_VERSION genesis has no $section section" -done -note "genesis carries feegrant, capability and oracle sections" - -start_node || fail "the $OLD_VERSION node did not start; see $LOG" -wait_for_height 3 90 >/dev/null || fail "no blocks from $OLD_VERSION; see $LOG" -note "running $OLD_VERSION at height $(height)" - -# --------------------------------------------------------------------------- -step "Seeding state with modules that are alive on $OLD_VERSION" -# --------------------------------------------------------------------------- - -# --- feegrant: a real allowance, then actually spend it --- -RESP=$(tx feegrant grant "$GRANTER" "$GRANTEE" --spend-limit 100000000usei --from granter) -[ "$(echo "$RESP" | jq -r '.code // 1')" = "0" ] \ - || fail "could not grant a fee allowance: $(echo "$RESP" | jq -r '.raw_log // .')" -wait_blocks 2 >/dev/null - -GRANT=$(seid q feegrant grant "$GRANTER" "$GRANTEE" -o json 2>&1) -echo "$GRANT" | jq -e '.allowance' >/dev/null 2>&1 \ - || fail "the allowance is not in state after granting: $GRANT" -note "feegrant allowance $GRANTER -> $GRANTEE is in state" - -# The grantee has almost no balance, so this can only pay its fee through the -# allowance. Succeeding here is what proves the grant is live, and it is the -# behaviour the upgrade takes away. -GRANTEE_BAL_BEFORE=$(seid q bank balances "$GRANTEE" --denom usei -o json 2>/dev/null | jq -r '.amount') -RESP=$(tx bank send "$GRANTEE" "$ADMIN" 1usei --fee-account "$GRANTER" --from grantee) -[ "$(echo "$RESP" | jq -r '.code // 1')" = "0" ] \ - || fail "a fee-granted send was rejected on $OLD_VERSION: $(echo "$RESP" | jq -r '.raw_log // .')" -wait_blocks 2 >/dev/null -GRANTEE_BAL_AFTER=$(seid q bank balances "$GRANTEE" --denom usei -o json 2>/dev/null | jq -r '.amount') -note "fee-granted send accepted on $OLD_VERSION (grantee balance $GRANTEE_BAL_BEFORE -> $GRANTEE_BAL_AFTER)" - -# --- oracle: real votes, tallied into exchange rates --- -for i in 1 2 3; do - tx oracle aggregate-vote "1.${i}uatom,2.${i}ueth" "$VALOPER" --from admin >/dev/null - wait_blocks 2 >/dev/null -done -RATES=$(seid q oracle exchange-rates -o json 2>&1) -echo "$RATES" | jq -e '.denom_oracle_exchange_rate_pairs | length > 0' >/dev/null 2>&1 \ - || fail "oracle votes did not tally into exchange rates on $OLD_VERSION: $RATES" -note "oracle exchange rates present: $(echo "$RATES" | jq -c '[.denom_oracle_exchange_rate_pairs[].denom]')" - -# --------------------------------------------------------------------------- -step "Recording what $OLD_VERSION holds" -# --------------------------------------------------------------------------- -seid q feegrant grant "$GRANTER" "$GRANTEE" -o json > "$OUT/old-grant.json" 2>&1 -seid q oracle exchange-rates -o json > "$OUT/old-rates.json" 2>&1 -OLD_MODULE_VERSIONS=$(seid q upgrade module_versions -o json 2>/dev/null | jq -r '[.module_versions[].name] | sort | join(" ")') -echo "$OLD_MODULE_VERSIONS" > "$OUT/old-modules.txt" -note "module versions: $OLD_MODULE_VERSIONS" -for m in feegrant capability oracle; do - echo "$OLD_MODULE_VERSIONS" | grep -qw "$m" || fail "$m has no module version on $OLD_VERSION" -done - -# --------------------------------------------------------------------------- -step "Passing a software-upgrade proposal for $PLAN" -# --------------------------------------------------------------------------- -TARGET=$(( $(height) + 25 )) -RESP=$(tx gov submit-proposal software-upgrade "$PLAN" --title "$PLAN" \ - --description "cross version upgrade test" --upgrade-height "$TARGET" \ - --deposit 20000000usei --is-expedited --from admin) -[ "$(echo "$RESP" | jq -r '.code // 1')" = "0" ] \ - || fail "could not submit the proposal: $(echo "$RESP" | jq -r '.raw_log // .')" -sleep 3 -PROPOSAL=$(seid q gov proposals --reverse --limit 1 -o json 2>/dev/null | jq -r '.proposals[0].proposal_id // .proposals[0].id') -[ -n "$PROPOSAL" ] && [ "$PROPOSAL" != "null" ] || fail "no proposal in gov state" -tx gov vote "$PROPOSAL" yes --from admin >/dev/null - -STATUS="" -for _ in $(seq 1 40); do - STATUS=$(seid q gov proposal "$PROPOSAL" -o json 2>/dev/null | jq -r '.status // ""') - [ "$STATUS" = "PROPOSAL_STATUS_PASSED" ] && break - case "$STATUS" in - PROPOSAL_STATUS_REJECTED|PROPOSAL_STATUS_FAILED) fail "proposal reached $STATUS" ;; - esac - sleep 1 -done -[ "$STATUS" = "PROPOSAL_STATUS_PASSED" ] || fail "proposal did not pass (last status ${STATUS:-unknown})" -note "proposal $PROPOSAL passed, targeting height $TARGET" - -# --------------------------------------------------------------------------- -step "Letting $OLD_VERSION halt at $TARGET" -# --------------------------------------------------------------------------- -# The old binary has no handler for $PLAN, so it must stop rather than produce -# the block. This is the halt a validator on the old release sees. -wait_for_height "$TARGET" 240 -case $? in - 2) note "halted, as a node without the $PLAN handler must" ;; - 0) fail "$OLD_VERSION produced block $TARGET without a $PLAN handler" ;; - *) fail "$OLD_VERSION neither halted nor reached $TARGET; see $LOG" ;; -esac -grep -q "UPGRADE \"$PLAN\" NEEDED" "$LOG" || fail "the halt was not an upgrade panic; see $LOG" -[ -f "$CHAIN_HOME/data/upgrade-info.json" ] || fail "no upgrade-info.json was written" -note "upgrade-info.json: $(cat "$CHAIN_HOME/data/upgrade-info.json")" - -# With the node down, export the state the old binary understands. This is the -# only chance to capture it with a binary that still has these modules. -step "Exporting state with $OLD_VERSION while it is stopped" -export_genesis "$OLD_BIN" "$OUT/pre-upgrade-by-old.json" \ - || fail "could not export with $OLD_VERSION: $(head -1 "$OUT/pre-upgrade-by-old.json.err")" -jq -e '.app_state.feegrant.allowances | length > 0' "$OUT/pre-upgrade-by-old.json" >/dev/null \ - || fail "the pre-upgrade export carries no feegrant allowance, so nothing was seeded" -note "feegrant: $(jq -c '.app_state.feegrant.allowances[0].allowance.spend_limit' "$OUT/pre-upgrade-by-old.json")" -note "capability: $(jq -c '.app_state.capability | {index, owners: (.owners | length)}' "$OUT/pre-upgrade-by-old.json")" -note "oracle rates: $(jq -c '[.app_state.oracle.exchange_rates[] | .denom + "=" + .exchange_rate]' "$OUT/pre-upgrade-by-old.json")" - -# --------------------------------------------------------------------------- -step "Swapping in the current binary and restarting" -# --------------------------------------------------------------------------- -use_binary "$NEW_BIN" -start_node || fail "the new binary did not start; see $LOG" -wait_for_height $((TARGET + 3)) 180 || fail "the new binary did not advance past $TARGET; see $LOG" -note "advanced to height $(height) on the current binary" -grep -q "BINARY UPDATED BEFORE TRIGGER" "$LOG" \ - && fail "the new binary refused the chain state; see $LOG" - -APPLIED=$(seid q upgrade applied "$PLAN" 2>/dev/null | jq -r '.header.height // 0') -[[ "$APPLIED" =~ ^[0-9]+$ ]] && [ "$APPLIED" -gt 0 ] \ - || fail "the chain does not report $PLAN as applied, so the handler never ran" -note "$PLAN applied at height $APPLIED" - -# --------------------------------------------------------------------------- -step "What the upgrade did to the seeded state" -# --------------------------------------------------------------------------- -FAILURES=0 -check() { # check - if [ "$2" = 0 ]; then printf ' \033[32mok\033[0m %s\n' "$1" - else printf ' \033[31mBAD\033[0m %s\n' "$1"; FAILURES=$((FAILURES + 1)); fi -} - -NEW_MODULE_VERSIONS=$(seid q upgrade module_versions -o json 2>/dev/null | jq -r '[.module_versions[].name] | sort | join(" ")') -note "module versions now: $NEW_MODULE_VERSIONS" - -for m in $EXPECTED_REMOVED; do - echo "$NEW_MODULE_VERSIONS" | grep -qw "$m" - [ $? = 1 ]; check "$m module version removed" $? -done -echo "$NEW_MODULE_VERSIONS" | grep -qw oracle -check "oracle keeps its module version (deprecated, not removed)" $? - -REMOVED=$(comm -23 <(tr ' ' '\n' <<<"$OLD_MODULE_VERSIONS" | sort -u) <(tr ' ' '\n' <<<"$NEW_MODULE_VERSIONS" | sort -u) | tr '\n' ' ' | xargs) -WANT=$(tr ' ' '\n' <<<"$EXPECTED_REMOVED" | sort -u | tr '\n' ' ' | xargs) -[ "$REMOVED" = "$WANT" ] -check "exactly the expected modules were removed (want: $WANT / got: ${REMOVED:-none})" $? - -# The behaviour change a user sees: the same fee-granted send that worked before -# the upgrade must now be refused, and refused for a stated reason. -RESP=$(tx bank send "$GRANTEE" "$ADMIN" 1usei --fee-account "$GRANTER" --from grantee) -echo "$RESP" | grep -q "fee grants are not enabled" -check "the fee-granted send that worked before is now refused: fee grants are not enabled" $? - -# Oracle rates seeded on the old binary are no longer queryable. -RATES_NOW=$(seid q oracle exchange-rates -o json 2>&1) -echo "$RATES_NOW" | grep -q "oracle module is deprecated" -check "oracle exchange rates are no longer queryable (handler deprecated)" $? - -# ...and the chain is still producing blocks with all of that behind it. -wait_blocks 3 >/dev/null -check "chain still producing blocks after the upgrade" $? - -step "Where the seeded state ended up" -stop_node - -# Three exports answer the question the release notes raise. The new binary -# cannot emit a section for a module it no longer registers, so its export -# alone cannot distinguish "kept on disk but unreachable" from "deleted". The -# old binary still has the feegrant and capability modules, so pointing it at -# the upgraded state reads whatever the upgrade left behind. -export_genesis "$NEW_BIN" "$OUT/post-upgrade-by-new.json" \ - || fail "could not export with the current binary: $(head -1 "$OUT/post-upgrade-by-new.json.err")" -export_genesis "$OLD_BIN" "$OUT/post-upgrade-by-old.json" \ - || note "the old binary could not read the upgraded state: $(head -1 "$OUT/post-upgrade-by-old.json.err")" - -for section in feegrant capability; do - jq -e --arg s "$section" '.app_state[$s]' "$OUT/post-upgrade-by-new.json" >/dev/null 2>&1 - [ $? = 1 ]; check "$section is absent from the export the current binary produces" $? -done - -# Oracle is deprecated rather than removed, so it is still a registered module -# and its state still exports. That contrast is the point: deprecating a module -# and removing one leave state in very different places. -jq -e '.app_state.oracle.exchange_rates | length > 0' "$OUT/post-upgrade-by-new.json" >/dev/null 2>&1 -check "oracle rates seeded on $OLD_VERSION still export after the upgrade" $? - -if jq -e . "$OUT/post-upgrade-by-old.json" >/dev/null 2>&1; then - RETAINED=$(jq -r '.app_state.feegrant.allowances | length' "$OUT/post-upgrade-by-old.json" 2>/dev/null) - if [ "${RETAINED:-0}" -gt 0 ]; then - check "the allowance survives on disk: $OLD_VERSION reads it back out of the upgraded state" 0 - note "spend limit before: $(jq -c '.app_state.feegrant.allowances[0].allowance.spend_limit' "$OUT/pre-upgrade-by-old.json")" - note "spend limit after: $(jq -c '.app_state.feegrant.allowances[0].allowance.spend_limit' "$OUT/post-upgrade-by-old.json")" - note "no code on the current binary can reach it, and no export carries it forward" - else - check "the upgrade dropped the feegrant allowance from the store" 1 - note "the stores are documented as retained for historical access, so an empty" - note " read here contradicts that and is application-hash relevant" - fi -fi - -# --------------------------------------------------------------------------- -step "Result" -# --------------------------------------------------------------------------- -note "artefacts in $OUT" -if [ "$FAILURES" -gt 0 ]; then - fail "$FAILURES check(s) did not hold across the $OLD_VERSION -> $PLAN upgrade" -fi -printf '\n\033[32mPASS\033[0m %s -> %s applied at height %s against state the removed modules actually wrote.\n' \ - "$OLD_VERSION" "$PLAN" "$APPLIED" diff --git a/scripts/local_upgrade_test.sh b/scripts/local_upgrade_test.sh deleted file mode 100755 index 3e2fabb71c..0000000000 --- a/scripts/local_upgrade_test.sh +++ /dev/null @@ -1,317 +0,0 @@ -#!/usr/bin/env bash -# -# Drive a real governance software upgrade on a single-node local chain, and -# compare how the chain answers for the modules the upgrade retires on either -# side of it. -# -# This is the cheapest layer that reaches the real upgrade machinery. The -# in-process Go tests call ApplyUpgrade directly, which never writes -# upgrade-info.json, never panics, and never reloads the stores, so the code -# that decides whether a store is dropped at an upgrade height -# (App.SetStoreUpgradeHandlers) is not reached by any of them. The docker suite -# does reach it but needs a cluster. This reaches it with one node on a laptop: -# -# init -> start on a version list without the plan -> probe -> pass a -# software-upgrade proposal -> node panics at the height -> restart with the -# plan in the list, which is what runs SetStoreUpgradeHandlers and the upgrade -# handler -> probe -> require both probes to agree. -# -# Note what this does NOT do: both processes are this binary, so the modules the -# upgrade retires are already gone before the chain starts and the pre-upgrade -# state is empty. Use scripts/cross_version_upgrade_test.sh when the point is to -# seed state with modules that were alive on the previous release. -# -# Everything lives under an isolated home directory. It never touches ~/.sei. -# -# Usage: -# scripts/local_upgrade_test.sh [--version v6.7] [--keep] [--skip-build] - -set -uo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -VERSION="v6.7" -KEEP=0 -SKIP_BUILD=0 -while [ $# -gt 0 ]; do - case "$1" in - --version) VERSION="$2"; shift 2 ;; - --keep) KEEP=1; shift ;; - --skip-build) SKIP_BUILD=1; shift ;; - -h|--help) sed -n '2,27p' "$0"; exit 0 ;; - *) echo "unknown argument: $1" >&2; exit 64 ;; - esac -done - -# The version list the node starts on. It deliberately excludes $VERSION so the -# first process does not know the upgrade: that is what makes it panic at the -# height and write upgrade-info.json, exactly as a validator on an old release -# does. Overridable so the halt guard itself can be exercised — starting with -# $VERSION already present must be reported, not accepted. -BASE_VERSION_LIST=${LOCAL_UPGRADE_BASE_LIST:-v1.0.0} - -# Modules $VERSION removes from the module version map. Each must answer -# not-found afterwards; one that survives means the handler is missing a -# DeleteModuleVersion call for it. -RETIRED_MODULES=${LOCAL_UPGRADE_RETIRED_MODULES:-"capability feegrant ibc transfer"} - -WORK_DIR="$REPO_ROOT/build/generated/local-upgrade" -CHAIN_HOME="$WORK_DIR/home" -SHIM_DIR="$WORK_DIR/bin" -LOG="$WORK_DIR/seid.log" -CHAIN_ID="sei-local-upgrade" -ADMIN_KEY="admin" -GRANTER_KEY="granter" -SEID="$REPO_ROOT/build/seid" - -step() { printf '\n\033[1m==> %s\033[0m\n' "$1"; } -note() { printf ' %s\n' "$1"; } -fail() { printf '\033[31mFAIL: %s\033[0m\n' "$1" >&2; exit 1; } - -node_pid() { pgrep -f "seid start --home $CHAIN_HOME" || true; } - -stop_node() { - local pid - pid=$(node_pid) - [ -n "$pid" ] && kill $pid 2>/dev/null - for _ in $(seq 1 30); do - [ -z "$(node_pid)" ] && return 0 - sleep 1 - done - [ -n "$(node_pid)" ] && kill -9 $(node_pid) 2>/dev/null - return 0 -} - -cleanup() { - if [ "$KEEP" = 1 ]; then - note "--keep: node left running against $CHAIN_HOME" - note " stop it with: pkill -f 'seid start --home $CHAIN_HOME'" - return - fi - stop_node -} -trap cleanup EXIT - -# --------------------------------------------------------------------------- -step "Building seid" -# --------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 1 ] && [ -x "$SEID" ]; then - note "reusing $SEID" -else - make build >/dev/null || fail "make build" -fi -[ -x "$SEID" ] || fail "no binary at $SEID" - -# A shim on PATH that pins --home, so the probe script and the helpers it -# sources can call a bare `seid` without knowing about this chain's home. This -# is the same indirection the in-process YAML runner uses. -mkdir -p "$SHIM_DIR" -cat > "$SHIM_DIR/seid" < "$LOG" - -seid init local-upgrade --chain-id "$CHAIN_ID" --overwrite >/dev/null 2>&1 -seid config keyring-backend test >/dev/null 2>&1 -seid config chain-id "$CHAIN_ID" >/dev/null 2>&1 - -for key in "$ADMIN_KEY" "$GRANTER_KEY"; do - seid keys add "$key" --keyring-backend test >/dev/null 2>&1 \ - || fail "could not create key $key" -done -ADMIN_ADDR=$(seid keys show "$ADMIN_KEY" -a --keyring-backend test) -GRANTER_ADDR=$(seid keys show "$GRANTER_KEY" -a --keyring-backend test) - -seid add-genesis-account "$ADMIN_ADDR" 1000000000000000000usei --keyring-backend test >/dev/null -seid add-genesis-account "$GRANTER_ADDR" 1000000000000usei --keyring-backend test >/dev/null -seid gentx "$ADMIN_KEY" 70000000000000usei --chain-id "$CHAIN_ID" --keyring-backend test >/dev/null 2>&1 - -# Pin the single validator into genesis, matching scripts/initialize_local_chain.sh. -GENESIS="$CHAIN_HOME/config/genesis.json" -VAL_KEY=$(jq '.pub_key' "$CHAIN_HOME/config/priv_validator_key.json" -c) -jq --argjson k "$VAL_KEY" '.validators = [{"power":"70000000","pub_key":$k}]' "$GENESIS" > "$GENESIS.tmp" && mv "$GENESIS.tmp" "$GENESIS" -seid collect-gentxs >/dev/null 2>&1 - -# Governance has to resolve in seconds, not days, for this to be a local test. -jq ' - .app_state.gov.deposit_params.max_deposit_period = "60s" - | .app_state.gov.voting_params.voting_period = "12s" - | .app_state.gov.voting_params.expedited_voting_period = "6s" - | .app_state.gov.tally_params.quorum = "0.100000000000000000" - | .app_state.gov.tally_params.expedited_quorum = "0.100000000000000000" - | .app_state.distribution.params.community_tax = "0.000000000000000000" - | .consensus_params.block.max_gas = "35000000" - | .consensus_params.block.max_gas_wanted = "50000000" - | .consensus_params.timeout.commit = "500000000" -' "$GENESIS" > "$GENESIS.tmp" && mv "$GENESIS.tmp" "$GENESIS" - -APP_TOML="$CHAIN_HOME/config/app.toml" -sed -i.bak -e 's/^sc-enable = .*/sc-enable = true/' -e 's/^ss-enable = .*/ss-enable = true/' "$APP_TOML" -sed -i.bak -e 's/^occ-enabled = .*/occ-enabled = true/' "$APP_TOML" - -# A lone validator has no peers to block-sync from, so in the default "full" -# mode it waits for a peer height that never arrives and never produces a block. -CONFIG_TOML="$CHAIN_HOME/config/config.toml" -sed -i.bak -e 's/^mode = "full"/mode = "validator"/' "$CONFIG_TOML" -sed -i.bak -e 's/^indexer = \["null"\]/indexer = ["kv"]/' "$CONFIG_TOML" -grep -q '^mode = "validator"' "$CONFIG_TOML" || fail "could not switch the node to validator mode" - -start_node() { - local version_list="$1" - UPGRADE_VERSION_LIST="$version_list" \ - "$SEID" start --home "$CHAIN_HOME" --chain-id "$CHAIN_ID" >> "$LOG" 2>&1 & - disown - for _ in $(seq 1 60); do - if seid status 2>/dev/null | jq -e '.SyncInfo.latest_block_height' >/dev/null 2>&1; then - return 0 - fi - sleep 1 - done - return 1 -} - -height() { seid status 2>/dev/null | jq -r '.SyncInfo.latest_block_height' 2>/dev/null; } - -wait_for_height() { - local target="$1" deadline=$((SECONDS + ${2:-120})) - while [ "$SECONDS" -lt "$deadline" ]; do - local h; h=$(height) - [ -z "$(node_pid)" ] && return 2 # the node died, which may be the point - if [[ "$h" =~ ^[0-9]+$ ]] && [ "$h" -ge "$target" ]; then return 0; fi - sleep 1 - done - return 1 -} - -# --------------------------------------------------------------------------- -step "Starting the node without $VERSION in its version list" -# --------------------------------------------------------------------------- -start_node "$BASE_VERSION_LIST" || fail "node did not start; see $LOG" -wait_for_height 3 60 || fail "node did not reach height 3; see $LOG" -note "running at height $(height) on UPGRADE_VERSION_LIST=$BASE_VERSION_LIST" - -# --------------------------------------------------------------------------- -step "Probing retired modules BEFORE the upgrade" -# --------------------------------------------------------------------------- -PROBE=integration_test/upgrade_module/scripts/probe_retired_modules.sh -BEFORE=$(CHAIN_ID="$CHAIN_ID" ADMIN_KEY="$ADMIN_KEY" bash "$PROBE" "$GRANTER_ADDR" 3) -echo "$BEFORE" -echo "$BEFORE" | grep -q '^PROBE_RESULT=ALL_MATCH$' \ - || fail "the pre-upgrade probe did not get the answers it expected, so the comparison below would be meaningless" - -# --------------------------------------------------------------------------- -step "Passing a software-upgrade proposal for $VERSION" -# --------------------------------------------------------------------------- -TARGET_HEIGHT=$(( $(height) + 25 )) -seid tx gov submit-proposal software-upgrade "$VERSION" \ - --title "$VERSION" --description "local upgrade test" \ - --upgrade-height "$TARGET_HEIGHT" --deposit 20000000usei --is-expedited \ - --from "$ADMIN_KEY" --keyring-backend test --chain-id "$CHAIN_ID" \ - --fees 200000usei -b sync -y --output json >/dev/null 2>&1 \ - || fail "could not submit the upgrade proposal" - -sleep 3 -PROPOSAL_ID=$(seid q gov proposals --reverse --limit 1 -o json 2>/dev/null | jq -r '.proposals[0].proposal_id // .proposals[0].id') -[ -n "$PROPOSAL_ID" ] && [ "$PROPOSAL_ID" != "null" ] || fail "no proposal appeared in gov state" -note "proposal $PROPOSAL_ID targets height $TARGET_HEIGHT" - -seid tx gov vote "$PROPOSAL_ID" yes --from "$ADMIN_KEY" --keyring-backend test \ - --chain-id "$CHAIN_ID" --fees 200000usei -b sync -y --output json >/dev/null 2>&1 \ - || fail "could not vote" - -STATUS="" -for _ in $(seq 1 40); do - STATUS=$(seid q gov proposal "$PROPOSAL_ID" -o json 2>/dev/null | jq -r '.status // ""') - [ "$STATUS" = "PROPOSAL_STATUS_PASSED" ] && break - case "$STATUS" in - PROPOSAL_STATUS_REJECTED|PROPOSAL_STATUS_FAILED) fail "proposal reached $STATUS" ;; - esac - sleep 1 -done -[ "$STATUS" = "PROPOSAL_STATUS_PASSED" ] || fail "proposal did not pass (last status ${STATUS:-unknown})" -note "proposal passed" - -# --------------------------------------------------------------------------- -step "Letting the node reach $TARGET_HEIGHT without the upgrade" -# --------------------------------------------------------------------------- -# A node that does not know the plan name must halt here rather than produce a -# block. Continuing past it would mean the upgrade was silently skipped. -wait_for_height "$TARGET_HEIGHT" 180 -case $? in - 2) note "node halted as expected" ;; - 0) fail "node produced block $TARGET_HEIGHT without knowing $VERSION; the upgrade was skipped" ;; - *) fail "node neither halted nor reached $TARGET_HEIGHT; see $LOG" ;; -esac -grep -q "UPGRADE \"$VERSION\" NEEDED" "$LOG" \ - || fail "the halt was not an upgrade panic; see $LOG" -# A node that already knows the plan refuses to start at all, with "BINARY -# UPDATED BEFORE TRIGGER". Reaching that here would mean the version list was -# wrong and this run proved nothing about the upgrade. -grep -q "BINARY UPDATED BEFORE TRIGGER" "$LOG" \ - && fail "the node knew $VERSION before the chain executed it; start it on a version list without $VERSION" -[ -f "$CHAIN_HOME/data/upgrade-info.json" ] \ - || fail "the halted node did not write upgrade-info.json, so a restart cannot apply store upgrades" -note "upgrade-info.json: $(cat "$CHAIN_HOME/data/upgrade-info.json")" - -# --------------------------------------------------------------------------- -step "Restarting with $VERSION, which runs the handler and the store upgrades" -# --------------------------------------------------------------------------- -start_node "$BASE_VERSION_LIST,$VERSION" || fail "node did not come back up; see $LOG" -wait_for_height $((TARGET_HEIGHT + 3)) 120 \ - || fail "node did not advance past the upgrade height; see $LOG" -note "advanced to height $(height)" - -# `q upgrade applied` prints the block header of the height the plan ran at, so -# the height is nested. A zero or unreadable answer means the handler never ran, -# which would make every comparison below a comparison against itself. -APPLIED=$(seid q upgrade applied "$VERSION" 2>/dev/null | jq -r '.header.height // 0') -[[ "$APPLIED" =~ ^[0-9]+$ ]] && [ "$APPLIED" -gt 0 ] \ - || fail "the chain does not report $VERSION as applied, so the upgrade handler never ran" -note "applied plan $VERSION at height $APPLIED" - -# --------------------------------------------------------------------------- -step "Probing retired modules AFTER the upgrade" -# --------------------------------------------------------------------------- -AFTER=$(CHAIN_ID="$CHAIN_ID" ADMIN_KEY="$ADMIN_KEY" bash "$PROBE" "$GRANTER_ADDR" 3) -echo "$AFTER" - -# --------------------------------------------------------------------------- -step "Result" -# --------------------------------------------------------------------------- -echo "$AFTER" | grep -q '^PROBE_RESULT=ALL_MATCH$' \ - || fail "a retired surface stopped answering as expected after the upgrade" - -if [ "$BEFORE" != "$AFTER" ]; then - printf '\033[31mthe chain answered differently after the upgrade:\033[0m\n' - diff <(echo "$BEFORE") <(echo "$AFTER") || true - fail "retiring a module changed how the chain answers a client" -fi - -# The modules this upgrade retires must be gone from the module version map. The -# keeper answers a named lookup for a missing module with a not-found error, so -# that error is the expected post-removal answer. -for module in $RETIRED_MODULES; do - ANSWER=$(seid q upgrade module_versions "$module" -o json 2>&1 || true) - case "$ANSWER" in - *"not found"*) note "$module module version removed" ;; - *) fail "$module still holds a module version after $VERSION: $ANSWER" ;; - esac -done - -printf '\n\033[32mPASS\033[0m %s applied at height %s; every retired surface answered identically before and after.\n' \ - "$VERSION" "$APPLIED" From fa8fe5ee0f30ae2627fe62448c142c22deeec34f Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 27 Aug 2026 18:12:40 +0200 Subject: [PATCH 3/3] Leave the shared release upgrade runner unchanged Rely on the merged end-to-end test for upgrade orchestration and keep this branch limited to focused v6.7 in-process coverage and its test helpers. Co-authored-by: Cursor --- .github/scripts/release-upgrade-test.sh | 529 ++++-------------- .github/workflows/release-upgrade-test.yml | 24 +- Makefile | 1 - docker/docker-compose.yml | 4 - docker/localnode/scripts/step2_genesis.sh | 3 +- .../runner/yamlparse_check_test.go | 62 -- 6 files changed, 126 insertions(+), 497 deletions(-) delete mode 100644 integration_test/runner/yamlparse_check_test.go diff --git a/.github/scripts/release-upgrade-test.sh b/.github/scripts/release-upgrade-test.sh index b0c8613e81..f6559c60e7 100755 --- a/.github/scripts/release-upgrade-test.sh +++ b/.github/scripts/release-upgrade-test.sh @@ -7,21 +7,14 @@ readonly RUN_ROOT="${RUNNER_TEMP:-/tmp}/sei-release-upgrade-${GITHUB_RUN_ID:-$$} readonly MAIN_WORKTREE="$RUN_ROOT/main" readonly RELEASE_WORKTREE="$RUN_ROOT/release" readonly BUILD_ROOT="$RUN_ROOT/bin" -readonly ARTIFACT_ROOT="$RUN_ROOT/artifacts" +readonly ARTIFACT_ROOT="$REPO_ROOT/artifacts/release-upgrade" readonly NODE_COUNT=4 -readonly ORACLE_VOTE_PERIOD=20 -readonly UPGRADE_NAME="v6.7" -readonly -a EXPECTED_REMOVED_MODULES=(capability feegrant ibc transfer) -RELEASE_BRANCH="${RELEASE_BRANCH:-release/v6.6}" -MAIN_REF="${MAIN_REF:-${GITHUB_SHA:-HEAD}}" +RELEASE_BRANCH="${RELEASE_BRANCH:-}" UPGRADE_LEAD_SECONDS="${UPGRADE_LEAD_SECONDS:-60}" POST_UPGRADE_BLOCKS="${POST_UPGRADE_BLOCKS:-10}" CLUSTER_STARTED=false MAIN_BINARY_HASH= -RELEASE_MODULE_VERSIONS= -FEE_GRANTER_ADDRESS= -FEE_GRANTEE_ADDRESS= log() { printf '\n[%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*" @@ -43,9 +36,45 @@ validate_inputs() { ((POST_UPGRADE_BLOCKS >= 2 && POST_UPGRADE_BLOCKS <= 100)) || die "post_upgrade_blocks must be between 2 and 100" - [[ "$RELEASE_BRANCH" =~ ^release/v[0-9]+\.[0-9]+(\.[0-9]+)?(-branch)?$ ]] || - die "release_branch must look like release/v6.6 or release/v6.2.0-branch" - [[ -n "$MAIN_REF" ]] || die "main_ref must not be empty" + if [[ -n "$RELEASE_BRANCH" ]]; then + [[ "$RELEASE_BRANCH" =~ ^release/v[0-9]+\.[0-9]+(\.[0-9]+)?(-branch)?$ ]] || + die "release_branch must look like release/v6.6 or release/v6.2.0-branch" + fi +} + +resolve_latest_release_branch() { + local heads_file="$RUN_ROOT/release-heads" + git ls-remote --heads origin 'release/v*' >"$heads_file" + + python3 - "$heads_file" <<'PY' +import re +import sys + +patterns = ( + (re.compile(r"^refs/heads/(release/v(\d+)\.(\d+))$"), 2), + (re.compile(r"^refs/heads/(release/v(\d+)\.(\d+)\.(\d+))$"), 1), + (re.compile(r"^refs/heads/(release/v(\d+)\.(\d+)\.(\d+)-branch)$"), 0), +) + +candidates = [] +with open(sys.argv[1], encoding="utf-8") as heads: + for line in heads: + _, ref = line.split() + for pattern, preference in patterns: + match = pattern.fullmatch(ref) + if not match: + continue + branch = match.group(1) + numbers = tuple(int(part) for part in match.groups()[1:]) + version = numbers if len(numbers) == 3 else (*numbers, 0) + candidates.append((version, preference, branch)) + break + +if not candidates: + raise SystemExit("no official release/v* branch found") + +print(max(candidates)[2]) +PY } latest_upgrade_tag() { @@ -68,17 +97,46 @@ print(max(versions)[1]) PY } -has_upgrade_tag() { - local source_dir="$1" - local upgrade_name="$2" - grep -Fxq "$upgrade_name" "$source_dir/app/tags" +version_greater_than() { + python3 - "$1" "$2" <<'PY' +import re +import sys + +def parse(value): + match = re.fullmatch(r"v(\d+)\.(\d+)(?:\.(\d+))?", value) + if not match: + raise SystemExit(f"invalid upgrade version: {value}") + return tuple(int(part or 0) for part in match.groups()) + +raise SystemExit(0 if parse(sys.argv[1]) > parse(sys.argv[2]) else 1) +PY +} + +next_minor_version() { + python3 - "$1" "$2" <<'PY' +import re +import sys + +def parse(value): + match = re.fullmatch(r"v(\d+)\.(\d+)(?:\.(\d+))?", value) + if not match: + raise SystemExit(f"invalid upgrade version: {value}") + return tuple(int(part or 0) for part in match.groups()) + +highest = max(parse(sys.argv[1]), parse(sys.argv[2])) +print(f"v{highest[0]}.{highest[1] + 1}") +PY } prepare_worktrees() { - log "Pinning main $MAIN_REF and release $RELEASE_BRANCH" + if [[ -z "$RELEASE_BRANCH" ]]; then + RELEASE_BRANCH="$(resolve_latest_release_branch)" + fi + + log "Pinning origin/main and $RELEASE_BRANCH" + git fetch --no-tags origin main local main_sha - main_sha="$(git rev-parse "$MAIN_REF^{commit}")" || - die "unable to resolve main ref $MAIN_REF" + main_sha="$(git rev-parse FETCH_HEAD)" git fetch --no-tags origin "$RELEASE_BRANCH" local release_sha @@ -88,7 +146,6 @@ prepare_worktrees() { git worktree add --detach "$RELEASE_WORKTREE" "$release_sha" { - printf 'main_ref=%s\n' "$MAIN_REF" printf 'main_sha=%s\n' "$main_sha" printf 'release_branch=%s\n' "$RELEASE_BRANCH" printf 'release_sha=%s\n' "$release_sha" @@ -101,15 +158,30 @@ prepare_upgrade_name() { release_upgrade="$(latest_upgrade_tag "$RELEASE_WORKTREE")" main_upgrade="$(latest_upgrade_tag "$MAIN_WORKTREE")" - ! has_upgrade_tag "$RELEASE_WORKTREE" "$UPGRADE_NAME" || - die "$RELEASE_BRANCH already knows $UPGRADE_NAME; it cannot exercise that upgrade" - has_upgrade_tag "$MAIN_WORKTREE" "$UPGRADE_NAME" || - die "main $MAIN_REF does not register $UPGRADE_NAME" - log "Testing $RELEASE_BRANCH ($release_upgrade) -> main with $UPGRADE_NAME" + if version_greater_than "$main_upgrade" "$release_upgrade"; then + UPGRADE_NAME="$main_upgrade" + log "Using main upgrade name $UPGRADE_NAME" + else + UPGRADE_NAME="$(next_minor_version "$main_upgrade" "$release_upgrade")" + log "Generating synthetic main upgrade name $UPGRADE_NAME" + python3 - "$MAIN_WORKTREE/app/tags" "$UPGRADE_NAME" <<'PY' +import pathlib +import sys + +tags_path = pathlib.Path(sys.argv[1]) +content = tags_path.read_bytes() +separator = b"" if not content or content.endswith(b"\n") else b"\n" +tags_path.write_bytes(content + separator + sys.argv[2].encode() + b"\n") +PY + ( + cd "$MAIN_WORKTREE" + go run ./scripts/bump_version + ) 2>&1 | tee "$ARTIFACT_ROOT/precompile-generation.log" + fi { printf 'release_upgrade=%s\n' "$release_upgrade" - printf 'main_latest_upgrade=%s\n' "$main_upgrade" + printf 'main_upgrade_before_generation=%s\n' "$main_upgrade" printf 'test_upgrade=%s\n' "$UPGRADE_NAME" } | tee -a "$ARTIFACT_ROOT/revisions.txt" } @@ -126,12 +198,8 @@ build_binary() { local source_dir="$1" local output_path="$2" local label="$3" - local source_commit - local source_version local go_mod_cache local go_build_cache - source_commit="$(git -C "$source_dir" rev-parse HEAD)" - source_version="$(git -C "$source_dir" describe --tags --always)" go_mod_cache="$(go env GOMODCACHE)" go_build_cache="$(go env GOCACHE)" mkdir -p "$go_mod_cache" "$go_build_cache" @@ -145,11 +213,8 @@ build_binary() { -v "$go_build_cache:/root/.cache/go-build:Z" \ -w /sei-protocol/sei-chain \ -e LEDGER_ENABLED=false \ - -e GOFLAGS=-buildvcs=false \ - -e "BUILD_COMMIT=$source_commit" \ - -e "BUILD_VERSION=$source_version" \ sei-chain/localnode \ - bash -c 'export PATH=/usr/local/go/bin:$PATH && make clean && make VERSION="$BUILD_VERSION" COMMIT="$BUILD_COMMIT" build-linux' + bash -c 'export PATH=/usr/local/go/bin:$PATH && make clean && make build-linux' install -m 0755 "$source_dir/build/seid" "$output_path" sha256sum "$output_path" | tee "$ARTIFACT_ROOT/$label.sha256" @@ -194,184 +259,6 @@ wait_for_node_height() { die "$node did not reach height $target within $timeout_seconds seconds" } -wait_for_blocks() { - local node="$1" - local blocks="$2" - local start - start="$(height "$node")" || die "unable to query $node before waiting for blocks" - wait_for_node_height "$node" "$((start + blocks))" 180 -} - -node_key_address() { - local node="$1" - local key="$2" - printf '12345678\n' | - docker exec -i "$node" seid keys show "$key" -a 2>/dev/null -} - -node_validator_address() { - local node="$1" - printf '12345678\n' | - docker exec -i "$node" seid keys show node_admin --bech val -a 2>/dev/null -} - -require_check_tx_success() { - local label="$1" - local output="$2" - local code - code="$(jq -r '.code // 0' <<<"$output" 2>/dev/null)" || - die "$label did not return JSON: $output" - [[ "$code" == "0" ]] || - die "$label was rejected: $(jq -r '.raw_log // .' <<<"$output")" -} - -feegrant_spend_limit() { - jq -er ' - [ - .. | objects | select(has("spend_limit")) | - .spend_limit[] | select(.denom == "usei") | .amount | tonumber - ][0] - ' -} - -query_module_versions() { - local node="$1" - docker exec "$node" seid q upgrade module_versions --output json | - jq -er '.module_versions | map(.name) | sort | .[]' -} - -seed_release_oracle_rates() { - log "Seeding oracle exchange rates with release validator votes" - local configured_vote_period - configured_vote_period="$( - docker exec sei-node-0 seid q oracle params --output json | - jq -er '.params.vote_period | tonumber' - )" || die "unable to query the release oracle vote period" - ((configured_vote_period == ORACLE_VOTE_PERIOD)) || - die "release oracle vote period is $configured_vote_period, expected $ORACLE_VOTE_PERIOD" - - local attempt - local code - local current - local start - local tally_height - local node - local output_file - local validator - local rates= - - for ((attempt = 1; attempt <= 3; attempt++)); do - current="$(height sei-node-0)" - start="$((current + ORACLE_VOTE_PERIOD - (current % ORACLE_VOTE_PERIOD) + 1))" - wait_for_node_height sei-node-0 "$start" 180 - - for ((i = 0; i < NODE_COUNT; i++)); do - node="sei-node-$i" - output_file="$ARTIFACT_ROOT/oracle-vote-$attempt-$i.json" - validator="$(node_validator_address "$node")" || - die "unable to resolve the oracle validator in $node" - [[ -n "$validator" ]] || die "oracle validator in $node is empty" - if ! ( - printf '12345678\n' | - docker exec -i "$node" seid tx oracle aggregate-vote \ - "1.${attempt}uatom,2.${attempt}ueth" "$validator" \ - --from node_admin --chain-id sei --fees 200000usei --gas 2000000 \ - --broadcast-mode sync --yes --output json - ) >"$output_file" 2>"$output_file.stderr"; then - die "oracle vote failed in $node; see $output_file.stderr" - fi - code="$(jq -r '.code // 0' "$output_file" 2>/dev/null)" || - die "oracle vote in $node did not return JSON; see $output_file" - [[ "$code" == "0" ]] || - die "oracle vote in $node was rejected; see $output_file" - done - - current="$(height sei-node-0)" - tally_height="$((current + ORACLE_VOTE_PERIOD - (current % ORACLE_VOTE_PERIOD) + 2))" - wait_for_node_height sei-node-0 "$tally_height" 180 - if rates="$(docker exec sei-node-0 seid q oracle exchange-rates --output json 2>&1)" && - jq -e '.denom_oracle_exchange_rate_pairs | length > 0' <<<"$rates" >/dev/null; then - printf '%s\n' "$rates" >"$ARTIFACT_ROOT/release-oracle-rates.json" - return - fi - done - - die "release validators did not produce oracle exchange rates; see oracle-vote artifacts" -} - -seed_release_state() { - log "Seeding state through modules available on the release binary" - FEE_GRANTER_ADDRESS="$(node_key_address sei-node-0 admin)" || - die "unable to resolve the release fee granter" - FEE_GRANTEE_ADDRESS="$(node_key_address sei-node-0 node_admin)" || - die "unable to resolve the release fee grantee" - [[ -n "$FEE_GRANTER_ADDRESS" && -n "$FEE_GRANTEE_ADDRESS" ]] || - die "release feegrant accounts are empty" - - local grant_output - if ! grant_output="$( - printf '12345678\n' | - docker exec -i sei-node-0 seid tx feegrant grant \ - "$FEE_GRANTER_ADDRESS" "$FEE_GRANTEE_ADDRESS" \ - --spend-limit 100000000usei --from admin --chain-id sei \ - --fees 200000usei --gas 2000000 --broadcast-mode sync --yes --output json \ - 2>"$ARTIFACT_ROOT/release-feegrant-grant.stderr" - )"; then - die "release binary could not grant a fee allowance; see release-feegrant-grant.stderr" - fi - require_check_tx_success "fee allowance grant" "$grant_output" - wait_for_blocks sei-node-0 3 - - local allowance - allowance="$(docker exec sei-node-0 seid q feegrant grant \ - "$FEE_GRANTER_ADDRESS" "$FEE_GRANTEE_ADDRESS" --output json 2>&1)" || - die "release binary could not query the seeded fee allowance: $allowance" - jq -e '.allowance' <<<"$allowance" >/dev/null || - die "release fee allowance was not stored: $allowance" - printf '%s\n' "$allowance" >"$ARTIFACT_ROOT/release-feegrant.json" - local allowance_before - allowance_before="$(feegrant_spend_limit <<<"$allowance")" || - die "release fee allowance has no usei spend limit: $allowance" - - local spend_output - if ! spend_output="$( - printf '12345678\n' | - docker exec -i sei-node-0 seid tx bank send node_admin \ - "$FEE_GRANTER_ADDRESS" 1usei --fee-account "$FEE_GRANTER_ADDRESS" \ - --from node_admin --chain-id sei --fees 200000usei --gas 2000000 \ - --broadcast-mode sync --yes --output json \ - 2>"$ARTIFACT_ROOT/release-feegrant-spend.stderr" - )"; then - die "release binary could not spend the fee allowance; see release-feegrant-spend.stderr" - fi - require_check_tx_success "fee-granted bank send" "$spend_output" - wait_for_blocks sei-node-0 3 - - local allowance_after_output - allowance_after_output="$(docker exec sei-node-0 seid q feegrant grant \ - "$FEE_GRANTER_ADDRESS" "$FEE_GRANTEE_ADDRESS" --output json 2>&1)" || - die "release binary could not re-query the spent fee allowance: $allowance_after_output" - local allowance_after - allowance_after="$(feegrant_spend_limit <<<"$allowance_after_output")" || - die "spent release fee allowance has no usei spend limit: $allowance_after_output" - ((allowance_after < allowance_before)) || - die "fee-granted send did not consume the allowance ($allowance_before -> $allowance_after)" - printf '%s\n' "$allowance_after_output" \ - >"$ARTIFACT_ROOT/release-feegrant-after-spend.json" - - seed_release_oracle_rates - - RELEASE_MODULE_VERSIONS="$(query_module_versions sei-node-0)" || - die "unable to query release module versions" - printf '%s\n' "$RELEASE_MODULE_VERSIONS" >"$ARTIFACT_ROOT/release-module-versions.txt" - - local module - for module in "${EXPECTED_REMOVED_MODULES[@]}" oracle; do - grep -Fxq "$module" <<<"$RELEASE_MODULE_VERSIONS" || - die "release module version map does not contain $module" - done -} - assert_all_nodes_progress() { local blocks="$1" local label="$2" @@ -410,8 +297,7 @@ start_release_cluster() { DOCKER_DETACH=true \ DOCKER_PLATFORM=linux/amd64 \ INVARIANT_CHECK_INTERVAL=10 \ - ORACLE_VOTE_PERIOD="$ORACLE_VOTE_PERIOD" \ - UPGRADE_VERSION_LIST='' \ + UPGRADE_VERSION_LIST= \ make docker-cluster-start-skipbuild ) CLUSTER_STARTED=true @@ -467,9 +353,6 @@ stage_main_binary() { local node local actual_hash - docker exec --user root sei-node-0 \ - sh -c 'cp /root/go/bin/seid /tmp/seid.release && chmod 0755 /tmp/seid.release' - for ((i = 0; i < NODE_COUNT; i++)); do node="sei-node-$i" docker cp "$BUILD_ROOT/main-seid" "$node:/tmp/seid.next" @@ -494,19 +377,16 @@ seid_process_state() { local node="$1" local state if ! state="$(docker exec "$node" sh -c ' - for comm in /proc/[0-9]*/comm; do - [ -r "$comm" ] || continue - read -r name <"$comm" || continue - if [ "$name" = seid ]; then - process_dir="${comm%/comm}" - read -r stat_pid stat_comm process_state stat_rest <"$process_dir/stat" || - continue - [ "$process_state" = Z ] && continue - printf running - exit + if pgrep -x seid >/dev/null 2>&1; then + printf running + else + status=$? + if [ "$status" -eq 1 ]; then + printf stopped + else + exit "$status" fi - done - printf stopped + fi ')"; then die "unable to inspect the seid process in $node" fi @@ -520,42 +400,11 @@ seid_process_state() { esac } -stop_node_process() { - local node="$1" - docker exec "$node" sh -c ' - for comm in /proc/[0-9]*/comm; do - [ -r "$comm" ] || continue - read -r name <"$comm" || continue - if [ "$name" = seid ]; then - process_dir="${comm%/comm}" - read -r stat_pid stat_comm process_state stat_rest <"$process_dir/stat" || - continue - [ "$process_state" = Z ] && continue - pid="${comm#/proc/}" - pid="${pid%/comm}" - kill -TERM "$pid" - fi - done - ' || die "unable to stop seid in $node" - - local deadline=$((SECONDS + 60)) - while ((SECONDS < deadline)); do - if [[ "$(seid_process_state "$node")" == stopped ]]; then - return - fi - sleep 1 - done - die "seid in $node did not stop within 60 seconds" -} - install_main_binary() { local node_id="$1" local node="sei-node-$node_id" local actual_hash - docker exec "$node" test -f /root/.sei/data/upgrade-info.json || - die "$node halted without writing upgrade-info.json" - log "Installing main on $node" mkdir -p "$ARTIFACT_ROOT/pre-upgrade-logs" cp "$REPO_ROOT/build/generated/logs/seid-$node_id.log" \ @@ -579,8 +428,7 @@ start_main_binary() { upgrade_nodes_as_they_halt() { log "Supervising each validator through the upgrade at height $TARGET_HEIGHT" - local timeout_seconds=$((UPGRADE_LEAD_SECONDS + 360)) - local deadline=$((SECONDS + timeout_seconds)) + local deadline=$((SECONDS + 360)) local upgraded_count=0 local ready_count=0 local current @@ -653,9 +501,9 @@ upgrade_nodes_as_they_halt() { done ((upgraded_count == NODE_COUNT)) || - die "only $upgraded_count of $NODE_COUNT validators halted and upgraded within $timeout_seconds seconds" + die "only $upgraded_count of $NODE_COUNT validators halted and upgraded within 360 seconds" ((ready_count == NODE_COUNT)) || - die "only $ready_count of $NODE_COUNT upgraded validators became ready within $timeout_seconds seconds" + die "only $ready_count of $NODE_COUNT upgraded validators became ready within 360 seconds" for ((i = 0; i < NODE_COUNT; i++)); do state="$(seid_process_state "sei-node-$i")" @@ -665,143 +513,6 @@ upgrade_nodes_as_they_halt() { verify_running_binary_hash "$MAIN_BINARY_HASH" main } -assert_v67_upgrade() { - log "Asserting the v6.7 module retirement" - local applied_output - applied_output="$(docker exec sei-node-0 \ - seid q upgrade applied "$UPGRADE_NAME" --output json 2>&1)" || - die "unable to query applied plan $UPGRADE_NAME: $applied_output" - - local applied_height - applied_height="$(jq -er '.header.height | tonumber' <<<"$applied_output")" || - die "applied plan $UPGRADE_NAME has no height: $applied_output" - ((applied_height == TARGET_HEIGHT)) || - die "$UPGRADE_NAME applied at height $applied_height, expected $TARGET_HEIGHT" - printf 'applied_height=%s\n' "$applied_height" | - tee -a "$ARTIFACT_ROOT/revisions.txt" - - local main_module_versions - main_module_versions="$(query_module_versions sei-node-0)" || - die "unable to query main module versions" - printf '%s\n' "$main_module_versions" \ - >"$ARTIFACT_ROOT/main-module-versions.txt" - - local removed_modules - local expected_removed - removed_modules="$( - comm -23 \ - <(printf '%s\n' "$RELEASE_MODULE_VERSIONS") \ - <(printf '%s\n' "$main_module_versions") - )" - expected_removed="$(printf '%s\n' "${EXPECTED_REMOVED_MODULES[@]}" | sort)" - [[ "$removed_modules" == "$expected_removed" ]] || - die "unexpected removed module versions; expected [$expected_removed], got [$removed_modules]" - - grep -Fxq oracle <<<"$main_module_versions" || - die "main module version map no longer contains oracle" - - local feegrant_output - feegrant_output="$( - printf '12345678\n' | - docker exec -i sei-node-0 seid tx bank send node_admin \ - "$FEE_GRANTER_ADDRESS" 1usei --fee-account "$FEE_GRANTER_ADDRESS" \ - --from node_admin --chain-id sei --fees 200000usei --gas 2000000 \ - --broadcast-mode sync --yes --output json 2>&1 || true - )" - printf '%s\n' "$feegrant_output" >"$ARTIFACT_ROOT/main-feegrant-response.txt" - grep -Fq "fee grants are not enabled" <<<"$feegrant_output" || - die "main did not reject the previously valid fee-granted send: $feegrant_output" - - local oracle_output - oracle_output="$( - docker exec sei-node-0 seid q oracle exchange-rates --output json 2>&1 || true - )" - printf '%s\n' "$oracle_output" >"$ARTIFACT_ROOT/main-oracle-response.txt" - grep -Fq "oracle module is deprecated" <<<"$oracle_output" || - die "main did not deprecate the release oracle query: $oracle_output" -} - -export_node_state() { - local binary_path="$1" - local label="$2" - local raw_output="$ARTIFACT_ROOT/$label.raw" - local json_output="$ARTIFACT_ROOT/$label.json" - local error_output="$ARTIFACT_ROOT/$label.stderr" - local exported=false - local attempt - - for ((attempt = 1; attempt <= 10; attempt++)); do - if docker exec sei-node-0 "$binary_path" export --home /root/.sei --chain-id sei \ - >"$raw_output" 2>"$error_output"; then - exported=true - break - fi - sleep 2 - done - [[ "$exported" == true ]] || return 1 - - if ! python3 - "$raw_output" "$json_output" <<'PY' -import json -import pathlib -import sys - -raw_path = pathlib.Path(sys.argv[1]) -output_path = pathlib.Path(sys.argv[2]) -text = raw_path.read_text(encoding="utf-8", errors="replace") -decoder = json.JSONDecoder() -offset = 0 - -for line in text.splitlines(keepends=True): - stripped = line.lstrip() - if stripped.startswith("{"): - start = offset + len(line) - len(stripped) - try: - value, _ = decoder.raw_decode(text[start:]) - except json.JSONDecodeError: - pass - else: - if isinstance(value, dict) and "app_state" in value: - output_path.write_text( - json.dumps(value, separators=(",", ":")) + "\n", - encoding="utf-8", - ) - break - offset += len(line) -else: - raise SystemExit("no genesis document found") -PY - then - return 1 - fi -} - -verify_retained_state() { - log "Verifying retired state through both binaries" - stop_node_process sei-node-0 - - export_node_state /root/go/bin/seid main-export || - die "main export failed; see main-export.stderr" - jq -e ' - (.app_state | has("feegrant") | not) and - (.app_state | has("capability") | not) and - (.app_state | has("ibc") | not) and - (.app_state | has("transfer") | not) and - (.app_state.oracle.exchange_rates | length > 0) - ' "$ARTIFACT_ROOT/main-export.json" >/dev/null || - die "main export did not omit retired modules while retaining oracle rates" - - if export_node_state /tmp/seid.release release-export-after-upgrade; then - jq -e ' - (.app_state.feegrant.allowances | length > 0) and - (.app_state | has("capability")) and - (.app_state.oracle.exchange_rates | length > 0) - ' "$ARTIFACT_ROOT/release-export-after-upgrade.json" >/dev/null || - die "release export lost retained feegrant, capability, or oracle state" - else - log "Release binary could not export upgraded state; keeping its diagnostics" - fi -} - verify_post_upgrade() { local required_height=$((TARGET_HEIGHT + POST_UPGRADE_BLOCKS)) log "Requiring every main node to reach height $required_height" @@ -814,15 +525,21 @@ verify_post_upgrade() { assert_all_nodes_progress 3 "post-upgrade" + local minimum= + local maximum=0 local current for ((i = 0; i < NODE_COUNT; i++)); do current="$(height "sei-node-$i")" - printf 'post_upgrade_node_%s_height=%s\n' "$i" "$current" | - tee -a "$ARTIFACT_ROOT/revisions.txt" + ((current > maximum)) && maximum="$current" + if [[ -z "$minimum" ]] || ((current < minimum)); then + minimum="$current" + fi done + ((maximum - minimum <= 3)) || + die "validators are not synchronized after upgrade (min=$minimum max=$maximum)" - assert_v67_upgrade - verify_retained_state + printf 'post_upgrade_min_height=%s\n' "$minimum" | + tee -a "$ARTIFACT_ROOT/revisions.txt" log "Upgrade $UPGRADE_NAME succeeded" } @@ -856,7 +573,6 @@ cleanup() { fi git -C "$REPO_ROOT" worktree remove --force "$MAIN_WORKTREE" 2>/dev/null git -C "$REPO_ROOT" worktree remove --force "$RELEASE_WORKTREE" 2>/dev/null - git -C "$REPO_ROOT" worktree prune exit "$exit_code" } @@ -871,7 +587,6 @@ main() { build_binary "$RELEASE_WORKTREE" "$BUILD_ROOT/release-seid" release build_binary "$MAIN_WORKTREE" "$BUILD_ROOT/main-seid" main start_release_cluster - seed_release_state stage_main_binary submit_upgrade upgrade_nodes_as_they_halt diff --git a/.github/workflows/release-upgrade-test.yml b/.github/workflows/release-upgrade-test.yml index cab9ca6c26..de7f73374c 100644 --- a/.github/workflows/release-upgrade-test.yml +++ b/.github/workflows/release-upgrade-test.yml @@ -7,21 +7,13 @@ on: paths: - .github/workflows/release-upgrade-test.yml - .github/scripts/release-upgrade-test.sh - - Makefile - - app/app.go - - app/tags - - app/upgrades.go - - docker/** - - integration_test/upgrade_module/scripts/** - - integration_test/utils/_tx_helpers.sh schedule: - cron: '17 7 * * *' workflow_dispatch: inputs: release_branch: - description: v6.6 release branch to upgrade from + description: Official release branch to test (empty selects the latest) required: false - default: release/v6.6 type: string upgrade_lead_seconds: description: Seconds between proposal submission and upgrade @@ -51,9 +43,7 @@ jobs: runs-on: ubuntu-large timeout-minutes: 90 env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - MAIN_REF: ${{ github.sha }} - RELEASE_BRANCH: ${{ inputs.release_branch || 'release/v6.6' }} + RELEASE_BRANCH: ${{ inputs.release_branch || '' }} UPGRADE_LEAD_SECONDS: ${{ inputs.upgrade_lead_seconds || '60' }} POST_UPGRADE_BLOCKS: ${{ inputs.post_upgrade_blocks || '10' }} steps: @@ -76,14 +66,6 @@ jobs: with: go-version: '1.25.6' - - name: Login to Docker Hub - # See: https://github.com/docker/login-action/releases/tag/v4.2.0 - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee - if: env.DOCKERHUB_USERNAME != '' - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Run coordinated release upgrade run: bash .github/scripts/release-upgrade-test.sh @@ -93,6 +75,6 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: release-upgrade-${{ github.run_id }} - path: ${{ runner.temp }}/sei-release-upgrade-${{ github.run_id }}/artifacts + path: artifacts/release-upgrade if-no-files-found: warn retention-days: 7 diff --git a/Makefile b/Makefile index 3b2ebabf8d..9ded00c1bf 100644 --- a/Makefile +++ b/Makefile @@ -393,7 +393,6 @@ kill-rpc-node: CLUSTER_ENV_VARS = DOCKER_PLATFORM=$(DOCKER_PLATFORM) USERID=$(shell id -u) GROUPID=$(shell id -g) \ GOCACHE=$(shell go env GOCACHE) NUM_ACCOUNTS=10 \ INVARIANT_CHECK_INTERVAL=$(INVARIANT_CHECK_INTERVAL) \ - ORACLE_VOTE_PERIOD=$(ORACLE_VOTE_PERIOD) \ UPGRADE_VERSION_LIST=$(UPGRADE_VERSION_LIST) \ MOCK_BALANCES=$(MOCK_BALANCES) \ GIGA_EXECUTOR=$(GIGA_EXECUTOR) \ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 47398e521b..b0db261842 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,7 +14,6 @@ services: - NUM_ACCOUNTS - SKIP_BUILD - INVARIANT_CHECK_INTERVAL - - ORACLE_VOTE_PERIOD - UPGRADE_VERSION_LIST - MOCK_BALANCES - GIGA_EXECUTOR @@ -51,7 +50,6 @@ services: - NUM_ACCOUNTS - SKIP_BUILD - INVARIANT_CHECK_INTERVAL - - ORACLE_VOTE_PERIOD - UPGRADE_VERSION_LIST - GIGA_EXECUTOR - GIGA_OCC @@ -83,7 +81,6 @@ services: - NUM_ACCOUNTS - SKIP_BUILD - INVARIANT_CHECK_INTERVAL - - ORACLE_VOTE_PERIOD - UPGRADE_VERSION_LIST - GIGA_EXECUTOR - GIGA_OCC @@ -119,7 +116,6 @@ services: - NUM_ACCOUNTS - SKIP_BUILD - INVARIANT_CHECK_INTERVAL - - ORACLE_VOTE_PERIOD - UPGRADE_VERSION_LIST - GIGA_EXECUTOR - GIGA_OCC diff --git a/docker/localnode/scripts/step2_genesis.sh b/docker/localnode/scripts/step2_genesis.sh index 42bd1c161c..9373ac6745 100755 --- a/docker/localnode/scripts/step2_genesis.sh +++ b/docker/localnode/scripts/step2_genesis.sh @@ -2,7 +2,6 @@ # Input parameters NODE_ID=${ID:-0} -ORACLE_VOTE_PERIOD=${ORACLE_VOTE_PERIOD:-2} echo "Preparing genesis file" @@ -16,7 +15,7 @@ override_genesis() { override_genesis '.app_state["mint"]["params"]["mint_denom"]="usei"' override_genesis '.app_state["staking"]["params"]["bond_denom"]="usei"' -override_genesis ".app_state[\"oracle\"][\"params\"][\"vote_period\"]=\"$ORACLE_VOTE_PERIOD\"" +override_genesis '.app_state["oracle"]["params"]["vote_period"]="2"' override_genesis '.app_state["slashing"]["params"]["signed_blocks_window"]="10000"' override_genesis '.app_state["slashing"]["params"]["min_signed_per_window"]="0.050000000000000000"' override_genesis '.app_state["staking"]["params"]["max_validators"]="50"' diff --git a/integration_test/runner/yamlparse_check_test.go b/integration_test/runner/yamlparse_check_test.go deleted file mode 100644 index 3c84245ada..0000000000 --- a/integration_test/runner/yamlparse_check_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package runner_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -// Every YAML suite must parse into the runner's schema and name a verifier type -// the runner implements. Without this, a malformed suite only surfaces on a -// cluster run, which is the slowest place to find a typo. -func TestYAMLSuitesParse(t *testing.T) { - files, err := filepath.Glob("../*/*.yaml") - require.NoError(t, err) - require.NotEmpty(t, files) - - type input struct { - Cmd string `yaml:"cmd"` - Env string `yaml:"env,omitempty"` - Node string `yaml:"node,omitempty"` - } - type verifier struct { - Type string `yaml:"type"` - Expr string `yaml:"expr"` - Result string `yaml:"result,omitempty"` - } - type testCase struct { - Name string `yaml:"name"` - Inputs []input `yaml:"inputs"` - Verifiers []verifier `yaml:"verifiers"` - } - - for _, file := range files { - t.Run(filepath.Base(file), func(t *testing.T) { - raw, err := os.ReadFile(file) - require.NoError(t, err) - - var cases []testCase - require.NoError(t, yaml.Unmarshal(raw, &cases)) - require.NotEmpty(t, cases) - - for _, tc := range cases { - require.NotEmpty(t, tc.Name) - for i, in := range tc.Inputs { - require.NotEmpty(t, in.Cmd, "input %d has no cmd", i) - } - for i, v := range tc.Verifiers { - require.Contains(t, []string{"eval", "regex"}, v.Type, - "verifier %d has unknown type", i) - require.NotEmpty(t, v.Expr, "verifier %d has no expr", i) - if v.Type == "regex" { - require.NotEmpty(t, v.Result, - "regex verifier %d needs the env var to match against", i) - } - } - } - }) - } -}