diff --git a/CHANGELOG.md b/CHANGELOG.md index e51309494..4f608dd7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#1949](https://github.com/NibiruChain/nibiru/pull/1949) - feat(evm): add fungible token mapping queries - [#1950](https://github.com/NibiruChain/nibiru/pull/1950) - feat(evm): Tx to create FunToken mapping from ERC20, contract embeds, and ERC20 queries. - [#1958](https://github.com/NibiruChain/nibiru/pull/1958) - chore(evm): wiped deprecated evm apis: miner, personal +- [#1959](https://github.com/NibiruChain/nibiru/pull/1959) - feat(evm): Add precompile to the EVM that enables trasnfers of ERC20 tokens to "nibi" accounts as regular Ethereum transactions #### Dapp modules: perp, spot, oracle, etc diff --git a/app/app.go b/app/app.go index a5bc73557..60ba12494 100644 --- a/app/app.go +++ b/app/app.go @@ -13,6 +13,7 @@ import ( "github.com/NibiruChain/nibiru/app/ante" "github.com/NibiruChain/nibiru/app/wasmext" + "github.com/NibiruChain/nibiru/x/evm/precompile" dbm "github.com/cometbft/cometbft-db" abci "github.com/cometbft/cometbft/abci/types" @@ -185,6 +186,9 @@ func NewNibiruApp( skipGenesisInvariants := cast.ToBool( appOpts.Get(crisis.FlagSkipGenesisInvariants)) + precompilesToAdd := precompile.InitPrecompiles(app.AppKeepers.PublicKeepers) + app.EvmKeeper.AddPrecompiles(precompilesToAdd) + app.initModuleManager(encodingConfig, skipGenesisInvariants) app.setupUpgrades() diff --git a/app/keepers.go b/app/keepers.go index be8e4a8c0..9c6c0e513 100644 --- a/app/keepers.go +++ b/app/keepers.go @@ -105,6 +105,7 @@ import ( // --------------------------------------------------------------- // Nibiru Custom Modules + "github.com/NibiruChain/nibiru/app/keepers" "github.com/NibiruChain/nibiru/eth" "github.com/NibiruChain/nibiru/x/common" "github.com/NibiruChain/nibiru/x/devgas/v1" @@ -134,22 +135,17 @@ import ( ) type AppKeepers struct { - // AccountKeeper encodes/decodes accounts using the go-amino (binary) encoding/decoding library - AccountKeeper authkeeper.AccountKeeper - // BankKeeper defines a module interface that facilitates the transfer of coins between accounts - BankKeeper bankkeeper.Keeper + keepers.PublicKeepers + privateKeepers +} + +type privateKeepers struct { capabilityKeeper *capabilitykeeper.Keeper - StakingKeeper *stakingkeeper.Keeper slashingKeeper slashingkeeper.Keeper - /* DistrKeeper is the keeper of the distribution store */ - DistrKeeper distrkeeper.Keeper - GovKeeper govkeeper.Keeper - crisisKeeper crisiskeeper.Keeper - upgradeKeeper upgradekeeper.Keeper - paramsKeeper paramskeeper.Keeper - authzKeeper authzkeeper.Keeper - FeeGrantKeeper feegrantkeeper.Keeper - ConsensusParamsKeeper consensusparamkeeper.Keeper + crisisKeeper crisiskeeper.Keeper + upgradeKeeper upgradekeeper.Keeper + paramsKeeper paramskeeper.Keeper + authzKeeper authzkeeper.Keeper // -------------------------------------------------------------------- // IBC keepers @@ -167,31 +163,6 @@ type AppKeepers struct { ibcTransferKeeper ibctransferkeeper.Keeper icaControllerKeeper icacontrollerkeeper.Keeper icaHostKeeper icahostkeeper.Keeper - - // make scoped keepers public for test purposes - ScopedIBCKeeper capabilitykeeper.ScopedKeeper - ScopedICAControllerKeeper capabilitykeeper.ScopedKeeper - ScopedICAHostKeeper capabilitykeeper.ScopedKeeper - ScopedTransferKeeper capabilitykeeper.ScopedKeeper - - // make IBC modules public for test purposes - // these modules are never directly routed to by the IBC Router - FeeMockModule ibcmock.IBCModule - - // --------------- - // Nibiru keepers - // --------------- - EpochsKeeper epochskeeper.Keeper - OracleKeeper oraclekeeper.Keeper - InflationKeeper inflationkeeper.Keeper - SudoKeeper keeper.Keeper - DevGasKeeper devgaskeeper.Keeper - TokenFactoryKeeper tokenfactorykeeper.Keeper - EvmKeeper evmkeeper.Keeper - - // WASM keepers - WasmKeeper wasmkeeper.Keeper - ScopedWasmKeeper capabilitykeeper.ScopedKeeper } func initStoreKeys() ( diff --git a/app/keepers/all_keepers.go b/app/keepers/all_keepers.go new file mode 100644 index 000000000..b0ea44922 --- /dev/null +++ b/app/keepers/all_keepers.go @@ -0,0 +1,71 @@ +package keepers + +import ( + wasmkeeper "github.com/CosmWasm/wasmd/x/wasm/keeper" + _ "github.com/cosmos/cosmos-sdk/client/docs/statik" + authkeeper "github.com/cosmos/cosmos-sdk/x/auth/keeper" + bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper" + capabilitykeeper "github.com/cosmos/cosmos-sdk/x/capability/keeper" + consensusparamkeeper "github.com/cosmos/cosmos-sdk/x/consensus/keeper" + distrkeeper "github.com/cosmos/cosmos-sdk/x/distribution/keeper" + feegrantkeeper "github.com/cosmos/cosmos-sdk/x/feegrant/keeper" + govkeeper "github.com/cosmos/cosmos-sdk/x/gov/keeper" + + stakingkeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper" + + // --------------------------------------------------------------- + // IBC imports + + ibcmock "github.com/cosmos/ibc-go/v7/testing/mock" + + // --------------------------------------------------------------- + // Nibiru Custom Modules + + devgaskeeper "github.com/NibiruChain/nibiru/x/devgas/v1/keeper" + epochskeeper "github.com/NibiruChain/nibiru/x/epochs/keeper" + evmkeeper "github.com/NibiruChain/nibiru/x/evm/keeper" + inflationkeeper "github.com/NibiruChain/nibiru/x/inflation/keeper" + oraclekeeper "github.com/NibiruChain/nibiru/x/oracle/keeper" + + "github.com/NibiruChain/nibiru/x/sudo/keeper" + + tokenfactorykeeper "github.com/NibiruChain/nibiru/x/tokenfactory/keeper" +) + +type PublicKeepers struct { + // AccountKeeper encodes/decodes accounts using the go-amino (binary) encoding/decoding library + AccountKeeper authkeeper.AccountKeeper + // BankKeeper defines a module interface that facilitates the transfer of coins between accounts + BankKeeper bankkeeper.Keeper + StakingKeeper *stakingkeeper.Keeper + /* DistrKeeper is the keeper of the distribution store */ + DistrKeeper distrkeeper.Keeper + GovKeeper govkeeper.Keeper + FeeGrantKeeper feegrantkeeper.Keeper + ConsensusParamsKeeper consensusparamkeeper.Keeper + + // make scoped keepers public for test purposes + ScopedIBCKeeper capabilitykeeper.ScopedKeeper + ScopedICAControllerKeeper capabilitykeeper.ScopedKeeper + ScopedICAHostKeeper capabilitykeeper.ScopedKeeper + ScopedTransferKeeper capabilitykeeper.ScopedKeeper + + // make IBC modules public for test purposes + // these modules are never directly routed to by the IBC Router + FeeMockModule ibcmock.IBCModule + + // --------------- + // Nibiru keepers + // --------------- + EpochsKeeper epochskeeper.Keeper + OracleKeeper oraclekeeper.Keeper + InflationKeeper inflationkeeper.Keeper + SudoKeeper keeper.Keeper + DevGasKeeper devgaskeeper.Keeper + TokenFactoryKeeper tokenfactorykeeper.Keeper + EvmKeeper evmkeeper.Keeper + + // WASM keepers + WasmKeeper wasmkeeper.Keeper + ScopedWasmKeeper capabilitykeeper.ScopedKeeper +} diff --git a/e2e/evm/contracts/FunToken.sol b/e2e/evm/contracts/TestERC20.sol similarity index 100% rename from e2e/evm/contracts/FunToken.sol rename to e2e/evm/contracts/TestERC20.sol diff --git a/e2e/evm/contracts/FunTokenCompiled.json b/e2e/evm/contracts/TestERC20Compiled.json similarity index 99% rename from e2e/evm/contracts/FunTokenCompiled.json rename to e2e/evm/contracts/TestERC20Compiled.json index f7aaeb730..f5ecf6af5 100644 --- a/e2e/evm/contracts/FunTokenCompiled.json +++ b/e2e/evm/contracts/TestERC20Compiled.json @@ -1,7 +1,7 @@ { "_format": "hh-sol-artifact-1", - "contractName": "FunToken", - "sourceName": "contracts/FunToken.sol", + "contractName": "TestERC20", + "sourceName": "contracts/TestERC20.sol", "abi": [ { "inputs": [], diff --git a/e2e/evm/test/erc20.test.ts b/e2e/evm/test/erc20.test.ts index 73023b218..d9ad78538 100644 --- a/e2e/evm/test/erc20.test.ts +++ b/e2e/evm/test/erc20.test.ts @@ -6,7 +6,7 @@ import { FunTokenCompiled } from "../types/ethers-contracts" describe("ERC-20 contract tests", () => { it("send, balanceOf", async () => { const contract = (await deployContract( - "FunTokenCompiled.json", + "TestERC20Compiled.json", )) as FunTokenCompiled const contractAddress = await contract.getAddress() expect(contractAddress).toBeDefined() diff --git a/eth/rpc/rpcapi/eth_api_test.go b/eth/rpc/rpcapi/eth_api_test.go index 35288b4e8..84eebdb2e 100644 --- a/eth/rpc/rpcapi/eth_api_test.go +++ b/eth/rpc/rpcapi/eth_api_test.go @@ -64,7 +64,7 @@ func (s *TestSuite) SetupSuite() { s.network = network s.ethClient = network.Validators[0].JSONRPCClient - s.contractData, err = embeds.SmartContract_FunToken.Load() + s.contractData, err = embeds.SmartContract_TestERC20.Load() s.Require().NoError(err) testAccPrivateKey, _ := crypto.GenerateKey() diff --git a/x/common/set/set.go b/x/common/set/set.go index 6b05a9055..eb33ff4a7 100644 --- a/x/common/set/set.go +++ b/x/common/set/set.go @@ -34,3 +34,9 @@ func New[T comparable](strs ...T) Set[T] { } return set } + +func (set Set[T]) AddMulti(sMulti ...T) { + for _, s := range sMulti { + set[s] = struct{}{} + } +} diff --git a/x/evm/deps.go b/x/evm/deps.go index 92bc38fb9..862f2cf6b 100644 --- a/x/evm/deps.go +++ b/x/evm/deps.go @@ -13,6 +13,16 @@ import ( // AccountKeeper defines the expected account keeper interface type AccountKeeper interface { NewAccountWithAddress(ctx sdk.Context, addr sdk.AccAddress) authtypes.AccountI + + // GetModuleAccount gets the module account from the auth account store, if the + // account does not exist in the AccountKeeper, then it is created. This + // differs from the "GetModuleAddress" function, which performs a pure + // computation. + GetModuleAccount(ctx sdk.Context, moduleName string) authtypes.ModuleAccountI + + // GetModuleAddress returns an address based on the module name, however it + // does not modify state at all. To create initialize the module account, + // instead use "GetModuleAccount". GetModuleAddress(moduleName string) sdk.AccAddress GetAllAccounts(ctx sdk.Context) (accounts []authtypes.AccountI) IterateAccounts(ctx sdk.Context, cb func(account authtypes.AccountI) bool) diff --git a/x/evm/embeds/.gitignore b/x/evm/embeds/.gitignore new file mode 100644 index 000000000..c18741d1f --- /dev/null +++ b/x/evm/embeds/.gitignore @@ -0,0 +1,22 @@ +node_modules +.env + +contracts +ignition +bun.lockb +package-lock.json + +# Hardhat files +/cache +/artifacts + +# TypeChain files +/typechain +/typechain-types + +# solidity-coverage files +/coverage +/coverage.json + +# Hardhat Ignition default folder for deployments against a local node +ignition/deployments/chain-31337 diff --git a/x/evm/embeds/IFunToken.sol b/x/evm/embeds/IFunToken.sol new file mode 100644 index 000000000..db1757f1b --- /dev/null +++ b/x/evm/embeds/IFunToken.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.19; + +/// @dev Implements the "bankSend" functionality for sending ERC20 tokens as bank +/// coins to a Nibiru bech32 address using the "FunToken" mapping between the +/// ERC20 and bank. +interface IFunToken { + /// @dev bankSend sends ERC20 tokens as coins to a Nibiru base account + /// @param erc20 the address of the ERC20 token contract + /// @param amount the amount of tokens to send + /// @param to the receiving Nibiru base account address as a string + function bankSend(address erc20, uint256 amount, string memory to) external; +} + +address constant FUNTOKEN_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000800; + +IFunToken constant FUNTOKEN_GATEWAY = IFunToken(FUNTOKEN_PRECOMPILE_ADDRESS); diff --git a/x/evm/embeds/IFunTokenCompiled.json b/x/evm/embeds/IFunTokenCompiled.json new file mode 100644 index 000000000..6fe6e838a --- /dev/null +++ b/x/evm/embeds/IFunTokenCompiled.json @@ -0,0 +1,34 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "IFunToken", + "sourceName": "contracts/IFunToken.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "erc20", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "string", + "name": "to", + "type": "string" + } + ], + "name": "bankSend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x", + "deployedBytecode": "0x", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/x/evm/embeds/compile.sh b/x/evm/embeds/compile.sh new file mode 100644 index 000000000..14e340eb9 --- /dev/null +++ b/x/evm/embeds/compile.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +# 1 | Get all .sol files in the current directory +SOL_FILES=$(find . -maxdepth 1 -name "*.sol") + +remove_suffix_from() { + local string="$1" + local suffix="$2" + echo "${string%"$suffix"}" +} + +compile_contract() { + local contract_name="$1" + echo "Compiling contract \"$contract_name\"" + + cp "$contract_name" "contracts/$contract_name" + npx hardhat compile + + contract_name_no_file_ext=$(remove_suffix_from "$contract_name" ".sol") + local artifact_fname="artifacts/contracts/$contract_name/${contract_name_no_file_ext}.json" + cp "$artifact_fname" "${contract_name_no_file_ext}Compiled.json" +} + +npm i --check-files 2>/dev/null 1>/dev/null +rm -f contracts/Lock.sol + +# 2 | Iterate through each file +for precompile_interface in $SOL_FILES +do + # Store the file name in a variable + filename=$(basename "$precompile_interface") + + # Call the "foo" function with the filename + # Replace this line with the actual "foo" function or command you want to run + compile_contract "$filename" + + # Optional: Print the filename being processed + echo "Processed: $filename" +done + +# 3 | Echo a success message +echo "Successfully processed all .sol files in the current directory!" diff --git a/x/evm/embeds/embeds.go b/x/evm/embeds/embeds.go index 0c0318849..da9c6193d 100644 --- a/x/evm/embeds/embeds.go +++ b/x/evm/embeds/embeds.go @@ -5,6 +5,8 @@ package embeds import ( + // The `_ "embed"` import adds access to files embedded in the running Go + // program (smart contracts). _ "embed" "encoding/json" "fmt" @@ -14,38 +16,47 @@ import ( "runtime" "strings" - // Adds access to files (smart contracts, in this case) embedded in the Go - gethabi "github.com/ethereum/go-ethereum/accounts/abi" gethcommon "github.com/ethereum/go-ethereum/common" ) var ( + // Contract_ERC20Minter: The default ERC20 contract deployed during the + // creation of a `FunToken` mapping from a bank coin. + Contract_ERC20Minter CompiledEvmContract + //go:embed ERC20MinterCompiled.json erc20MinterContractJSON []byte - EmbeddedContractERC20Minter CompiledEvmContract + // Contract_Funtoken: Precompile contract interface for + // "IFunToken.sol". This precompile enables transfers of ERC20 tokens + // to non-EVM accounts. Only the ABI is used. + Contract_Funtoken CompiledEvmContract + //go:embed IFunTokenCompiled.json + funtokenContractJSON []byte ) func init() { - out, err := SmartContract_ERC20Minter.Load() - if err != nil { - panic(err) - } - EmbeddedContractERC20Minter = out + Contract_ERC20Minter = SmartContract_ERC20Minter.MustLoad() + Contract_Funtoken = SmartContract_FunToken.MustLoad() } var ( - SmartContract_FunToken = SmartContractFixture{ - Name: "FunToken.sol", + SmartContract_TestERC20 = SmartContractFixture{ + Name: "TestERC20.sol", FixtureType: FixtueType_Test, } SmartContract_ERC20Minter = SmartContractFixture{ Name: "ERC20Minter.sol", - FixtureType: FixtueType_Embed, + FixtureType: FixtueType_Prod, EmbedJSON: &erc20MinterContractJSON, } + SmartContract_FunToken = SmartContractFixture{ + Name: "FunToken.sol", + FixtureType: FixtueType_Prod, + EmbedJSON: &funtokenContractJSON, + } ) // CompiledEvmContract: EVM contract that can be deployed into the EVM state and @@ -61,11 +72,13 @@ type SmartContractFixture struct { EmbedJSON *[]byte } +// ContractFixtureType: Enum type for embedded smart contracts. This type +// expresses whether a contract is used in production or only for testing. type ContractFixtureType string const ( - FixtueType_Embed = "embed" - FixtueType_Test = "test" + FixtueType_Prod = "prod" + FixtueType_Test = "test" ) // HardhatOutput: Expected format for smart contract test fixtures. @@ -120,12 +133,20 @@ func (jsonObj HardhatOutput) EvmContract() (out CompiledEvmContract, err error) }, err } +func (sc SmartContractFixture) MustLoad() (out CompiledEvmContract) { + out, err := sc.Load() + if err != nil { + panic(err) + } + return out +} + func (sc SmartContractFixture) Load() (out CompiledEvmContract, err error) { var jsonBz []byte // Locate the contracts directory. switch sc.FixtureType { - case FixtueType_Embed: + case FixtueType_Prod: if sc.EmbedJSON == nil { return out, fmt.Errorf("missing compiled contract embed") } diff --git a/x/evm/embeds/embeds_test.go b/x/evm/embeds/embeds_test.go index 21a6051a4..2567ca3c8 100644 --- a/x/evm/embeds/embeds_test.go +++ b/x/evm/embeds/embeds_test.go @@ -10,8 +10,9 @@ import ( func TestLoadContracts(t *testing.T) { for _, tc := range []embeds.SmartContractFixture{ - embeds.SmartContract_FunToken, + embeds.SmartContract_TestERC20, embeds.SmartContract_ERC20Minter, + embeds.SmartContract_FunToken, } { t.Run(tc.Name, func(t *testing.T) { _, err := tc.Load() diff --git a/x/evm/embeds/hardhat.config.js b/x/evm/embeds/hardhat.config.js new file mode 100644 index 000000000..87c52f5f1 --- /dev/null +++ b/x/evm/embeds/hardhat.config.js @@ -0,0 +1,6 @@ +require("@nomicfoundation/hardhat-toolbox"); + +/** @type import('hardhat/config').HardhatUserConfig */ +module.exports = { + solidity: "0.8.19", +}; diff --git a/x/evm/embeds/package.json b/x/evm/embeds/package.json new file mode 100644 index 000000000..09a8e3c45 --- /dev/null +++ b/x/evm/embeds/package.json @@ -0,0 +1,10 @@ +{ + "devDependencies": { + "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "bun": "^1.1.18", + "hardhat": "^2.22.5" + }, + "dependencies": { + "@openzeppelin/contracts": "^4.9.0" + } +} diff --git a/x/evm/evm_test.go b/x/evm/evm_test.go index 6e4f1e3ef..8da03c1c3 100644 --- a/x/evm/evm_test.go +++ b/x/evm/evm_test.go @@ -11,6 +11,7 @@ import ( "github.com/NibiruChain/nibiru/eth" "github.com/NibiruChain/nibiru/x/evm" + "github.com/NibiruChain/nibiru/x/evm/evmtest" ) type TestSuite struct { @@ -118,7 +119,24 @@ func (s *TestSuite) TestModuleAddressEVM() { s.Equal(addr.Hex(), "0x603871c2ddd41c26Ee77495E2E31e6De7f9957e0") // Sanity check - moduleAddr := authtypes.NewModuleAddress(evm.ModuleName) - evmModuleAddr := gethcommon.BytesToAddress(moduleAddr) + nibiAddr := authtypes.NewModuleAddress(evm.ModuleName) + evmModuleAddr := gethcommon.BytesToAddress(nibiAddr) s.Equal(addr.Hex(), evmModuleAddr.Hex()) + + // EVM addr module acc and EVM address should be connected + // EVM module should have mint perms + deps := evmtest.NewTestDeps() + { + _, err := deps.K.EthAccount(deps.GoCtx(), &evm.QueryEthAccountRequest{ + Address: evmModuleAddr.Hex(), + }) + s.NoError(err) + } + { + resp, err := deps.K.NibiruAccount(deps.GoCtx(), &evm.QueryNibiruAccountRequest{ + Address: evmModuleAddr.Hex(), + }) + s.NoError(err) + s.Equal(nibiAddr.String(), resp.Address) + } } diff --git a/x/evm/evmmodule/genesis.go b/x/evm/evmmodule/genesis.go index 9be8352d8..c2ea8faa7 100644 --- a/x/evm/evmmodule/genesis.go +++ b/x/evm/evmmodule/genesis.go @@ -25,7 +25,7 @@ func InitGenesis( ) []abci.ValidatorUpdate { k.SetParams(ctx, genState.Params) - if addr := accountKeeper.GetModuleAddress(evm.ModuleName); addr == nil { + if evmModule := accountKeeper.GetModuleAccount(ctx, evm.ModuleName); evmModule == nil { panic("the EVM module account has not been set") } diff --git a/x/evm/evmtest/erc20.go b/x/evm/evmtest/erc20.go new file mode 100644 index 000000000..88d32aa4e --- /dev/null +++ b/x/evm/evmtest/erc20.go @@ -0,0 +1,31 @@ +package evmtest + +import ( + "math/big" + "testing" + + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + + "github.com/NibiruChain/nibiru/x/evm" +) + +func DoEthTx( + deps *TestDeps, contract, from gethcommon.Address, input []byte, +) (evmResp *evm.MsgEthereumTxResponse, err error) { + commit := true + return deps.K.CallContractWithInput( + deps.Ctx, from, &contract, commit, input, + ) +} + +func AssertERC20BalanceEqual( + t *testing.T, + deps *TestDeps, + contract, account gethcommon.Address, + balance *big.Int, +) { + gotBalance, err := deps.K.ERC20().BalanceOf(contract, account, deps.Ctx) + assert.NoError(t, err) + assert.Equal(t, balance.String(), gotBalance.String()) +} diff --git a/x/evm/evmtest/tx.go b/x/evm/evmtest/tx.go index 3814898ca..bbe5a5456 100644 --- a/x/evm/evmtest/tx.go +++ b/x/evm/evmtest/tx.go @@ -215,7 +215,7 @@ func DeployAndExecuteERC20Transfer( deps *TestDeps, t *testing.T, ) (*evm.MsgEthereumTx, []*evm.MsgEthereumTx) { // TX 1: Deploy ERC-20 contract - deployResp, err := DeployContract(deps, embeds.SmartContract_FunToken, t) + deployResp, err := DeployContract(deps, embeds.SmartContract_TestERC20, t) require.NoError(t, err) contractData := deployResp.ContractData nonce := deployResp.Nonce diff --git a/x/evm/keeper/erc20.go b/x/evm/keeper/erc20.go index d58654f22..8a89ff4f5 100644 --- a/x/evm/keeper/erc20.go +++ b/x/evm/keeper/erc20.go @@ -23,11 +23,20 @@ import ( "github.com/NibiruChain/nibiru/x/evm/statedb" ) +// FindERC20Metadata retrieves the metadata of an ERC20 token. +// +// Parameters: +// - ctx: The SDK context for the transaction. +// - contract: The Ethereum address of the ERC20 contract. +// +// Returns: +// - info: ERC20Metadata containing name, symbol, and decimals. +// - err: An error if metadata retrieval fails. func (k Keeper) FindERC20Metadata( ctx sdk.Context, contract gethcommon.Address, ) (info ERC20Metadata, err error) { - var abi gethabi.ABI = embeds.EmbeddedContractERC20Minter.ABI + var abi gethabi.ABI = embeds.Contract_ERC20Minter.ABI errs := []error{} @@ -59,8 +68,11 @@ type ERC20Metadata struct { type ( ERC20String struct{ Value string } + // ERC20Uint8: Unpacking type for "uint8" from Solidity. This is only used in + // the "ERC20.decimals" function. ERC20Uint8 struct{ Value uint8 } ERC20Bool struct{ Value bool } + ERC20BigInt struct{ Value *big.Int } ) // CreateFunTokenFromERC20 creates a new FunToken mapping from an existing ERC20 token. @@ -151,8 +163,6 @@ func (k *Keeper) CreateFunTokenFromERC20( ) } -func callContractError(errMsg error) error { return fmt.Errorf("CallContractError: %s", errMsg) } - // CallContract invokes a smart contract on the method specified by [methodName] // using the given [args]. // @@ -182,14 +192,13 @@ func (k Keeper) CallContract( err = errors.Wrap(err, "failed to pack ABI args") return } - return k.CallContractWithInput(ctx, abi, fromAcc, contract, commit, contractInput) + return k.CallContractWithInput(ctx, fromAcc, contract, commit, contractInput) } // CallContractWithInput invokes a smart contract with the given [contractInput]. // // Parameters: // - ctx: The SDK context for the transaction. -// - abi: The ABI (Application Binary Interface) of the smart contract. // - fromAcc: The Ethereum address of the account initiating the contract call. // - contract: Pointer to the Ethereum address of the contract to be called. // - commit: Boolean flag indicating whether to commit the transaction (true) or simulate it (false). @@ -200,50 +209,38 @@ func (k Keeper) CallContract( // simulations and estimates gas for actual transactions. func (k Keeper) CallContractWithInput( ctx sdk.Context, - abi gethabi.ABI, fromAcc gethcommon.Address, contract *gethcommon.Address, commit bool, contractInput []byte, ) (evmResp *evm.MsgEthereumTxResponse, err error) { - nonce := k.GetAccNonce(ctx, fromAcc) - - gasLimit := serverconfig.DefaultEthCallGasLimit - if commit { - jsonArgs, err := json.Marshal(evm.JsonTxArgs{ - From: &fromAcc, - To: contract, - Data: (*hexutil.Bytes)(&contractInput), - }) - if err != nil { - return evmResp, callContractError(err) - } - - gasRes, err := k.EstimateGasForEvmCallType( - sdk.WrapSDKContext(ctx), - &evm.EthCallRequest{ - Args: jsonArgs, - GasCap: gasLimit, - }, - evm.CallTypeSmart, - ) + // This is a `defer` pattern to add behavior that runs in the case that the error is + // non-nil, creating a concise way to add extra information. + defer func() { if err != nil { - return evmResp, callContractError(err) + err = fmt.Errorf("CallContractError: %w", err) } + }() + nonce := k.GetAccNonce(ctx, fromAcc) - gasLimit = gasRes.Gas + gasLimit := serverconfig.DefaultEthCallGasLimit + gasLimit, err = computeCommitGasLimit( + commit, gasLimit, &fromAcc, contract, contractInput, k, ctx, + ) + if err != nil { + return } - unusedBitInt := big.NewInt(0) + unusedBigInt := big.NewInt(0) evmMsg := gethcore.NewMessage( fromAcc, contract, nonce, - unusedBitInt, // amount + unusedBigInt, // amount gasLimit, - unusedBitInt, // gasFeeCap - unusedBitInt, // gasTipCap - unusedBitInt, // gasPrice + unusedBigInt, // gasFeeCap + unusedBigInt, // gasTipCap + unusedBigInt, // gasPrice contractInput, gethcore.AccessList{}, !commit, // isFake @@ -256,34 +253,77 @@ func (k Keeper) CallContractWithInput( k.EthChainID(ctx), ) if err != nil { - return evmResp, callContractError( - fmt.Errorf("failed to load evm config: %s", err)) + err = fmt.Errorf("failed to load evm config: %s", err) + return } txConfig := statedb.NewEmptyTxConfig(gethcommon.BytesToHash(ctx.HeaderHash())) evmResp, err = k.ApplyEvmMsg( ctx, evmMsg, evm.NewNoOpTracer(), commit, cfg, txConfig, ) if err != nil { - return evmResp, callContractError(err) + return } if evmResp.Failed() { - return evmResp, callContractError(fmt.Errorf("%s: %s", err, evmResp.VmError)) + err = fmt.Errorf("%w: EVM error: %s", err, evmResp.VmError) + return } return evmResp, err } +func computeCommitGasLimit( + commit bool, + gasLimit uint64, + fromAcc, contract *gethcommon.Address, + contractInput []byte, + k Keeper, + ctx sdk.Context, +) (newGasLimit uint64, err error) { + if !commit { + return gasLimit, nil + } + + // Create a cached context for gas estimation + cachedCtx, _ := ctx.CacheContext() + + jsonArgs, err := json.Marshal(evm.JsonTxArgs{ + From: fromAcc, + To: contract, + Data: (*hexutil.Bytes)(&contractInput), + }) + if err != nil { + err = fmt.Errorf("failed compute gas limit to marshal tx args: %w", err) + return + } + + gasRes, err := k.EstimateGasForEvmCallType( + sdk.WrapSDKContext(cachedCtx), + &evm.EthCallRequest{ + Args: jsonArgs, + GasCap: gasLimit, + }, + evm.CallTypeSmart, + ) + if err != nil { + err = fmt.Errorf("failed to compute gas limit: %w", err) + return + } + + newGasLimit = gasRes.Gas + return newGasLimit, nil +} + func (k Keeper) LoadERC20Name( ctx sdk.Context, abi gethabi.ABI, erc20 gethcommon.Address, ) (out string, err error) { - return k.loadERC20String(ctx, abi, erc20, "name") + return k.LoadERC20String(ctx, abi, erc20, "name") } func (k Keeper) LoadERC20Symbol( ctx sdk.Context, abi gethabi.ABI, erc20 gethcommon.Address, ) (out string, err error) { - return k.loadERC20String(ctx, abi, erc20, "symbol") + return k.LoadERC20String(ctx, abi, erc20, "symbol") } func (k Keeper) LoadERC20Decimals( @@ -292,7 +332,7 @@ func (k Keeper) LoadERC20Decimals( return k.loadERC20Uint8(ctx, abi, erc20, "decimals") } -func (k Keeper) loadERC20String( +func (k Keeper) LoadERC20String( ctx sdk.Context, erc20Abi gethabi.ABI, erc20Contract gethcommon.Address, @@ -308,14 +348,14 @@ func (k Keeper) loadERC20String( return out, err } - erc20string := new(ERC20String) + erc20Val := new(ERC20String) err = erc20Abi.UnpackIntoInterface( - erc20string, methodName, res.Ret, + erc20Val, methodName, res.Ret, ) if err != nil { return out, err } - return erc20string.Value, err + return erc20Val.Value, err } func (k Keeper) loadERC20Uint8( @@ -334,12 +374,115 @@ func (k Keeper) loadERC20Uint8( return out, err } - erc20uint8 := new(ERC20Uint8) + erc20Val := new(ERC20Uint8) err = erc20Abi.UnpackIntoInterface( - erc20uint8, methodName, res.Ret, + erc20Val, methodName, res.Ret, ) if err != nil { return out, err } - return erc20uint8.Value, err + return erc20Val.Value, err +} + +func (k Keeper) LoadERC20BigInt( + ctx sdk.Context, + erc20Abi gethabi.ABI, + erc20Contract gethcommon.Address, + methodName string, + args ...any, +) (out *big.Int, err error) { + res, err := k.CallContract( + ctx, erc20Abi, + evm.ModuleAddressEVM(), + &erc20Contract, + false, methodName, + args..., + ) + if err != nil { + return out, err + } + + erc20Val := new(ERC20BigInt) + err = erc20Abi.UnpackIntoInterface( + erc20Val, methodName, res.Ret, + ) + if err != nil { + return out, err + } + return erc20Val.Value, err +} + +func (k Keeper) ERC20() erc20Calls { + return erc20Calls{ + Keeper: &k, + ABI: embeds.Contract_ERC20Minter.ABI, + } +} + +type erc20Calls struct { + *Keeper + ABI gethabi.ABI +} + +func (e erc20Calls) Mint( + contract, from, to gethcommon.Address, amount *big.Int, + ctx sdk.Context, +) (evmResp *evm.MsgEthereumTxResponse, err error) { + input, err := e.ABI.Pack("mint", to, amount) + if err != nil { + return + } + commit := true + return e.CallContractWithInput(ctx, from, &contract, commit, input) +} + +/* +Transfer implements "ERC20.transfer" + +```solidity +/// @dev Moves `amount` tokens from the caller's account to `to`. +/// Returns a boolean value indicating whether the operation succeeded. +/// Emits a {Transfer} event. +function transfer(address to, uint256 amount) external returns (bool); +``` +*/ +func (e erc20Calls) Transfer( + contract, from, to gethcommon.Address, amount *big.Int, + ctx sdk.Context, +) (evmResp *evm.MsgEthereumTxResponse, err error) { + input, err := e.ABI.Pack("transfer", to, amount) + if err != nil { + return + } + commit := true + return e.CallContractWithInput(ctx, from, &contract, commit, input) +} + +// BalanceOf retrieves the balance of an ERC20 token for a specific account. +// Implements "ERC20.balanceOf". +func (e erc20Calls) BalanceOf( + contract, account gethcommon.Address, + ctx sdk.Context, +) (out *big.Int, err error) { + return e.LoadERC20BigInt(ctx, e.ABI, contract, "balanceOf", account) +} + +/* +Burn implements "ERC20Burnable.burn" + +```solidity +/// @dev Destroys `amount` tokens from the caller. +function burn(uint256 amount) public virtual { +``` +*/ +func (e erc20Calls) Burn( + contract, from gethcommon.Address, amount *big.Int, + ctx sdk.Context, +) (evmResp *evm.MsgEthereumTxResponse, err error) { + input, err := e.ABI.Pack("burn", amount) + if err != nil { + return + } + commit := true + return e.CallContractWithInput(ctx, from, &contract, commit, input) } diff --git a/x/evm/keeper/erc20_from_coin.go b/x/evm/keeper/erc20_from_coin.go index 83d9d9a32..bfde3487e 100644 --- a/x/evm/keeper/erc20_from_coin.go +++ b/x/evm/keeper/erc20_from_coin.go @@ -66,7 +66,7 @@ func (k *Keeper) DeployERC20ForBankCoin( decimals = uint8(bankCoin.DenomUnits[decimalsIdx].Exponent) } - erc20Embed := embeds.EmbeddedContractERC20Minter + erc20Embed := embeds.Contract_ERC20Minter callArgs := []any{bankCoin.Name, bankCoin.Symbol, decimals} methodName := "" // pass empty method name to deploy the contract packedArgs, err := erc20Embed.ABI.Pack(methodName, callArgs...) @@ -83,8 +83,7 @@ func (k *Keeper) DeployERC20ForBankCoin( commit := true _, err = k.CallContractWithInput( - ctx, erc20Embed.ABI, fromEvmAddr, erc20Contract, commit, - bytecodeForCall, + ctx, fromEvmAddr, erc20Contract, commit, bytecodeForCall, ) if err != nil { err = errors.Wrap(err, "deploy ERC20 failed") diff --git a/x/evm/keeper/grpc_query_test.go b/x/evm/keeper/grpc_query_test.go index 06451053d..ad562368d 100644 --- a/x/evm/keeper/grpc_query_test.go +++ b/x/evm/keeper/grpc_query_test.go @@ -5,7 +5,6 @@ import ( "fmt" "math/big" "regexp" - "strings" "cosmossdk.io/math" "github.com/NibiruChain/collections" @@ -497,7 +496,7 @@ func (s *Suite) TestQueryEthCall() { { name: "happy: eth call for erc20 token transfer", scenario: func(deps *evmtest.TestDeps) (req In, wantResp Out) { - fungibleTokenContract, err := embeds.SmartContract_FunToken.Load() + fungibleTokenContract, err := embeds.SmartContract_TestERC20.Load() s.Require().NoError(err) jsonTxArgs, err := json.Marshal(&evm.JsonTxArgs{ @@ -821,14 +820,8 @@ func (s *Suite) TestTraceTx() { if len(actualResp) > 1000 { actualResp = actualResp[:len(wantResp)] } - - // FIXME: Why is this sometimes 35050 and sometimes 35062? - replaceTimes := 1 - hackedWantResp := strings.Replace(wantResp, "35062", "35050", replaceTimes) - s.True( - wantResp == actualResp || hackedWantResp == actualResp, - "got \"%s\", want \"%s\"", actualResp, wantResp, - ) + // FIXME: Why does this trace sometimes have gas 35050 and sometimes 35062? + s.Equal(wantResp, actualResp) }) } } @@ -900,7 +893,8 @@ func (s *Suite) TestTraceBlock() { if len(actualResp) > 1000 { actualResp = actualResp[:len(wantResp)] } - s.Assert().Equal(wantResp, actualResp) + // FIXME: Why does this trace sometimes have gas 35050 and sometimes 35062? + s.Equal(wantResp, actualResp) }) } } diff --git a/x/evm/keeper/keeper.go b/x/evm/keeper/keeper.go index e809b7577..45a357338 100644 --- a/x/evm/keeper/keeper.go +++ b/x/evm/keeper/keeper.go @@ -36,7 +36,8 @@ type Keeper struct { // mappings. FunTokens FunTokenState - // the address capable of executing a MsgUpdateParams message. Typically, this should be the x/gov module account. + // the address capable of executing a MsgUpdateParams message. Typically, + // this should be the x/gov module account. authority sdk.AccAddress bankKeeper evm.BankKeeper diff --git a/x/evm/keeper/msg_server.go b/x/evm/keeper/msg_server.go index 128061740..607c624e9 100644 --- a/x/evm/keeper/msg_server.go +++ b/x/evm/keeper/msg_server.go @@ -259,7 +259,9 @@ func (k *Keeper) NewEVM( tracer = k.Tracer(ctx, msg, cfg.ChainConfig) } vmConfig := k.VMConfig(ctx, msg, cfg, tracer) - return vm.NewEVM(blockCtx, txCtx, stateDB, cfg.ChainConfig, vmConfig) + theEvm := vm.NewEVM(blockCtx, txCtx, stateDB, cfg.ChainConfig, vmConfig) + theEvm.WithPrecompiles(k.precompiles, k.PrecompileAddrsSorted()) + return theEvm } // GetHashFn implements vm.GetHashFunc for Ethermint. It handles 3 cases: @@ -378,31 +380,19 @@ func (k *Keeper) ApplyEvmMsg(ctx sdk.Context, stateDB := statedb.New(ctx, k, txConfig) evmObj := k.NewEVM(ctx, msg, cfg, tracer, stateDB) - // set the custom precompiles to the EVM (if any) - if cfg.Params.HasCustomPrecompiles() { - customPrecompiles := cfg.Params.GetActivePrecompilesAddrs() - - activePrecompiles := make([]gethcommon.Address, len(vm.PrecompiledAddressesBerlin)+len(customPrecompiles)) - copy(activePrecompiles[:len(vm.PrecompiledAddressesBerlin)], vm.PrecompiledAddressesBerlin) - copy(activePrecompiles[len(vm.PrecompiledAddressesBerlin):], customPrecompiles) - - // Check if the transaction is sent to an inactive precompile - // - // NOTE: This has to be checked here instead of in the actual evm.Call method - // because evm.WithPrecompiles only populates the EVM with the active precompiles, - // so there's no telling if the To address is an inactive precompile further down the call stack. - toAddr := msg.To() - if toAddr != nil && - slices.Contains(evm.AvailableEVMExtensions, toAddr.String()) && - !slices.Contains(activePrecompiles, *toAddr) { - return nil, errors.Wrap(evm.ErrInactivePrecompile, "failed to call precompile") - } - - // NOTE: this only adds active precompiles to the EVM. - // This means that evm.Precompile(addr) will return false for inactive precompiles - // even though this is actually a reserved address. - precompileMap := k.Precompiles(activePrecompiles...) - evmObj.WithPrecompiles(precompileMap, activePrecompiles) + numPrecompiles := len(k.precompiles) + precompileAddrs := make([]gethcommon.Address, numPrecompiles) + + // Check if the transaction is sent to an inactive precompile + // + // NOTE: This has to be checked here instead of in the actual evm.Call method + // because evm.WithPrecompiles only populates the EVM with the active precompiles, + // so there's no telling if the To address is an inactive precompile further down the call stack. + toAddr := msg.To() + if toAddr != nil && + slices.Contains(evm.AvailableEVMExtensions, toAddr.String()) && + !slices.Contains(precompileAddrs, *toAddr) { + return nil, errors.Wrap(evm.ErrInactivePrecompile, "failed to call precompile") } leftoverGas := msg.Gas() diff --git a/x/evm/keeper/precompiles.go b/x/evm/keeper/precompiles.go index 5c9e2790e..d1fdd4801 100644 --- a/x/evm/keeper/precompiles.go +++ b/x/evm/keeper/precompiles.go @@ -8,13 +8,29 @@ import ( "sort" sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/ethereum/go-ethereum/common" + gethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/vm" + + "github.com/NibiruChain/nibiru/x/common/set" ) -func AvailablePrecompiles() map[common.Address]vm.PrecompiledContract { - contractMap := make(map[common.Address]vm.PrecompiledContract) +// PrecompileSet is the set of all known precompile addresses. It includes defaults +// from go-ethereum and the custom ones specific to the Nibiru EVM. +func (k Keeper) PrecompileSet() set.Set[gethcommon.Address] { + precompiles := set.New[gethcommon.Address]() + for addr := range k.precompiles { + precompiles.Add(addr) + } + return precompiles +} +func (k *Keeper) AddPrecompiles(precompileMap map[gethcommon.Address]vm.PrecompiledContract) { + if len(k.precompiles) == 0 { + k.precompiles = make(map[gethcommon.Address]vm.PrecompiledContract) + } + for addr, precompile := range precompileMap { + k.precompiles[addr] = precompile + } // The following TODOs can go in an epic together. // TODO: feat(evm): implement precompiled contracts for fungible tokens @@ -30,36 +46,6 @@ func AvailablePrecompiles() map[common.Address]vm.PrecompiledContract { // TODO: feat(evm): implement precompiled contracts for wasm calls // Check if there is sufficient demand for this. - return contractMap -} - -// WithPrecompiles sets the available precompiled contracts. -func (k *Keeper) WithPrecompiles(precompiles map[common.Address]vm.PrecompiledContract) *Keeper { - if k.precompiles != nil { - panic("available precompiles map already set") - } - - k.precompiles = precompiles - return k -} - -// Precompiles returns the subset of the available precompiled contracts that -// are active given the current parameters. -func (k Keeper) Precompiles( - activePrecompiles ...common.Address, -) map[common.Address]vm.PrecompiledContract { - activePrecompileMap := make(map[common.Address]vm.PrecompiledContract) - - for _, address := range activePrecompiles { - precompile, ok := k.precompiles[address] - if !ok { - panic(fmt.Sprintf("precompiled contract not initialized: %s", address)) - } - - activePrecompileMap[address] = precompile - } - - return activePrecompileMap } // AddEVMExtensions adds the given precompiles to the list of active precompiles in the EVM parameters @@ -70,13 +56,14 @@ func (k *Keeper) AddEVMExtensions( ) error { params := k.GetParams(ctx) - addresses := make([]string, len(precompiles)) + // precompileAddrs := make([]string, len(precompiles)) + precompileAddrs := set.New[string]() precompilesMap := maps.Clone(k.precompiles) - for i, precompile := range precompiles { + for _, precompile := range precompiles { // add to active precompiles address := precompile.Address() - addresses[i] = address.String() + precompileAddrs.Add(address.String()) // add to available precompiles, but check for duplicates if _, ok := precompilesMap[address]; ok { @@ -85,7 +72,7 @@ func (k *Keeper) AddEVMExtensions( precompilesMap[address] = precompile } - params.ActivePrecompiles = append(params.ActivePrecompiles, addresses...) + params.ActivePrecompiles = append(params.ActivePrecompiles, precompileAddrs.ToSlice()...) // NOTE: the active precompiles are sorted and validated before setting them // in the params @@ -97,17 +84,17 @@ func (k *Keeper) AddEVMExtensions( // IsAvailablePrecompile returns true if the given precompile address is contained in the // EVM keeper's available precompiles map. -func (k Keeper) IsAvailablePrecompile(address common.Address) bool { +func (k Keeper) IsAvailablePrecompile(address gethcommon.Address) bool { _, ok := k.precompiles[address] return ok } -// GetAvailablePrecompileAddrs returns the list of available precompile addresses. +// PrecompileAddrsSorted returns the list of available precompile addresses. // // NOTE: uses index based approach instead of append because it's supposed to be faster. // Check https://stackoverflow.com/questions/21362950/getting-a-slice-of-keys-from-a-map. -func (k Keeper) GetAvailablePrecompileAddrs() []common.Address { - addresses := make([]common.Address, len(k.precompiles)) +func (k Keeper) PrecompileAddrsSorted() []gethcommon.Address { + addresses := make([]gethcommon.Address, len(k.precompiles)) i := 0 //#nosec G705 -- two operations in for loop here are fine diff --git a/x/evm/params.go b/x/evm/params.go index d2d707252..a72cdce44 100644 --- a/x/evm/params.go +++ b/x/evm/params.go @@ -129,11 +129,6 @@ func (p Params) EIPs() []int { return eips } -// HasCustomPrecompiles returns true if the ActivePrecompiles slice is not empty. -func (p Params) HasCustomPrecompiles() bool { - return len(p.ActivePrecompiles) > 0 -} - // GetActivePrecompilesAddrs is a util function that the Active Precompiles // as a slice of addresses. func (p Params) GetActivePrecompilesAddrs() []common.Address { diff --git a/x/evm/precompile/.gitignore b/x/evm/precompile/.gitignore new file mode 100644 index 000000000..e8c12ff4f --- /dev/null +++ b/x/evm/precompile/.gitignore @@ -0,0 +1,17 @@ +node_modules +.env + +# Hardhat files +/cache +/artifacts + +# TypeChain files +/typechain +/typechain-types + +# solidity-coverage files +/coverage +/coverage.json + +# Hardhat Ignition default folder for deployments against a local node +ignition/deployments/chain-31337 diff --git a/x/evm/precompile/README.md b/x/evm/precompile/README.md new file mode 100644 index 000000000..d04248dab --- /dev/null +++ b/x/evm/precompile/README.md @@ -0,0 +1,13 @@ +# Sample Hardhat Project + +This project demonstrates a basic Hardhat use case. It comes with a sample contract, a test for that contract, and a Hardhat Ignition module that deploys that contract. + +Try running some of the following tasks: + +```shell +npx hardhat help +npx hardhat test +REPORT_GAS=true npx hardhat test +npx hardhat node +npx hardhat ignition deploy ./ignition/modules/Lock.js +``` diff --git a/x/evm/precompile/funtoken.go b/x/evm/precompile/funtoken.go new file mode 100644 index 000000000..a75eca440 --- /dev/null +++ b/x/evm/precompile/funtoken.go @@ -0,0 +1,235 @@ +package precompile + +import ( + "fmt" + "math/big" + "reflect" + "sync" + + "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + gethabi "github.com/ethereum/go-ethereum/accounts/abi" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/vm" + + "github.com/NibiruChain/nibiru/app/keepers" + "github.com/NibiruChain/nibiru/eth" + "github.com/NibiruChain/nibiru/x/evm" + "github.com/NibiruChain/nibiru/x/evm/embeds" +) + +var ( + _ vm.PrecompiledContract = (*precompileFunToken)(nil) + _ NibiruPrecompile = (*precompileFunToken)(nil) +) + +// Precompile address for "FunToken.sol", the contract that +// enables transfers of ERC20 tokens to "nibi" addresses as bank coins +// using the ERC20's `FunToken` mapping. +var PrecompileAddr_FuntokenGateway eth.HexAddr = eth.MustNewHexAddrFromStr( + "0x0000000000000000000000000000000000000800", +) + +func (p precompileFunToken) Address() gethcommon.Address { + return PrecompileAddr_FuntokenGateway.ToAddr() +} + +func (p precompileFunToken) RequiredGas(input []byte) (gasPrice uint64) { + // TODO: UD-DEBUG: not implemented yet. Currently set to 0 gasPrice + return 22 +} + +const ( + FunTokenMethod_BankSend FunTokenMethod = "bankSend" +) + +type FunTokenMethod string + +// Run runs the precompiled contract +func (p precompileFunToken) Run( + evm *vm.EVM, contract *vm.Contract, readonly bool, +) (bz []byte, err error) { + // This is a `defer` pattern to add behavior that runs in the case that the error is + // non-nil, creating a concise way to add extra information. + defer func() { + if err != nil { + precompileType := reflect.TypeOf(p).Name() + err = fmt.Errorf("Precompile error: failed to run %s: %w", precompileType, err) + } + }() + + contractInput := contract.Input + ctx, method, args, err := OnStart(p, evm, contractInput) + if err != nil { + return nil, err + } + + caller := contract.CallerAddress + switch FunTokenMethod(method.Name) { + case FunTokenMethod_BankSend: + // TODO: UD-DEBUG: Test that calling non-method on the right address does + // nothing. + bz, err = p.bankSend(ctx, caller, method, args, readonly) + default: + // TODO: UD-DEBUG: test invalid method called + err = fmt.Errorf("invalid method called with name \"%s\"", method.Name) + return + } + return +} + +func PrecompileFunToken(keepers keepers.PublicKeepers) vm.PrecompiledContract { + return precompileFunToken{ + PublicKeepers: keepers, + } +} + +func (p precompileFunToken) ABI() gethabi.ABI { + return embeds.Contract_Funtoken.ABI +} + +type precompileFunToken struct { + keepers.PublicKeepers + NibiruPrecompile +} + +var executionGuard sync.Mutex + +/* +bankSend: Implements "IFunToken.bankSend" + +The "args" populate the following function signature in Solidity: +```solidity +/// @dev bankSend sends ERC20 tokens as coins to a Nibiru base account +/// @param erc20 the address of the ERC20 token contract +/// @param amount the amount of tokens to send +/// @param to the receiving Nibiru base account address as a string +function bankSend(address erc20, uint256 amount, string memory to) external; +``` +*/ +func (p precompileFunToken) bankSend( + ctx sdk.Context, + caller gethcommon.Address, + method *gethabi.Method, + args []interface{}, + readOnly bool, +) (bz []byte, err error) { + if readOnly { + // Check required for transactions but not needed for queries + err = fmt.Errorf("cannot write state from staticcall (a read-only call)") + return + } + if !executionGuard.TryLock() { + return nil, fmt.Errorf("bankSend is already in progress") + } + defer executionGuard.Unlock() + + erc20, amount, to, err := p.AssertArgTypesBankSend(args) + if err != nil { + return + } + + // ERC20 must have FunToken mapping + funtokens := p.EvmKeeper.FunTokens.Collect( + ctx, p.EvmKeeper.FunTokens.Indexes.ERC20Addr.ExactMatch(ctx, erc20), + ) + if len(funtokens) != 1 { + err = fmt.Errorf("no FunToken mapping exists for ERC20 \"%s\"", erc20.Hex()) + return + } + funtoken := funtokens[0] + + // Amount should be positive + if amount == nil || amount.Cmp(big.NewInt(0)) != 1 { + err = fmt.Errorf("transfer amount must be positive") + return + } + + // The "to" argument must be a valid Nibiru address + toAddr, err := sdk.AccAddressFromBech32(to) + if err != nil { + err = fmt.Errorf("\"to\" is not a valid address (%s): %w", to, err) + return + } + + // Caller transfers ERC20 to the EVM account + transferTo := evm.ModuleAddressEVM() + _, err = p.EvmKeeper.ERC20().Transfer(erc20, caller, transferTo, amount, ctx) + if err != nil { + err = fmt.Errorf("failed to send from caller to the EVM account: %w", err) + return + } + + // EVM account mints FunToken.BankDenom to module account + amt := math.NewIntFromBigInt(amount) + coins := sdk.NewCoins(sdk.NewCoin(funtoken.BankDenom, amt)) + err = p.BankKeeper.MintCoins(ctx, evm.ModuleName, coins) + if err != nil { + err = fmt.Errorf("mint failed for module \"%s\" (%s): contract caller %s: %w", + evm.ModuleName, evm.ModuleAddressEVM().Hex(), caller.Hex(), err, + ) + return + } + + err = p.BankKeeper.SendCoinsFromModuleToAccount(ctx, evm.ModuleName, toAddr, coins) + if err != nil { + err = fmt.Errorf("send failed for module \"%s\" (%s): contract caller %s: %w", + evm.ModuleName, evm.ModuleAddressEVM().Hex(), caller.Hex(), err, + ) + return + } + + // If the FunToken mapping was created from a bank coin, then the EVM account + // owns the ERC20 contract and was the original minter of the ERC20 tokens. + // Since we're sending them away and want accurate total supply tracking, the + // tokens need to be burned. + if funtoken.IsMadeFromCoin { + caller := evm.ModuleAddressEVM() + _, err = p.EvmKeeper.ERC20().Burn(erc20, caller, amount, ctx) + if err != nil { + err = fmt.Errorf("ERC20.Burn: %w", err) + return + } + } + + // TODO: UD-DEBUG: feat: Emit EVM events + // TODO: UD-DEBUG: feat: Emit ABCI events + + return method.Outputs.Pack() // TODO: change interface +} + +func ArgsFunTokenBankSend( + erc20 gethcommon.Address, + amount *big.Int, + to sdk.AccAddress, +) []any { + return []any{erc20, amount, to.String()} +} + +func (p precompileFunToken) AssertArgTypesBankSend(args []any) ( + erc20 gethcommon.Address, + amount *big.Int, + to string, + err error, +) { + err = AssertArgCount(args, 3) + if err != nil { + return + } + + erc20, ok1 := args[0].(gethcommon.Address) + amount, ok2 := args[1].(*big.Int) + to, ok3 := args[2].(string) + if !(ok1 && ok2 && ok3) { + err = fmt.Errorf("type validation for failed for \"%s\"", + "function bankSend(address erc20, uint256 amount, string memory to) external") + } + return +} + +func AssertArgCount(args []interface{}, wantNumArgs int) error { + if len(args) != wantNumArgs { + return fmt.Errorf("expected %d arguments but got %d", wantNumArgs, len(args)) + } + return nil +} diff --git a/x/evm/precompile/funtoken_test.go b/x/evm/precompile/funtoken_test.go new file mode 100644 index 000000000..edf25382b --- /dev/null +++ b/x/evm/precompile/funtoken_test.go @@ -0,0 +1,199 @@ +package precompile_test + +import ( + "fmt" + "math/big" + "testing" + + bank "github.com/cosmos/cosmos-sdk/x/bank/types" + + "github.com/NibiruChain/nibiru/x/common/testutil" + "github.com/NibiruChain/nibiru/x/evm" + "github.com/NibiruChain/nibiru/x/evm/embeds" + "github.com/NibiruChain/nibiru/x/evm/evmtest" + "github.com/NibiruChain/nibiru/x/evm/precompile" + + "github.com/stretchr/testify/suite" +) + +type Suite struct { + suite.Suite +} + +// TestPrecompileSuite: Runs all the tests in the suite. +func TestPrecompileSuite(t *testing.T) { + s := new(Suite) + suite.Run(t, s) +} + +func (s *Suite) TestPrecompile_FunToken() { + s.Run("PrecompileExists", s.FunToken_PrecompileExists) + s.Run("HappyPath", s.FunToken_HappyPath) +} + +func CreateFunTokenForBankCoin( + deps *evmtest.TestDeps, bankDenom string, s *Suite, +) (funtoken evm.FunToken) { + s.T().Log("Setup: Create a coin in the bank state") + bankMetadata := bank.Metadata{ + DenomUnits: []*bank.DenomUnit{ + { + Denom: bankDenom, + Exponent: 0, + }, + }, + Base: bankDenom, + Display: bankDenom, + Name: bankDenom, + Symbol: "TOKEN", + } + deps.Chain.BankKeeper.SetDenomMetaData(deps.Ctx, bankMetadata) + + s.T().Log("happy: CreateFunToken for the bank coin") + createFuntokenResp, err := deps.K.CreateFunToken( + deps.GoCtx(), + &evm.MsgCreateFunToken{ + FromBankDenom: bankDenom, + Sender: deps.Sender.NibiruAddr.String(), + }, + ) + s.NoError(err, "bankDenom %s", bankDenom) + erc20 := createFuntokenResp.FuntokenMapping.Erc20Addr + funtoken = evm.FunToken{ + Erc20Addr: erc20, + BankDenom: bankDenom, + IsMadeFromCoin: true, + } + s.Equal(createFuntokenResp.FuntokenMapping, funtoken) + + s.T().Log("Expect ERC20 to be deployed") + erc20Addr := erc20.ToAddr() + queryCodeReq := &evm.QueryCodeRequest{ + Address: erc20Addr.String(), + } + _, err = deps.K.Code(deps.Ctx, queryCodeReq) + s.NoError(err) + + return funtoken +} + +// PrecompileExists: An integration test showing that a "PrecompileError" occurs +// when calling the FunToken +func (s *Suite) FunToken_PrecompileExists() { + precompileAddr := precompile.PrecompileAddr_FuntokenGateway + abi := embeds.Contract_Funtoken.ABI + deps := evmtest.NewTestDeps() + + codeResp, err := deps.K.Code( + deps.GoCtx(), + &evm.QueryCodeRequest{ + Address: precompileAddr.String(), + }, + ) + s.NoError(err) + s.Equal(string(codeResp.Code), "") + + s.True(deps.K.PrecompileSet().Has(precompileAddr.ToAddr()), + "did not see precompile address during \"InitPrecompiles\"") + + callArgs := []any{"nonsense", "args here", "to see if", "precompile is", "called"} + methodName := string(precompile.FunTokenMethod_BankSend) + packedArgs, err := abi.Pack(methodName, callArgs...) + if err != nil { + err = fmt.Errorf("failed to pack ABI args: %w", err) // easier to read + } + s.ErrorContains( + err, fmt.Sprintf("argument count mismatch: got %d for 3", len(callArgs)), + "callArgs: ", callArgs) + + fromEvmAddr := evm.ModuleAddressEVM() + contractAddr := precompileAddr.ToAddr() + commit := true + bytecodeForCall := packedArgs + _, err = deps.K.CallContractWithInput( + deps.Ctx, fromEvmAddr, &contractAddr, commit, + bytecodeForCall, + ) + s.ErrorContains(err, "Precompile error") +} + +func (s *Suite) FunToken_HappyPath() { + precompileAddr := precompile.PrecompileAddr_FuntokenGateway + abi := embeds.Contract_Funtoken.ABI + deps := evmtest.NewTestDeps() + + theUser := deps.Sender.EthAddr + theEvm := evm.ModuleAddressEVM() + + s.True(deps.K.PrecompileSet().Has(precompileAddr.ToAddr()), + "did not see precompile address during \"InitPrecompiles\"") + + s.T().Log("Create FunToken mapping and ERC20") + bankDenom := "ibc/usdc" + funtoken := CreateFunTokenForBankCoin(&deps, bankDenom, s) + contract := funtoken.Erc20Addr.ToAddr() + + s.T().Log("Balances of the ERC20 should start empty") + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theUser, big.NewInt(0)) + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theEvm, big.NewInt(0)) + + s.T().Log("Mint tokens - Fail from non-owner") + { + from := theUser + to := theUser + input, err := embeds.Contract_ERC20Minter.ABI.Pack("mint", to, big.NewInt(69_420)) + s.NoError(err) + _, err = evmtest.DoEthTx(&deps, contract, from, input) + s.ErrorContains(err, "Ownable: caller is not the owner") + } + + s.T().Log("Mint tokens - Success") + { + from := theEvm + to := theUser + input, err := embeds.Contract_ERC20Minter.ABI.Pack("mint", to, big.NewInt(69_420)) + s.NoError(err) + + _, err = evmtest.DoEthTx(&deps, contract, from, input) + s.NoError(err) + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theUser, big.NewInt(69_420)) + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theEvm, big.NewInt(0)) + } + + s.T().Log("Transfer - Success (sanity check)") + randomAcc := testutil.AccAddress() + { + from := theUser + to := theEvm + _, err := deps.K.ERC20().Transfer(contract, from, to, big.NewInt(1), deps.Ctx) + s.NoError(err) + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theUser, big.NewInt(69_419)) + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theEvm, big.NewInt(1)) + s.Equal("0", + deps.Chain.BankKeeper.GetBalance(deps.Ctx, randomAcc, funtoken.BankDenom).Amount.String(), + ) + } + + s.T().Log("Send using precompile") + amtToSend := int64(419) + callArgs := precompile.ArgsFunTokenBankSend(contract, big.NewInt(amtToSend), randomAcc) + methodName := string(precompile.FunTokenMethod_BankSend) + input, err := abi.Pack(methodName, callArgs...) + s.NoError(err) + + from := theUser + _, err = evmtest.DoEthTx(&deps, precompileAddr.ToAddr(), from, input) + s.Require().NoError(err) + + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theUser, big.NewInt(69_419-amtToSend)) + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theEvm, big.NewInt(1)) + s.Equal(fmt.Sprintf("%d", amtToSend), + deps.Chain.BankKeeper.GetBalance(deps.Ctx, randomAcc, funtoken.BankDenom).Amount.String(), + ) + + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theUser, big.NewInt(69_000)) + evmtest.AssertERC20BalanceEqual(s.T(), &deps, contract, theEvm, big.NewInt(1)) + s.Equal("419", + deps.Chain.BankKeeper.GetBalance(deps.Ctx, randomAcc, funtoken.BankDenom).Amount.String(), + ) +} diff --git a/x/evm/precompile/precompile.go b/x/evm/precompile/precompile.go new file mode 100644 index 000000000..9060021fb --- /dev/null +++ b/x/evm/precompile/precompile.go @@ -0,0 +1,134 @@ +package precompile + +import ( + "bytes" + "fmt" + "sync" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/NibiruChain/collections" + gethabi "github.com/ethereum/go-ethereum/accounts/abi" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/vm" + + "github.com/NibiruChain/nibiru/app/keepers" + "github.com/NibiruChain/nibiru/x/common/set" + "github.com/NibiruChain/nibiru/x/evm/statedb" +) + +// InitPrecompiles initializes and returns a map of precompiled contracts for the EVM. +// It combines default Ethereum precompiles with custom Nibiru precompiles. +// +// Parameters: +// - k: A keepers.PublicKeepers instance providing access to various blockchain state. +// +// Returns: +// - A map of Ethereum addresses to PrecompiledContract implementations. +func InitPrecompiles( + k keepers.PublicKeepers, +) (precompiles map[gethcommon.Address]vm.PrecompiledContract) { + initMutex.Lock() + defer initMutex.Unlock() + + precompiles = make(map[gethcommon.Address]vm.PrecompiledContract) + + // Default precompiles + for addr, pc := range vm.PrecompiledContractsBerlin { + precompiles[addr] = pc + } + + // Custom precompiles + for _, precompileSetupFn := range []func(k keepers.PublicKeepers) vm.PrecompiledContract{ + PrecompileFunToken, + } { + pc := precompileSetupFn(k) + addPrecompileToVM(pc) + precompiles[pc.Address()] = pc + } + return precompiles +} + +// initMutex: Mutual exclusion lock (mutex) to prevent race conditions with +// consecutive calls of InitPrecompiles. +var initMutex = &sync.Mutex{} + +// addPrecompileToVM adds a precompiled contract to the EVM's set of recognized +// precompiles. It updates both the contract map and the list of precompile +// addresses for the latest major upgrade or hard fork of Ethereum (Berlin). +func addPrecompileToVM(p vm.PrecompiledContract) { + addr := p.Address() + + vm.PrecompiledContractsBerlin[addr] = p + // TODO: 2024-07-05 feat: Cancun after go-ethereum upgrade + // https://github.com/NibiruChain/nibiru/issues/1921 + // vm.PrecompiledContractsCancun, + + // Done if the precompiled contracts are already added + // This check is only relevant during tests to prevent races. The iteration + // doesn't get repeated in production. + vmSet := set.New(vm.PrecompiledAddressesBerlin...) + if vmSet.Has(addr) { + return + } + + vm.PrecompiledAddressesBerlin = append(vm.PrecompiledAddressesBerlin, addr) + // TODO: 2024-07-05 feat: Cancun after go-ethereum upgrade + // https://github.com/NibiruChain/nibiru/issues/1921 + // vm.PrecompiledAddressesCancun, +} + +type NibiruPrecompile interface { + ABI() gethabi.ABI +} + +// ABIMethodByID: Looks up an ABI method by the 4-byte id. +// Copy of "ABI.MethodById" from go-ethereum version > 1.10 +func ABIMethodByID(abi gethabi.ABI, sigdata []byte) (*gethabi.Method, error) { + if len(sigdata) < 4 { + return nil, fmt.Errorf("data too short (%d bytes) for abi method lookup", len(sigdata)) + } + for _, method := range abi.Methods { + if bytes.Equal(method.ID, sigdata[:4]) { + return &method, nil + } + } + return nil, fmt.Errorf("no method with id: %#x", sigdata[:4]) +} + +func OnStart( + p NibiruPrecompile, evm *vm.EVM, input []byte, +) (ctx sdk.Context, method *gethabi.Method, args []interface{}, err error) { + // 1 | Get context from StateDB + stateDB, ok := evm.StateDB.(*statedb.StateDB) + if !ok { + err = fmt.Errorf("failed to load the sdk.Context from the EVM StateDB") + return + } + ctx = stateDB.GetContext() + + // 2 | Parse the ABI method + // ABI method IDs are at least 4 bytes according to "gethabi.ABI.MethodByID". + methodIdBytes := 4 + if len(input) < methodIdBytes { + readableBz := collections.HumanizeBytes(input) + err = fmt.Errorf("input \"%s\" too short to extract method ID (less than 4 bytes)", readableBz) + return + } + methodID := input[:methodIdBytes] + abi := p.ABI() + method, err = ABIMethodByID(abi, methodID) + if err != nil { + err = fmt.Errorf("unable to parse ABI method by its 4-byte ID: %w", err) + return + } + + argsBz := input[methodIdBytes:] + args, err = method.Inputs.Unpack(argsBz) + if err != nil { + err = fmt.Errorf("unable to unpack input args: %w", err) + return + } + + return ctx, method, args, nil +} diff --git a/x/oracle/integration/app_test.go b/x/oracle/keeper/app_test.go similarity index 98% rename from x/oracle/integration/app_test.go rename to x/oracle/keeper/app_test.go index d10663814..2614e9274 100644 --- a/x/oracle/integration/app_test.go +++ b/x/oracle/keeper/app_test.go @@ -1,4 +1,4 @@ -package integration_test +package keeper_test import ( "context" @@ -58,8 +58,7 @@ func (s *TestSuite) SetupTest() { s.Require().NoError(err) s.network = network - _, err = s.network.WaitForHeight(2) - require.NoError(s.T(), err) + s.Require().NoError(s.network.WaitForNextBlock()) } func (s *TestSuite) TestSuccessfulVoting() {