From 26e343aa2e55583763913926cd0be730ac36740c Mon Sep 17 00:00:00 2001 From: Aleksei Kokinos Date: Wed, 26 Aug 2026 22:30:52 +0700 Subject: [PATCH 1/4] chore(v1.6.2): fold broadcast-tx fix, changelog, no-op upgrade Squash of three v1.6.2-prep commits (previously b87bd40, 006b4a4, 667af53): - fix(app): treat a duplicate broadcast (Comet code=19, "tx already in mempool") as normal rather than a failure, and collect batch failures instead of aborting the whole batch on the first bad tx. Added a table test covering the three node responses. - docs(changelog): condense the v1.6.0/v1.6.1 changelog to symptom, cause and fix; the peer-broadcast work becomes a single entry. - chore(upgrade): register an empty v1.6.2 upgrade handler for a binary-level version bump carrying no state migration. go.mod points cosmos/evm at the local ../evm fork for this build. --- app/app.go | 29 +++- app/evm_mempool_broadcast_test.go | 56 ++++++- app/upgrades.go | 2 + app/upgrades/v1.6.2/upgrades.go | 45 ++++++ docs/CHANGELOG-v160-v161.md | 244 ++++++++++-------------------- go.mod | 4 +- 6 files changed, 212 insertions(+), 168 deletions(-) create mode 100644 app/upgrades/v1.6.2/upgrades.go diff --git a/app/app.go b/app/app.go index 54849b9..55cd1f5 100644 --- a/app/app.go +++ b/app/app.go @@ -2,6 +2,7 @@ package app import ( "encoding/json" + "errors" "fmt" "io" "maps" @@ -78,6 +79,7 @@ import ( servertypes "github.com/cosmos/cosmos-sdk/server/types" testdata_pulsar "github.com/cosmos/cosmos-sdk/testutil/testdata/testpb" sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" "github.com/cosmos/cosmos-sdk/types/module" "github.com/cosmos/cosmos-sdk/types/msgservice" signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing" @@ -1049,6 +1051,10 @@ func (app *TacChainApp) broadcastEVMTransactions(ethTxs []*ethtypes.Transaction) signer := ethtypes.LatestSigner(evmvmtypes.GetEthChainConfig()) baseDenom := evmvmtypes.GetEVMCoinDenom() + // One bad transaction must not cost the rest of the batch its broadcast, so + // failures are collected and reported once at the end. + var errs []error + for _, ethTx := range ethTxs { // The sender has to be recovered and set here: MsgEthereumTx.ValidateBasic // rejects a message without it, so a broadcast built with FromEthereumTx @@ -1057,7 +1063,8 @@ func (app *TacChainApp) broadcastEVMTransactions(ethTxs []*ethtypes.Transaction) // node's mempool directly, but the transaction never reaches its peers. msg := &evmvmtypes.MsgEthereumTx{} if err := msg.FromSignedEthereumTx(ethTx, signer); err != nil { - return fmt.Errorf("failed to recover sender of transaction %s: %w", ethTx.Hash().Hex(), err) + errs = append(errs, fmt.Errorf("failed to recover sender of transaction %s: %w", ethTx.Hash().Hex(), err)) + continue } // Build through the message itself rather than filling a builder by hand: @@ -1068,23 +1075,33 @@ func (app *TacChainApp) broadcastEVMTransactions(ethTxs []*ethtypes.Transaction) // contained within a tx with 'ExtensionOptionsEthereumTx' option". cosmosTx, err := msg.BuildTx(app.txConfig.NewTxBuilder(), baseDenom) if err != nil { - return fmt.Errorf("failed to build cosmos tx for %s: %w", ethTx.Hash().Hex(), err) + errs = append(errs, fmt.Errorf("failed to build cosmos tx for %s: %w", ethTx.Hash().Hex(), err)) + continue } txBytes, err := app.txConfig.TxEncoder()(cosmosTx) if err != nil { - return fmt.Errorf("failed to encode transaction: %w", err) + errs = append(errs, fmt.Errorf("failed to encode transaction %s: %w", ethTx.Hash().Hex(), err)) + continue } res, err := app.clientCtx.BroadcastTxSync(txBytes) if err != nil { - return fmt.Errorf("failed to broadcast transaction %s: %w", ethTx.Hash().Hex(), err) + errs = append(errs, fmt.Errorf("failed to broadcast transaction %s: %w", ethTx.Hash().Hex(), err)) + continue + } + // The submitting path has already handed this transaction to Comet, so + // this node's own cache answers the peer broadcast with "tx already in + // mempool". That is the expected outcome here, not a failure. + if res.Code == sdkerrors.ErrTxInMempoolCache.ABCICode() { + continue } if res.Code != 0 { - return fmt.Errorf("transaction %s rejected by mempool: code=%d, log=%s", ethTx.Hash().Hex(), res.Code, res.RawLog) + errs = append(errs, fmt.Errorf("transaction %s rejected by mempool: code=%d, log=%s", ethTx.Hash().Hex(), res.Code, res.RawLog)) } } - return nil + + return errors.Join(errs...) } func (app *TacChainApp) setPostHandler() { diff --git a/app/evm_mempool_broadcast_test.go b/app/evm_mempool_broadcast_test.go index f253d26..dc20e56 100644 --- a/app/evm_mempool_broadcast_test.go +++ b/app/evm_mempool_broadcast_test.go @@ -18,6 +18,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" authante "github.com/cosmos/cosmos-sdk/x/auth/ante" "github.com/cosmos/evm/mempool/txpool/legacypool" evmtypes "github.com/cosmos/evm/x/vm/types" @@ -51,6 +52,9 @@ func signedEthTx(t *testing.T) (*ethtypes.Transaction, ethcmn.Address) { type broadcastRecorder struct { rpcmock.Client + // code the node answers the broadcast with; zero means accepted + code uint32 + mu sync.Mutex calls int tx cmttypes.Tx @@ -62,7 +66,14 @@ func (r *broadcastRecorder) BroadcastTxSync(_ context.Context, tx cmttypes.Tx) ( r.calls++ r.tx = append(r.tx[:0], tx...) - return &coretypes.ResultBroadcastTx{}, nil + return &coretypes.ResultBroadcastTx{Code: r.code}, nil +} + +func (r *broadcastRecorder) setCode(code uint32) { + r.mu.Lock() + defer r.mu.Unlock() + r.code = code + r.calls = 0 } func (r *broadcastRecorder) callCount() int { @@ -210,3 +221,46 @@ func TestEVMMempoolBroadcastTxFnDoesNotBlockOnBroadcast(t *testing.T) { t.Fatal("background broadcast goroutine did not exit") } } + +func TestBroadcastEVMTransactionsMempoolCodes(t *testing.T) { + tacApp := NewTacChainAppWithCustomOptions(t, true, SetupOptions{ + Logger: log.NewTestLogger(t), + DB: dbm.NewMemDB(), + AppOpts: simtestutil.NewAppOptionsWithFlagHome(t.TempDir()), + }) + + ethTx, _ := signedEthTx(t) + + // RegisterTxService may only run once per app, so the same client is reused + // and its answer changed per case. + rpcClient := &broadcastRecorder{} + tacApp.RegisterTxService(client.Context{}. + WithTxConfig(tacApp.txConfig). + WithClient(rpcClient), + ) + + for _, tc := range []struct { + name string + code uint32 + wantErr bool + }{ + // The submitting path has already given this transaction to Comet, so + // the node's own cache answers the peer broadcast as a duplicate. That + // is the normal outcome, not something to report. + {"duplicate in mempool cache", sdkerrors.ErrTxInMempoolCache.ABCICode(), false}, + {"accepted", 0, false}, + {"rejected", sdkerrors.ErrInvalidRequest.ABCICode(), true}, + } { + t.Run(tc.name, func(t *testing.T) { + rpcClient.setCode(tc.code) + + err := tacApp.broadcastEVMTransactions([]*ethtypes.Transaction{ethTx}) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, 1, rpcClient.callCount()) + }) + } +} diff --git a/app/upgrades.go b/app/upgrades.go index 57fb865..bf479b1 100644 --- a/app/upgrades.go +++ b/app/upgrades.go @@ -15,6 +15,7 @@ import ( v160 "github.com/TacBuild/tacchain/app/upgrades/v1.6.0" v160spbhotfix "github.com/TacBuild/tacchain/app/upgrades/v1.6.0-spb-hotfix" v161 "github.com/TacBuild/tacchain/app/upgrades/v1.6.1" + v162 "github.com/TacBuild/tacchain/app/upgrades/v1.6.2" ) // Upgrades list of chain upgrades @@ -28,6 +29,7 @@ var Upgrades = []upgrades.Upgrade{ v160.Upgrade, // upgrade to cosmos/evm v0.6.0 v160spbhotfix.Upgrade, v161.Upgrade, // no-op handler; binary-level fixes only + v162.Upgrade, // no-op handler; binary-level fixes only } // RegisterUpgradeHandlers registers the chain upgrade handlers diff --git a/app/upgrades/v1.6.2/upgrades.go b/app/upgrades/v1.6.2/upgrades.go new file mode 100644 index 0000000..a254d73 --- /dev/null +++ b/app/upgrades/v1.6.2/upgrades.go @@ -0,0 +1,45 @@ +package v162 + +import ( + "context" + "fmt" + + storetypes "cosmossdk.io/store/types" + upgradetypes "cosmossdk.io/x/upgrade/types" + + "github.com/TacBuild/tacchain/app/upgrades" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" +) + +const UpgradeName = "v1.6.2" + +// Upgrade is a no-op state upgrade: it ships binary-level fixes only and carries +// no state migration. It exists so the network can coordinate a version bump; the +// handler just runs standard module migrations and returns. +var Upgrade = upgrades.Upgrade{ + UpgradeName: UpgradeName, + CreateUpgradeHandler: CreateUpgradeHandler, + StoreUpgrades: storetypes.StoreUpgrades{}, +} + +func CreateUpgradeHandler( + mm upgrades.ModuleManager, + configurator module.Configurator, + _ *upgrades.AppKeepers, +) upgradetypes.UpgradeHandler { + return func(ctx context.Context, _ upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) { + sdkCtx := sdk.UnwrapSDKContext(ctx) + logger := sdkCtx.Logger() + + logger.Info("Starting v1.6.2 upgrade (binary fixes only, no state migration)") + + vm, err := mm.RunMigrations(ctx, configurator, fromVM) + if err != nil { + return nil, fmt.Errorf("RunMigrations failed: %w", err) + } + + logger.Info("v1.6.2 upgrade complete") + return vm, nil + } +} diff --git a/docs/CHANGELOG-v160-v161.md b/docs/CHANGELOG-v160-v161.md index 1361713..f53f187 100644 --- a/docs/CHANGELOG-v160-v161.md +++ b/docs/CHANGELOG-v160-v161.md @@ -4,95 +4,65 @@ > **Upgrade name:** `v1.6.1` > **Release tag:** `v1.6.1` (to be cut from `main`) > **Previous version:** v1.6.0 -> **Chains:** SPB testnet — upgraded 2026-07-30 at height 23,205,042 with the -> `v1.6.1-beta.1` pre-release; TacChain Mainnet — planned +> **Chains:** SPB testnet — upgraded 2026-07-30 at height 23,205,042 with +> `v1.6.1-beta.1`; TacChain Mainnet — planned + +--- + +## Summary + +Maintenance release on top of v1.6.0. No state migration, no consensus-affecting +change: the handler exists only to coordinate a version bump for binary fixes. --- ## Rollout -`v1.6.1` reached the SPB testnet as the `v1.6.1-beta.1` pre-release, which was cut -before the last three EVM fork changes landed. The stable `v1.6.1` build is -therefore **not** byte-identical to what SPB has been running: +`v1.6.1` is **not** byte-identical to the `v1.6.1-beta.1` pre-release running on SPB. | Build | `cosmos/evm` fork | `cosmos-sdk` fork | Where | |-------|-------------------|-------------------|-------| | `v1.6.1-beta.1` | `v0.6.0-tac.10` | `v0.53.6-tac.3` | SPB testnet since 2026-07-30 | | `v1.6.1` | `v0.6.0-tac.13` | `v0.53.6-tac.3` | mainnet target | -Present in `v1.6.1` and **not** in `v1.6.1-beta.1`: +In `v1.6.1`, not in `v1.6.1-beta.1`: -- `tac_simulate` JSON-RPC method (`9a602662`) -- Balance override reaching gas estimation (`5f530858`) -- Static precompiles kept under an `eth_call` state override (`96d5164b`) -- Sender set on EVM transactions broadcast to peers (`6759d93`, tacchain side) +| Change | Commit | Path | +|--------|--------|------| +| `tac_simulate` JSON-RPC method | `9a602662` | query | +| Balance override reaches gas estimation | `5f530858` | query | +| Static precompiles kept under an `eth_call` state override | `96d5164b` | query | +| EVM transaction broadcast to peers | `6759d93`, `548387b`, `b87bd40` | p2p | -The first three are query-path only. The fourth touches how a node gossips a -transaction to its peers, not how any node executes one. None of them affects -execution or consensus, so the SPB run still validates the consensus-relevant +None affects execution or consensus, so the SPB run covers the consensus-relevant part of the release. --- -## Summary - -TacChain v1.6.1 is a maintenance release on top of v1.6.0. It carries **no state -migration**: the upgrade handler exists only so the network can coordinate a -version bump to ship binary-level fixes. - -Most of it comes from the `cosmos/evm` and `cosmos-sdk` forks: - -- Delegating vesting-locked tokens through the EVM staking precompile no longer - burns coins or panics -- Historical EVM queries below the v1.6.0 store-migration height work again -- A state override on `eth_call` no longer drops the static precompiles -- New `tac_simulate` JSON-RPC method - -One fix is on the tacchain side: +## Breaking Changes -- EVM transactions broadcast to peers now carry their sender, so they are no - longer refused by the receiving mempool +None. No state migration, no parameter change, no consensus version bump. --- -## Breaking Changes +## New Features -None. No consensus-affecting change, no state migration, no parameter change. +### `tac_simulate` (JSON-RPC) ---- +Non-committing simulated call. Parameters mirror `eth_call` (call args, block +number or hash, state overrides). Returns in one round-trip: -## New Features +```json +{ "success": true, "output": "0x...", "vmError": "", "logs": [], "gasEstimated": "0x..." } +``` + +- `logs` — events emitted during simulation. +- `gasEstimated` — full `eth_estimateGas` search against the same overridden state. +- A revert returns `success: false` with `vmError` and revert data in `output`, + instead of a JSON-RPC error. -### RPC - -- **`tac_simulate`** — a simulated, non-committing call that reports in one - round-trip what `eth_call`, `eth_estimateGas` and a log inspection would take - three of: - - ```json - { - "success": true, - "output": "0x...", - "vmError": "", - "logs": [], - "gasEstimated": "0x..." - } - ``` - - Parameters mirror `eth_call`: call args, block number or hash, and state - overrides. - - - **Event logs** emitted during the simulation are returned in `logs`. - - **`gasEstimated`** is a full `eth_estimateGas` binary search run against the - *same* overridden state as the call, so an estimate can be produced for state - that does not exist on chain yet. - - **A revert is a result, not an error.** Where `eth_call` turns a revert into - a JSON-RPC error, `tac_simulate` returns `success: false` together with - `vmError` and the revert data in `output`, so a caller can decode a custom - error instead of just seeing the request fail. - - Served under the **`tac` namespace, which is not enabled by default** — it has - to be listed in `app.toml` under `[json-rpc] api` to be reachable. +Served under the `tac` namespace, **not enabled by default** — must be listed in +`app.toml` under `[json-rpc] api`. --- @@ -100,134 +70,90 @@ None. No consensus-affecting change, no state migration, no parameter change. ### Vesting delegations via the EVM staking precompile -Delegating vesting-**locked** tokens through the staking precompile either -silently burned coins (when `amount <= spendable`) or aborted with an -integer-overflow panic (when `amount > spendable`). +Delegating vesting-**locked** tokens silently burned coins (`amount <= spendable`) +or panicked on integer overflow (`amount > spendable`): the EVM balance handler +subtracted the full `CoinSpent` from a balance that only reflects the spendable +portion. -The EVM balance handler subtracted the full `CoinSpent` amount from an EVM -balance that only ever reflects the *spendable* portion, while a delegation from -a vesting account is drawn from the locked portion and does not reduce spendable -at all. +`x/bank` now tags `coin_spent` with `locked_amount`, and the handler subtracts +`amount - locked`. `locked > amount` is an error, not a silent clamp. `SubBalance` +guarded against underflow (upstream `cosmos/evm` #1176). -`x/bank` now tags `coin_spent` with a `locked_amount` attribute -(`cosmos-sdk` fork), and the handler subtracts only the spendable part -(`amount - locked`). An inconsistent event where `locked > amount` is now an -error rather than a silent clamp. `SubBalance` is additionally guarded against -underflow (upstream `cosmos/evm` #1176). - -> Requires both fork bumps together — the EVM-side handler depends on the -> `locked_amount` emission from the SDK side. +> Needs both fork bumps together — the EVM handler depends on the SDK-side emission. ### Historical EVM queries below the v1.6.0 migration height -`eth_call`, `eth_estimateGas` and traces at heights **below** the v1.6.0 store -migration failed or returned wrong results, because pre-migration state was -being decoded with the current, incompatible layout: +`eth_call`, `eth_estimateGas` and traces below the v1.6.0 store migration failed +or returned wrong results — pre-migration state was decoded with the current layout: -- **`x/vm` params** — proto field numbers shifted in v1.6.0 (old field 10 - `active_static_precompiles` vs. new `history_serve_window`), so decoding - panicked with `wrong wireType = 2 for field HistoryServeWindow`. -- **`x/erc20` precompiles** — the native/dynamic lists moved from a single - concatenated-blob key to per-address keys, so precompiles looked unregistered - at historical heights and calls executed the decoy ERC20 bytecode instead of - the precompile. In practice `gTAC.balanceOf` at an old height returned `0` - instead of the real balance. +- `x/vm` params — field numbers shifted in v1.6.0, decoding panicked with + `wrong wireType = 2 for field HistoryServeWindow`. +- `x/erc20` precompiles — lists moved from a concatenated-blob key to per-address + keys, so precompiles looked unregistered and calls hit the decoy ERC20 bytecode + (`gTAC.balanceOf` at an old height returned `0`). -A lazy, height-gated shim now decodes the legacy layout on the read path below -the migration height. The height is resolved from the applied `x/upgrade` -done-height and cached; the caches are warmed in the `x/vm` `BeginBlock`. +A height-gated shim now decodes the legacy layout on the read path; the height +comes from the applied `x/upgrade` done-height, caches warmed in `x/vm` `BeginBlock`. -> **Query path only.** At current heights the code path is byte-for-byte -> unchanged, so this is not consensus-affecting. +> Query path only — at current heights the code path is byte-for-byte unchanged. ### State overrides dropped the static precompiles -Backport of upstream [cosmos/evm #1096](https://github.com/cosmos/evm/pull/1096). - -An `eth_call` carrying state overrides replaced the whole precompile set with -go-ethereum's stock one, so the chain's static precompiles were not installed. -A call to a precompile address then landed on an account with no code and came -back with **empty output, intrinsic-only gas and no error** — a silent wrong -answer rather than a failure. - -An empty `{}` was enough to trigger it, which is what most tooling sends when it -passes the override argument at all. +Backport of upstream [cosmos/evm #1096](https://github.com/cosmos/evm/pull/1096), +fixed in 0.7.x and never backported to 0.6.x, so **v1.6.0 is affected**. -Upstream fixed this in 0.7.x in April and never backported it to 0.6.x, so -**v1.6.0 is affected** and any `eth_call` with overrides against `0x800` -(staking), `0x801` (distribution), `0x804` (bank) and friends has been returning -`0x` there. +An `eth_call` with state overrides — an empty `{}` was enough — replaced the +precompile set with go-ethereum's stock one. Calls to `0x800` (staking), `0x801` +(distribution), `0x804` (bank) returned empty output, intrinsic-only gas, no error. -> Query path only — a transaction never carries overrides, so nothing about -> execution or consensus changes. +`tac_simulate` inherited it: gas estimate ~4.6x too low on precompile calls +(`delegate`: 25 390 vs 115 613). Both agree after the backport. -`tac_simulate` inherited the same problem, which made its gas estimate ~4.6x too -low on precompile calls (measured on a `delegate`: 25 390 against the 115 613 -`eth_estimateGas` reports). Both agree after the backport. +> Query path only — transactions never carry overrides. ### Gas estimation with a balance override -The upper bound of the gas estimation binary search is capped by what the sender -can pay for gas, and that balance was read straight off the bank keeper — the -state override never reached it. Pricing a call for an account that is not funded -yet therefore returned no estimate at all: the call itself succeeded on the -overridden state while `gasEstimated` came back `0`. +The gas-search upper bound is capped by what the sender can pay, and that balance +was read off the bank keeper, never the state override — so pricing a call for an +unfunded account returned `gasEstimated: 0` while the call itself succeeded. -The balance now comes from the override when it carries one for the sender, the -way go-ethereum reads it off the overridden state. Without an override nothing -changes, so `eth_estimateGas` keeps its behaviour. +The balance now comes from the override when it carries one. No override, no change. -> Only observable when the caller passes a gas price; otherwise the fee cap -> defaults to `0` and the recap is skipped entirely. +> Only observable when the caller passes a gas price. -### EVM transactions broadcast to peers carried no sender +### EVM transaction broadcast to peers -The EVM mempool broadcasts a transaction to its peers when it promotes it out of -the queue, and built that message with `FromEthereumTx`, which fills in only the -raw transaction. `MsgEthereumTx.ValidateBasic` rejects a message without a -sender, so every such broadcast came back as +The broadcast message was assembled by hand and refused by the receiving mempool: +no sender (`code=18`), then no `ExtensionOptionsEthereumTx` option, fee or gas +limit (`code=29`). It is now built through `MsgEthereumTx.BuildTx`, as +`SendRawTransaction` does. A duplicate from the node's own cache no longer counts +as a failure, and one bad transaction no longer aborts its batch. -``` -rejected by mempool: code=18, log=sender address is missing: invalid request -``` - -The sender is now recovered from the signature, the way `SendRawTransaction` -already does on the direct path. +Transactions still landed (a proposing node reads its own mempool) but did not +reach other validators. -This was easy to miss because the transactions still landed: block proposal -reads the node's own mempool, so a node that proposes a block includes what it -holds regardless of the broadcast. Measured on a localnet — 77 of 77 failed -broadcasts ended up in a block, all successful. What did not happen is the -transaction reaching the other validators, which is what the broadcast is for. - -> Affects propagation between nodes, not execution. A single-node network sees -> only the log noise. +> p2p propagation only, not execution. --- -## Upgrade Handler Details +## Upgrade Handler -`v1.6.1` is a **no-op state upgrade**. The handler runs the standard module -migrations and returns: +No-op: runs module migrations and returns. -- No `StoreUpgrades` — no store added, renamed or deleted. -- No KV migration. -- No parameter change. +- No `StoreUpgrades`, no KV migration, no parameter change. - No `ConsensusVersion` bump in any module. - -A vesting schedule shift and an account migration were considered for this -release and **dropped** — they were not approved — so no vesting logic ships in -the handler. +- Vesting schedule shift and account migration were considered and dropped — not + approved, no vesting logic ships. --- ## Documentation & Tooling -- Cosmovisor setup guide now covers migrating an already running node. -- Deprecated Turin testnet (`tacchain_2390-1`) removed: `NETWORKS.md` section and - the `networks/tacchain_2390-1/` directory (genesis, compose file, env). -- The localnet script now lists `tac` in `json-rpc.api`, so `tac_simulate` is - reachable out of the box on a locally initialised chain. +- Cosmovisor setup guide covers migrating an already running node. +- Deprecated Turin testnet (`tacchain_2390-1`) removed from `NETWORKS.md` and + `networks/tacchain_2390-1/`. +- Localnet script lists `tac` in `json-rpc.api`, so `tac_simulate` works out of the box. +- `gen_localnode.sh` — stray `exit` cut the script short before the docker build. --- @@ -242,8 +168,6 @@ the handler. | `cosmos/ibc-go/v10` | `v10.3.1` | unchanged | | Go | 1.23.8 | unchanged | -### Fork changes in detail - **`cosmos/evm` `v0.6.0-tac.8` → `v0.6.0-tac.13`** | Commit | Change | diff --git a/go.mod b/go.mod index 4f1b683..b2d789e 100644 --- a/go.mod +++ b/go.mod @@ -295,7 +295,9 @@ replace ( // tac.10: allow delegating vesting-locked tokens via staking precompile (amount-locked; needs cosmos-sdk tac.3). // tac.12: add the tac_simulate rpc (state overrides, event logs and gas estimate in one call). // tac.13: backport upstream #1096 so a state override stops dropping the static precompiles. - github.com/cosmos/evm => github.com/TacBuild/evm v0.6.0-tac.13 + // TEMP local replace for the v1.6.2 hotfix build (evm branch hotfix/ghsa-aug-2026, + // = tac.13 + upstream cosmos-evm security fix). Replace with a tagged version before public release. + github.com/cosmos/evm => ../evm // replace with our fork using geth v1.16.2 github.com/ethereum/go-ethereum => github.com/cosmos/go-ethereum v1.16.2-cosmos-1 From 6be48138d843f23c8f55b3f54c60c14161b12cdf Mon Sep 17 00:00:00 2001 From: Aleksei Kokinos Date: Fri, 28 Aug 2026 01:36:56 +0700 Subject: [PATCH 2/4] feat(recovery): recover chain state after the exploit --- app/app.go | 17 +- app/hardforks/aug2026/recovery.go | 284 +++++++++++++++++++++++++ app/hardforks/aug2026/recovery_test.go | 269 +++++++++++++++++++++++ 3 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 app/hardforks/aug2026/recovery.go create mode 100644 app/hardforks/aug2026/recovery_test.go diff --git a/app/app.go b/app/app.go index 55cd1f5..482b633 100644 --- a/app/app.go +++ b/app/app.go @@ -40,6 +40,7 @@ import ( "github.com/spf13/cast" appconfig "github.com/TacBuild/tacchain/app/config" + recovery "github.com/TacBuild/tacchain/app/hardforks/aug2026" v160 "github.com/TacBuild/tacchain/app/upgrades/v1.6.0" autocliv1 "cosmossdk.io/api/cosmos/autocli/v1" @@ -1120,7 +1121,21 @@ func (app *TacChainApp) Name() string { return app.BaseApp.Name() } // PreBlocker application updates every pre block func (app *TacChainApp) PreBlocker(ctx sdk.Context, _ *abci.RequestFinalizeBlock) (*sdk.ResponsePreBlock, error) { - return app.ModuleManager.PreBlock(ctx) + res, err := app.ModuleManager.PreBlock(ctx) + if err != nil { + return res, err + } + // Aug-2026 incident recovery: runs only on a chain-id in ParamsByChainID, at its Height. + if p, ok := recovery.ParamsByChainID[ctx.ChainID()]; ok && ctx.BlockHeight() == p.Height { + if err := recovery.Migrate(ctx, recovery.Keepers{ + Account: app.AccountKeeper, + Bank: app.BankKeeper, + Staking: app.StakingKeeper, + }, p); err != nil { + panic(fmt.Sprintf("recovery migration failed at %d on %s: %v", ctx.BlockHeight(), ctx.ChainID(), err)) + } + } + return res, nil } // BeginBlocker application updates every begin block diff --git a/app/hardforks/aug2026/recovery.go b/app/hardforks/aug2026/recovery.go new file mode 100644 index 0000000..d0c0aba --- /dev/null +++ b/app/hardforks/aug2026/recovery.go @@ -0,0 +1,284 @@ +// Package recovery holds the Aug-2026 incident state-migration. +// +// The exploit drained the bonded_tokens_pool bank balance to ~0 while the bonded +// validators still hold their tokens -> the pool is insolvent (bank(pool) != +// sum of bonded validator.tokens). This migration is SUPPLY-NEUTRAL: it mints the +// pool deficit and burns the SAME total across a set of accounts (ToBurn), so the +// total supply is unchanged. It then moves the remainder of any partially-drained +// account to its destination (Transfer); a transfer does not change supply. +// +// GATING: the migration runs ONLY on a chain-id present in ParamsByChainID, and +// ONLY at that entry's Height. On any other network the PreBlocker skips it +// (returns nil) instead of touching state or halting. All amounts are hardcoded +// absolute values, audited against the halt state; the guards assert the invariants +// before and after and abort (deterministic halt) on any mismatch. +package recovery + +import ( + "context" + "fmt" + "slices" + + "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" +) + +// Transfer moves Amount (base units) from a source account to To. Supply-neutral. +type Transfer struct { + To string + Amount string +} + +// Params is the per-network recovery configuration. Absolute base-unit (utac) +// amounts, audited against that network's halt state. Runtime invariants: +// - PoolRestore == sum(ToBurn amounts) (supply-neutral: mint == burn) +// - PoolBefore + PoolRestore == PoolTargetAfter == sum of bonded validator.tokens +type Params struct { + Height int64 // block at which PreBlocker runs Migrate (halt height + 1) + PoolBefore string // expected bonded_pool bank balance BEFORE (drained) + PoolTargetAfter string // expected == sum of bonded validator.tokens + PoolRestore string // minted into bonded_pool (== deficit); must == sum(ToBurn) + ToBurn map[string]string // address -> amount burned from it + Transfer map[string]Transfer // from-address -> {To, Amount}; moves the remainder +} + +// ParamsByChainID: only the listed chains ever run the migration. The map lookup +// in the PreBlocker is the chain-id gate (absent key -> skip). +var ParamsByChainID = map[string]Params{ + // MAINNET recovery (Aug-2026 incident). ARMED at the recovery height 24671476 + "tacchain_239-1": { + Height: 24671476, // recovery block = last committed 24671475 + 1 + PoolBefore: "1", + PoolTargetAfter: "2985651403404712731337326750", + PoolRestore: "2985651403404712731337326749", + ToBurn: map[string]string{ + "tac1ajc2l9myf5kz33vrd8rxxqr6rdmuwlyyj69wks": "65100988589194488679326677", + "tac1zgvugz06hckz00gdrft9mthdn0vlyuw76rfwvj": "1662322352703987721367144079", + "tac1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkfj7lh": "292374143198882909207027939", + "tac1l78ukdclvydl8e0glatuj5vc30ga8m5xs78wlx": "3736999225285000000000000", + "tac14aurf5xtlp2z695m8q8hdrvgnslmqmvxqs4lnq": "65233628611981199281858474", + "tac173zcgurfpzkca6vgvpff6waqynvmu83rff3mkf": "50000000186061070724208656", + "tac1txr4j9407r5kcyw7z6ppy7jw4frwrhufy58mqg": "299999972524288078755499732", + "tac1n5hkyteynj8vn43cwy5wl96hajl32m9zek4p8k": "399899972495530457098839762", + "tac15yjhxu4pte4872stmew5favzx0q8hx77rs7g6w": "146983345869501806223421430", + }, + Transfer: map[string]Transfer{ + "tac1zgvugz06hckz00gdrft9mthdn0vlyuw76rfwvj": {To: "tac15yjhxu4pte4872stmew5favzx0q8hx77rs7g6w", Amount: "777956288465320278632855921"}, + }, + }, +} + +// Modules for mint/burn (verified against app.go maccPerms): +// mint has {Minter} (only), gov has {Burner}. +const ( + minterModule = "mint" + burnerModule = "gov" +) + +// Keepers is the minimal keeper set, satisfied by the app's real keepers. +type Keepers struct { + Account AccountKeeper + Bank BankKeeper + Staking StakingKeeper +} +type AccountKeeper interface { + GetModuleAddress(name string) sdk.AccAddress +} +type BankKeeper interface { + GetBalance(ctx context.Context, addr sdk.AccAddress, denom string) sdk.Coin + GetSupply(ctx context.Context, denom string) sdk.Coin + MintCoins(ctx context.Context, module string, amt sdk.Coins) error + BurnCoins(ctx context.Context, module string, amt sdk.Coins) error + SendCoins(ctx context.Context, from, to sdk.AccAddress, amt sdk.Coins) error + SendCoinsFromModuleToModule(ctx context.Context, from, to string, amt sdk.Coins) error + SendCoinsFromAccountToModule(ctx context.Context, from sdk.AccAddress, to string, amt sdk.Coins) error +} +type StakingKeeper interface { + BondDenom(ctx context.Context) (string, error) + GetAllValidators(ctx context.Context) ([]stakingtypes.Validator, error) +} + +// Migrate applies the supply-neutral recovery for the already-selected params p. +// All-or-nothing: any returned error must make the PreBlocker panic. +func Migrate(ctx sdk.Context, k Keepers, p Params) error { + logger := ctx.Logger() + denom, err := k.Staking.BondDenom(ctx) + if err != nil { + return fmt.Errorf("bond denom: %w", err) + } + toInt := func(s string) math.Int { + v, ok := math.NewIntFromString(s) + if !ok { + panic("recovery: bad int literal " + s) + } + return v + } + + poolAddr := k.Account.GetModuleAddress(stakingtypes.BondedPoolName) + burnAddrs := sortedKeys(p.ToBurn) // deterministic iteration for consensus + xferAddrs := sortedKeys(p.Transfer) // deterministic iteration for consensus + + // supply-neutral by construction: mint == total burned. Also validate addresses. + burnTotal := math.ZeroInt() + for _, a := range burnAddrs { + mustAddr(a) + burnTotal = burnTotal.Add(toInt(p.ToBurn[a])) + } + for _, a := range xferAddrs { + mustAddr(a) + mustAddr(p.Transfer[a].To) + } + if minted := toInt(p.PoolRestore); !minted.Equal(burnTotal) { + return fmt.Errorf("params not supply-neutral: mint %s != burn %s", minted, burnTotal) + } + + supplyBefore := k.Bank.GetSupply(ctx, denom).Amount + + // PREFLIGHT: the hardcoded target must match the live bonded set. + sumBonded, err := sumBondedValidatorTokens(ctx, k) + if err != nil { + return err + } + if want := toInt(p.PoolTargetAfter); !sumBonded.Equal(want) { + diff := sumBonded.Sub(want) + logger.Error("recovery INCONSISTENT: sum bonded != poolTargetAfter", + "sum_bonded", sumBonded.String(), "want", want.String(), "diff", diff.String()) + return fmt.Errorf("bonded sum %s != poolTargetAfter %s", sumBonded, want) + } + + // PRECONDITIONS: pool drained as audited; every source can cover what leaves it + // (its burn plus, if it is also a transfer source, the transferred remainder). + if err := eq("bonded_pool before", bal(ctx, k, poolAddr, denom), toInt(p.PoolBefore)); err != nil { + return err + } + for _, a := range burnAddrs { + need := toInt(p.ToBurn[a]) + if t, ok := p.Transfer[a]; ok { + need = need.Add(toInt(t.Amount)) + } + if err := gte("source "+a, bal(ctx, k, mustAddr(a), denom), need); err != nil { + return err + } + } + for _, a := range xferAddrs { + if _, ok := p.ToBurn[a]; ok { + continue // already covered above (burn + transfer) + } + if err := gte("transfer "+a, bal(ctx, k, mustAddr(a), denom), toInt(p.Transfer[a].Amount)); err != nil { + return err + } + } + + // APPLY: restore the pool, burn every ToBurn, then move every Transfer remainder. + if err := mintTo(ctx, k, stakingtypes.BondedPoolName, denom, toInt(p.PoolRestore)); err != nil { + return err + } + for _, a := range burnAddrs { + if err := burnFrom(ctx, k, mustAddr(a), denom, toInt(p.ToBurn[a]), "burn:"+a); err != nil { + return err + } + } + // Snapshot each transfer recipient AFTER the burns (a recipient may itself be a + // burn source): its expected end balance is (post-burn balance + amount received). + recvBefore := make(map[string]math.Int, len(xferAddrs)) + recvTotal := make(map[string]math.Int, len(xferAddrs)) + for _, a := range xferAddrs { + to := p.Transfer[a].To + if _, ok := recvBefore[to]; !ok { + recvBefore[to] = bal(ctx, k, mustAddr(to), denom) + recvTotal[to] = math.ZeroInt() + } + recvTotal[to] = recvTotal[to].Add(toInt(p.Transfer[a].Amount)) + } + for _, a := range xferAddrs { + t := p.Transfer[a] + c := sdk.NewCoins(sdk.NewCoin(denom, toInt(t.Amount))) + if err := k.Bank.SendCoins(ctx, mustAddr(a), mustAddr(t.To), c); err != nil { + return fmt.Errorf("transfer %s -> %s: %w", a, t.To, err) + } + } + + // POSTCONDITIONS: pool solvent, each recipient credited, supply unchanged. + if err := eq("bank(pool) == sum bonded", bal(ctx, k, poolAddr, denom), sumBonded); err != nil { + return err + } + for _, to := range sortedKeys(recvBefore) { + want := recvBefore[to].Add(recvTotal[to]) + if err := eq("recipient "+to, bal(ctx, k, mustAddr(to), denom), want); err != nil { + return err + } + } + if supplyAfter := k.Bank.GetSupply(ctx, denom).Amount; !supplyAfter.Equal(supplyBefore) { + return fmt.Errorf("supply changed: before %s after %s (mint must equal burn)", supplyBefore, supplyAfter) + } + logger.Info("recovery migration OK", "chain_id", ctx.ChainID(), "height", ctx.BlockHeight(), + "pool_restored", p.PoolRestore, "burned", burnTotal.String(), "supply_delta", "0") + return nil +} + +// ---- helpers ---- +func sortedKeys[V any](m map[string]V) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + slices.Sort(ks) + return ks +} +func bal(ctx sdk.Context, k Keepers, a sdk.AccAddress, denom string) math.Int { + return k.Bank.GetBalance(ctx, a, denom).Amount +} +func eq(tag string, got, want math.Int) error { + if !got.Equal(want) { + return fmt.Errorf("guard %q: got %s want %s", tag, got, want) + } + return nil +} +func gte(tag string, got, min math.Int) error { + if got.LT(min) { + return fmt.Errorf("guard %q: got %s < required %s", tag, got, min) + } + return nil +} +func mustAddr(b string) sdk.AccAddress { + a, err := sdk.AccAddressFromBech32(b) + if err != nil { + panic(fmt.Sprintf("recovery: bad addr %q: %v", b, err)) + } + return a +} +func mintTo(ctx sdk.Context, k Keepers, module, denom string, amt math.Int) error { + c := sdk.NewCoins(sdk.NewCoin(denom, amt)) + if err := k.Bank.MintCoins(ctx, minterModule, c); err != nil { + return fmt.Errorf("mint: %w", err) + } + return k.Bank.SendCoinsFromModuleToModule(ctx, minterModule, module, c) +} +func burnFrom(ctx sdk.Context, k Keepers, addr sdk.AccAddress, denom string, amt math.Int, tag string) error { + c := sdk.NewCoins(sdk.NewCoin(denom, amt)) + if err := k.Bank.SendCoinsFromAccountToModule(ctx, addr, burnerModule, c); err != nil { + return fmt.Errorf("%s send-to-burn: %w", tag, err) + } + if err := k.Bank.BurnCoins(ctx, burnerModule, c); err != nil { + return fmt.Errorf("%s burn: %w", tag, err) + } + return nil +} + +// sumBondedValidatorTokens = sum of tokens of all BONDED validators - the amount +// the bonded pool must hold (staking ModuleAccountInvariant). Independent of the +// drained pool bank balance. +func sumBondedValidatorTokens(ctx sdk.Context, k Keepers) (math.Int, error) { + vals, err := k.Staking.GetAllValidators(ctx) + if err != nil { + return math.ZeroInt(), err + } + sum := math.ZeroInt() + for _, v := range vals { + if v.IsBonded() { + sum = sum.Add(v.GetTokens()) + } + } + return sum, nil +} diff --git a/app/hardforks/aug2026/recovery_test.go b/app/hardforks/aug2026/recovery_test.go new file mode 100644 index 0000000..05c2cb5 --- /dev/null +++ b/app/hardforks/aug2026/recovery_test.go @@ -0,0 +1,269 @@ +package recovery + +import ( + "context" + "testing" + + "cosmossdk.io/log" + "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" +) + +const testDenom = "utac" + +// Local-test addresses (fake; not the real audited accounts). +const ( + tAttacker = "tac1p0wnk86ere6zxajp4ykflfr5ap3lmla6t0k22n" + tEscrow = "tac1e3qva29tw932mh3mq382ufk7rlpyvvt6hn5nu2" + tAdmin = "tac1k4lcd0amymuqlh2lay66k87ezygj8rmzenm0m2" + tTre1 = "tac15xedv3w78gfx5rcpk7knxlmfg8lff7wemsgp2v" + tTre2 = "tac1eggmmczew7ekxyt8q2yx9032zuuhdjs3yd8s2h" +) + +func init() { + // The migration parses/prints "tac"-prefixed addresses; the app sets this at + // startup, so the unit test must set it too (SDK default is "cosmos"). + sdk.GetConfig().SetBech32PrefixForAccount("tac", "tacpub") +} + +func bi(s string) math.Int { v, _ := math.NewIntFromString(s); return v } + +// ---- minimal in-memory mocks ---- + +type mockAccount struct{} + +func (mockAccount) GetModuleAddress(name string) sdk.AccAddress { + return authtypes.NewModuleAddress(name) +} + +type mockBank struct { + bal map[string]math.Int + supply math.Int +} + +func newMockBank() *mockBank { return &mockBank{bal: map[string]math.Int{}, supply: math.ZeroInt()} } + +func (m *mockBank) get(k string) math.Int { + if v, ok := m.bal[k]; ok { + return v + } + return math.ZeroInt() +} +func (m *mockBank) GetBalance(_ context.Context, addr sdk.AccAddress, _ string) sdk.Coin { + return sdk.NewCoin(testDenom, m.get(addr.String())) +} +func (m *mockBank) GetSupply(_ context.Context, _ string) sdk.Coin { + return sdk.NewCoin(testDenom, m.supply) +} +func (m *mockBank) MintCoins(_ context.Context, mod string, amt sdk.Coins) error { + k := authtypes.NewModuleAddress(mod).String() + a := amt.AmountOf(testDenom) + m.bal[k] = m.get(k).Add(a) + m.supply = m.supply.Add(a) + return nil +} +func (m *mockBank) BurnCoins(_ context.Context, mod string, amt sdk.Coins) error { + k := authtypes.NewModuleAddress(mod).String() + a := amt.AmountOf(testDenom) + m.bal[k] = m.get(k).Sub(a) + m.supply = m.supply.Sub(a) + return nil +} +func (m *mockBank) SendCoinsFromModuleToModule(_ context.Context, from, to string, amt sdk.Coins) error { + fk, tk := authtypes.NewModuleAddress(from).String(), authtypes.NewModuleAddress(to).String() + a := amt.AmountOf(testDenom) + m.bal[fk] = m.get(fk).Sub(a) + m.bal[tk] = m.get(tk).Add(a) + return nil +} +func (m *mockBank) SendCoinsFromAccountToModule(_ context.Context, from sdk.AccAddress, to string, amt sdk.Coins) error { + fk, tk := from.String(), authtypes.NewModuleAddress(to).String() + a := amt.AmountOf(testDenom) + m.bal[fk] = m.get(fk).Sub(a) + m.bal[tk] = m.get(tk).Add(a) + return nil +} +func (m *mockBank) SendCoins(_ context.Context, from, to sdk.AccAddress, amt sdk.Coins) error { + a := amt.AmountOf(testDenom) + m.bal[from.String()] = m.get(from.String()).Sub(a) + m.bal[to.String()] = m.get(to.String()).Add(a) + return nil +} + +type mockStaking struct{ bonded math.Int } + +func (mockStaking) BondDenom(context.Context) (string, error) { return testDenom, nil } +func (s mockStaking) GetAllValidators(context.Context) ([]stakingtypes.Validator, error) { + return []stakingtypes.Validator{{Status: stakingtypes.Bonded, Tokens: s.bonded}}, nil +} + +func testCtx() sdk.Context { + return sdk.Context{}.WithLogger(log.NewNopLogger()).WithChainID("tacchain_2391337-1").WithBlockHeight(1164) +} + +// localParams — self-contained local-exploit values for the guard tests, independent +// of ParamsByChainID (mainnet entry ships; the local entry is not present there). +// Shape: burn attacker + escrow-share + two treasury accounts; sweep the escrow +// remainder to a standalone admin. +func localParams() Params { + return Params{ + Height: 1164, + PoolBefore: "0", + PoolTargetAfter: "10000000000000000000000001", + PoolRestore: "10000000000000000000000001", + ToBurn: map[string]string{ + tAttacker: "999999699999999999999999", + tEscrow: "2000000000000000000000000", // partial: the rest is transferred out + tTre1: "4000000000000000000000000", + tTre2: "3000000300000000000000002", + }, + Transfer: map[string]Transfer{ + tEscrow: {To: tAdmin, Amount: "500000000000000000000000"}, + }, + } +} + +// localParamsB — variant with a dead-address burn folded into ToBurn (tTre2 plays 0x00) +// AND a transfer recipient (tTre1) that is itself a ToBurn source (the mainnet shape). +func localParamsB() Params { + return Params{ + Height: 1164, + PoolBefore: "0", + PoolTargetAfter: "11000000000000000000000000", + PoolRestore: "11000000000000000000000000", + ToBurn: map[string]string{ + tAttacker: "1000000000000000000000000", + tEscrow: "2000000000000000000000000", // partial: the rest is transferred out + tTre2: "3000000000000000000000000", // dead-address (0x00) role, now a plain burn + tAdmin: "1000000000000000000000000", + tTre1: "4000000000000000000000000", // also the transfer recipient (overlap) + }, + Transfer: map[string]Transfer{ + tEscrow: {To: tTre1, Amount: "500000000000000000000000"}, + }, + } +} + +func seed(bank *mockBank, p Params) { + add := func(addr string, amt math.Int) { + bank.bal[addr] = bank.get(addr).Add(amt) + bank.supply = bank.supply.Add(amt) + } + add(authtypes.NewModuleAddress(stakingtypes.BondedPoolName).String(), bi(p.PoolBefore)) + for a, amt := range p.ToBurn { + add(a, bi(amt)) + } + for a, t := range p.Transfer { + add(a, bi(t.Amount)) // stacked on top of the source's burn balance + } +} + +func keepers(bank *mockBank, bonded math.Int) Keepers { + return Keepers{Account: mockAccount{}, Bank: bank, Staking: mockStaking{bonded: bonded}} +} + +// ---- tests ---- + +func TestMigrate_HappyPath(t *testing.T) { + p := localParams() + bank := newMockBank() + seed(bank, p) + supplyBefore := bank.supply + if err := Migrate(testCtx(), keepers(bank, bi(p.PoolTargetAfter)), p); err != nil { + t.Fatalf("expected success, got %v", err) + } + if got := bank.get(authtypes.NewModuleAddress(stakingtypes.BondedPoolName).String()); !got.Equal(bi(p.PoolTargetAfter)) { + t.Fatalf("pool = %s, want %s (== sum bonded)", got, p.PoolTargetAfter) + } + // every burn source ends at 0 (seeded exactly its burn; the escrow's rest is transferred out) + for a := range p.ToBurn { + if got := bank.get(a); !got.IsZero() { + t.Fatalf("burn source %s = %s, want 0", a, got) + } + } + // the standalone recipient received the swept remainder + for _, tr := range p.Transfer { + if got := bank.get(tr.To); !got.Equal(bi(tr.Amount)) { + t.Fatalf("recipient %s = %s, want %s", tr.To, got, tr.Amount) + } + } + if !bank.supply.Equal(supplyBefore) { + t.Fatalf("supply changed %s -> %s (must be neutral)", supplyBefore, bank.supply) + } +} + +func TestMigrate_VariantB_ZeroBurnAndRecipientOverlap(t *testing.T) { + p := localParamsB() + bank := newMockBank() + seed(bank, p) + // tTre1 is a burn source AND the transfer recipient: give it a residual above its + // burn amount, so after the burn it keeps the residual and then receives the sweep. + residual := bi("250000000000000000000000") + bank.bal[tTre1] = bank.get(tTre1).Add(residual) + bank.supply = bank.supply.Add(residual) + supplyBefore := bank.supply + + if err := Migrate(testCtx(), keepers(bank, bi(p.PoolTargetAfter)), p); err != nil { + t.Fatalf("expected success, got %v", err) + } + // the dead-address role (tTre2) is fully burned + if got := bank.get(tTre2); !got.IsZero() { + t.Fatalf("dead-address = %s, want 0 (burned)", got) + } + // escrow source fully drained + if got := bank.get(tEscrow); !got.IsZero() { + t.Fatalf("escrow = %s, want 0 (fully drained)", got) + } + // recipient: burned its share, kept the residual, then received the sweep + if want := residual.Add(bi(p.Transfer[tEscrow].Amount)); !bank.get(tTre1).Equal(want) { + t.Fatalf("recipient = %s, want %s (residual + sweep)", bank.get(tTre1), want) + } + if !bank.supply.Equal(supplyBefore) { + t.Fatalf("supply changed %s -> %s (must be neutral)", supplyBefore, bank.supply) + } +} + +func TestMigrate_InsufficientBalance_Aborts(t *testing.T) { + p := localParams() + bank := newMockBank() + seed(bank, p) + bank.bal[tAttacker] = bi(p.ToBurn[tAttacker]).Sub(math.OneInt()) // source short by 1 utac + if err := Migrate(testCtx(), keepers(bank, bi(p.PoolTargetAfter)), p); err == nil { + t.Fatal("expected insufficient-balance abort, got nil") + } +} + +func TestMigrate_BondedSumMismatch_Aborts(t *testing.T) { + p := localParams() + bank := newMockBank() + seed(bank, p) + // live bonded set != hardcoded PoolTargetAfter + if err := Migrate(testCtx(), keepers(bank, bi(p.PoolTargetAfter).Add(math.OneInt())), p); err == nil { + t.Fatal("expected preflight abort, got nil") + } +} + +func TestMigrate_NotSupplyNeutral_Aborts(t *testing.T) { + p := localParams() + p.PoolRestore = bi(p.PoolRestore).Add(math.OneInt()).String() // mint 1 more than burned + bank := newMockBank() + seed(bank, p) + if err := Migrate(testCtx(), keepers(bank, bi(p.PoolTargetAfter)), p); err == nil { + t.Fatal("expected supply-neutral abort, got nil") + } +} + +func TestChainIDGate(t *testing.T) { + // Mainnet entry ships; the local-test entries are not present. + if _, ok := ParamsByChainID["tacchain_239-1"]; !ok { + t.Fatal("mainnet chain-id must be present (migration eligible)") + } + // The gate is the map lookup: any chain-id not listed is skipped by the PreBlocker. + for _, cid := range []string{"tacchain_2391337-1", "tacchain_2391-1", "some-other-chain"} { + if _, ok := ParamsByChainID[cid]; ok { + t.Fatalf("chain-id %q must be ABSENT in this build (gate must skip it)", cid) + } + } +} From 5a37d9fb108df3643eb4c7f9510e05e76b182cfa Mon Sep 17 00:00:00 2001 From: Aleksei Kokinos Date: Fri, 28 Aug 2026 01:36:56 +0700 Subject: [PATCH 3/4] chore(deps): pin cosmos/evm to v0.6.0-tac.14 (GHSA hotfix tag) --- go.mod | 5 ++--- go.sum | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b2d789e..a29ae10 100644 --- a/go.mod +++ b/go.mod @@ -295,9 +295,8 @@ replace ( // tac.10: allow delegating vesting-locked tokens via staking precompile (amount-locked; needs cosmos-sdk tac.3). // tac.12: add the tac_simulate rpc (state overrides, event logs and gas estimate in one call). // tac.13: backport upstream #1096 so a state override stops dropping the static precompiles. - // TEMP local replace for the v1.6.2 hotfix build (evm branch hotfix/ghsa-aug-2026, - // = tac.13 + upstream cosmos-evm security fix). Replace with a tagged version before public release. - github.com/cosmos/evm => ../evm + // tac.14: v1.6.2 GHSA hotfix (= tac.13 + upstream cosmos-evm security fix for the Aug-2026 incident). + github.com/cosmos/evm => github.com/TacBuild/evm v0.6.0-tac.14 // replace with our fork using geth v1.16.2 github.com/ethereum/go-ethereum => github.com/cosmos/go-ethereum v1.16.2-cosmos-1 diff --git a/go.sum b/go.sum index 04508e7..c1a4cf6 100644 --- a/go.sum +++ b/go.sum @@ -687,6 +687,8 @@ github.com/TacBuild/cosmos-sdk v0.53.6-tac.3 h1:tKkYpmiVGrCFy3V11fCy/AP+JOu3duaw github.com/TacBuild/cosmos-sdk v0.53.6-tac.3/go.mod h1:N6YuprhAabInbT3YGumGDKONbvPX5dNro7RjHvkQoKE= github.com/TacBuild/evm v0.6.0-tac.13 h1:GPS/9N9KvbIRifU/WjT68omOX7J39mB5AqdFLUnPtTw= github.com/TacBuild/evm v0.6.0-tac.13/go.mod h1:9DaIfOYirXAQplL677tYEmy0crZWLI4glre84+qi5FQ= +github.com/TacBuild/evm v0.6.0-tac.14 h1:1s1vPeCUyys5Iv1Zizz55d4ghoNl2IvcAWzRrdbUTgA= +github.com/TacBuild/evm v0.6.0-tac.14/go.mod h1:9DaIfOYirXAQplL677tYEmy0crZWLI4glre84+qi5FQ= github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= From 0d677f3fefbe805cd91b8ba14f0098e7a7e39afc Mon Sep 17 00:00:00 2001 From: Aleksei Kokinos Date: Fri, 28 Aug 2026 12:49:55 +0700 Subject: [PATCH 4/4] docs(changelog): add v1.6.2 --- docs/CHANGELOG-v161-v162.md | 102 ++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/CHANGELOG-v161-v162.md diff --git a/docs/CHANGELOG-v161-v162.md b/docs/CHANGELOG-v161-v162.md new file mode 100644 index 0000000..3b5daad --- /dev/null +++ b/docs/CHANGELOG-v161-v162.md @@ -0,0 +1,102 @@ +# TacChain v1.6.2 — Changelog + +> **Status:** Ready for release +> **Upgrade name:** `v1.6.2` +> **Release tag:** `v1.6.2` (to be cut from `main`) +> **Previous version:** v1.6.1 (code lineage) +> **Mainnet upgrade path:** v1.6.0 → v1.6.2 directly — v1.6.1 shipped to SPB +> testnet only and never reached mainnet +> **Chains:** TacChain Mainnet — planned + +--- + +## Summary + +Recovery release on top of v1.6.1. Ships the Aug-2026 incident state recovery and +the upstream `cosmos/evm` security fix that closes the exploited primitive. The +v1.6.2 upgrade handler is a no-op; the only state change is the one-shot recovery +migration, which is height- and chain-id-gated. + +Because v1.6.1 only ran on SPB testnet, mainnet upgrades directly from v1.6.0. The +v1.6.2 mainnet binary therefore also carries every v1.6.1 change — see the +[v1.6.1 changelog](./CHANGELOG-v160-v161.md) for those. + +--- + +## Breaking Changes + +None. No parameter change, no `ConsensusVersion` bump. The recovery migration is a +one-shot, gated, supply-neutral state change (see below), not a consensus change. + +--- + +## State Recovery — Aug-2026 incident + +`app/hardforks/aug2026/` + +The exploit drained the `bonded_tokens_pool` bank balance to ~0 while the bonded +validators still held their tokens, leaving the pool insolvent +(`bank(pool) != Σ bonded validator.tokens`). A one-shot `PreBlocker` migration +restores it: + +- **Supply-neutral.** Mints the pool deficit and burns the exact same total across + the affected accounts (`mint == burn`), then moves the remainder of a + partially-drained account to its destination (transfer — also supply-neutral). + Total supply is unchanged. +- **Gated.** Runs only on a chain-id present in `ParamsByChainID`, and only at that + entry's `Height`. On any other network / height the `PreBlocker` skips it and + touches no state. + - Mainnet (`tacchain_239-1`): armed at height **24,671,476** (halt height + 1). +- **Guarded.** All amounts are hardcoded absolute base-unit (`utac`) values, audited + against the halt state. The migration asserts its invariants before and after — + pool solvency (`bank(pool) == Σ bonded validator.tokens`), the credited + recipient, and unchanged total supply — and aborts deterministically (halt) on + any mismatch. + +> Verified on the real mainnet halt-state (resume-from-DB via `in-place-testnet` +> on the 24,671,475 snapshot): migration executed, block committed, chain +> continued, `supply_delta = 0`. + +--- + +## Security Fix + +### `cosmos/evm` GHSA hotfix (Aug-2026 primitive) + +Bumps the `cosmos/evm` fork from `v0.6.0-tac.13` to `v0.6.0-tac.14`: `tac.13` +plus the upstream `cosmos/evm` security fix for the Aug-2026 incident, which +closes the phantom-balance primitive that was exploited. + +--- + +## Upgrade Handler + +No-op: runs module migrations and returns. + +- No `StoreUpgrades`, no KV migration, no parameter change. +- No `ConsensusVersion` bump in any module. +- Exists only to coordinate the network version bump; all fixes are binary-level. + +--- + +## Dependency Versions + +| Dependency | v1.6.1 | v1.6.2 | +|------------|--------|--------| +| `cosmos/evm` | fork @ `v0.6.0-tac.13` | fork @ `v0.6.0-tac.14` | +| `cosmos/cosmos-sdk` | fork @ `v0.53.6-tac.3` | unchanged | +| `ethereum/go-ethereum` | `v1.16.2-cosmos-1` | unchanged | +| `cometbft/cometbft` | `v0.38.21` | unchanged | +| `cosmos/ibc-go/v10` | `v10.3.1` | unchanged | +| Go | 1.23.8 | unchanged | + +Cumulative from the mainnet **v1.6.0** binary (v1.6.1 was testnet-only): +`cosmos/evm` `v0.6.0-tac.8` → `v0.6.0-tac.14`, `cosmos/cosmos-sdk` +`v0.53.6-tac.2` → `v0.53.6-tac.3`. For the intermediate `tac.8` → `tac.13` +commit list, see the [v1.6.1 changelog](./CHANGELOG-v160-v161.md). + +**`cosmos/evm` `v0.6.0-tac.13` → `v0.6.0-tac.14`** + +| Change | +|--------| +| Upstream GHSA hotfix: close the Aug-2026 phantom-balance primitive |