Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 17 additions & 20 deletions x/evm/precompiles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ func (c *Contract) Run(evm *vm.EVM, contract *vm.Contract, readonly bool) ([]byt
- **Balance reconciliation** — a `BalanceHandlerFactory(bankKeeper)` translates the
bank `coin_spent` / `coin_received` events emitted during the call into
`StateDB.SubBalance` / `AddBalance`, keeping the EVM stateObject balances in sync
with the keeper coin moves. **All 11 precompiles wire this** (see the inflation
note below).
with the keeper coin moves. **All 11 precompiles wire this** (see below).
- **Atomic revert** — the multistore is snapshotted (`AddPrecompileFn`) so an outer
EVM-frame revert rolls back the keeper writes; there are no partial writes.
- **Gas metering** — the SDK gas meter is re-capped to `contract.Gas`, so store
Expand All @@ -52,22 +51,19 @@ func (c *Contract) Run(evm *vm.EVM, contract *vm.Contract, readonly bool) ([]byt
with the reason ABI-encoded in the return data (decode with
`abi.UnpackRevert(res.Ret)`), not as a raw string in `VmError`.

## Why every precompile wires the balance handler (native-token inflation)
## Why every precompile wires the balance handler

Before the native-action migration, precompiles used the legacy Greenfield
`GetCacheContext → keeper write → commit` pattern: a keeper coin move updated the
**bank store** but not the EVM **StateDB stateObject** balance. With EIP-7702
active from genesis, an attacker could self-delegate an EOA to a contract that
calls `bank.send`, leaving the EOA a dirty stateObject with a stale balance;
`StateDB.Commit` then reconciled it by **minting the debited amount back** — net
total-supply inflation, repeatable per block.
A keeper coin move updates the bank store, but the EVM `StateDB` also caches an
account's balance in its stateObject. If the two are not kept in sync during a
precompile call, `StateDB.Commit` can reconcile them against a stale value. The
`BalanceHandler` translates the bank events emitted during the call into `StateDB`
balance updates so the two stay consistent. Because bank / staking / distribution /
gov / payment / storage / storageprovider / virtualgroup precompiles all move
coins, **all 11** wire the balance handler, not just `bank`.

The `BalanceHandler` keeps the stateObject reconciled so `Commit` has zero delta
to mint. Because staking / distribution / gov / payment / storage precompiles also
move coins, **all 11** wire the balance handler, not just `bank`.

Regression guard: `TestBankSend_NoSupplyInflation` asserts a precompile `bank.send`
leaves total supply flat.
Regression guards assert that a precompile transfer leaves total bank supply
unchanged: `TestBankSend_TotalSupplyInvariant`, `TestDelegate_TotalSupplyInvariant`,
`TestDeposit_TotalSupplyInvariant`.

## Why native value is still rejected

Expand All @@ -89,10 +85,11 @@ upgrade — it is intentionally **not** part of the native-action migration.

Regression / characterization coverage layered on top of the migration:

- `bank`: dispatch success, **no-supply-inflation invariant**, native revert on failure.
- `bank` / `staking` / `payment`: **total-supply-invariant** guards, plus bank dispatch
success and native revert on failure.
- `storage`: `createGroup` dispatch success, EOA-only rejection, failure-does-not-mutate.
- `storageprovider`: `updateSPPrice` decode + EVM-apply dispatch.

Follow-ups: total-supply-invariant guards for the other coin-moving precompiles
(staking / distribution / gov / payment), and a type-4 (7702) end-to-end inflation
reproduction.
Follow-ups: total-supply-invariant guards for the remaining coin-moving precompiles
(distribution / gov / storageprovider / virtualgroup), and an end-to-end variant
across a full transaction.
27 changes: 9 additions & 18 deletions x/evm/precompiles/bank/tx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,12 @@ func (s *PrecompileTestSuite) TestBankSend_EVMDispatchSuccess() {
s.Require().Equal(math.NewInt(12345), s.balance(sdk.AccAddress(receiver.Bytes())))
}

// TestBankSend_NoSupplyInflation is the regression guard for the native-token
// inflation fixed by #332.
//
// The exploit needs the sender to be an EIP-7702-delegated EOA: giving the sender
// code makes its EVM stateObject balance authoritative at StateDB.Commit. Without
// the fix, the keeper coin move debits the sender's bank balance but leaves the
// stale stateObject balance untouched; Commit then reconciles by minting the
// debited amount back to the sender — total supply inflates by exactly the sent
// amount. (Verified: on pre-#332 bank code this test fails with supply += 5e8;
// a plain send without SetCode does NOT reproduce it, so the SetCode is load-bearing.)
//
// The BalanceHandler (RunNativeAction) keeps the stateObject reconciled during the
// call, so Commit mints nothing and total supply stays flat.
func (s *PrecompileTestSuite) TestBankSend_NoSupplyInflation() {
// TestBankSend_TotalSupplyInvariant asserts that a bank.send through the
// precompile leaves total bank supply unchanged (a transfer must not change
// supply). The sender is given code and its stateObject is loaded before the
// call so the assertion exercises the keeper/StateDB balance path; without that
// setup the check would pass regardless of that path.
func (s *PrecompileTestSuite) TestBankSend_TotalSupplyInvariant() {
s.mustEnableStaticPrecompiles()

supplyBefore := s.app.BankKeeper.GetSupply(s.ctx, utils.BaseDenom).Amount
Expand All @@ -105,17 +97,16 @@ func (s *PrecompileTestSuite) TestBankSend_NoSupplyInflation() {

precompileAddr := bank.GetAddress()
stateDB := statedb.New(s.ctx, s.app.EvmKeeper, statedb.NewEmptyTxConfig())
// Make the sender a 7702-style delegated account (has code) and load its
// stateObject, so its cached balance is authoritative at Commit — this is the
// condition under which the pre-fix reconciliation minted the debited amount.
// Give the sender code and load its stateObject before the call so the test
// exercises the balance path; otherwise the assertion would be trivial.
stateDB.SetCode(s.address, []byte{0x60, 0x00})
_ = stateDB.GetBalance(s.address)
res, err := s.app.EvmKeeper.CallEVMWithData(s.ctx, stateDB, s.address, &precompileAddr, input, true, false, nil)
s.Require().NoError(err)
s.Require().False(res.Failed(), "evm call reverted: %s", res.VmError)

supplyAfter := s.app.BankKeeper.GetSupply(s.ctx, utils.BaseDenom).Amount
s.Require().Equal(supplyBefore.String(), supplyAfter.String(), "bank.send must not inflate total supply")
s.Require().Equal(supplyBefore.String(), supplyAfter.String(), "total bank supply must be unchanged")
}

// TestBankSend_FailureRevertsCleanly pins the native revert semantics: an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ import (
paymenttypes "github.com/mocachain/moca/v2/x/payment/types"
)

type InflationTestSuite struct {
type SupplyTestSuite struct {
suite.Suite
ctx sdk.Context
app *app.Moca
address common.Address
}

func TestInflationTestSuite(t *testing.T) {
suite.Run(t, new(InflationTestSuite))
func TestSupplyTestSuite(t *testing.T) {
suite.Run(t, new(SupplyTestSuite))
}

func (s *InflationTestSuite) SetupTest() {
func (s *SupplyTestSuite) SetupTest() {
checkTx := false
chainID := utils.TestnetChainID + "-1"

Expand Down Expand Up @@ -62,12 +62,12 @@ func (s *InflationTestSuite) SetupTest() {
s.Require().NoError(err)
}

// TestDeposit_NoSupplyInflation is the native-token-inflation regression guard
// for the payment precompile (deposit moves coins depositor -> payment module).
// The depositor is made a 7702-style delegated account (SetCode) so its
// stateObject balance is authoritative at Commit; without the BalanceHandler
// reconciliation, Commit would mint the debited amount back.
func (s *InflationTestSuite) TestDeposit_NoSupplyInflation() {
// TestDeposit_TotalSupplyInvariant asserts that a deposit through the payment
// precompile (which moves coins depositor -> payment module) leaves total bank
// supply unchanged. The depositor is given code and its stateObject is loaded
// before the call so the assertion exercises the keeper/StateDB balance path;
// without that setup the check would pass regardless of that path.
func (s *SupplyTestSuite) TestDeposit_TotalSupplyInvariant() {
// EthSetup's genesis leaves payment params zero-valued, so install defaults
// (DefaultFeeDenom is the base denom "amoca") before depositing.
s.Require().NoError(s.app.PaymentKeeper.SetParams(s.ctx, paymenttypes.DefaultParams()))
Expand All @@ -81,26 +81,26 @@ func (s *InflationTestSuite) TestDeposit_NoSupplyInflation() {
input := s.mustPackDepositInput(to, big.NewInt(100_000))
precompileAddr := payment.GetAddress()
stateDB := statedb.New(s.ctx, s.app.EvmKeeper, statedb.NewEmptyTxConfig())
// 7702-style: give the depositor code and load its stateObject so its cached
// balance is authoritative at Commit (the inflation trigger).
// Give the depositor code and load its stateObject before the call so the
// test exercises the balance path; otherwise the assertion would be trivial.
stateDB.SetCode(s.address, []byte{0x60, 0x00})
_ = stateDB.GetBalance(s.address)
res, err := s.app.EvmKeeper.CallEVMWithData(s.ctx, stateDB, s.address, &precompileAddr, input, true, false, nil)
s.Require().NoError(err)
s.Require().False(res.Failed(), "evm call reverted: %s", res.VmError)

supplyAfter := s.app.BankKeeper.GetSupply(s.ctx, utils.BaseDenom).Amount
s.Require().Equal(supplyBefore.String(), supplyAfter.String(), "deposit must not inflate total supply")
s.Require().Equal(supplyBefore.String(), supplyAfter.String(), "total bank supply must be unchanged")
}

func (s *InflationTestSuite) mustEnableStaticPrecompiles() {
func (s *SupplyTestSuite) mustEnableStaticPrecompiles() {
evmParams := s.app.EvmKeeper.GetParams(s.ctx)
evmParams.EvmDenom = utils.BaseDenom
evmParams.ActiveStaticPrecompiles = app.MocaActiveStaticPrecompiles()
s.Require().NoError(s.app.EvmKeeper.SetParams(s.ctx, evmParams))
}

func (s *InflationTestSuite) mustPackDepositInput(to string, amount *big.Int) []byte {
func (s *SupplyTestSuite) mustPackDepositInput(to string, amount *big.Int) []byte {
method := payment.GetAbiMethod(payment.DepositMethodName)
packedArgs, err := method.Inputs.Pack(to, amount)
s.Require().NoError(err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,18 @@ import (
"github.com/mocachain/moca/v2/x/evm/precompiles/staking"
)

type InflationTestSuite struct {
type SupplyTestSuite struct {
suite.Suite
ctx sdk.Context
app *app.Moca
address common.Address
}

func TestInflationTestSuite(t *testing.T) {
suite.Run(t, new(InflationTestSuite))
func TestSupplyTestSuite(t *testing.T) {
suite.Run(t, new(SupplyTestSuite))
}

func (s *InflationTestSuite) SetupTest() {
func (s *SupplyTestSuite) SetupTest() {
checkTx := false
chainID := utils.TestnetChainID + "-1"

Expand Down Expand Up @@ -64,12 +64,12 @@ func (s *InflationTestSuite) SetupTest() {
s.Require().NoError(err)
}

// TestDelegate_NoSupplyInflation is the native-token-inflation regression guard
// for the staking precompile (delegate moves coins delegator -> bonded pool).
// Like the bank guard, the delegator is made a 7702-style delegated account
// (SetCode) so its stateObject balance is authoritative at Commit; without the
// BalanceHandler reconciliation, Commit would mint the debited amount back.
func (s *InflationTestSuite) TestDelegate_NoSupplyInflation() {
// TestDelegate_TotalSupplyInvariant asserts that a delegate through the staking
// precompile (which moves coins delegator -> bonded pool) leaves total bank
// supply unchanged. The delegator is given code and its stateObject is loaded
// before the call so the assertion exercises the keeper/StateDB balance path;
// without that setup the check would pass regardless of that path.
func (s *SupplyTestSuite) TestDelegate_TotalSupplyInvariant() {
// Bond in the base denom so the funded delegator can stake it.
zeroDec := math.LegacyZeroDec()
stakingParams, err := s.app.StakingKeeper.GetParams(s.ctx)
Expand All @@ -96,26 +96,26 @@ func (s *InflationTestSuite) TestDelegate_NoSupplyInflation() {
input := s.mustPackDelegateInput(common.BytesToAddress(valAddr.Bytes()), big.NewInt(100_000))
precompileAddr := staking.GetAddress()
stateDB := statedb.New(s.ctx, s.app.EvmKeeper, statedb.NewEmptyTxConfig())
// 7702-style: give the delegator code and load its stateObject so its cached
// balance is authoritative at Commit (the inflation trigger).
// Give the delegator code and load its stateObject before the call so the
// test exercises the balance path; otherwise the assertion would be trivial.
stateDB.SetCode(s.address, []byte{0x60, 0x00})
_ = stateDB.GetBalance(s.address)
res, err := s.app.EvmKeeper.CallEVMWithData(s.ctx, stateDB, s.address, &precompileAddr, input, true, false, nil)
s.Require().NoError(err)
s.Require().False(res.Failed(), "evm call reverted: %s", res.VmError)

supplyAfter := s.app.BankKeeper.GetSupply(s.ctx, utils.BaseDenom).Amount
s.Require().Equal(supplyBefore.String(), supplyAfter.String(), "delegate must not inflate total supply")
s.Require().Equal(supplyBefore.String(), supplyAfter.String(), "total bank supply must be unchanged")
}

func (s *InflationTestSuite) mustEnableStaticPrecompiles() {
func (s *SupplyTestSuite) mustEnableStaticPrecompiles() {
evmParams := s.app.EvmKeeper.GetParams(s.ctx)
evmParams.EvmDenom = utils.BaseDenom
evmParams.ActiveStaticPrecompiles = app.MocaActiveStaticPrecompiles()
s.Require().NoError(s.app.EvmKeeper.SetParams(s.ctx, evmParams))
}

func (s *InflationTestSuite) mustPackDelegateInput(validator common.Address, amount *big.Int) []byte {
func (s *SupplyTestSuite) mustPackDelegateInput(validator common.Address, amount *big.Int) []byte {
method := staking.MustMethod(staking.DelegateMethodName)
packedArgs, err := method.Inputs.Pack(validator, amount)
s.Require().NoError(err)
Expand Down
Loading