Skip to content

Commit 515cdb4

Browse files
authored
Merge pull request #311 from pushchain/feat/read-state-upgrade-handler
feat: add read-state upgrade handler
2 parents fdd9cdb + 761d585 commit 515cdb4

10 files changed

Lines changed: 245 additions & 10 deletions

File tree

app/coininfo.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build !test
2+
// +build !test
3+
4+
package app
5+
6+
import (
7+
evmtypes "github.com/cosmos/evm/x/vm/types"
8+
)
9+
10+
// seedDefaultEvmCoinInfo populates the EVM coin-info fallback at app construction.
11+
//
12+
// The authoritative value is set by x/vm's PreBlock, but x/upgrade's PreBlocker
13+
// runs before it. An upgrade handler that touches EVM state — reading an account,
14+
// or writing one, which sets a balance — resolves the coin denom through a global
15+
// that is still nil at that point, and the node dies on a nil dereference partway
16+
// through the upgrade. That is a chain halt, not a failed tx.
17+
//
18+
// Upstream provides this fallback for exactly that window (and for RPC served
19+
// before the first PreBlock); nothing in this app was populating it. It is a
20+
// different variable from the one x/vm sets, so there is no double-set: the getter
21+
// prefers the PreBlock value and falls back to this only while that is nil.
22+
func seedDefaultEvmCoinInfo(coinInfo evmtypes.EvmCoinInfo) {
23+
evmtypes.SetDefaultEvmCoinInfo(coinInfo)
24+
}

app/coininfo_test_build.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build test
2+
// +build test
3+
4+
package app
5+
6+
import (
7+
evmtypes "github.com/cosmos/evm/x/vm/types"
8+
)
9+
10+
// seedDefaultEvmCoinInfo is a no-op under the test build tag.
11+
//
12+
// There, SetDefaultEvmCoinInfo writes the single testing coin-info variable rather
13+
// than a separate fallback, so seeding it here would make x/vm's InitGenesis panic
14+
// with "EVM coin info already set". Test apps configure coin info themselves.
15+
func seedDefaultEvmCoinInfo(_ evmtypes.EvmCoinInfo) {}

app/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ func EVMAppOptions(chainID string) error {
5959
return err
6060
}
6161

62+
seedDefaultEvmCoinInfo(coinInfo)
63+
6264
sealed = true
6365
return nil
6466
}

