diff --git a/.github/actions/setup-private-go/action.yml b/.github/actions/setup-private-go/action.yml new file mode 100644 index 00000000..1351b6f9 --- /dev/null +++ b/.github/actions/setup-private-go/action.yml @@ -0,0 +1,37 @@ +name: "Setup private Go modules" +description: >- + Grants the runner read access to KiiChain's private Go module dependencies + (currently the github.com/KiiChain/evm-private fork used by the cosmos/evm + replace directive) so that go commands can resolve them. + +inputs: + token: + description: >- + Read-only token for the private KiiChain module repositories. The built-in + GITHUB_TOKEN is not sufficient: it is scoped to this repository only. + required: true + +runs: + using: "composite" + steps: + - name: Configure git credentials and GOPRIVATE + shell: bash + env: + TOKEN: ${{ inputs.token }} + run: | + set -euo pipefail + + if [ -z "${TOKEN}" ]; then + echo "::error::EVM_PRIVATE_TOKEN is not set. The cosmos/evm replace" \ + "directive points at the private KiiChain/evm-private fork, which" \ + "cannot be resolved without it." + exit 1 + fi + + git config --global \ + url."https://x-access-token:${TOKEN}@github.com/KiiChain/".insteadOf \ + "https://github.com/KiiChain/" + + # Independently of authentication, the public module proxy and checksum + # database cannot serve a private repository, so bypass both. + echo "GOPRIVATE=github.com/KiiChain/*" >> "$GITHUB_ENV" diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 32471d0f..9d9f3fa4 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -27,6 +27,11 @@ jobs: go-version: "1.24" check-latest: true + - name: Setup private Go modules + uses: ./.github/actions/setup-private-go + with: + token: ${{ secrets.EVM_PRIVATE_TOKEN }} + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 3522f305..5f3bb5cc 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -21,6 +21,11 @@ jobs: go-version: 1.24.x - uses: actions/checkout@v4 + - name: Setup private Go modules + uses: ./.github/actions/setup-private-go + with: + token: ${{ secrets.EVM_PRIVATE_TOKEN }} + - uses: technote-space/get-diff-action@v6.1.2 id: git_diff with: @@ -44,6 +49,10 @@ jobs: - name: Build Docker and download packages in parallel if: env.GIT_DIFF + env: + # Forwarded into the image build as a BuildKit secret so that + # `go mod download` inside the container can reach the private fork. + EVM_PRIVATE_TOKEN: ${{ secrets.EVM_PRIVATE_TOKEN }} run: | set -e echo "Starting docker build and go mod download in parallel..." diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ebfedbb7..268f7412 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,6 +24,11 @@ jobs: go-version: "1.24" check-latest: true + - name: Setup private Go modules + uses: ./.github/actions/setup-private-go + with: + token: ${{ secrets.EVM_PRIVATE_TOKEN }} + - uses: technote-space/get-diff-action@v6.1.2 id: git_diff with: diff --git a/.github/workflows/liveness.yaml b/.github/workflows/liveness.yaml index 3590f6ac..78650005 100644 --- a/.github/workflows/liveness.yaml +++ b/.github/workflows/liveness.yaml @@ -19,6 +19,11 @@ jobs: - uses: actions/setup-go@v5 with: go-version: 1.24.x + - name: Setup private Go modules + uses: ./.github/actions/setup-private-go + with: + token: ${{ secrets.EVM_PRIVATE_TOKEN }} + - uses: technote-space/get-diff-action@v6.1.2 id: git_diff with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f5c6583..08257b78 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,12 @@ jobs: check-latest: true cache: true cache-dependency-path: go.sum + + - name: Setup private Go modules + uses: ./.github/actions/setup-private-go + with: + token: ${{ secrets.EVM_PRIVATE_TOKEN }} + - uses: technote-space/get-diff-action@v6.1.2 id: git_diff with: @@ -45,10 +51,16 @@ jobs: ${{ runner.os }}-go- - name: test & coverage report creation if: env.GIT_DIFF + # Required, not cosmetic. The implicit default shell is `bash -e {0}` + # with NO pipefail, so the `| grep` below swallows a non-zero exit from + # `go test` and the job reports green on failing tests. Setting + # `shell: bash` gets `bash --noprofile --norc -eo pipefail {0}`. + shell: bash run: | - go test -v -coverprofile=profile.txt -covermode=atomic -coverpkg=./... $(go list ./... | grep -v -e '/tests/e2e' | grep -v -e '/tests/interchain') | grep -v "store.go:" | grep -v "mutable_tree.go:" + go test -tags=test -v -coverprofile=profile.txt -covermode=atomic -coverpkg=./... $(go list -tags=test ./... | grep -v -e '/tests/e2e' | grep -v -e '/tests/interchain') | grep -v "store.go:" | grep -v "mutable_tree.go:" - name: wasmbinding coverage (test build tag) if: env.GIT_DIFF + shell: bash run: | go test -tags=test -coverprofile=profile-wasmbinding.txt -covermode=atomic ./wasmbinding/... - uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index b04c5921..3f3504d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,49 @@ ## Unreleased +### Fixed + +- Reject `MsgCreateVestingAccount`, `MsgCreatePeriodicVestingAccount`, and `MsgCreatePermanentLockedAccount` in the Cosmos ante (top-level and nested in `authz.MsgExec`) so new vesting / locked accounts cannot be opened after the v7.4.0 upgrade +- Enable a bank `SendRestriction` for the 22 Aug 2026 incident addresses in the `v7.4.0` upgrade (after fund recovery) so Cosmos, precompile, and EVM native transfers cannot send from or to them after the upgrade height + + + +### Dependencies + +- Bump EVM fork to `v0.6.2-fork.1`, applied via the coordinated `v7.4.0` upgrade (private Cosmos EVM security hotfix; cannot build from public source until 2026-08-28) +- Bump EVM fork to `v0.6.2-fork.2` + +## v7.3.1 - 2026-08-06 + +### Fixed + +- Keep the gov module account on the bank blocked list so EVM BalanceHandler does not mirror gov deposits into StateDB (fixes Safe → gov precompile `deposit` failing on commit with `unauthorized`) ([#368](https://github.com/KiiChain/kiichain/pull/368)) + +## v7.3.0 - 2026-07-28 + ### Dependencies - [EVM](https://github.com/KiiChain/evm) fork from v0.6.0-fork.1 to [v0.6.0-fork.2](https://github.com/KiiChain/evm/releases/tag/v0.6.0-fork.2): bounded internal EVM call gas limit, EVM fee refunds, distribution precompile 32-byte withdraw fix, and CosmWasm EVM query undercharge fix - [EVM](https://github.com/KiiChain/evm) fork bump from `v0.6.0-fork.2` to [v0.6.1-fork.1](https://github.com/KiiChain/evm/releases/tag/v0.6.1-fork.1), applied via the coordinated `v7.3.0` upgrade: the July 2026 Cosmos EVM hotfix (precompile gas accounting alignment and StateDB locked-balance snapshotting), published upstream as [cosmos/evm v0.6.1](https://github.com/cosmos/evm/releases/tag/v0.6.1) +### Fixed + +- Close an expedited-governance whitelist bypass in `GovExpeditedProposalsDecorator` where the check only inspected top-level messages: a non-whitelisted `MsgSubmitProposal` wrapped in `authz.MsgExec` could enter the expedited voting path. The decorator now recurses into `authz.MsgExec` (including nested execs) and applies the expedited whitelist validation to wrapped proposals ([#353](https://github.com/KiiChain/kiichain/pull/353)) +- Compute the oracle ballot `StandardDeviation` as a stake-weighted variance (weight each squared deviation by the vote's power and divide by total voting power) instead of an unweighted average divided by the vote count, aligning the reward-band width with the stake-weighted median and preventing a group of low-stake validators from inflating the deviation to widen the accepted vote window ([#354](https://github.com/KiiChain/kiichain/pull/354)) +- Close an oracle slashing bypass in the `EndBlocker` where validators were scored against the post-filtered `voteTargets` map: a denom that received votes but was pushed below the vote threshold (e.g. by a coordinated group abstaining) was dropped from the scoring denominator, letting the abstainers avoid miss penalties. Participation is now scored against the configured targets that received votes (passing targets plus below-threshold targets), crediting validators that voted on a below-threshold target while counting abstention on it as a miss; targets that received no votes at all are still excluded so a legitimately unpriceable denom cannot mass-slash the validator set ([#352](https://github.com/KiiChain/kiichain/pull/352)) +- Allow EIP-7702 delegated EOAs to send direct EVM transactions by exempting delegation-designator code from the externally-owned-account-only check in `VerifyIfAccountExists`, so accounts that delegate via `SetCodeTx` can still manage (and revoke) their own delegation without a sponsored transaction ([#350](https://github.com/KiiChain/kiichain/pull/350)) +- Remove the forced minimum 1-unit-per-block reward release in `CalculateReward` and skip (instead of deactivating) sub-unit blocks in the rewards `BeginBlocker`, so the proportional share accumulates and the pool follows the configured schedule independent of block time ([#347](https://github.com/KiiChain/kiichain/pull/347)) +- Reject `MsgEthereumTx` from being dispatched through the authz keeper (including when nested inside `authz.MsgExec`), closing an EVM ante bypass on message-router execution paths that skip the ante handler ([#342](https://github.com/KiiChain/kiichain/pull/342)) +- Fix feegrant denomination bypass in the cosmos fee ante handler by converting the fee before consuming the grant, so `UseGrantedFees` is checked against the same coins later deducted ([#343](https://github.com/KiiChain/kiichain/pull/343)) +- Bound tokenfactory denom metadata size (`MaxDenomMetadataSize`) in `MsgSetDenomMetadata.ValidateBasic` and `msgServer.SetDenomMetadata` to prevent oversized metadata rewrites from forcing unbounded native store writes ([#341](https://github.com/KiiChain/kiichain/pull/341)) +- Fix native token supply inflation from the stateful precompiles by wrapping the account address codec (`evmAddressCodec`) to reject non-20-byte accounts at decode time ([#340](https://github.com/KiiChain/kiichain/pull/340)) +- Close governance vote minimum-stake bypass in `GovVoteDecorator` by enforcing the stake check on `MsgVoteWeighted` and recursing into nested `authz.MsgExec` messages ([#344](https://github.com/KiiChain/kiichain/pull/344)) +- Prevent a chain halt in the rewards `BeginBlocker` by routing `SendCoinsFromModuleToModule` failures through `haltSchedule` instead of returning a fatal error ([#346](https://github.com/KiiChain/kiichain/pull/346)) +- Add a `ValidateModuleAccounting` check (rewards module bank balance must cover the `CommunityPool`) and run it at genesis +- Fix CosmWasm EVM query path repeatable undercharged EVM execution ([#345](https://github.com/KiiChain/kiichain/pull/345)) + +## v7.2.0 - 2026-04-16 + ### Added - Emit `update_params`, `fund_pool`, `change_schedule`, and `reward_distributed` events from x/rewards @@ -16,16 +54,9 @@ ### Fixed -- Close an expedited-governance whitelist bypass in `GovExpeditedProposalsDecorator` where the check only inspected top-level messages: a non-whitelisted `MsgSubmitProposal` wrapped in `authz.MsgExec` could enter the expedited voting path. The decorator now recurses into `authz.MsgExec` (including nested execs) and applies the expedited whitelist validation to wrapped proposals -- Compute the oracle ballot `StandardDeviation` as a stake-weighted variance (weight each squared deviation by the vote's power and divide by total voting power) instead of an unweighted average divided by the vote count, aligning the reward-band width with the stake-weighted median and preventing a group of low-stake validators from inflating the deviation to widen the accepted vote window -- Close an oracle slashing bypass in the `EndBlocker` where validators were scored against the post-filtered `voteTargets` map: a denom that received votes but was pushed below the vote threshold (e.g. by a coordinated group abstaining) was dropped from the scoring denominator, letting the abstainers avoid miss penalties. Participation is now scored against the configured targets that received votes (passing targets plus below-threshold targets), crediting validators that voted on a below-threshold target while counting abstention on it as a miss; targets that received no votes at all are still excluded so a legitimately unpriceable denom cannot mass-slash the validator set -- Allow EIP-7702 delegated EOAs to send direct EVM transactions by exempting delegation-designator code from the externally-owned-account-only check in `VerifyIfAccountExists`, so accounts that delegate via `SetCodeTx` can still manage (and revoke) their own delegation without a sponsored transaction -- Remove the forced minimum 1-unit-per-block reward release in `CalculateReward` and skip (instead of deactivating) sub-unit blocks in the rewards `BeginBlocker`, so the proportional share accumulates and the pool follows the configured schedule independent of block time (previously a 10-year, 1M-unit schedule drained in ~12 days at the 1s target block time and ~28 days at the current ~2.4s rate, regardless of the configured duration) -- Reject `MsgEthereumTx` from being dispatched through the authz keeper (including when nested inside `authz.MsgExec`), closing an EVM ante bypass on message-router execution paths that skip the ante handler -- Fix feegrant denomination bypass in the cosmos fee ante handler by converting the fee before consuming the grant, so `UseGrantedFees` is checked against the same coins later deducted (prevents a grantee from forcing the granter to pay in a non-granted fee-abstraction denom) -- Refactor `PerformSetMetadata` in wasmbinding to delegate to `msgServer.SetDenomMetadata`, ensuring the `EnableSetMetadata` capability check is enforced -- Ensure that `UpdateTokenMetadata.Decimals` matches the ERC20 or bank records -- Fixed odd validation on tokenfactory change admin that blocked removing admin from the token +- Refactor `PerformSetMetadata` in wasmbinding to delegate to `msgServer.SetDenomMetadata`, ensuring the `EnableSetMetadata` capability check is enforced ([#329](https://github.com/KiiChain/kiichain/pull/329)) +- Ensure that `UpdateTokenMetadata.Decimals` matches the ERC20 or bank records ([#325](https://github.com/KiiChain/kiichain/pull/325)) +- Fixed odd validation on tokenfactory change admin that blocked removing admin from the token ([#324](https://github.com/KiiChain/kiichain/pull/324)) - Fix division-by-zero chain halt in `CalculateReward` caused by sub-second schedule durations; replace `Seconds()` truncation with `Nanoseconds()` precision and release full remaining reward when `EndTime <= LastReleaseTime` ([#267](https://github.com/KiiChain/kiichain/issues/267)) - Add denom string length validation (max 128 bytes) to oracle precompile and query server to prevent memory exhaustion via oversized inputs - Add result limits to oracle list queries (ExchangeRates, Actives, VoteTargets capped at 1000; PriceSnapshotHistory capped at 500) to prevent unbounded iteration @@ -37,26 +68,15 @@ - Validate rewards baseDenom using sdk.ValidateDenom to enforce proper denom format (min 3 chars, valid characters, no leading digits) - Ensure feeTokens is not nil at genesis - Ensure feeTokenMetadata initial prices after updateFeeTokenMetadata is picked up from oracle -- Use `DecCoins.Validate()` on `RewardPool.ValidateGenesis` to catch malformed denom formats, duplicate denoms, bad ordering +- Use `DecCoins.Validate()` on `RewardPool.ValidateGenesis` to catch malformed denom formats, duplicate denoms, bad ordering ([#323](https://github.com/KiiChain/kiichain/pull/323)) - Enforce denom consistency in `GenesisState.Validate` with `Params.TokenDenom` -- Bound tokenfactory denom metadata size (`MaxDenomMetadataSize`) in `MsgSetDenomMetadata.ValidateBasic` and `msgServer.SetDenomMetadata` to prevent oversized metadata rewrites (including via the CosmWasm binding) from forcing unbounded native store writes that overrun the transaction's declared gas -- Limited tokenfactory queries, removing denial of service possibility -- Indexed admins to reduce query space on tokenfactory denom queries -- Fix native token supply inflation from the stateful precompiles by wrapping the account address codec (`evmAddressCodec`) to reject non-20-byte accounts (e.g. a 32-byte bech32 withdraw, module, or CosmWasm contract address) at decode time, preventing such addresses from being truncated and minted a duplicate balance when mirrored into the EVM StateDB -- Close governance vote minimum-stake bypass in `GovVoteDecorator` by enforcing the stake check on `MsgVoteWeighted` (`govv1` and `govv1beta1`) and recursing into nested `authz.MsgExec` messages so wrapped votes can no longer skip the requirement -- Prevent a chain halt in the rewards `BeginBlocker` by routing `SendCoinsFromModuleToModule` failures through `haltSchedule` (graceful schedule deactivation) instead of returning a fatal error, matching the other reward release error paths -- Add a `ValidateModuleAccounting` check (rewards module bank balance must cover the `CommunityPool`) and run it at genesis to surface accounting/bank divergences early +- Limited tokenfactory queries, removing denial of service possibility ([#328](https://github.com/KiiChain/kiichain/pull/328)) +- Indexed admins to reduce query space on tokenfactory denom queries ([#328](https://github.com/KiiChain/kiichain/pull/328)) ### Removed - Removed price field input in updateTokenMetadata request -## v7.3.1 - 2026-08-06 - -### Fixed - -- Keep the gov module account on the bank blocked list so EVM BalanceHandler does not mirror gov deposits into StateDB (fixes Safe → gov precompile `deposit` failing on commit with `unauthorized`) ([#368](https://github.com/KiiChain/kiichain/pull/368)) - ## v7.1.0-mainnet - 2026-03-13 ### Fixed diff --git a/Dockerfile b/Dockerfile index b508d4cd..be52e7ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # Info on how to use this docker image can be found in DOCKER_README.md ARG IMG_TAG=latest @@ -16,7 +17,16 @@ RUN sha256sum /lib/libwasmvm_muslc.x86_64.a | grep cefe73f0caa5a9eaba3733c639cdf RUN cp "/lib/libwasmvm_muslc.$(uname -m).a" /lib/libwasmvm_muslc.a COPY go.mod go.sum* ./ -RUN go mod download + +ENV GOPRIVATE=github.com/KiiChain/* +RUN --mount=type=secret,id=gh_token \ + set -e; \ + if [ -s /run/secrets/gh_token ]; then \ + export GIT_CONFIG_COUNT=1; \ + export GIT_CONFIG_KEY_0="url.https://x-access-token:$(cat /run/secrets/gh_token)@github.com/KiiChain/.insteadOf"; \ + export GIT_CONFIG_VALUE_0="https://github.com/KiiChain/"; \ + fi; \ + go mod download COPY . . RUN LEDGER_ENABLED=false LINK_STATICALLY=true BUILD_TAGS=muslc make build diff --git a/Makefile b/Makefile index cc2dc35c..e7170734 100644 --- a/Makefile +++ b/Makefile @@ -307,8 +307,12 @@ test-unit-cover-html: test-unit-cover @echo "--> Generating HTML coverage report" @go tool cover -html=coverage.txt -o coverage.html +ifdef EVM_PRIVATE_TOKEN +DOCKER_BUILD_SECRETS := --secret id=gh_token,env=EVM_PRIVATE_TOKEN +endif + docker-build-debug: - @docker build -t kiichain/kiichaind-e2e -f Dockerfile . + @DOCKER_BUILDKIT=1 docker build $(DOCKER_BUILD_SECRETS) -t kiichain/kiichaind-e2e -f Dockerfile . docker-build-hermes: @cd tests/e2e/docker; docker build -t kiichain/hermes-e2e:1.0.0 -f hermes.Dockerfile . diff --git a/ante/ante_cosmos.go b/ante/ante_cosmos.go index 32a45855..07b5baa0 100644 --- a/ante/ante_cosmos.go +++ b/ante/ante_cosmos.go @@ -29,7 +29,10 @@ func NewCosmosAnteHandler(ctx sdk.Context, options HandlerOptions) sdk.AnteHandl evmcosmosante.NewAuthzLimiterDecorator( // disable the Msg types that cannot be included on an authz.MsgExec msgs field sdk.MsgTypeURL(&evmtypes.MsgEthereumTx{}), sdk.MsgTypeURL(&sdkvesting.MsgCreateVestingAccount{}), + sdk.MsgTypeURL(&sdkvesting.MsgCreatePeriodicVestingAccount{}), + sdk.MsgTypeURL(&sdkvesting.MsgCreatePermanentLockedAccount{}), ), + NewVestingAccountCreationDecorator(options.Cdc), // reject vesting-create msgs at the top level and inside authz ante.NewSetUpContextDecorator(), oracle.NewVoteAloneDecorator(), // Since this only iterate TXs, it must be executed early diff --git a/ante/vesting_ante.go b/ante/vesting_ante.go new file mode 100644 index 00000000..ad83d5de --- /dev/null +++ b/ante/vesting_ante.go @@ -0,0 +1,71 @@ +package ante + +import ( + errorsmod "cosmossdk.io/errors" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkvesting "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" + "github.com/cosmos/cosmos-sdk/x/authz" + + xerrors "github.com/kiichain/kiichain/v7/x/types/errors" +) + +// blockedVestingCreateMsgs is the set of vesting-module messages that open a +// new account with LockedCoins. Those accounts are the book the EVM locked- +// balance snapshot must stay consistent with; new creates are rejected until +// that path is safe. +var blockedVestingCreateMsgs = map[string]struct{}{ + sdk.MsgTypeURL(&sdkvesting.MsgCreateVestingAccount{}): {}, + sdk.MsgTypeURL(&sdkvesting.MsgCreatePeriodicVestingAccount{}): {}, + sdk.MsgTypeURL(&sdkvesting.MsgCreatePermanentLockedAccount{}): {}, +} + +// VestingAccountCreationDecorator rejects messages that create vesting or +// permanently locked accounts, including when nested in authz.MsgExec. +type VestingAccountCreationDecorator struct { + cdc codec.BinaryCodec +} + +// NewVestingAccountCreationDecorator returns a decorator that blocks new +// vesting account creation. +func NewVestingAccountCreationDecorator(cdc codec.BinaryCodec) VestingAccountCreationDecorator { + return VestingAccountCreationDecorator{cdc: cdc} +} + +// AnteHandle rejects blocked vesting-create messages at any authz nesting depth. +func (d VestingAccountCreationDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) { + if err := d.validateMsgs(tx.GetMsgs()); err != nil { + return ctx, err + } + return next(ctx, tx, simulate) +} + +func (d VestingAccountCreationDecorator) validateMsgs(msgs []sdk.Msg) error { + for _, msg := range msgs { + if execMsg, ok := msg.(*authz.MsgExec); ok { + if err := d.validateAuthzExec(execMsg); err != nil { + return err + } + continue + } + + typeURL := sdk.MsgTypeURL(msg) + if _, blocked := blockedVestingCreateMsgs[typeURL]; blocked { + return errorsmod.Wrapf(xerrors.ErrUnauthorized, "vesting account creation is disabled: %s", typeURL) + } + } + return nil +} + +func (d VestingAccountCreationDecorator) validateAuthzExec(execMsg *authz.MsgExec) error { + innerMsgs := make([]sdk.Msg, 0, len(execMsg.Msgs)) + for _, v := range execMsg.Msgs { + var innerMsg sdk.Msg + if err := d.cdc.UnpackAny(v, &innerMsg); err != nil { + return errorsmod.Wrapf(xerrors.ErrUnauthorized, "cannot unmarshal authz exec msg (type %s): %v", v.TypeUrl, err) + } + innerMsgs = append(innerMsgs, innerMsg) + } + return d.validateMsgs(innerMsgs) +} diff --git a/ante/vesting_ante_internal_test.go b/ante/vesting_ante_internal_test.go new file mode 100644 index 00000000..274b0f25 --- /dev/null +++ b/ante/vesting_ante_internal_test.go @@ -0,0 +1,101 @@ +package ante + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "cosmossdk.io/math" + + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkvesting "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" + "github.com/cosmos/cosmos-sdk/x/authz" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" +) + +func TestVestingAccountCreationDecorator(t *testing.T) { + registry := codectypes.NewInterfaceRegistry() + authz.RegisterInterfaces(registry) + sdkvesting.RegisterInterfaces(registry) + banktypes.RegisterInterfaces(registry) + decorator := NewVestingAccountCreationDecorator(codec.NewProtoCodec(registry)) + + from := sdk.AccAddress("from________________") + to := sdk.AccAddress("to__________________") + coins := sdk.NewCoins(sdk.NewCoin("akii", math.NewInt(1))) + + exec := func(msgs ...sdk.Msg) sdk.Msg { + m := authz.NewMsgExec(from, msgs) + return &m + } + + testCases := []struct { + name string + msgs []sdk.Msg + expectErr bool + }{ + { + name: "allow bank send", + msgs: []sdk.Msg{&banktypes.MsgSend{ + FromAddress: from.String(), + ToAddress: to.String(), + Amount: coins, + }}, + }, + { + name: "block MsgCreateVestingAccount", + msgs: []sdk.Msg{sdkvesting.NewMsgCreateVestingAccount(from, to, coins, 1, false)}, + expectErr: true, + }, + { + name: "block delayed MsgCreateVestingAccount", + msgs: []sdk.Msg{sdkvesting.NewMsgCreateVestingAccount(from, to, coins, 1, true)}, + expectErr: true, + }, + { + name: "block MsgCreatePeriodicVestingAccount", + msgs: []sdk.Msg{sdkvesting.NewMsgCreatePeriodicVestingAccount(from, to, 1, []sdkvesting.Period{{ + Length: 1, + Amount: coins, + }})}, + expectErr: true, + }, + { + name: "block MsgCreatePermanentLockedAccount", + msgs: []sdk.Msg{sdkvesting.NewMsgCreatePermanentLockedAccount(from, to, coins)}, + expectErr: true, + }, + { + name: "block MsgCreateVestingAccount inside authz.MsgExec", + msgs: []sdk.Msg{exec(sdkvesting.NewMsgCreateVestingAccount(from, to, coins, 1, false))}, + expectErr: true, + }, + { + name: "block MsgCreatePeriodicVestingAccount inside nested authz.MsgExec", + msgs: []sdk.Msg{exec(exec(sdkvesting.NewMsgCreatePeriodicVestingAccount(from, to, 1, []sdkvesting.Period{{ + Length: 1, + Amount: coins, + }})))}, + expectErr: true, + }, + { + name: "block MsgCreatePermanentLockedAccount inside authz.MsgExec", + msgs: []sdk.Msg{exec(sdkvesting.NewMsgCreatePermanentLockedAccount(from, to, coins))}, + expectErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := decorator.validateMsgs(tc.msgs) + if tc.expectErr { + require.Error(t, err) + require.ErrorContains(t, err, "vesting account creation is disabled") + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/app/app.go b/app/app.go index 5c9fc607..9a759ca4 100644 --- a/app/app.go +++ b/app/app.go @@ -1,6 +1,7 @@ package kiichain import ( + "errors" "fmt" "io" "io/fs" @@ -50,6 +51,7 @@ import ( authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" txmodule "github.com/cosmos/cosmos-sdk/x/auth/tx/config" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" govkeeper "github.com/cosmos/cosmos-sdk/x/gov/keeper" wasm "github.com/CosmWasm/wasmd/x/wasm" @@ -65,9 +67,11 @@ import ( evmtypes "github.com/cosmos/evm/x/vm/types" kiiante "github.com/kiichain/kiichain/v7/ante" + "github.com/kiichain/kiichain/v7/app/blockedaddrs" "github.com/kiichain/kiichain/v7/app/keepers" "github.com/kiichain/kiichain/v7/app/upgrades" v7_3_1 "github.com/kiichain/kiichain/v7/app/upgrades/v7_3_1" + v7_4_0 "github.com/kiichain/kiichain/v7/app/upgrades/v7_4" "github.com/kiichain/kiichain/v7/client/docs" ) @@ -78,6 +82,7 @@ var ( // Upgrades is a list of all the upgrades that are available for the application. Upgrades = []upgrades.Upgrade{ v7_3_1.Upgrade, + v7_4_0.Upgrade, } ) @@ -360,9 +365,49 @@ func (app *KiichainApp) Name() string { return app.BaseApp.Name() } // PreBlocker application updates every pre block func (app *KiichainApp) PreBlocker(ctx sdk.Context, _ *abci.RequestFinalizeBlock) (*sdk.ResponsePreBlock, error) { + if ctx.ChainID() == v7_4_0.MainnetChainID { + if ctx.BlockHeight() == v7_4_0.UpgradeHeight { + expected := upgradetypes.Plan{ + Name: v7_4_0.UpgradeName, + Height: ctx.BlockHeight(), + Info: "emergency fund recovery post-exploit", + } + + current, err := app.UpgradeKeeper.GetUpgradePlan(ctx) + switch { + case errors.Is(err, upgradetypes.ErrNoUpgradePlanFound): + if err := app.UpgradeKeeper.ScheduleUpgrade(ctx, expected); err != nil { + panic(fmt.Errorf("failed to schedule emergency upgrade: %w", err)) + } + case err != nil: + panic(fmt.Errorf("cannot read active upgrade plan: %w", err)) + case current != expected: + panic(fmt.Errorf("unexpected active upgrade plan: %+v", current)) + } + } else if ctx.BlockHeight() > v7_4_0.UpgradeHeight { + app.assertEmergencyRecoveryApplied(ctx) + } + } return app.mm.PreBlock(ctx) } +// assertEmergencyRecoveryApplied refuses to let this node keep processing +// mainnet blocks past UpgradeHeight unless the v7.4.0 recovery genuinely ran +// at that exact height and the incident address restriction is active. +func (app *KiichainApp) assertEmergencyRecoveryApplied(ctx sdk.Context) { + doneHeight, err := app.UpgradeKeeper.GetDoneHeight(ctx, v7_4_0.UpgradeName) + if err != nil { + panic(fmt.Errorf("cannot verify emergency recovery: %w", err)) + } + if doneHeight != v7_4_0.UpgradeHeight { + panic(fmt.Errorf("emergency recovery missing: expected done at height %d, got %d", v7_4_0.UpgradeHeight, doneHeight)) + } + + if !blockedaddrs.IsEnabled(ctx, app.GetKey(banktypes.StoreKey)) { + panic(errors.New("emergency recovery incomplete: incident address restriction is not enabled")) + } +} + // BeginBlocker application updates every begin block func (app *KiichainApp) BeginBlocker(ctx sdk.Context) (sdk.BeginBlock, error) { return app.mm.BeginBlock(ctx) diff --git a/app/blockedaddrs/addrs.go b/app/blockedaddrs/addrs.go new file mode 100644 index 00000000..248424cd --- /dev/null +++ b/app/blockedaddrs/addrs.go @@ -0,0 +1,76 @@ +package blockedaddrs + +import ( + "encoding/hex" + "sort" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// AttackerAddrs is the 22 Aug 2026 incident's frozen wallets: bech32 -> hex +var AttackerAddrs = map[string]string{ + "kii1peafvgnleuyl20tyfwnyvtvvwwvnaujxmqe5qe": "0x0e7a96227fcf09f53d644ba6462d8c73993ef246", + "kii1vvwu93nya4ku9yds3v6ns2uq0fsmrnf4cf4yht": "0x631dc2c664ed6dc291b08b35382b807a61b1cd35", + "kii1p3zmn7m6xq82jna6me04p8awt7k4u4k2alwu99": "0x0c45b9fb7a300ea94fbade5f509fae5fad5e56ca", + "kii1zamzjyjcwl0dejjvr90rtrwttxx2zhspqx4sm5": "0x177629125877dedcca4c195e358dcb598ca15e01", + "kii1zlqdn7706xym7q3k2mdleag0uqjnhv8wu4sfsj": "0x17c0d9fbcfd189bf023656dbfcf50fe0253bb0ee", + "kii1rehngnge8qn3ngszw4a8xxf2kqwmact602wtm8": "0x1e6f344d19382719a202757a73192ab01dbee17a", + "kii1y8m0qyc4n3m0rw4rcd7qnqahjh3r7p9uu3ert8": "0x21f6f013159c76f1baa3c37c0983b795e23f04bc", + "kii19p9h2nw2y4fs85sgwgj2qrhhx7jmz6zujldh3n": "0x284b754dca255303d2087224a00ef737a5b1685c", + "kii183h7rz9p4r8a7j8q2ardnrc7pgwnjp9jvhc8kq": "0x3c6fe188a1a8cfdf48e05746d98f1e0a1d3904b2", + "kii1gp7ar4hdlqntl5qkerm5n8mfxhqkegm76zqskr": "0x407dd1d6edf826bfd016c8f7499f6935c16ca37e", + "kii1gf9a9jjnnv8q3zcr8kczx0r5425zcfgpdw72tt": "0x424bd2ca539b0e088b033db0233c74aaa82c2501", + "kii1t7gzjh4gsrcuyfx3xdsem05chluqfsa43j9g54": "0x5f90295ea880f1c224d133619dbe98bff804c3b5", + "kii1wucgj4wxe0zvmmew2000cltc5qrl99eedtrzv4": "0x77308955c6cbc4cdef2e53defc7d78a007f29739", + "kii1syetlh585kl6yv5hmflhfehla5re7ay4um2skh": "0x8132bfde87a5bfa23297da7f74e6ffed079f7495", + "kii1s7jw5ffqgjfn4ywxtgtq3nhpgcn05z28fsmkhm": "0x87a4ea252044933a91c65a1608cee14626fa0947", + "kii13ndtp734ntzx0jqvr80rlmj62slztqm9agzwce": "0x8cdab0fa359ac467c80c19de3fee5a543e258365", + "kii13umhqxg56cxwa9wv4gu6l9v4vyz9e70g4hupvn": "0x8f37701914d60cee95ccaa39af959561045cf9e8", + "kii156expaxlymu5uhepe2dh647c9lu4slxpyml28q": "0xa6b260f4df26f94e5f21ca9b7d57d82ff9587cc1", + "kii1k8vyx8d9ru2hk3k207p3az84xedjxz2gkdyle0": "0xb1d8431da51f157b46ca7f831e88f5365b230948", + "kii16tr429kvneexqf4jttueuecm75ptc5l3gtj34q": "0xd2c75516cc9e726026b25af99e671bf502bc53f1", + "kii1mkhdmdgklsskgcgzz699nzhafav2hkea4qp2dj": "0xddaeddb516fc21646102168a598afd4f58abdb3d", + "kii1a5v3eaeaugdh3vk57nlh8q8xcu7z46w0ttlrw9": "0xed191cf73de21b78b2d4f4ff7380e6c73c2ae9cf", +} + +// SortedAttackerAddresses returns AttackerAddrs' keys in a fixed, +// deterministic order. Range over AttackerAddrs directly only where +// iteration order can't matter (e.g. IsBlockedAccAddress's lookup) — Go +// randomizes map iteration order per process, and code that walks this list +// from a consensus-critical upgrade handler must behave identically, in the +// same order, on every validator. +func SortedAttackerAddresses() []string { + addrs := make([]string, 0, len(AttackerAddrs)) + for addr := range AttackerAddrs { + addrs = append(addrs, addr) + } + sort.Strings(addrs) + return addrs +} + +// IsBlockedAccAddress reports whether addr is one of AttackerAddrs. +func IsBlockedAccAddress(addr sdk.AccAddress) bool { + if len(addr) == 0 { + return false + } + _, blocked := AttackerAddrs[addr.String()] + return blocked +} + +// IsBlockedAddr reports whether addr (hex or bech32) is one of AttackerAddrs. +func IsBlockedAddr(addr string) bool { + if accAddr, err := sdk.AccAddressFromBech32(addr); err == nil { + return IsBlockedAccAddress(accAddr) + } + + s := strings.TrimSpace(addr) + if len(s) >= 2 && (s[:2] == "0x" || s[:2] == "0X") { + s = s[2:] + } + bz, err := hex.DecodeString(s) + if err != nil { + return false + } + return IsBlockedAccAddress(sdk.AccAddress(bz)) +} diff --git a/app/blockedaddrs/addrs_test.go b/app/blockedaddrs/addrs_test.go new file mode 100644 index 00000000..b38a8db5 --- /dev/null +++ b/app/blockedaddrs/addrs_test.go @@ -0,0 +1,42 @@ +package blockedaddrs + +import ( + "testing" + + "github.com/stretchr/testify/require" + + // Sets the global "kii" bech32 prefix via its init(), same as the real + // binary — without this, sdk.AccAddressFromBech32 rejects "kii1..." + // addresses (default prefix is "cosmos"). + _ "github.com/kiichain/kiichain/v7/app/params" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +func TestIsBlockedAddr(t *testing.T) { + require.Len(t, AttackerAddrs, 22) + + for bech32Addr := range AttackerAddrs { + require.True(t, IsBlockedAddr(bech32Addr), bech32Addr) + + accAddr := sdk.MustAccAddressFromBech32(bech32Addr) + require.True(t, IsBlockedAccAddress(accAddr), bech32Addr) + } +} + +func TestIsBlockedAddr_HexForm(t *testing.T) { + // kii1peafvgnleuyl20tyfwnyvtvvwwvnaujxmqe5qe, confirmed via `kiichaind + // debug addr` against its bech32 form. + require.True(t, IsBlockedAddr("0x0e7a96227fcf09f53d644ba6462d8c73993ef246")) + require.True(t, IsBlockedAddr("0X0E7A96227FCF09F53D644BA6462D8C73993EF246")) +} + +func TestIsBlockedAddr_NotBlocked(t *testing.T) { + require.False(t, IsBlockedAddr("kii1c6cgjmsx0ewl6j552sp06musutmfcvxcaq4n9h")) + require.False(t, IsBlockedAddr("0x0000000000000000000000000000000000000001")) + require.False(t, IsBlockedAddr("not-an-address")) +} + +func TestIsBlockedAccAddress_Empty(t *testing.T) { + require.False(t, IsBlockedAccAddress(sdk.AccAddress{})) +} diff --git a/app/blockedaddrs/restriction.go b/app/blockedaddrs/restriction.go new file mode 100644 index 00000000..69d9f1a9 --- /dev/null +++ b/app/blockedaddrs/restriction.go @@ -0,0 +1,52 @@ +package blockedaddrs + +import ( + "context" + + errorsmod "cosmossdk.io/errors" + storetypes "cosmossdk.io/store/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + errortypes "github.com/cosmos/cosmos-sdk/types/errors" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" +) + +// enabledKey is stored in the bank KV store. Chosen to sit outside the +// x/bank collections prefixes. +var enabledKey = []byte{0xF1, 'i', 'n', 'c', 'i', 'd', 'e', 'n', 't', '-', 'b', 'l', 'o', 'c', 'k'} + +// Enable turns on the incident send restriction. Called from the v7.4.0 +// upgrade handler after fund recovery. +func Enable(ctx sdk.Context, key storetypes.StoreKey) { + ctx.KVStore(key).Set(enabledKey, []byte{1}) +} + +// IsEnabled reports whether the incident send restriction has been turned on. +func IsEnabled(ctx sdk.Context, key storetypes.StoreKey) bool { + if key == nil { + return false + } + return ctx.KVStore(key).Has(enabledKey) +} + +// NewSendRestriction returns a bank send hook that no-ops until Enable is +// written, then rejects sends whose from or to address is on the incident list. +func NewSendRestriction(key storetypes.StoreKey) banktypes.SendRestrictionFn { + return func(ctx context.Context, fromAddr, toAddr sdk.AccAddress, _ sdk.Coins) (sdk.AccAddress, error) { + sdkCtx := sdk.UnwrapSDKContext(ctx) + if !IsEnabled(sdkCtx, key) { + return toAddr, nil + } + if IsBlockedAccAddress(fromAddr) { + return nil, blockedSendErr(fromAddr.String()) + } + if IsBlockedAccAddress(toAddr) { + return nil, blockedSendErr(toAddr.String()) + } + return toAddr, nil + } +} + +func blockedSendErr(addr string) error { + return errorsmod.Wrapf(errortypes.ErrUnauthorized, "address is blocked: %s", addr) +} diff --git a/app/blockedaddrs/restriction_test.go b/app/blockedaddrs/restriction_test.go new file mode 100644 index 00000000..58c4067a --- /dev/null +++ b/app/blockedaddrs/restriction_test.go @@ -0,0 +1,52 @@ +package blockedaddrs + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + "cosmossdk.io/math" + storetypes "cosmossdk.io/store/types" + + "github.com/cosmos/cosmos-sdk/testutil" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +func TestSendRestrictionGatedByUpgrade(t *testing.T) { + key := storetypes.NewKVStoreKey("bank") + ctx := testutil.DefaultContext(key, storetypes.NewTransientStoreKey("transient")) + restriction := NewSendRestriction(key) + + blocked := sdk.AccAddress(mustDecodeHex("0e7a96227fcf09f53d644ba6462d8c73993ef246")) + allowed := sdk.AccAddress(mustDecodeHex("0000000000000000000000000000000000000001")) + coins := sdk.NewCoins(sdk.NewCoin("akii", math.NewInt(1))) + + require.False(t, IsEnabled(ctx, key)) + _, err := restriction(ctx, blocked, allowed, coins) + require.NoError(t, err) + _, err = restriction(ctx, allowed, blocked, coins) + require.NoError(t, err) + + Enable(ctx, key) + require.True(t, IsEnabled(ctx, key)) + + _, err = restriction(ctx, allowed, allowed, coins) + require.NoError(t, err) + + _, err = restriction(ctx, blocked, allowed, coins) + require.Error(t, err) + require.ErrorContains(t, err, "address is blocked") + + _, err = restriction(ctx, allowed, blocked, coins) + require.Error(t, err) + require.ErrorContains(t, err, "address is blocked") +} + +func mustDecodeHex(s string) []byte { + bz, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return bz +} diff --git a/app/keepers/keepers.go b/app/keepers/keepers.go index cd21f691..fb21dd07 100644 --- a/app/keepers/keepers.go +++ b/app/keepers/keepers.go @@ -83,6 +83,7 @@ import ( evmkeeper "github.com/cosmos/evm/x/vm/keeper" evmtypes "github.com/cosmos/evm/x/vm/types" + "github.com/kiichain/kiichain/v7/app/blockedaddrs" kiiparams "github.com/kiichain/kiichain/v7/app/params" "github.com/kiichain/kiichain/v7/wasmbinding" feeabstractionkeeper "github.com/kiichain/kiichain/v7/x/feeabstraction/keeper" @@ -220,6 +221,7 @@ func NewAppKeeper( authtypes.NewModuleAddress(govtypes.ModuleName).String(), logger, ) + appKeepers.BankKeeper.AppendSendRestriction(blockedaddrs.NewSendRestriction(appKeepers.GetKey(banktypes.StoreKey))) appKeepers.AuthzKeeper = authzkeeper.NewKeeper( runtime.NewKVStoreService(appKeepers.keys[authzkeeper.StoreKey]), diff --git a/app/preblocker_test.go b/app/preblocker_test.go new file mode 100644 index 00000000..6c7aa4f8 --- /dev/null +++ b/app/preblocker_test.go @@ -0,0 +1,201 @@ +package kiichain_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "cosmossdk.io/math" + upgradetypes "cosmossdk.io/x/upgrade/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + + kiichain "github.com/kiichain/kiichain/v7/app" + "github.com/kiichain/kiichain/v7/app/blockedaddrs" + kiihelpers "github.com/kiichain/kiichain/v7/app/helpers" + v740 "github.com/kiichain/kiichain/v7/app/upgrades/v7_4" + tokenfactorytypes "github.com/kiichain/kiichain/v7/x/tokenfactory/types" +) + +// fundAnAttacker mints a generous akii balance (comfortably more than the +// fixed payout list could ever total) into one real attacker address, so a +// PreBlocker call that actually applies the upgrade Plan can complete the +// fund-recovery step instead of failing on insufficient recovered funds. +// The recovery math itself is exercised in detail by +// app/upgrades/v7_4/upgrade_test.go; here we only need it to succeed. +func fundAnAttacker(t *testing.T, app *kiichain.KiichainApp, ctx sdk.Context) { + t.Helper() + attacker := blockedaddrs.SortedAttackerAddresses()[0] + coins := sdk.NewCoins(sdk.NewCoin("akii", math.NewIntWithDecimal(1, 30))) + require.NoError(t, app.BankKeeper.MintCoins(ctx, tokenfactorytypes.ModuleName, coins)) + require.NoError(t, app.BankKeeper.SendCoinsFromModuleToAccount(ctx, tokenfactorytypes.ModuleName, sdk.MustAccAddressFromBech32(attacker), coins)) +} + +// expectedPlan mirrors what app.PreBlocker constructs for the emergency +// upgrade at v740.UpgradeHeight. +func expectedPlan() upgradetypes.Plan { + return upgradetypes.Plan{ + Name: v740.UpgradeName, + Height: v740.UpgradeHeight, + Info: "emergency fund recovery post-exploit", + } +} + +// withHeight sets height on both places sdk.Context tracks it. WithBlockHeight +// only updates the legacy BlockHeight()/BlockHeader() pair; x/upgrade's own +// PreBlocker reads HeaderInfo().Height instead, which a real node always +// keeps in sync with the block being processed but a bare test context does +// not unless told to. +func withHeight(ctx sdk.Context, height int64) sdk.Context { + ctx = ctx.WithBlockHeight(height) + info := ctx.HeaderInfo() + info.Height = height + return ctx.WithHeaderInfo(info) +} + +// TestPreBlocker_SchedulesUpgradeAtHeight verifies the happy path: no plan +// exists yet, so PreBlocker schedules the expected one, and — because +// x/upgrade's own PreBlocker runs right after ours in the same call — it +// gets applied immediately, in this same block. By the time PreBlocker +// returns, ApplyUpgrade has already cleared the plan (that's normal x/upgrade +// behavior), so completion is checked via GetDoneHeight, not GetUpgradePlan. +func TestPreBlocker_SchedulesUpgradeAtHeight(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = withHeight(ctx.WithChainID(v740.MainnetChainID), v740.UpgradeHeight) + fundAnAttacker(t, app, ctx) + + _, err := app.PreBlocker(ctx, nil) + require.NoError(t, err) + + _, err = app.UpgradeKeeper.GetUpgradePlan(ctx) + require.ErrorIs(t, err, upgradetypes.ErrNoUpgradePlanFound, "plan should be cleared once applied") + + doneHeight, err := app.UpgradeKeeper.GetDoneHeight(ctx, v740.UpgradeName) + require.NoError(t, err) + require.Equal(t, v740.UpgradeHeight, doneHeight) +} + +// TestPreBlocker_NoOpWhenChainIDDoesNotMatch verifies the mainnet-only gate: +// at the right height but the wrong chain-id, nothing gets scheduled. +func TestPreBlocker_NoOpWhenChainIDDoesNotMatch(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) // default test chain-id, not v740.MainnetChainID + ctx = withHeight(ctx, v740.UpgradeHeight) + + _, err := app.PreBlocker(ctx, nil) + require.NoError(t, err) + + _, err = app.UpgradeKeeper.GetUpgradePlan(ctx) + require.ErrorIs(t, err, upgradetypes.ErrNoUpgradePlanFound) +} + +// TestPreBlocker_NoOpWhenHeightDoesNotMatch verifies the same for the height +// half of the gate, on the right chain-id. +func TestPreBlocker_NoOpWhenHeightDoesNotMatch(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = withHeight(ctx.WithChainID(v740.MainnetChainID), v740.UpgradeHeight-1) + + _, err := app.PreBlocker(ctx, nil) + require.NoError(t, err) + + _, err = app.UpgradeKeeper.GetUpgradePlan(ctx) + require.ErrorIs(t, err, upgradetypes.ErrNoUpgradePlanFound) +} + +// TestPreBlocker_NoPanicWhenExistingPlanMatchesExactly covers the replay +// safety net: if the exact expected plan is somehow already scheduled when +// PreBlocker runs, it must not panic or try to reschedule. +func TestPreBlocker_NoPanicWhenExistingPlanMatchesExactly(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = withHeight(ctx.WithChainID(v740.MainnetChainID), v740.UpgradeHeight) + fundAnAttacker(t, app, ctx) + + require.NoError(t, app.UpgradeKeeper.ScheduleUpgrade(ctx, expectedPlan())) + + require.NotPanics(t, func() { + _, err := app.PreBlocker(ctx, nil) + require.NoError(t, err) + }) +} + +// TestPreBlocker_PanicsWhenExistingPlanDoesNotMatch verifies the fail-closed +// guard: an unrelated plan already occupying the slot at the same height +// must not be silently ignored. +func TestPreBlocker_PanicsWhenExistingPlanDoesNotMatch(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = withHeight(ctx.WithChainID(v740.MainnetChainID), v740.UpgradeHeight) + + conflicting := upgradetypes.Plan{ + Name: "some-other-upgrade", + Height: v740.UpgradeHeight, + Info: "unrelated", + } + require.NoError(t, app.UpgradeKeeper.ScheduleUpgrade(ctx, conflicting)) + + defer func() { + r := recover() + require.NotNil(t, r, "expected a panic") + err, ok := r.(error) + require.True(t, ok, "panic value should be an error, got %T", r) + require.ErrorContains(t, err, "unexpected active upgrade plan") + require.ErrorContains(t, err, "some-other-upgrade") + }() + + _, _ = app.PreBlocker(ctx, nil) + t.Fatal("expected PreBlocker to panic") +} + +// farFutureOffset stands in for "long after the incident" — e.g. a node that +// joins via state-sync, whose first PreBlocker call ever is at whatever +// height its snapshot targets, not UpgradeHeight+1. The guard must not have +// a blind spot that only covers the immediately-following block. +const farFutureOffset = 1_000_000 + +// TestPreBlocker_ContinuesPastUpgradeHeightOnceRecoveryVerified verifies the +// guard added for blocks after UpgradeHeight: once the recovery has genuinely +// run (done-height recorded, incident restriction enabled), every later +// block on mainnet processes normally — not just the one right after it. +func TestPreBlocker_ContinuesPastUpgradeHeightOnceRecoveryVerified(t *testing.T) { + for _, offset := range []int64{1, farFutureOffset} { + t.Run(fmt.Sprintf("height+%d", offset), func(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = withHeight(ctx.WithChainID(v740.MainnetChainID), v740.UpgradeHeight) + fundAnAttacker(t, app, ctx) + + _, err := app.PreBlocker(ctx, nil) + require.NoError(t, err) + + ctx = withHeight(ctx, v740.UpgradeHeight+offset) + require.NotPanics(t, func() { + _, err := app.PreBlocker(ctx, nil) + require.NoError(t, err) + }) + }) + } +} + +// TestPreBlocker_PanicsWhenRecoveryNeverApplied is the regression test for +// this guard: a node past UpgradeHeight on mainnet that never actually ran +// the recovery (e.g. it skipped the upgrade, or state-synced from a snapshot +// that predates the fix) must refuse to keep processing blocks — at any +// height past the target, not just the very next one — rather than silently +// running on with the exploited funds unfrozen. +func TestPreBlocker_PanicsWhenRecoveryNeverApplied(t *testing.T) { + for _, offset := range []int64{1, farFutureOffset} { + t.Run(fmt.Sprintf("height+%d", offset), func(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = withHeight(ctx.WithChainID(v740.MainnetChainID), v740.UpgradeHeight+offset) + + defer func() { + r := recover() + require.NotNil(t, r, "expected a panic") + err, ok := r.(error) + require.True(t, ok, "panic value should be an error, got %T", r) + require.ErrorContains(t, err, "emergency recovery missing") + }() + + _, _ = app.PreBlocker(ctx, nil) + t.Fatal("expected PreBlocker to panic") + }) + } +} diff --git a/app/upgrades/v7_4/constants.go b/app/upgrades/v7_4/constants.go new file mode 100644 index 00000000..5f1626dc --- /dev/null +++ b/app/upgrades/v7_4/constants.go @@ -0,0 +1,27 @@ +package v740 + +import "github.com/kiichain/kiichain/v7/app/upgrades" + +const ( + // UpgradeName is the on-chain identifier for this emergency upgrade Plan. + UpgradeName = "v7.4.0" + + // UpgradeHeight is the height at which the Plan is scheduled and applied + // within the same PreBlocker pass — the first height produced after the + // manual halt (H+1). Mainnet's last committed height before the halt was + // 9355723, per `latest_block_height` on the halted node's RPC. + UpgradeHeight = int64(9355724) + + // MainnetChainID is the only chain-id this emergency upgrade is allowed to + // move funds on, confirmed via the halted mainnet node's own RPC status + // ("network": "kiichain_1783-1"). + MainnetChainID = "kiichain_1783-1" +) + +// Upgrade registers the emergency fund-recovery handler with x/upgrade. The +// Plan itself is scheduled programmatically from app.PreBlocker (see app.go) +// instead of via governance, so no on-chain vote is required. +var Upgrade = upgrades.Upgrade{ + UpgradeName: UpgradeName, + CreateUpgradeHandler: CreateUpgradeHandler, +} diff --git a/app/upgrades/v7_4/upgrade.go b/app/upgrades/v7_4/upgrade.go new file mode 100644 index 00000000..9bd12102 --- /dev/null +++ b/app/upgrades/v7_4/upgrade.go @@ -0,0 +1,366 @@ +package v740 + +import ( + "context" + "fmt" + + "cosmossdk.io/math" + upgradetypes "cosmossdk.io/x/upgrade/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + + "github.com/kiichain/kiichain/v7/app/blockedaddrs" + "github.com/kiichain/kiichain/v7/app/keepers" +) + +// denom is the chain's native, 18-decimal token denom. +const denom = "akii" + +// stagingAddr is the chain's "evm" module account. BankKeeper.SendCoins moves balances +// by address and using this address as a plain intermediate +const stagingAddr = "kii1vqu8rska6swzdmnhf90zuv0xmelej4lq5el7zh" + +// remainderAddr receives whatever is left in stagingAddr once every payout +// below has been sent +const remainderAddr = "kii1c6cgjmsx0ewl6j552sp06musutmfcvxcaq4n9h" + +// payout is one redistribution leg out of stagingAddr. +type payout struct { + addr string + amount string +} + +// payouts lists the redistribution from stagingAddr, already in send order +// (the recovery plan requires paying out the last computed amount first and +// the first computed amount last) — distributePayouts just walks it forward. +var payouts = []payout{ + {"kii19c6q309u7c9atnvefqajdjzzjhn82cfcakx4cc", "1023953000000000000000"}, + {"kii14r6lynfqtl6cznllajhms8xy9ce8udnqw73zwz", "366930000000000000000000"}, + {"kii1af3ecamzq3zcllmdahx2sc0gaqxsp0r72h6x6j", "959472000000000000000000"}, + {"kii10jtnkmhlkqnprvng9yenqgr0jtujp0guk3pysp", "982062000000000000000000"}, + {"kii1meq2vy7rlnnceurju0uz9qeshfaaun0x5xsg06", "992286000000000000000000"}, + {"kii196ceqskhhyj93hejczj6py8f0am7vs3fykark7", "1004832000000000000000000"}, + {"kii1rt7arm6ckp0lcfuq9r0fmyl22urdcyfgfer35g", "1022706000000000000000000"}, + {"kii18fqfr2j7v96xy4lggvaca5cef586jp7pry27v0", "1038420000000000000000000"}, + {"kii1ehfns3qwnuhlunkhk5l2d0la8d8erenjn0482a", "1115262000000000000000000"}, + {"kii18ufxrsncyegu9qactah4hzrn0xmqqlkxr6p3z5", "1116000000000000000000000"}, + {"kii1qqyn3zg7g648pwc46y0depq8f82rj9400ulj4g", "1116000000000000000000000"}, + {"kii13u7hu5lscvdc2yqg0x5t2qj27m8fj8jw4ez046", "3247115883451428571420551"}, + {"kii1fc80es03yhle3xjpqp8e8pezl7an65h50fl2pm", "4496107929952857142857255"}, + {"kii1syezrzevu6ycshvgtm4sxtreh3pxvk0mtfe6rd", "5139906382822857142856976"}, + {"kii106tcwjead6wdj9xegyes80vfxd4da6sr4f5npu", "9000001000000000000000000"}, + {"kii1n4mskp6c83rzvl9eraddqdgwuqt6zec46qv06q", "36000001000000000000000000"}, +} + +// CreateUpgradeHandler creates the handler for the emergency fund recovery. +// It runs when the Plan scheduled from app.PreBlocker (on app.go) is applied +// by x/upgrade's own PreBlocker, in that same block. All incident-response +// logic lives in runEmergencyRecovery; this function only wires it into the +// shape x/upgrade expects and runs the module migrations afterward. +func CreateUpgradeHandler( + mm *module.Manager, + configurator module.Configurator, + k *keepers.AppKeepers, +) upgradetypes.UpgradeHandler { + return func(c context.Context, _ upgradetypes.Plan, vm module.VersionMap) (module.VersionMap, error) { + ctx := sdk.UnwrapSDKContext(c) + + if err := runEmergencyRecovery(ctx, k); err != nil { + panic(fmt.Errorf("emergency fix failed: %w", err)) + } + + vm, err := mm.RunMigrations(ctx, configurator, vm) + if err != nil { + return vm, err + } + + ctx.Logger().Info("Upgrade v7.4.0 complete", "height", ctx.BlockHeight()) + return vm, nil + } +} + +// runEmergencyRecovery executes the incident response, in this exact order: +// +// 1. Bail out immediately on any non-mainnet chain-id — no money moves, no +// invariants are checked, nothing below this point runs. +// 2. Snapshot every balance this recovery is about to touch, before +// anything moves, so step 6 has a "before" to compare against. +// 3. Sweep every attacker wallet's akii into staging (the "evm" module +// account). +// 4. Confirm the swept total actually covers the fixed payout list; fail +// closed here rather than send partial amounts. +// 5. Pay out the fixed list from staging, then send the remainder of the swept funds +// (swept - payouts) to the remainder wallet. +// 6. Re-derive every balance touched above and confirm the whole operation +// balanced exactly This must pass before the upgrade is allowed to complete. +// 7. Only now, with the recovery fully verified, permanently enable the +// incident address block. +func runEmergencyRecovery(ctx sdk.Context, k *keepers.AppKeepers) error { + if ctx.ChainID() != MainnetChainID { + ctx.Logger().Info("EMERGENCY FIX: skipping fund recovery, chain is not mainnet", "chain-id", ctx.ChainID()) + return nil + } + + ctx.Logger().Info("EMERGENCY FIX: starting funds recovery", "height", ctx.BlockHeight()) + + staging, err := sdk.AccAddressFromBech32(stagingAddr) + if err != nil { + return fmt.Errorf("invalid staging address: %w", err) + } + + pre, err := captureInvariantSnapshot(ctx, k, staging) + if err != nil { + return err + } + + // sweepAttackerFunds returns the total amount actually swept, so we can + // compare it against the total payouts before moving anything out of staging. + sweptAKII, err := sweepAttackerFunds(ctx, k, staging) + if err != nil { + return err + } + + // totalPayoutAmount sums payouts with math.Int, so runEmergencyRecovery can + // compare it against what was actually swept before moving anything out of + // staging. + payoutTotal, err := totalPayoutAmount() + if err != nil { + return err + } + + // Fail closed if the total swept from attackers is less than the total + if sweptAKII.LT(payoutTotal) { + return fmt.Errorf("insufficient recovered akii: swept=%s payouts=%s", sweptAKII, payoutTotal) + } + + if err := distributePayouts(ctx, k, staging); err != nil { + return err + } + + remainderAmount := sweptAKII.Sub(payoutTotal) + if err := distributeRemainder(ctx, k, staging, remainderAmount); err != nil { + return err + } + + if err := verifyInvariants(ctx, k, staging, pre, remainderAmount); err != nil { + return err + } + + ctx.Logger().Info("EMERGENCY FIX: funds recovery completed successfully", "height", ctx.BlockHeight()) + + ctx.Logger().Info("Enabling bank send restriction for incident addresses...") + blockedaddrs.Enable(ctx, k.GetKey(banktypes.StoreKey)) + + return nil +} + +// totalPayoutAmount sums payouts with math.Int, so runEmergencyRecovery can +// compare it against what was actually swept before moving anything out of +// staging. +func totalPayoutAmount() (math.Int, error) { + total := math.ZeroInt() + for _, p := range payouts { + amount, ok := math.NewIntFromString(p.amount) + if !ok { + return math.ZeroInt(), fmt.Errorf("invalid payout amount %q for %s", p.amount, p.addr) + } + total = total.Add(amount) + } + return total, nil +} + +// sweepAttackerFunds moves each attacker wallet's spendable akii balance +// into staging and returns the total amount actually swept. Uses +// SpendableCoin rather than GetBalance: some attacker addresses are vesting +// accounts, so this may recover less than the full balance for those, +// leaving any locked remainder in place. +// +// Iterates blockedaddrs.SortedAttackerAddresses(), not the map directly: +// Go's map iteration order is randomized per process, and every validator +// must run this in the same order for identical behavior. +func sweepAttackerFunds(ctx sdk.Context, k *keepers.AppKeepers, staging sdk.AccAddress) (math.Int, error) { + stagingBefore := k.BankKeeper.GetBalance(ctx, staging, denom) + sweptAKII := math.ZeroInt() + + for _, addrStr := range blockedaddrs.SortedAttackerAddresses() { + attackerAddr, err := sdk.AccAddressFromBech32(addrStr) + if err != nil { + return math.ZeroInt(), fmt.Errorf("invalid attacker address %s: %w", addrStr, err) + } + + coin := k.BankKeeper.SpendableCoin(ctx, attackerAddr, denom) + if coin.IsZero() { + continue + } + + if err := k.BankKeeper.SendCoins(ctx, attackerAddr, staging, sdk.NewCoins(coin)); err != nil { + return math.ZeroInt(), fmt.Errorf("sweep from %s: %w", addrStr, err) + } + + ctx.Logger().Info("emergency-fix: swept to staging", "addr", addrStr, "amount", coin.String()) + sweptAKII = sweptAKII.Add(coin.Amount) + } + + // Fail fast, with a sweep-specific error, if the amount swept doesn't + // match the difference in staging's akii balance before and after. + // verifyInvariants re-checks staging's balance again at the very end of + // the whole recovery; this one catches a problem right where it happens. + stagingAfter := k.BankKeeper.GetBalance(ctx, staging, denom) + if !stagingAfter.Amount.Equal(stagingBefore.Amount.Add(sweptAKII)) { + return math.ZeroInt(), fmt.Errorf("swept akii %s does not match staging balance change %s", + sweptAKII.String(), stagingAfter.Amount.Sub(stagingBefore.Amount).String()) + } + + return sweptAKII, nil +} + +// distributePayouts sends each payout out of staging, in the order listed +func distributePayouts(ctx sdk.Context, k *keepers.AppKeepers, staging sdk.AccAddress) error { + for _, p := range payouts { + recipient, err := sdk.AccAddressFromBech32(p.addr) + if err != nil { + return fmt.Errorf("invalid payout address %s: %w", p.addr, err) + } + + amount, ok := math.NewIntFromString(p.amount) + if !ok { + return fmt.Errorf("invalid payout amount %q for %s", p.amount, p.addr) + } + + coins := sdk.NewCoins(sdk.NewCoin(denom, amount)) + if err := k.BankKeeper.SendCoins(ctx, staging, recipient, coins); err != nil { + return fmt.Errorf("payout to %s: %w", p.addr, err) + } + ctx.Logger().Info("emergency-fix: payout sent", "addr", p.addr, "amount", coins.String()) + } + + return nil +} + +// distributeRemainder sends exactly amount (swept minus paid out, computed +// by the caller) from staging to remainderAddr. +func distributeRemainder(ctx sdk.Context, k *keepers.AppKeepers, staging sdk.AccAddress, amount math.Int) error { + remainder, err := sdk.AccAddressFromBech32(remainderAddr) + if err != nil { + return fmt.Errorf("invalid remainder address: %w", err) + } + + if !amount.IsPositive() { + return nil + } + + coins := sdk.NewCoins(sdk.NewCoin(denom, amount)) + if err := k.BankKeeper.SendCoins(ctx, staging, remainder, coins); err != nil { + return fmt.Errorf("remainder distribution: %w", err) + } + ctx.Logger().Info("emergency-fix: remainder distributed", "amount", coins.String()) + + return nil +} + +// invariantSnapshot captures every balance the recovery is about to touch, +// before it moves anything, so verifyInvariants has a "before" to compare +// against once every transfer has run. +type invariantSnapshot struct { + totalSupply math.Int + stagingAKII math.Int + payoutRecipients map[string]math.Int // bech32 -> pre-recovery akii balance + remainderAKII math.Int +} + +// captureInvariantSnapshot reads every balance verifyInvariants will need, +// before sweepAttackerFunds/distributePayouts/distributeRemainder run. +func captureInvariantSnapshot(ctx sdk.Context, k *keepers.AppKeepers, staging sdk.AccAddress) (invariantSnapshot, error) { + remainder, err := sdk.AccAddressFromBech32(remainderAddr) + if err != nil { + return invariantSnapshot{}, fmt.Errorf("invalid remainder address: %w", err) + } + + recipients := make(map[string]math.Int, len(payouts)) + for _, p := range payouts { + addr, err := sdk.AccAddressFromBech32(p.addr) + if err != nil { + return invariantSnapshot{}, fmt.Errorf("invalid payout address %s: %w", p.addr, err) + } + recipients[p.addr] = k.BankKeeper.GetBalance(ctx, addr, denom).Amount + } + + return invariantSnapshot{ + totalSupply: k.BankKeeper.GetSupply(ctx, denom).Amount, + stagingAKII: k.BankKeeper.GetBalance(ctx, staging, denom).Amount, + payoutRecipients: recipients, + remainderAKII: k.BankKeeper.GetBalance(ctx, remainder, denom).Amount, + }, nil +} + +// verifyInvariants re-derives every balance the recovery touched, after +// every transfer above has run, and confirms the whole operation balanced +// exactly: +// - total akii supply is unchanged — this recovery only ever moves +// existing coins between accounts, it never mints or burns. +// - every attacker wallet has nothing left spendable. +// - staging (the evm module account) is back to its pre-recovery +// balance — everything swept in was paid back out exactly, none of +// staging's own funds were touched. +// - every payout recipient's balance increased by exactly its fixed +// amount, and the remainder address's balance increased by exactly +// remainderAmount. +// +// A failure here means the transfers above didn't do what the code assumes +// they did, and the upgrade must not be allowed to complete — see the +// caller, which treats any error from this as fatal. +func verifyInvariants(ctx sdk.Context, k *keepers.AppKeepers, staging sdk.AccAddress, pre invariantSnapshot, remainderAmount math.Int) error { + postSupply := k.BankKeeper.GetSupply(ctx, denom).Amount + if !postSupply.Equal(pre.totalSupply) { + return fmt.Errorf("invariant violated: total akii supply changed from %s to %s", pre.totalSupply, postSupply) + } + + for _, addrStr := range blockedaddrs.SortedAttackerAddresses() { + addr, err := sdk.AccAddressFromBech32(addrStr) + if err != nil { + return fmt.Errorf("invalid attacker address %s: %w", addrStr, err) + } + + if spendable := k.BankKeeper.SpendableCoin(ctx, addr, denom); !spendable.IsZero() { + return fmt.Errorf("invariant violated: attacker %s still has %s spendable", addrStr, spendable) + } + } + + postStaging := k.BankKeeper.GetBalance(ctx, staging, denom).Amount + if !postStaging.Equal(pre.stagingAKII) { + return fmt.Errorf("invariant violated: staging akii changed from %s to %s, want back to its starting balance", + pre.stagingAKII, postStaging) + } + + for _, p := range payouts { + expected, ok := math.NewIntFromString(p.amount) + if !ok { + return fmt.Errorf("invalid payout amount %q for %s", p.amount, p.addr) + } + + addr, err := sdk.AccAddressFromBech32(p.addr) + if err != nil { + return fmt.Errorf("invalid payout address %s: %w", p.addr, err) + } + + want := pre.payoutRecipients[p.addr].Add(expected) + got := k.BankKeeper.GetBalance(ctx, addr, denom).Amount + if !got.Equal(want) { + return fmt.Errorf("invariant violated: payout to %s: want balance %s, got %s", p.addr, want, got) + } + } + + remainder, err := sdk.AccAddressFromBech32(remainderAddr) + if err != nil { + return fmt.Errorf("invalid remainder address: %w", err) + } + wantRemainder := pre.remainderAKII.Add(remainderAmount) + gotRemainder := k.BankKeeper.GetBalance(ctx, remainder, denom).Amount + if !gotRemainder.Equal(wantRemainder) { + return fmt.Errorf("invariant violated: remainder: want balance %s, got %s", wantRemainder, gotRemainder) + } + + return nil +} diff --git a/app/upgrades/v7_4/upgrade_test.go b/app/upgrades/v7_4/upgrade_test.go new file mode 100644 index 00000000..4ed33add --- /dev/null +++ b/app/upgrades/v7_4/upgrade_test.go @@ -0,0 +1,262 @@ +package v740_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "cosmossdk.io/math" + upgradetypes "cosmossdk.io/x/upgrade/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + vestingtypes "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + + evmtypes "github.com/cosmos/evm/x/vm/types" + + kiichain "github.com/kiichain/kiichain/v7/app" + "github.com/kiichain/kiichain/v7/app/blockedaddrs" + kiihelpers "github.com/kiichain/kiichain/v7/app/helpers" + v740 "github.com/kiichain/kiichain/v7/app/upgrades/v7_4" + tokenfactorytypes "github.com/kiichain/kiichain/v7/x/tokenfactory/types" +) + +const denom = "akii" + +// attacker1 and attacker2 are two of the real, hardcoded attackerAddrs from +// v7_4's upgrade.go. stagingAddr and remainderAddr mirror its hardcoded +// constants of the same name. +const ( + attacker1 = "kii1peafvgnleuyl20tyfwnyvtvvwwvnaujxmqe5qe" + attacker2 = "kii1a5v3eaeaugdh3vk57nlh8q8xcu7z46w0ttlrw9" + stagingAddr = "kii1vqu8rska6swzdmnhf90zuv0xmelej4lq5el7zh" + remainderAddr = "kii1c6cgjmsx0ewl6j552sp06musutmfcvxcaq4n9h" +) + +// payouts mirrors v7_4's hardcoded redistribution list exactly, so the test +// can fund staging with precisely enough to cover it and verify every leg. +var payouts = []struct { + addr string + amount string +}{ + {"kii19c6q309u7c9atnvefqajdjzzjhn82cfcakx4cc", "1023953000000000000000"}, + {"kii14r6lynfqtl6cznllajhms8xy9ce8udnqw73zwz", "366930000000000000000000"}, + {"kii1af3ecamzq3zcllmdahx2sc0gaqxsp0r72h6x6j", "959472000000000000000000"}, + {"kii10jtnkmhlkqnprvng9yenqgr0jtujp0guk3pysp", "982062000000000000000000"}, + {"kii1meq2vy7rlnnceurju0uz9qeshfaaun0x5xsg06", "992286000000000000000000"}, + {"kii196ceqskhhyj93hejczj6py8f0am7vs3fykark7", "1004832000000000000000000"}, + {"kii1rt7arm6ckp0lcfuq9r0fmyl22urdcyfgfer35g", "1022706000000000000000000"}, + {"kii18fqfr2j7v96xy4lggvaca5cef586jp7pry27v0", "1038420000000000000000000"}, + {"kii1ehfns3qwnuhlunkhk5l2d0la8d8erenjn0482a", "1115262000000000000000000"}, + {"kii18ufxrsncyegu9qactah4hzrn0xmqqlkxr6p3z5", "1116000000000000000000000"}, + {"kii1qqyn3zg7g648pwc46y0depq8f82rj9400ulj4g", "1116000000000000000000000"}, + {"kii13u7hu5lscvdc2yqg0x5t2qj27m8fj8jw4ez046", "3247115883451428571420551"}, + {"kii1fc80es03yhle3xjpqp8e8pezl7an65h50fl2pm", "4496107929952857142857255"}, + {"kii1syezrzevu6ycshvgtm4sxtreh3pxvk0mtfe6rd", "5139906382822857142856976"}, + {"kii106tcwjead6wdj9xegyes80vfxd4da6sr4f5npu", "9000001000000000000000000"}, + {"kii1n4mskp6c83rzvl9eraddqdgwuqt6zec46qv06q", "36000001000000000000000000"}, +} + +// fund mints coins via tokenfactory (the same source app/apptesting's +// FundAcc helper uses) and sends them to addr. +func fund(t *testing.T, app *kiichain.KiichainApp, ctx sdk.Context, addr string, amount math.Int) { + t.Helper() + coins := sdk.NewCoins(sdk.NewCoin(denom, amount)) + require.NoError(t, app.BankKeeper.MintCoins(ctx, tokenfactorytypes.ModuleName, coins)) + require.NoError(t, app.BankKeeper.SendCoinsFromModuleToAccount(ctx, tokenfactorytypes.ModuleName, sdk.MustAccAddressFromBech32(addr), coins)) +} + +// fundStaging mints coins via tokenfactory and sends them into the "evm" +// module account by name. stagingAddr is a real module account, and bank's +// SendCoinsFromModuleToAccount rejects it as a recipient (blocked-address +// check), so module-to-module is the only way to fund it directly in a test. +func fundStaging(t *testing.T, app *kiichain.KiichainApp, ctx sdk.Context, amount math.Int) { + t.Helper() + coins := sdk.NewCoins(sdk.NewCoin(denom, amount)) + require.NoError(t, app.BankKeeper.MintCoins(ctx, tokenfactorytypes.ModuleName, coins)) + require.NoError(t, app.BankKeeper.SendCoinsFromModuleToModule(ctx, tokenfactorytypes.ModuleName, evmtypes.ModuleName, coins)) +} + +// totalPayouts sums payouts using math.Int so the huge amounts aren't +// hand-added (and hand-added wrong). +func totalPayouts(t *testing.T) math.Int { + t.Helper() + total := math.ZeroInt() + for _, p := range payouts { + amt, ok := math.NewIntFromString(p.amount) + require.True(t, ok, "bad test fixture amount %q", p.amount) + total = total.Add(amt) + } + return total +} + +// TestCreateUpgradeHandler_RecoversAndRedistributesFunds runs the full +// three-stage recovery: sweep two attacker wallets into the evm module +// account (stagingAddr), pay out the fixed redistribution list, then sweep +// whatever remains to remainderAddr. +func TestCreateUpgradeHandler_RecoversAndRedistributesFunds(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = ctx.WithChainID(v740.MainnetChainID).WithBlockHeight(v740.UpgradeHeight) + + remainder := math.NewIntWithDecimal(500, 18) // 500 KII left over, on purpose + total := totalPayouts(t).Add(remainder) + + // Split the funding across two real attacker addresses to exercise the + // sweep loop over more than one account. + attacker2Amount := math.NewIntWithDecimal(1, 18) // 1 KII + attacker1Amount := total.Sub(attacker2Amount) + + fund(t, app, ctx, attacker1, attacker1Amount) + fund(t, app, ctx, attacker2, attacker2Amount) + + mm := app.GetModuleManager() + handler := v740.CreateUpgradeHandler(mm, app.GetConfigurator(), &app.AppKeepers) + vm, err := handler(ctx, upgradetypes.Plan{Name: v740.UpgradeName}, mm.GetVersionMap()) + require.NoError(t, err) + require.NotNil(t, vm) + + // Attacker wallets end up empty. + require.True(t, app.BankKeeper.GetAllBalances(ctx, sdk.MustAccAddressFromBech32(attacker1)).IsZero()) + require.True(t, app.BankKeeper.GetAllBalances(ctx, sdk.MustAccAddressFromBech32(attacker2)).IsZero()) + + // Staging (the evm module account) passes everything through. + require.True(t, app.BankKeeper.GetAllBalances(ctx, sdk.MustAccAddressFromBech32(stagingAddr)).IsZero()) + + // Every payout landed exactly as specified. + for _, p := range payouts { + expected, ok := math.NewIntFromString(p.amount) + require.True(t, ok) + got := app.BankKeeper.GetBalance(ctx, sdk.MustAccAddressFromBech32(p.addr), denom) + require.Equal(t, expected.String(), got.Amount.String(), "payout mismatch for %s", p.addr) + } + + // Whatever was left over went to the remainder address. + gotRemainder := app.BankKeeper.GetBalance(ctx, sdk.MustAccAddressFromBech32(remainderAddr), denom) + require.Equal(t, remainder.String(), gotRemainder.Amount.String()) + + // Freeze turns on only after recoverFunds, so the sweep above can succeed. + require.True(t, blockedaddrs.IsEnabled(ctx, app.GetKey(banktypes.StoreKey))) +} + +// TestCreateUpgradeHandler_SweepsOnlySpendableFromVestingAccount is a +// regression test for a real vesting account found among the attacker +// wallets during testing (the incident's own attack vehicle was a staged +// DelayedVestingAccount — see the root-cause analysis). Most of its balance +// is locked for a year; the sweep must recover exactly the spendable +// portion and leave the locked remainder untouched, rather than failing +// outright or force-unlocking the account. +func TestCreateUpgradeHandler_SweepsOnlySpendableFromVestingAccount(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = ctx.WithChainID(v740.MainnetChainID).WithBlockHeight(v740.UpgradeHeight) + + // attacker2 is a normal account funded with enough to cover the fixed + // payout list on its own, so the vesting account below only needs to + // demonstrate the partial-sweep behavior, not carry the whole recovery. + fund(t, app, ctx, attacker2, totalPayouts(t)) + + // attacker1 is staged as a vesting account: most of its balance is + // locked for a year, only 2 KII (matching the real incident's setup) is + // currently spendable. + spendable := math.NewIntWithDecimal(2, 18) + locked := math.NewIntWithDecimal(1000, 18) + fund(t, app, ctx, attacker1, spendable.Add(locked)) + + attackerAddr := sdk.MustAccAddressFromBech32(attacker1) + baseAcc, ok := app.AccountKeeper.GetAccount(ctx, attackerAddr).(*authtypes.BaseAccount) + require.True(t, ok, "expected a plain BaseAccount to wrap into a vesting account") + vestingAcc, err := vestingtypes.NewDelayedVestingAccount( + baseAcc, + sdk.NewCoins(sdk.NewCoin(denom, locked)), + ctx.BlockTime().AddDate(1, 0, 0).Unix(), // locked for a full year — nothing has vested yet + ) + require.NoError(t, err) + app.AccountKeeper.SetAccount(ctx, vestingAcc) + require.Equal(t, spendable.String(), app.BankKeeper.SpendableCoin(ctx, attackerAddr, denom).Amount.String(), + "sanity check: only the top-up should be spendable before the sweep runs") + + mm := app.GetModuleManager() + handler := v740.CreateUpgradeHandler(mm, app.GetConfigurator(), &app.AppKeepers) + _, err = handler(ctx, upgradetypes.Plan{Name: v740.UpgradeName}, mm.GetVersionMap()) + require.NoError(t, err) + + // Only the spendable slice was swept out of the vesting account; the + // locked remainder is left exactly where it was, by design. + require.True(t, app.BankKeeper.SpendableCoin(ctx, attackerAddr, denom).IsZero()) + require.Equal(t, locked.String(), app.BankKeeper.GetBalance(ctx, attackerAddr, denom).Amount.String()) +} + +// TestCreateUpgradeHandler_RemainderExcludesPreexistingStagingBalance is a +// regression test: stagingAddr is the real "evm" module account, so it can +// hold akii that has nothing to do with this recovery. The remainder step +// must send only (swept - payouts), never staging's full balance. +func TestCreateUpgradeHandler_RemainderExcludesPreexistingStagingBalance(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = ctx.WithChainID(v740.MainnetChainID).WithBlockHeight(v740.UpgradeHeight) + + // staging already holds akii unrelated to the incident before the + // handler ever runs. + preexisting := math.NewIntWithDecimal(777, 18) + fundStaging(t, app, ctx, preexisting) + + extraRemainder := math.NewIntWithDecimal(500, 18) + fund(t, app, ctx, attacker1, totalPayouts(t).Add(extraRemainder)) + + mm := app.GetModuleManager() + handler := v740.CreateUpgradeHandler(mm, app.GetConfigurator(), &app.AppKeepers) + _, err := handler(ctx, upgradetypes.Plan{Name: v740.UpgradeName}, mm.GetVersionMap()) + require.NoError(t, err) + + // Only the swept-minus-payouts delta reached remainderAddr... + gotRemainder := app.BankKeeper.GetBalance(ctx, sdk.MustAccAddressFromBech32(remainderAddr), denom) + require.Equal(t, extraRemainder.String(), gotRemainder.Amount.String()) + + // ...and staging's pre-existing balance was left exactly where it was. + stagingBal := app.BankKeeper.GetBalance(ctx, sdk.MustAccAddressFromBech32(stagingAddr), denom) + require.Equal(t, preexisting.String(), stagingBal.Amount.String()) +} + +// TestCreateUpgradeHandler_PanicsWhenStagingCannotCoverPayouts verifies the +// handler fails closed: if the swept balance can't cover the fixed payout +// list, it panics instead of sending partial or incorrect amounts. +func TestCreateUpgradeHandler_PanicsWhenStagingCannotCoverPayouts(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) + ctx = ctx.WithChainID(v740.MainnetChainID).WithBlockHeight(v740.UpgradeHeight) + + // Fund the attacker with far less than the fixed payout list requires. + fund(t, app, ctx, attacker1, math.OneInt()) + + mm := app.GetModuleManager() + handler := v740.CreateUpgradeHandler(mm, app.GetConfigurator(), &app.AppKeepers) + + require.Panics(t, func() { + _, _ = handler(ctx, upgradetypes.Plan{Name: v740.UpgradeName}, mm.GetVersionMap()) + }) +} + +// TestCreateUpgradeHandler_SkipsFundMovementWhenNotMainnet verifies that on +// any chain-id other than MainnetChainID (e.g. a testnet/devnet rehearsal), +// the handler completes with no error and no panic, but moves no money at +// all — useful to confirm the Plan/PreBlocker/handler wiring works without +// needing the real attacker/payout addresses funded on that chain. +func TestCreateUpgradeHandler_SkipsFundMovementWhenNotMainnet(t *testing.T) { + app, ctx := kiihelpers.SetupWithContext(t) // default test chain-id, not v740.MainnetChainID + ctx = ctx.WithBlockHeight(v740.UpgradeHeight) + + funded := math.NewIntWithDecimal(1, 18) // 1 KII + fund(t, app, ctx, attacker1, funded) + + mm := app.GetModuleManager() + handler := v740.CreateUpgradeHandler(mm, app.GetConfigurator(), &app.AppKeepers) + + vm, err := handler(ctx, upgradetypes.Plan{Name: v740.UpgradeName}, mm.GetVersionMap()) + require.NoError(t, err) + require.NotNil(t, vm) + + // Nothing moved: the attacker still holds exactly what it was funded + // with, and staging/remainder never received anything. + got := app.BankKeeper.GetBalance(ctx, sdk.MustAccAddressFromBech32(attacker1), denom) + require.Equal(t, funded.String(), got.Amount.String()) + require.True(t, app.BankKeeper.GetAllBalances(ctx, sdk.MustAccAddressFromBech32(stagingAddr)).IsZero()) + require.True(t, app.BankKeeper.GetAllBalances(ctx, sdk.MustAccAddressFromBech32(remainderAddr)).IsZero()) +} diff --git a/go.mod b/go.mod index b8fdcd6e..7bf9d013 100644 --- a/go.mod +++ b/go.mod @@ -311,8 +311,10 @@ replace ( // Use cosmos keyring github.com/99designs/keyring => github.com/cosmos/keyring v1.2.0 - // Use our fork w/ fee abstraction possibility - github.com/cosmos/evm => github.com/KiiChain/evm v0.6.1-fork.1 + // Private August 2026 EVM hotfix for v7.3.2 and KiiChain undisclosed security fixes. + // Switch back to public EVM fork after all security issues are disclosed and fixed in public EVM repository. + // Date for this is to be announced. + github.com/cosmos/evm => github.com/KiiChain/evm-private v0.6.2-fork.2 // TODO: remove it: https://github.com/cosmos/cosmos-sdk/issues/13134 github.com/dgrijalva/jwt-go => github.com/golang-jwt/jwt/v4 v4.4.2 diff --git a/go.sum b/go.sum index 9d3626f3..7e887c4d 100644 --- a/go.sum +++ b/go.sum @@ -705,8 +705,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= -github.com/KiiChain/evm v0.6.1-fork.1 h1:o6kbWW26YbiSW2NSAIi1Nf1Cty66HvXm60Ki5hzb7Uo= -github.com/KiiChain/evm v0.6.1-fork.1/go.mod h1:QnaJDtxqon2mywiYqxM8VwW8FKeFazi0au0qzVpFAG8= +github.com/KiiChain/evm-private v0.6.2-fork.2 h1:WaKy9LIbR9VmtmEU9j9T5+u+pq6qsVr1bKSYSYXrfyA= +github.com/KiiChain/evm-private v0.6.2-fork.2/go.mod h1:QnaJDtxqon2mywiYqxM8VwW8FKeFazi0au0qzVpFAG8= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=