Skip to content

Commit 0ac12cc

Browse files
authored
fix: proper statedb isolation in nibiru bank_extension (#2357)
* fix: non tx statedb isolation in nibiru bank_extension * fix: typo * test: coverage of the statedb corruption fixes * chore: lint * chore: changelog
1 parent 1652a06 commit 0ac12cc

File tree

9 files changed

+472
-2
lines changed

9 files changed

+472
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ See https://github.com/dangoslen/changelog-enforcer.
5454
- [#2348](https://github.com/NibiruChain/nibiru/pull/2348) - fix(oracle): max expiration a label rather than an invalidation for additional query liveness
5555
- [#2350](https://github.com/NibiruChain/nibiru/pull/2350) - fix(simapp): sim tests with empty validator set panic
5656
- [#2352](https://github.com/NibiruChain/nibiru/pull/2352) - chore(token-registry): Add bank coin versions of USDC and USDT from Stargate and LayerZero, and update ErisEvm.sol to fix redeem
57-
- [#2354](https://github.com/NibiruChain/nibiru/pull/2354) - chore: linter upgrade to v2
57+
- [#2357](https://github.com/NibiruChain/nibiru/pull/2357) - fix: proper statedb isolation in nibiru bank_extension
5858

5959
### Dependencies
6060
- Bump `form-data` from 4.0.1 to 4.0.4 ([#2347](https://github.com/NibiruChain/nibiru/pull/2347))

evm-e2e/contracts/IFunToken.sol

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.19;
3+
4+
address constant FUNTOKEN_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000800;
5+
IFunToken constant FUNTOKEN_PRECOMPILE = IFunToken(FUNTOKEN_PRECOMPILE_ADDRESS);
6+
7+
import "./NibiruEvmUtils.sol";
8+
9+
/// @notice Implements the functionality for sending ERC20 tokens and bank
10+
/// coins to various Nibiru accounts using either the Nibiru Bech32 address
11+
/// using the "FunToken" mapping between the ERC20 and bank.
12+
interface IFunToken is INibiruEvm {
13+
/// @notice sendToBank sends ERC20 tokens as coins to a Nibiru base account
14+
/// @param erc20 - the address of the ERC20 token contract
15+
/// @param amount - the amount of tokens to send
16+
/// @param to - the receiving Nibiru base account address as a string
17+
/// @return sentAmount - amount of tokens received by the recipient. This may
18+
/// not be equal to `amount` if the corresponding ERC20 contract has a fee or
19+
/// deduction on transfer.
20+
function sendToBank(
21+
address erc20,
22+
uint256 amount,
23+
string calldata to
24+
) external returns (uint256 sentAmount);
25+
26+
/// @notice Retrieves the ERC20 contract address associated with a given bank denomination.
27+
/// @param bankDenom The bank denomination string (e.g., "unibi", "erc20/0x...", "ibc/...").
28+
/// @return erc20Address The corresponding ERC20 contract address, or address(0) if no mapping exists.
29+
function getErc20Address(
30+
string memory bankDenom
31+
) external view returns (address erc20Address);
32+
33+
struct NibiruAccount {
34+
address ethAddr;
35+
string bech32Addr;
36+
}
37+
struct FunToken {
38+
address erc20;
39+
string bankDenom;
40+
}
41+
42+
/// @notice Method "balance" returns the ERC20 balance and Bank Coin balance
43+
/// of some fungible token held by the given account.
44+
function balance(
45+
address who,
46+
address funtoken
47+
)
48+
external
49+
view
50+
returns (
51+
uint256 erc20Balance,
52+
uint256 bankBalance,
53+
FunToken memory token,
54+
NibiruAccount memory whoAddrs
55+
);
56+
57+
/// @notice Method "bankBalance" returns the Bank Coin balance of some
58+
/// fungible token held by the given account.
59+
function bankBalance(
60+
address who,
61+
string calldata bankDenom
62+
)
63+
external
64+
view
65+
returns (uint256 bankBalance, NibiruAccount memory whoAddrs);
66+
67+
/// @notice Method "whoAmI" performs address resolution for the given address
68+
/// string
69+
/// @param who Ethereum hexadecimal (EVM) address or nibi-prefixed Bech32
70+
/// (non-EVM) address
71+
/// @return whoAddrs Addresses of "who" in EVM and non-EVM formats
72+
function whoAmI(
73+
string calldata who
74+
) external view returns (NibiruAccount memory whoAddrs);
75+
76+
/// @notice sendToEvm transfers the caller's Bank Coins specified by `denom`
77+
/// to the corresponding ERC-20 representation on the EVM side. The `to`
78+
/// argument must be either an Ethereum hex address (0x...) or a Bech32
79+
/// address.
80+
///
81+
/// The underlying logic mints (or un-escrows) the ERC-20 tokens to the `to` address if
82+
/// the funtoken mapping was originally minted from a coin.
83+
///
84+
/// @param bankDenom The bank denom of the coin to send from the caller to the EVM side.
85+
/// @param amount The number of coins to send.
86+
/// @param to The Ethereum hex or bech32 address receiving the ERC-20.
87+
/// @return sentAmount The number of ERC-20 tokens minted or un-escrowed.
88+
function sendToEvm(
89+
string calldata bankDenom,
90+
uint256 amount,
91+
string calldata to
92+
) external returns (uint256 sentAmount);
93+
94+
/// @notice bankMsgSend performs a `cosmos.bank.v1beta1.MsgSend` transaction
95+
/// message to transfer Bank Coin funds to the given address.
96+
///
97+
/// @param to The recipient address (hex or bech32).
98+
/// @param bankDenom The bank coin denom to send.
99+
/// @param amount The number of coins to send.
100+
/// @return success True if the bank send succeeded, false otherwise.
101+
function bankMsgSend(
102+
string calldata to,
103+
string calldata bankDenom,
104+
uint256 amount
105+
) external returns (bool success);
106+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.19;
3+
4+
/// @notice Interface defining the AbciEvent for interoperability between
5+
/// Ethereum and the ABCI (Application Blockchain Interface).
6+
interface INibiruEvm {
7+
struct BankCoin {
8+
string denom;
9+
uint256 amount;
10+
}
11+
12+
/// @notice Event emitted to in precompiled contracts to relay information
13+
/// from the ABCI to the EVM logs and indexers. Consumers of this event should
14+
/// decode the `attrs` parameter based on the `eventType` context.
15+
///
16+
/// @param eventType An identifier type of the event, used for indexing.
17+
/// Event types indexable with CometBFT indexer are in snake case like
18+
/// "pending_ethereum_tx" or "message", while protobuf typed events use the
19+
/// proto message name as their event type (e.g.
20+
/// "eth.evm.v1.EventEthereumTx").
21+
/// @param abciEvent JSON object string with the event type and fields of an
22+
/// ABCI event.
23+
event AbciEvent(string indexed eventType, string abciEvent);
24+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it } from "@jest/globals"
2+
import { Contract, Wallet } from "ethers"
3+
import * as fs from "fs"
4+
5+
import { account, provider, TEST_TIMEOUT } from "./setup"
6+
7+
// Load the full ABI from the artifact file
8+
const contractArtifact = JSON.parse(
9+
fs.readFileSync('./artifacts/contracts/IFunToken.sol/IFunToken.json', 'utf8')
10+
)
11+
12+
const FUNTOKEN_PRECOMPILE = "0x0000000000000000000000000000000000000800"
13+
14+
describe("StateDB corruption test", () => {
15+
it(
16+
"concurrent simulations don't corrupt StateDB during transactions",
17+
async () => {
18+
const contract = new Contract(FUNTOKEN_PRECOMPILE, contractArtifact.abi, account)
19+
const recipient = Wallet.createRandom()
20+
21+
// Get initial balances
22+
const senderBalanceBefore = await provider.getBalance(account.address)
23+
const recipientBalanceBefore = await provider.getBalance(recipient.address)
24+
25+
const SIMULATION_COUNT = 100
26+
const TX_COUNT = 10
27+
const TX_AMOUNT = 1 // 1 unibi
28+
const SIMULATION_AMOUNT = 1000 // 1000 unibi
29+
30+
// Run aggressive simulations
31+
const runSimulations = async (): Promise<void> => {
32+
const promises = []
33+
34+
for (let i = 0; i < SIMULATION_COUNT; i++) {
35+
if (i % 2 === 0) {
36+
promises.push(
37+
contract.bankMsgSend.estimateGas(recipient.address, "unibi", SIMULATION_AMOUNT)
38+
.catch(() => {})
39+
)
40+
} else {
41+
promises.push(
42+
contract.bankMsgSend.staticCall(recipient.address, "unibi", SIMULATION_AMOUNT)
43+
.catch(() => {})
44+
)
45+
}
46+
}
47+
48+
await Promise.all(promises)
49+
}
50+
51+
// Start continuous simulations
52+
let simulationRunning = true
53+
const simulationPromise = (async () => {
54+
while (simulationRunning) {
55+
await runSimulations()
56+
await new Promise(resolve => setTimeout(resolve, 1))
57+
}
58+
})()
59+
60+
// Wait for simulations to start
61+
await new Promise(resolve => setTimeout(resolve, 50))
62+
63+
// Send real transactions
64+
const currentNonce = await provider.getTransactionCount(account.address, 'pending')
65+
const txPromises = []
66+
67+
for (let i = 0; i < TX_COUNT; i++) {
68+
const tx = contract.bankMsgSend(
69+
recipient.address,
70+
"unibi",
71+
TX_AMOUNT,
72+
{
73+
gasLimit: 1000000,
74+
nonce: currentNonce + i
75+
}
76+
)
77+
78+
txPromises.push(tx)
79+
}
80+
81+
const transactions = await Promise.all(txPromises)
82+
const receipts = await Promise.all(transactions.map(tx => tx.wait()))
83+
84+
// Stop simulations
85+
simulationRunning = false
86+
await simulationPromise
87+
88+
// Get final balances
89+
const senderBalanceAfter = await provider.getBalance(account.address)
90+
const recipientBalanceAfter = await provider.getBalance(recipient.address)
91+
92+
// Assert balances - expecting 10 unibi = 10 * 10^12 wei
93+
const totalSentWei = BigInt(TX_AMOUNT * TX_COUNT) * BigInt(10 ** 12) // 10 unibi in wei
94+
expect(recipientBalanceAfter - recipientBalanceBefore).toEqual(totalSentWei)
95+
96+
// Sender balance should be reduced by 10 * 10^12 wei + gas fees
97+
expect(senderBalanceBefore - senderBalanceAfter).toBeGreaterThan(totalSentWei)
98+
},
99+
TEST_TIMEOUT * 3,
100+
)
101+
})

x/evm/keeper/bank_extension.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ func (bk *NibiruBankKeeper) SyncStateDBWithAccount(
235235
ctx sdk.Context, acc sdk.AccAddress,
236236
) {
237237
// If there's no StateDB set, it means we're not in an EthereumTx.
238-
if bk.StateDB == nil {
238+
if bk.StateDB == nil || !IsDeliverTx(ctx) {
239239
return
240240
}
241241

x/evm/keeper/bank_extension_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package keeper_test
33
import (
44
"encoding/json"
55
"fmt"
6+
"math/big"
67

78
sdkmath "cosmossdk.io/math"
89
sdk "github.com/cosmos/cosmos-sdk/types"
@@ -11,6 +12,9 @@ import (
1112
gethparams "github.com/ethereum/go-ethereum/params"
1213
"github.com/rs/zerolog/log"
1314

15+
"github.com/NibiruChain/nibiru/v2/eth"
16+
"github.com/NibiruChain/nibiru/v2/x/evm/keeper"
17+
1418
"github.com/NibiruChain/nibiru/v2/x/common/testutil/testapp"
1519
"github.com/NibiruChain/nibiru/v2/x/evm"
1620
"github.com/NibiruChain/nibiru/v2/x/evm/embeds"
@@ -425,3 +429,82 @@ func (s *Suite) TestStateDBReadonlyInvariant() {
425429
s.True(first == db.StateDB, db.Explanation)
426430
}
427431
}
432+
433+
// TestSyncStateDBWithAccount_DeliverTxCheck ensures that SyncStateDBWithAccount
434+
// only syncs during DeliverTx, not during CheckTx, ReCheckTx, or simulations
435+
func (s *Suite) TestSyncStateDBWithAccount_DeliverTxCheck() {
436+
deps := evmtest.NewTestDeps()
437+
438+
testAddr := evmtest.NewEthPrivAcc()
439+
440+
// Fund the account with some NIBI
441+
fundCoins := sdk.NewCoins(sdk.NewInt64Coin(evm.EVMBankDenom, 1000))
442+
s.NoError(testapp.FundAccount(deps.App.BankKeeper, deps.Ctx, testAddr.NibiruAddr, fundCoins))
443+
444+
testCases := []struct {
445+
name string
446+
setupContext func(ctx sdk.Context) sdk.Context
447+
shouldSync bool
448+
expectedBalance string
449+
}{
450+
{
451+
name: "DeliverTx context - should sync",
452+
setupContext: func(ctx sdk.Context) sdk.Context {
453+
// Default context is DeliverTx
454+
return ctx
455+
},
456+
shouldSync: true,
457+
expectedBalance: evm.NativeToWei(fundCoins[0].Amount.BigInt()).String(),
458+
},
459+
{
460+
name: "CheckTx context - should NOT sync",
461+
setupContext: func(ctx sdk.Context) sdk.Context {
462+
return ctx.WithIsCheckTx(true)
463+
},
464+
shouldSync: false,
465+
expectedBalance: "0", // Should remain 0 as sync is skipped
466+
},
467+
{
468+
name: "ReCheckTx context - should NOT sync",
469+
setupContext: func(ctx sdk.Context) sdk.Context {
470+
return ctx.WithIsReCheckTx(true)
471+
},
472+
shouldSync: false,
473+
expectedBalance: "0",
474+
},
475+
{
476+
name: "Simulation context - should NOT sync",
477+
setupContext: func(ctx sdk.Context) sdk.Context {
478+
// Mark context as simulation using SimulationContextKey
479+
return ctx.WithValue(keeper.SimulationContextKey, true)
480+
},
481+
shouldSync: false,
482+
expectedBalance: "0",
483+
},
484+
}
485+
486+
for _, tc := range testCases {
487+
s.Run(tc.name, func() {
488+
// Create a fresh StateDB for each test
489+
deps.EvmKeeper.Bank.StateDB = deps.NewStateDB()
490+
491+
// Set initial balance to 0 in StateDB
492+
ethAddr := eth.NibiruAddrToEthAddr(testAddr.NibiruAddr)
493+
deps.EvmKeeper.Bank.StateDB.SetBalanceWei(ethAddr, big.NewInt(0))
494+
495+
// Apply context modifications
496+
testCtx := tc.setupContext(deps.Ctx)
497+
498+
// Call SyncStateDBWithAccount
499+
deps.EvmKeeper.Bank.SyncStateDBWithAccount(testCtx, testAddr.NibiruAddr)
500+
501+
// Check if balance was synced
502+
actualBalance := deps.EvmKeeper.Bank.StateDB.GetBalance(ethAddr)
503+
s.Equal(tc.expectedBalance, actualBalance.String(),
504+
"Balance sync behavior incorrect for %s", tc.name)
505+
506+
// Clean up
507+
deps.EvmKeeper.Bank.StateDB = nil
508+
})
509+
}
510+
}

x/evm/keeper/grpc_query.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ func (k *Keeper) EthCall(
265265
}
266266

267267
ctx := sdk.UnwrapSDKContext(goCtx)
268+
ctx = ctx.WithValue(SimulationContextKey, true)
268269

269270
var args evm.JsonTxArgs
270271
err := json.Unmarshal(req.Args, &args)
@@ -325,6 +326,7 @@ func (k Keeper) EstimateGasForEvmCallType(
325326
}
326327

327328
ctx := sdk.UnwrapSDKContext(goCtx)
329+
ctx = ctx.WithValue(SimulationContextKey, true)
328330
evmCfg := k.GetEVMConfig(ctx)
329331

330332
if req.GasCap < gethparams.TxGas {
@@ -480,6 +482,7 @@ func (k Keeper) TraceTx(
480482
contextHeight := max(req.BlockNumber, 1)
481483

482484
ctx := sdk.UnwrapSDKContext(goCtx)
485+
ctx = ctx.WithValue(SimulationContextKey, true)
483486
ctx = ctx.WithBlockHeight(contextHeight)
484487
ctx = ctx.WithBlockTime(req.BlockTime)
485488
ctx = ctx.WithHeaderHash(gethcommon.Hex2Bytes(req.BlockHash))
@@ -576,6 +579,7 @@ func (k Keeper) TraceCall(
576579
contextHeight := max(req.BlockNumber, 1)
577580

578581
ctx := sdk.UnwrapSDKContext(goCtx)
582+
ctx = ctx.WithValue(SimulationContextKey, true)
579583
ctx = ctx.WithBlockHeight(contextHeight)
580584
ctx = ctx.WithBlockTime(req.BlockTime)
581585
ctx = ctx.WithHeaderHash(gethcommon.Hex2Bytes(req.BlockHash))
@@ -665,6 +669,7 @@ func (k Keeper) TraceBlock(
665669
WithConsensusParams(&cmtproto.ConsensusParams{
666670
Block: &cmtproto.BlockParams{MaxGas: req.BlockMaxGas},
667671
})
672+
ctx = ctx.WithValue(SimulationContextKey, true)
668673

669674
evmCfg := k.GetEVMConfig(ctx)
670675

0 commit comments

Comments
 (0)