app/upgrades.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ import (
1616
ethhashfix "github.com/pushchain/push-chain-node/app/upgrades/eth-hash-fix"
1717
evmblockscoutfix "github.com/pushchain/push-chain-node/app/upgrades/evm-blockscout-fix"
1818
evmchainidffix "github.com/pushchain/push-chain-node/app/upgrades/evm-chainid-fix"
19+
evmderivedgasprice "github.com/pushchain/push-chain-node/app/upgrades/evm-derived-gas-price"
1920
evmparamsmigration "github.com/pushchain/push-chain-node/app/upgrades/evm-params-migration"
2021
evmpreinstalls "github.com/pushchain/push-chain-node/app/upgrades/evm-preinstalls"
2122
evmrpcfix "github.com/pushchain/push-chain-node/app/upgrades/evm-rpc-fix"
2223
evmv040 "github.com/pushchain/push-chain-node/app/upgrades/evm-v0-4-0"
2324
evmv050 "github.com/pushchain/push-chain-node/app/upgrades/evm-v0-5-0"
24-
evmderivedgasprice "github.com/pushchain/push-chain-node/app/upgrades/evm-derived-gas-price"
2525
evmv060 "github.com/pushchain/push-chain-node/app/upgrades/evm-v0-6-0"
2626
feeabs "github.com/pushchain/push-chain-node/app/upgrades/fee-abs"
2727
gasoracle "github.com/pushchain/push-chain-node/app/upgrades/gas-oracle"
@@ -31,6 +31,7 @@ import (
3131
pc20 "github.com/pushchain/push-chain-node/app/upgrades/pc20"
3232
proxybytecodefix "github.com/pushchain/push-chain-node/app/upgrades/proxy-bytecode-fix"
3333
purgeexpiredoutbounds "github.com/pushchain/push-chain-node/app/upgrades/purge-expired-outbounds"
34+
readstate "github.com/pushchain/push-chain-node/app/upgrades/read-state"
3435
removefeeabsv1 "github.com/pushchain/push-chain-node/app/upgrades/remove-fee-abs-v1"
3536
removeutxverifier "github.com/pushchain/push-chain-node/app/upgrades/remove-utxverifier"
3637
sdkv053 "github.com/pushchain/push-chain-node/app/upgrades/sdk-v0-53"
@@ -94,6 +95,9 @@ var Upgrades = []upgrades.Upgrade{
9495
// evm-derived-gas-price — cosmos/evm bump for the derived-tx gas price fix;
9596
// no-op (JSON-RPC reporting only, no module ConsensusVersion changes)
9697
evmderivedgasprice.NewUpgrade(),
98+
// read-state — adds the x/ucallback store and reserves every system-contract
99+
// address still unclaimed in the A/B/C ranges (41 of 47 on donut, incl. 0xC2)
100+
readstate.NewUpgrade(),
97101
}
98102

99103
// RegisterUpgradeHandlers registers the chain upgrade handlers

app/upgrades/read-state/upgrade.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package readstate
2+
3+
import (
4+
"context"
5+
6+
storetypes "cosmossdk.io/store/types"
7+
upgradetypes "cosmossdk.io/x/upgrade/types"
8+
9+
sdk "github.com/cosmos/cosmos-sdk/types"
10+
"github.com/cosmos/cosmos-sdk/types/module"
11+
12+
"github.com/pushchain/push-chain-node/app/upgrades"
13+
ucallbacktypes "github.com/pushchain/push-chain-node/x/ucallback/types"
14+
)
15+
16+
const UpgradeName = "read-state"
17+
18+
// NewUpgrade constructs the upgrade definition.
19+
//
20+
// x/ucallback is a new module, so its store has to be added here — RunMigrations
21+
// alone will register the module's consensus version but cannot create a store that
22+
// the multistore was never told about, and the node fails to load at the upgrade
23+
// height without it.
24+
func NewUpgrade() upgrades.Upgrade {
25+
return upgrades.Upgrade{
26+
UpgradeName: UpgradeName,
27+
CreateUpgradeHandler: CreateUpgradeHandler,
28+
StoreUpgrades: storetypes.StoreUpgrades{
29+
Added: []string{ucallbacktypes.StoreKey},
30+
Deleted: []string{},
31+
},
32+
}
33+
}
34+
35+
func CreateUpgradeHandler(
36+
mm upgrades.ModuleManager,
37+
configurator module.Configurator,
38+
ak *upgrades.AppKeepers,
39+
) upgradetypes.UpgradeHandler {
40+
return func(ctx context.Context, plan upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) {
41+
sdkCtx := sdk.UnwrapSDKContext(ctx)
42+
sdkCtx.Logger().Info("🔧 Running upgrade:", "name", UpgradeName)
43+
44+
// Reserve every system-contract address that is still empty.
45+
//
46+
// SYSTEM_CONTRACTS is not just the named contracts: constants.go's init()
47+
// fills the 0xA0-0xAF, 0xB0-0xBF and 0xC0-0xCF ranges with a full
48+
// proxy+admin+impl triple per slot, 47 entries in total. The genesis loop
49+
// runs only at InitGenesis, so every slot added after a chain launched is
50+
// unreserved on it — and an unreserved slot can be squatted by an ordinary
51+
// account, which is the squatting defence those ranges exist for.
52+
//
53+
// On donut that is 41 of the 47, all three addresses bare, including
54+
// UNIVERSAL_CALLBACK (0x…C2). x/ucallback only accepts ReadRequested logs
55+
// from that exact address, so until it holds the proxy the module is inert.
56+
// The remaining 6 are already deployed and the already-deployed guard skips
57+
// them, leaving their code untouched.
58+
if err := ak.URegistryKeeper.DeployMissingSystemContracts(sdkCtx); err != nil {
59+
return nil, err
60+
}
61+
sdkCtx.Logger().Info("Reserved any missing system contract addresses")
62+
63+
// RunMigrations registers x/ucallback at its current consensus version and
64+
// runs its InitGenesis, which seeds Params. There is no state to migrate:
65+
// the module is new, so it starts empty.
66+
return mm.RunMigrations(ctx, configurator, fromVM)
67+
}
68+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package integrationtest
2+
3+
import (
4+
"testing"
5+
6+
evmtypes "github.com/cosmos/evm/x/vm/types"
7+
"github.com/ethereum/go-ethereum/common"
8+
"github.com/stretchr/testify/require"
9+
10+
utils "github.com/pushchain/push-chain-node/test/utils"
11+
uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types"
12+
)
13+
14+
// The read-state upgrade must reserve every system-contract slot that is still
15+
// empty, and leave the ones already deployed exactly as they are.
16+
//
17+
// UNIVERSAL_CALLBACK (0x…C2) was added to SYSTEM_CONTRACTS after donut launched, so
18+
// on that chain its proxy, admin and implementation are all bare — verified against
19+
// the live testnet. x/ucallback only accepts ReadRequested logs from that exact
20+
// address, so if the upgrade does not reserve it the module is inert.
21+
func TestReadStateUpgrade_ReservesMissingSystemContracts(t *testing.T) {
22+
chainApp, ctx, _ := utils.SetAppWithValidators(t)
23+
k := chainApp.UregistryKeeper
24+
25+
code := func(addr string) []byte {
26+
a := common.HexToAddress(addr)
27+
acct := chainApp.EVMKeeper.GetAccountOrEmpty(ctx, a)
28+
return chainApp.EVMKeeper.GetCode(ctx, common.BytesToHash(acct.CodeHash))
29+
}
30+
31+
// Simulate a chain that launched before the slot existed: clear the triple.
32+
cb := uregistrytypes.SYSTEM_CONTRACTS["UNIVERSAL_CALLBACK"]
33+
for _, a := range []string{cb.Address, cb.ProxyAdmin, cb.Implementation} {
34+
// Point the account back at the empty-code sentinel; that is exactly the
35+
// state the reservation guard treats as "not deployed".
36+
chainApp.EVMKeeper.SetCodeHash(ctx,
37+
common.HexToAddress(a).Bytes(), evmtypes.EmptyCodeHash)
38+
}
39+
require.Empty(t, code(cb.Address), "precondition: the slot must start bare")
40+
41+
// A slot that IS deployed must be left untouched.
42+
core := uregistrytypes.SYSTEM_CONTRACTS["UNIVERSAL_CORE"]
43+
coreBefore := code(core.Address)
44+
require.NotEmpty(t, coreBefore, "precondition: UNIVERSAL_CORE is deployed at genesis")
45+
46+
require.NoError(t, k.DeployMissingSystemContracts(ctx))
47+
48+
require.NotEmpty(t, code(cb.Address), "the callback proxy must be reserved")
49+
require.NotEmpty(t, code(cb.ProxyAdmin), "its ProxyAdmin must be reserved")
50+
require.NotEmpty(t, code(cb.Implementation), "its implementation must be reserved")
51+
require.Equal(t, coreBefore, code(core.Address),
52+
"an already-deployed contract must not be redeployed or overwritten")
53+
}
54+
55+
// Running the reservation twice must change nothing. The upgrade runs once, but the
56+
// same guard protects chains where the slot is already present, and a second pass is
57+
// the cheapest way to prove the guard actually holds.
58+
func TestReadStateUpgrade_ReservationIsIdempotent(t *testing.T) {
59+
chainApp, ctx, _ := utils.SetAppWithValidators(t)
60+
k := chainApp.UregistryKeeper
61+
62+
code := func(addr string) []byte {
63+
a := common.HexToAddress(addr)
64+
acct := chainApp.EVMKeeper.GetAccountOrEmpty(ctx, a)
65+
return chainApp.EVMKeeper.GetCode(ctx, common.BytesToHash(acct.CodeHash))
66+
}
67+
68+
require.NoError(t, k.DeployMissingSystemContracts(ctx))
69+
70+
before := map[string][]byte{}
71+
for name, c := range uregistrytypes.SYSTEM_CONTRACTS {
72+
before[name] = code(c.Address)
73+
require.NotEmpty(t, before[name], "%s must be reserved after the first pass", name)
74+
}
75+
76+
require.NoError(t, k.DeployMissingSystemContracts(ctx))
77+
78+
for name, c := range uregistrytypes.SYSTEM_CONTRACTS {
79+
require.Equal(t, before[name], code(c.Address), "%s changed on the second pass", name)
80+
}
81+
}

x/uregistry/keeper/genesis.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/ethereum/go-ethereum/crypto"
1313

1414
"github.com/cosmos/evm/x/vm/statedb"
15+
evmtypes "github.com/cosmos/evm/x/vm/types"
1516
"github.com/pushchain/push-chain-node/x/uregistry/types"
1617
)
1718

@@ -53,7 +54,7 @@ func deployImplementationContract(ctx context.Context, evmKeeper types.EVMKeeper
5354

5455
// Create the EVM account object
5556
evmAccount := statedb.Account{
56-
Nonce: 1, // prevent tx nonce=0 conflicts
57+
Nonce: 1, // prevent tx nonce=0 conflicts
5758
Balance: new(uint256.Int), // zero balance by default
5859
CodeHash: codeHash,
5960
}
@@ -77,9 +78,9 @@ func deployProxyAdminContract(ctx context.Context, evmKeeper types.EVMKeeper, pr
7778

7879
// Create the EVM account object
7980
evmAccount := statedb.Account{
80-
Nonce: 1, // to prevent tx nonce=0 conflicts
81+
Nonce: 1, // to prevent tx nonce=0 conflicts
8182
Balance: new(uint256.Int), // zero balance by default
82-
CodeHash: codeHash, // link to deployed code
83+
CodeHash: codeHash, // link to deployed code
8384
}
8485

8586
// Set the EVM account with the proxy admin contract
@@ -138,15 +139,18 @@ func deploySystemContracts(ctx context.Context, evmKeeper types.EVMKeeper, syste
138139
// EOAs in cosmos/evm carry the keccak256-of-empty-bytes sentinel, so a
139140
// length-only check would treat any touched EOA as a deployed contract and
140141
// silently skip the deploy sequence for that slot (F-2026-17025). Compare
141-
// against the empty-code-hash sentinel via Account.HasCodeHash instead.
142+
// against the empty-code-hash sentinel instead.
143+
//
144+
// GetCodeHash, not GetAccount: GetAccount reads the balance, which resolves the
145+
// EVM coin denom from a global that x/vm only populates in its own PreBlock.
146+
// x/upgrade's PreBlocker runs first, so an upgrade handler calling this through
147+
// GetAccount dereferences a nil coin config and halts the chain. Reading the
148+
// code-hash store directly needs none of that, and a code check has no business
149+
// loading balances anyway.
142150
func isContractDeployed(
143151
ctx sdk.Context,
144152
evmKeeper types.EVMKeeper,
145153
addr common.Address,
146154
) bool {
147-
acc := evmKeeper.GetAccount(ctx, addr)
148-
if acc == nil || len(acc.CodeHash) == 0 {
149-
return false
150-
}
151-
return acc.HasCodeHash()
155+
return evmKeeper.GetCodeHash(ctx, addr) != common.BytesToHash(evmtypes.EmptyCodeHash)
152156
}

x/uregistry/keeper/genesis_internal_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ func (s stubEVMKeeper) GetAccount(_ sdk.Context, addr common.Address) *statedb.A
2727
return s.accounts[addr]
2828
}
2929

30+
// GetCodeHash mirrors the real keeper: absent or code-less accounts report the
31+
// empty-code-hash sentinel rather than a zero hash.
32+
func (s stubEVMKeeper) GetCodeHash(_ sdk.Context, addr common.Address) common.Hash {
33+
acc := s.accounts[addr]
34+
if acc == nil || len(acc.CodeHash) == 0 {
35+
return common.BytesToHash(evmtypes.EmptyCodeHash)
36+
}
37+
return common.BytesToHash(acc.CodeHash)
38+
}
39+
3040
func (stubEVMKeeper) SetAccount(_ sdk.Context, _ common.Address, _ statedb.Account) error {
3141
panic("not used in test")
3242
}
@@ -121,6 +131,14 @@ func (t *trackerEVMKeeper) GetAccount(_ sdk.Context, addr common.Address) *state
121131
return nil
122132
}
123133

134+
func (t *trackerEVMKeeper) GetCodeHash(_ sdk.Context, addr common.Address) common.Hash {
135+
acc, ok := t.accounts[addr]
136+
if !ok || len(acc.CodeHash) == 0 {
137+
return common.BytesToHash(evmtypes.EmptyCodeHash)
138+
}
139+
return common.BytesToHash(acc.CodeHash)
140+
}
141+
124142
func (t *trackerEVMKeeper) SetAccount(_ sdk.Context, addr common.Address, account statedb.Account) error {
125143
t.accounts[addr] = account
126144
return nil

x/uregistry/keeper/keeper.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,17 @@ func (k Keeper) FixReservedBytecode(ctx context.Context) error {
360360

361361
return nil
362362
}
363+
364+
// DeployMissingSystemContracts reserves every address in types.SYSTEM_CONTRACTS
365+
// that does not already hold code.
366+
//
367+
// The genesis loop only runs at InitGenesis, so a slot added to SYSTEM_CONTRACTS
368+
// after a chain launched is never reserved on that chain — an ordinary account can
369+
// take the address, and the module that expects a system contract there finds an
370+
// EOA. This is the upgrade-time counterpart: same map, same bytecode, same
371+
// deterministic order, and the same already-deployed guard, so it deploys only what
372+
// is genuinely missing and is safe to run on a chain where everything is present.
373+
func (k Keeper) DeployMissingSystemContracts(ctx context.Context) error {
374+
deploySystemContracts(ctx, k.evmKeeper, types.SYSTEM_CONTRACTS)
375+
return nil
376+
}

x/uregistry/types/expected_keepers.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import (
99
// EVMKeeper defines the expected interface for the EVM module.
1010
type EVMKeeper interface {
1111
GetAccount(_ sdk.Context, addr common.Address) *statedb.Account
12+
13+
// GetCodeHash reads the code-hash store directly. Unlike GetAccount it does
14+
// not touch balances, so it works before x/vm's PreBlock has configured the
15+
// EVM coin info — which is exactly the situation an upgrade handler runs in.
16+
GetCodeHash(ctx sdk.Context, addr common.Address) common.Hash
1217
SetAccount(ctx sdk.Context, addr common.Address, account statedb.Account) error
1318
SetState(ctx sdk.Context, addr common.Address, key common.Hash, value []byte)
1419
GetCode(_ sdk.Context, codeHash common.Hash) []byte

0 commit comments

Comments
 (0)