diff --git a/CHANGELOG.md b/CHANGELOG.md index 821b29e13..db348a90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ consistent setup and dynamic gas calculations, addressing the following tickets. - [#2088](https://github.com/NibiruChain/nibiru/pull/2088) - refactor(evm): remove outdated comment and improper error message text - [#2089](https://github.com/NibiruChain/nibiru/pull/2089) - better handling of gas consumption within erc20 contract execution - [#2091](https://github.com/NibiruChain/nibiru/pull/2091) - feat(evm): add fun token creation fee validation +- [#2093](https://github.com/NibiruChain/nibiru/pull/2093) - feat(evm): gas usage in precompiles: limits, local gas meters #### Nibiru EVM | Before Audit 1 - 2024-10-18 diff --git a/x/evm/embeds/artifacts/contracts/TestFunTokenPrecompileLocalGas.sol/TestFunTokenPrecompileLocalGas.json b/x/evm/embeds/artifacts/contracts/TestFunTokenPrecompileLocalGas.sol/TestFunTokenPrecompileLocalGas.json new file mode 100644 index 000000000..970a31649 --- /dev/null +++ b/x/evm/embeds/artifacts/contracts/TestFunTokenPrecompileLocalGas.sol/TestFunTokenPrecompileLocalGas.json @@ -0,0 +1,63 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "TestFunTokenPrecompileLocalGas", + "sourceName": "contracts/TestFunTokenPrecompileLocalGas.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "erc20_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "string", + "name": "bech32Recipient", + "type": "string" + } + ], + "name": "callBankSend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "string", + "name": "bech32Recipient", + "type": "string" + }, + { + "internalType": "uint256", + "name": "customGas", + "type": "uint256" + } + ], + "name": "callBankSendLocalGas", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x608060405234801561001057600080fd5b50604051610951380380610951833981810160405281019061003291906100db565b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050610108565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b60006100a88261007d565b9050919050565b6100b88161009d565b81146100c357600080fd5b50565b6000815190506100d5816100af565b92915050565b6000602082840312156100f1576100f0610078565b5b60006100ff848285016100c6565b91505092915050565b61083a806101176000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c806359b6ed891461003b57806390d2b5e714610057575b600080fd5b610055600480360381019061005091906104d0565b610073565b005b610071600480360381019061006c919061053f565b6101db565b005b600061080073ffffffffffffffffffffffffffffffffffffffff168260008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1686866040516024016100c49392919061066a565b6040516020818303038152906040527f03003bc5000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19166020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff838183161783525050505060405161014e91906106ef565b60006040518083038160008787f1925050503d806000811461018c576040519150601f19603f3d011682016040523d82523d6000602084013e610191565b606091505b50509050806101d5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016101cc90610778565b60405180910390fd5b50505050565b600061080073ffffffffffffffffffffffffffffffffffffffff1660008054906101000a900473ffffffffffffffffffffffffffffffffffffffff16848460405160240161022b9392919061066a565b6040516020818303038152906040527f03003bc5000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19166020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff83818316178352505050506040516102b591906106ef565b6000604051808303816000865af19150503d80600081146102f2576040519150601f19603f3d011682016040523d82523d6000602084013e6102f7565b606091505b505090508061033b576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610332906107e4565b60405180910390fd5b505050565b6000604051905090565b600080fd5b600080fd5b6000819050919050565b61036781610354565b811461037257600080fd5b50565b6000813590506103848161035e565b92915050565b600080fd5b600080fd5b6000601f19601f8301169050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6103dd82610394565b810181811067ffffffffffffffff821117156103fc576103fb6103a5565b5b80604052505050565b600061040f610340565b905061041b82826103d4565b919050565b600067ffffffffffffffff82111561043b5761043a6103a5565b5b61044482610394565b9050602081019050919050565b82818337600083830152505050565b600061047361046e84610420565b610405565b90508281526020810184848401111561048f5761048e61038f565b5b61049a848285610451565b509392505050565b600082601f8301126104b7576104b661038a565b5b81356104c7848260208601610460565b91505092915050565b6000806000606084860312156104e9576104e861034a565b5b60006104f786828701610375565b935050602084013567ffffffffffffffff8111156105185761051761034f565b5b610524868287016104a2565b925050604061053586828701610375565b9150509250925092565b600080604083850312156105565761055561034a565b5b600061056485828601610375565b925050602083013567ffffffffffffffff8111156105855761058461034f565b5b610591858286016104a2565b9150509250929050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b60006105c68261059b565b9050919050565b6105d6816105bb565b82525050565b6105e581610354565b82525050565b600081519050919050565b600082825260208201905092915050565b60005b8381101561062557808201518184015260208101905061060a565b60008484015250505050565b600061063c826105eb565b61064681856105f6565b9350610656818560208601610607565b61065f81610394565b840191505092915050565b600060608201905061067f60008301866105cd565b61068c60208301856105dc565b818103604083015261069e8184610631565b9050949350505050565b600081519050919050565b600081905092915050565b60006106c9826106a8565b6106d381856106b3565b93506106e3818560208601610607565b80840191505092915050565b60006106fb82846106be565b915081905092915050565b7f4661696c656420746f2063616c6c2062616e6b53656e6420776974682063757360008201527f746f6d2067617300000000000000000000000000000000000000000000000000602082015250565b60006107626027836105f6565b915061076d82610706565b604082019050919050565b6000602082019050818103600083015261079181610755565b9050919050565b7f4661696c656420746f2063616c6c2062616e6b53656e64000000000000000000600082015250565b60006107ce6017836105f6565b91506107d982610798565b602082019050919050565b600060208201905081810360008301526107fd816107c1565b905091905056fea2646970667358221220cf16927fc50953575dc9f444e1aefe75fa60bf10c7688dd039cb7503669ab76964736f6c63430008180033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100365760003560e01c806359b6ed891461003b57806390d2b5e714610057575b600080fd5b610055600480360381019061005091906104d0565b610073565b005b610071600480360381019061006c919061053f565b6101db565b005b600061080073ffffffffffffffffffffffffffffffffffffffff168260008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1686866040516024016100c49392919061066a565b6040516020818303038152906040527f03003bc5000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19166020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff838183161783525050505060405161014e91906106ef565b60006040518083038160008787f1925050503d806000811461018c576040519150601f19603f3d011682016040523d82523d6000602084013e610191565b606091505b50509050806101d5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016101cc90610778565b60405180910390fd5b50505050565b600061080073ffffffffffffffffffffffffffffffffffffffff1660008054906101000a900473ffffffffffffffffffffffffffffffffffffffff16848460405160240161022b9392919061066a565b6040516020818303038152906040527f03003bc5000000000000000000000000000000000000000000000000000000007bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19166020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff83818316178352505050506040516102b591906106ef565b6000604051808303816000865af19150503d80600081146102f2576040519150601f19603f3d011682016040523d82523d6000602084013e6102f7565b606091505b505090508061033b576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610332906107e4565b60405180910390fd5b505050565b6000604051905090565b600080fd5b600080fd5b6000819050919050565b61036781610354565b811461037257600080fd5b50565b6000813590506103848161035e565b92915050565b600080fd5b600080fd5b6000601f19601f8301169050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6103dd82610394565b810181811067ffffffffffffffff821117156103fc576103fb6103a5565b5b80604052505050565b600061040f610340565b905061041b82826103d4565b919050565b600067ffffffffffffffff82111561043b5761043a6103a5565b5b61044482610394565b9050602081019050919050565b82818337600083830152505050565b600061047361046e84610420565b610405565b90508281526020810184848401111561048f5761048e61038f565b5b61049a848285610451565b509392505050565b600082601f8301126104b7576104b661038a565b5b81356104c7848260208601610460565b91505092915050565b6000806000606084860312156104e9576104e861034a565b5b60006104f786828701610375565b935050602084013567ffffffffffffffff8111156105185761051761034f565b5b610524868287016104a2565b925050604061053586828701610375565b9150509250925092565b600080604083850312156105565761055561034a565b5b600061056485828601610375565b925050602083013567ffffffffffffffff8111156105855761058461034f565b5b610591858286016104a2565b9150509250929050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b60006105c68261059b565b9050919050565b6105d6816105bb565b82525050565b6105e581610354565b82525050565b600081519050919050565b600082825260208201905092915050565b60005b8381101561062557808201518184015260208101905061060a565b60008484015250505050565b600061063c826105eb565b61064681856105f6565b9350610656818560208601610607565b61065f81610394565b840191505092915050565b600060608201905061067f60008301866105cd565b61068c60208301856105dc565b818103604083015261069e8184610631565b9050949350505050565b600081519050919050565b600081905092915050565b60006106c9826106a8565b6106d381856106b3565b93506106e3818560208601610607565b80840191505092915050565b60006106fb82846106be565b915081905092915050565b7f4661696c656420746f2063616c6c2062616e6b53656e6420776974682063757360008201527f746f6d2067617300000000000000000000000000000000000000000000000000602082015250565b60006107626027836105f6565b915061076d82610706565b604082019050919050565b6000602082019050818103600083015261079181610755565b9050919050565b7f4661696c656420746f2063616c6c2062616e6b53656e64000000000000000000600082015250565b60006107ce6017836105f6565b91506107d982610798565b602082019050919050565b600060208201905081810360008301526107fd816107c1565b905091905056fea2646970667358221220cf16927fc50953575dc9f444e1aefe75fa60bf10c7688dd039cb7503669ab76964736f6c63430008180033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/x/evm/embeds/contracts/TestFunTokenPrecompileLocalGas.sol b/x/evm/embeds/contracts/TestFunTokenPrecompileLocalGas.sol new file mode 100644 index 000000000..00c1d735d --- /dev/null +++ b/x/evm/embeds/contracts/TestFunTokenPrecompileLocalGas.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./FunToken.sol"; + +contract TestFunTokenPrecompileLocalGas { + address erc20; + + constructor(address erc20_) { + erc20 = erc20_; + } + + // Calls bankSend of the FunToken Precompile with the default gas. + // Internal call could use all the gas for the parent call. + function callBankSend( + uint256 amount, + string memory bech32Recipient + ) public { + (bool success,) = FUNTOKEN_PRECOMPILE_ADDRESS.call( + abi.encodeWithSignature( + "bankSend(address,uint256,string)", + erc20, + amount, + bech32Recipient + ) + ); + require(success, "Failed to call bankSend"); + } + + // Calls bankSend of the FunToken Precompile with the gas amount set in parameter. + // Internal call should fail if the gas provided is insufficient. + function callBankSendLocalGas( + uint256 amount, + string memory bech32Recipient, + uint256 customGas + ) public { + (bool success,) = FUNTOKEN_PRECOMPILE_ADDRESS.call{gas: customGas}( + abi.encodeWithSignature( + "bankSend(address,uint256,string)", + erc20, + amount, + bech32Recipient + ) + ); + require(success, "Failed to call bankSend with custom gas"); + } +} \ No newline at end of file diff --git a/x/evm/embeds/embeds.go b/x/evm/embeds/embeds.go index 5ac65655d..bd4a2873a 100644 --- a/x/evm/embeds/embeds.go +++ b/x/evm/embeds/embeds.go @@ -29,6 +29,8 @@ var ( testErc20MaliciousNameJson []byte //go:embed artifacts/contracts/TestERC20MaliciousTransfer.sol/TestERC20MaliciousTransfer.json testErc20MaliciousTransferJson []byte + //go:embed artifacts/contracts/TestFunTokenPrecompileLocalGas.sol/TestFunTokenPrecompileLocalGas.json + testFunTokenPrecompileLocalGasJson []byte ) var ( @@ -76,6 +78,12 @@ var ( Name: "TestERC20MaliciousTransfer.sol", EmbedJSON: testErc20MaliciousTransferJson, } + // SmartContract_TestFunTokenPrecompileLocalGas is a test contract + // which allows precompile execution with custom local gas set (calling precompile within contract) + SmartContract_TestFunTokenPrecompileLocalGas = CompiledEvmContract{ + Name: "TestFunTokenPrecompileLocalGas.sol", + EmbedJSON: testFunTokenPrecompileLocalGasJson, + } ) func init() { @@ -86,6 +94,7 @@ func init() { SmartContract_TestERC20.MustLoad() SmartContract_TestERC20MaliciousName.MustLoad() SmartContract_TestERC20MaliciousTransfer.MustLoad() + SmartContract_TestFunTokenPrecompileLocalGas.MustLoad() } type CompiledEvmContract struct { diff --git a/x/evm/embeds/embeds_test.go b/x/evm/embeds/embeds_test.go index 586ff68b9..e3006dd9e 100644 --- a/x/evm/embeds/embeds_test.go +++ b/x/evm/embeds/embeds_test.go @@ -16,5 +16,6 @@ func TestLoadContracts(t *testing.T) { embeds.SmartContract_TestERC20.MustLoad() embeds.SmartContract_TestERC20MaliciousName.MustLoad() embeds.SmartContract_TestERC20MaliciousTransfer.MustLoad() + embeds.SmartContract_TestFunTokenPrecompileLocalGas.MustLoad() }) } diff --git a/x/evm/evmtest/test_deps.go b/x/evm/evmtest/test_deps.go index 1810b1c8f..5e238cca9 100644 --- a/x/evm/evmtest/test_deps.go +++ b/x/evm/evmtest/test_deps.go @@ -60,3 +60,8 @@ func (deps *TestDeps) GethSigner() gethcore.Signer { func (deps TestDeps) GoCtx() context.Context { return sdk.WrapSDKContext(deps.Ctx) } + +func (deps TestDeps) ResetGasMeter() { + deps.EvmKeeper.ResetTransientGasUsed(deps.Ctx) + deps.EvmKeeper.ResetGasMeterAndConsumeGas(deps.Ctx, 0) +} diff --git a/x/evm/keeper/call_contract.go b/x/evm/keeper/call_contract.go new file mode 100644 index 000000000..1a89eaf51 --- /dev/null +++ b/x/evm/keeper/call_contract.go @@ -0,0 +1,155 @@ +package keeper + +import ( + "fmt" + "math/big" + "strings" + + "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" + gethabi "github.com/ethereum/go-ethereum/accounts/abi" + gethcommon "github.com/ethereum/go-ethereum/common" + gethcore "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + + "github.com/NibiruChain/nibiru/v2/x/evm" +) + +// CallContract invokes a smart contract on the method specified by [methodName] +// using the given [args]. +// +// 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). +// - methodName: The name of the contract method to be called. +// - args: Variadic parameter for the arguments to be passed to the contract method. +// +// Note: This function handles both contract method calls and simulations, +// depending on the 'commit' parameter. +func (k Keeper) CallContract( + ctx sdk.Context, + abi *gethabi.ABI, + fromAcc gethcommon.Address, + contract *gethcommon.Address, + commit bool, + gasLimit uint64, + methodName string, + args ...any, +) (evmResp *evm.MsgEthereumTxResponse, err error) { + contractInput, err := abi.Pack(methodName, args...) + if err != nil { + return nil, fmt.Errorf("failed to pack ABI args: %w", err) + } + evmResp, _, err = k.CallContractWithInput(ctx, fromAcc, contract, commit, contractInput, gasLimit) + return evmResp, err +} + +// CallContractWithInput invokes a smart contract with the given [contractInput] +// or deploys a new contract. +// +// Parameters: +// - ctx: The SDK context for the transaction. +// - fromAcc: The Ethereum address of the account initiating the contract call. +// - contract: Pointer to the Ethereum address of the contract. Nil if new +// contract is deployed. +// - commit: Boolean flag indicating whether to commit the transaction (true) +// or simulate it (false). +// - contractInput: Hexadecimal-encoded bytes use as input data to the call. +// +// Note: This function handles both contract method calls and simulations, +// depending on the 'commit' parameter. It uses a default gas limit. +func (k Keeper) CallContractWithInput( + ctx sdk.Context, + fromAcc gethcommon.Address, + contract *gethcommon.Address, + commit bool, + contractInput []byte, + gasLimit uint64, +) (evmResp *evm.MsgEthereumTxResponse, evmObj *vm.EVM, 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 { + err = fmt.Errorf("CallContractError: %w", err) + } + }() + nonce := k.GetAccNonce(ctx, fromAcc) + + unusedBigInt := big.NewInt(0) + evmMsg := gethcore.NewMessage( + fromAcc, + contract, + nonce, + unusedBigInt, // amount + gasLimit, + unusedBigInt, // gasFeeCap + unusedBigInt, // gasTipCap + unusedBigInt, // gasPrice + contractInput, + gethcore.AccessList{}, + !commit, // isFake + ) + + // Apply EVM message + evmCfg, err := k.GetEVMConfig( + ctx, + sdk.ConsAddress(ctx.BlockHeader().ProposerAddress), + k.EthChainID(ctx), + ) + if err != nil { + err = errors.Wrapf(err, "failed to load EVM config") + return + } + + // Generating TxConfig with an empty tx hash as there is no actual eth tx + // sent by a user + txConfig := k.TxConfig(ctx, gethcommon.BigToHash(big.NewInt(0))) + + // Using tmp context to not modify the state in case of evm revert + tmpCtx, commitCtx := ctx.CacheContext() + + evmResp, evmObj, err = k.ApplyEvmMsg( + tmpCtx, evmMsg, evm.NewNoOpTracer(), commit, evmCfg, txConfig, true, + ) + if err != nil { + // We don't know the actual gas used, so consuming the gas limit + k.ResetGasMeterAndConsumeGas(ctx, gasLimit) + err = errors.Wrap(err, "failed to apply ethereum core message") + return + } + if evmResp.Failed() { + k.ResetGasMeterAndConsumeGas(ctx, evmResp.GasUsed) + if !strings.Contains(evmResp.VmError, vm.ErrOutOfGas.Error()) { + if evmResp.VmError == vm.ErrExecutionReverted.Error() { + err = fmt.Errorf("VMError: %w", evm.NewExecErrorWithReason(evmResp.Ret)) + return + } + err = fmt.Errorf("VMError: %s", evmResp.VmError) + return + } + err = fmt.Errorf("gas required exceeds allowance (%d)", gasLimit) + return + } else { + // Success, committing the state to ctx + if commit { + commitCtx() + totalGasUsed, err := k.AddToBlockGasUsed(ctx, evmResp.GasUsed) + if err != nil { + k.ResetGasMeterAndConsumeGas(ctx, ctx.GasMeter().Limit()) + return nil, nil, errors.Wrap(err, "error adding transient gas used to block") + } + k.ResetGasMeterAndConsumeGas(ctx, totalGasUsed) + k.updateBlockBloom(ctx, evmResp, uint64(txConfig.LogIndex)) + err = k.EmitEthereumTxEvents(ctx, contract, gethcore.LegacyTxType, evmMsg, evmResp) + if err != nil { + return nil, nil, errors.Wrap(err, "error emitting ethereum tx events") + } + blockTxIdx := uint64(txConfig.TxIndex) + 1 + k.EvmState.BlockTxIndex.Set(ctx, blockTxIdx) + } + return evmResp, evmObj, nil + } +} diff --git a/x/evm/keeper/erc20.go b/x/evm/keeper/erc20.go index 10404bea4..0838a7412 100644 --- a/x/evm/keeper/erc20.go +++ b/x/evm/keeper/erc20.go @@ -5,19 +5,26 @@ import ( "fmt" "math/big" - "cosmossdk.io/errors" sdk "github.com/cosmos/cosmos-sdk/types" gethabi "github.com/ethereum/go-ethereum/accounts/abi" gethcommon "github.com/ethereum/go-ethereum/common" - gethcore "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/core/vm" - - serverconfig "github.com/NibiruChain/nibiru/v2/app/server/config" "github.com/NibiruChain/nibiru/v2/x/evm" "github.com/NibiruChain/nibiru/v2/x/evm/embeds" ) +const ( + // Erc20GasLimitDeploy only used internally when deploying ERC20Minter. + // Deployment requires ~1_600_000 gas + Erc20GasLimitDeploy uint64 = 2_000_000 + // Erc20GasLimitQuery used only for querying name, symbol and decimals + // Cannot be heavy. Only if the contract is malicious. + Erc20GasLimitQuery uint64 = 100_000 + // Erc20GasLimitExecute used for transfer, mint and burn. + // All must not exceed 200_000 + Erc20GasLimitExecute uint64 = 200_000 +) + // ERC20 returns a mutable reference to the keeper with an ERC20 contract ABI and // Go functions corresponding to contract calls in the ERC20 standard like "mint" // and "transfer" in the ERC20 standard. @@ -56,7 +63,7 @@ func (e erc20Calls) Mint( if err != nil { return nil, fmt.Errorf("failed to pack ABI args: %w", err) } - evmResp, _, err = e.CallContractWithInput(ctx, from, &contract, true, input) + evmResp, _, err = e.CallContractWithInput(ctx, from, &contract, true, input, Erc20GasLimitExecute) return evmResp, err } @@ -78,7 +85,7 @@ func (e erc20Calls) Transfer( if err != nil { return false, fmt.Errorf("failed to pack ABI args: %w", err) } - resp, _, err := e.CallContractWithInput(ctx, from, &contract, true, input) + resp, _, err := e.CallContractWithInput(ctx, from, &contract, true, input, Erc20GasLimitExecute) if err != nil { return false, err } @@ -118,152 +125,10 @@ func (e erc20Calls) Burn( return } commit := true - evmResp, _, err = e.CallContractWithInput(ctx, from, &contract, commit, input) + evmResp, _, err = e.CallContractWithInput(ctx, from, &contract, commit, input, Erc20GasLimitExecute) return } -// CallContract invokes a smart contract on the method specified by [methodName] -// using the given [args]. -// -// 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). -// - methodName: The name of the contract method to be called. -// - args: Variadic parameter for the arguments to be passed to the contract method. -// -// Note: This function handles both contract method calls and simulations, -// depending on the 'commit' parameter. It uses a default gas limit for -// simulations and estimates gas for actual transactions. -func (k Keeper) CallContract( - ctx sdk.Context, - abi *gethabi.ABI, - fromAcc gethcommon.Address, - contract *gethcommon.Address, - commit bool, - methodName string, - args ...any, -) (evmResp *evm.MsgEthereumTxResponse, err error) { - contractInput, err := abi.Pack(methodName, args...) - if err != nil { - return nil, fmt.Errorf("failed to pack ABI args: %w", err) - } - evmResp, _, err = k.CallContractWithInput(ctx, fromAcc, contract, commit, contractInput) - return evmResp, err -} - -// CallContractWithInput invokes a smart contract with the given [contractInput] -// or deploys a new contract. -// -// Parameters: -// - ctx: The SDK context for the transaction. -// - fromAcc: The Ethereum address of the account initiating the contract call. -// - contract: Pointer to the Ethereum address of the contract. Nil if new -// contract is deployed. -// - commit: Boolean flag indicating whether to commit the transaction (true) -// or simulate it (false). -// - contractInput: Hexadecimal-encoded bytes use as input data to the call. -// -// Note: This function handles both contract method calls and simulations, -// depending on the 'commit' parameter. It uses a default gas limit. -func (k Keeper) CallContractWithInput( - ctx sdk.Context, - fromAcc gethcommon.Address, - contract *gethcommon.Address, - commit bool, - contractInput []byte, -) (evmResp *evm.MsgEthereumTxResponse, evmObj *vm.EVM, 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 { - err = fmt.Errorf("CallContractError: %w", err) - } - }() - nonce := k.GetAccNonce(ctx, fromAcc) - - // Gas cap sufficient for all "honest" ERC20 calls without malicious (gas - // intensive) code in contracts - gasLimit := serverconfig.DefaultEthCallGasLimit - - unusedBigInt := big.NewInt(0) - evmMsg := gethcore.NewMessage( - fromAcc, - contract, - nonce, - unusedBigInt, // amount - gasLimit, - unusedBigInt, // gasFeeCap - unusedBigInt, // gasTipCap - unusedBigInt, // gasPrice - contractInput, - gethcore.AccessList{}, - !commit, // isFake - ) - - // Apply EVM message - evmCfg, err := k.GetEVMConfig( - ctx, - sdk.ConsAddress(ctx.BlockHeader().ProposerAddress), - k.EthChainID(ctx), - ) - if err != nil { - err = errors.Wrapf(err, "failed to load EVM config") - return - } - - // Generating TxConfig with an empty tx hash as there is no actual eth tx - // sent by a user - txConfig := k.TxConfig(ctx, gethcommon.BigToHash(big.NewInt(0))) - - // Using tmp context to not modify the state in case of evm revert - tmpCtx, commitCtx := ctx.CacheContext() - - evmResp, evmObj, err = k.ApplyEvmMsg( - tmpCtx, evmMsg, evm.NewNoOpTracer(), commit, evmCfg, txConfig, - ) - if err != nil { - // We don't know the actual gas used, so consuming the gas limit - k.ResetGasMeterAndConsumeGas(ctx, gasLimit) - err = errors.Wrap(err, "failed to apply ethereum core message") - return - } - if evmResp.Failed() { - k.ResetGasMeterAndConsumeGas(ctx, evmResp.GasUsed) - if evmResp.VmError != vm.ErrOutOfGas.Error() { - if evmResp.VmError == vm.ErrExecutionReverted.Error() { - err = fmt.Errorf("VMError: %w", evm.NewExecErrorWithReason(evmResp.Ret)) - return - } - err = fmt.Errorf("VMError: %s", evmResp.VmError) - return - } - err = fmt.Errorf("gas required exceeds allowance (%d)", gasLimit) - return - } else { - // Success, committing the state to ctx - if commit { - commitCtx() - totalGasUsed, err := k.AddToBlockGasUsed(ctx, evmResp.GasUsed) - if err != nil { - k.ResetGasMeterAndConsumeGas(ctx, ctx.GasMeter().Limit()) - return nil, nil, errors.Wrap(err, "error adding transient gas used to block") - } - k.ResetGasMeterAndConsumeGas(ctx, totalGasUsed) - k.updateBlockBloom(ctx, evmResp, uint64(txConfig.LogIndex)) - err = k.EmitEthereumTxEvents(ctx, contract, gethcore.LegacyTxType, evmMsg, evmResp) - if err != nil { - return nil, nil, errors.Wrap(err, "error emitting ethereum tx events") - } - blockTxIdx := uint64(txConfig.TxIndex) + 1 - k.EvmState.BlockTxIndex.Set(ctx, blockTxIdx) - } - return evmResp, evmObj, nil - } -} - func (k Keeper) LoadERC20Name( ctx sdk.Context, abi *gethabi.ABI, erc20 gethcommon.Address, ) (out string, err error) { @@ -289,10 +154,13 @@ func (k Keeper) LoadERC20String( methodName string, ) (out string, err error) { res, err := k.CallContract( - ctx, erc20Abi, + ctx, + erc20Abi, evm.EVM_MODULE_ADDRESS, &erc20Contract, - false, methodName, + false, + Erc20GasLimitQuery, + methodName, ) if err != nil { return out, err @@ -318,7 +186,9 @@ func (k Keeper) loadERC20Uint8( ctx, erc20Abi, evm.EVM_MODULE_ADDRESS, &erc20Contract, - false, methodName, + false, + Erc20GasLimitQuery, + methodName, ) if err != nil { return out, err @@ -347,6 +217,7 @@ func (k Keeper) LoadERC20BigInt( evm.EVM_MODULE_ADDRESS, &contract, false, + Erc20GasLimitQuery, methodName, args..., ) diff --git a/x/evm/keeper/funtoken_from_coin.go b/x/evm/keeper/funtoken_from_coin.go index 6f0f2efd0..feee20b43 100644 --- a/x/evm/keeper/funtoken_from_coin.go +++ b/x/evm/keeper/funtoken_from_coin.go @@ -79,7 +79,7 @@ func (k *Keeper) deployERC20ForBankCoin( // nil address for contract creation _, _, err = k.CallContractWithInput( - ctx, evm.EVM_MODULE_ADDRESS, nil, true, bytecodeForCall, + ctx, evm.EVM_MODULE_ADDRESS, nil, true, bytecodeForCall, Erc20GasLimitDeploy, ) if err != nil { return gethcommon.Address{}, errors.Wrap(err, "failed to deploy ERC20 contract") diff --git a/x/evm/keeper/funtoken_from_coin_test.go b/x/evm/keeper/funtoken_from_coin_test.go index 1f5e3a85a..070f4c960 100644 --- a/x/evm/keeper/funtoken_from_coin_test.go +++ b/x/evm/keeper/funtoken_from_coin_test.go @@ -257,6 +257,8 @@ func (s *FunTokenFromCoinSuite) TestConvertCoinToEvmAndBack() { ) s.Require().ErrorContains(err, "insufficient funds") + deps.ResetGasMeter() + s.T().Log("Convert erc-20 to back to bank coin") _, err = deps.EvmKeeper.CallContract( deps.Ctx, @@ -264,6 +266,7 @@ func (s *FunTokenFromCoinSuite) TestConvertCoinToEvmAndBack() { alice.EthAddr, &precompile.PrecompileAddr_FunToken, true, + precompile.FunTokenGasLimitBankSend, "bankSend", funTokenErc20Addr.Address, big.NewInt(10), @@ -291,6 +294,7 @@ func (s *FunTokenFromCoinSuite) TestConvertCoinToEvmAndBack() { alice.EthAddr, &precompile.PrecompileAddr_FunToken, true, + precompile.FunTokenGasLimitBankSend, "bankSend", funTokenErc20Addr.Address, big.NewInt(10), diff --git a/x/evm/keeper/funtoken_from_erc20_test.go b/x/evm/keeper/funtoken_from_erc20_test.go index eb209f7ca..9f798835e 100644 --- a/x/evm/keeper/funtoken_from_erc20_test.go +++ b/x/evm/keeper/funtoken_from_erc20_test.go @@ -202,6 +202,7 @@ func (s *FunTokenFromErc20Suite) TestSendFromEvmToBank() { deps.Sender.EthAddr, &deployResp.ContractAddr, true, + keeper.Erc20GasLimitExecute, "mint", deps.Sender.EthAddr, big.NewInt(69_420), @@ -210,6 +211,8 @@ func (s *FunTokenFromErc20Suite) TestSendFromEvmToBank() { randomAcc := testutil.AccAddress() + deps.ResetGasMeter() + s.T().Log("send erc20 tokens to Bank") _, err = deps.EvmKeeper.CallContract( deps.Ctx, @@ -217,6 +220,7 @@ func (s *FunTokenFromErc20Suite) TestSendFromEvmToBank() { deps.Sender.EthAddr, &precompile.PrecompileAddr_FunToken, true, + precompile.FunTokenGasLimitBankSend, "bankSend", deployResp.ContractAddr, big.NewInt(1), @@ -231,6 +235,8 @@ func (s *FunTokenFromErc20Suite) TestSendFromEvmToBank() { deps.App.BankKeeper.GetBalance(deps.Ctx, randomAcc, bankDemon).Amount, ) + deps.ResetGasMeter() + s.T().Log("sad: send too many erc20 tokens to Bank") evmResp, err := deps.EvmKeeper.CallContract( deps.Ctx, @@ -238,6 +244,7 @@ func (s *FunTokenFromErc20Suite) TestSendFromEvmToBank() { deps.Sender.EthAddr, &precompile.PrecompileAddr_FunToken, true, + precompile.FunTokenGasLimitBankSend, "bankSend", deployResp.ContractAddr, big.NewInt(70_000), @@ -246,6 +253,8 @@ func (s *FunTokenFromErc20Suite) TestSendFromEvmToBank() { s.T().Log("check balances") s.Require().Error(err, evmResp.String()) + deps.ResetGasMeter() + s.T().Log("send Bank tokens back to erc20") _, err = deps.EvmKeeper.ConvertCoinToEvm(sdk.WrapSDKContext(deps.Ctx), &evm.MsgConvertCoinToEvm{ @@ -358,6 +367,8 @@ func (s *FunTokenFromErc20Suite) TestFunTokenFromERC20MaliciousTransfer() { s.Require().NoError(err) randomAcc := testutil.AccAddress() + deps.ResetGasMeter() + s.T().Log("send erc20 tokens to cosmos") _, err = deps.EvmKeeper.CallContract( deps.Ctx, @@ -365,6 +376,7 @@ func (s *FunTokenFromErc20Suite) TestFunTokenFromERC20MaliciousTransfer() { deps.Sender.EthAddr, &precompile.PrecompileAddr_FunToken, true, + precompile.FunTokenGasLimitBankSend, "bankSend", deployResp.ContractAddr, big.NewInt(1), diff --git a/x/evm/keeper/grpc_query.go b/x/evm/keeper/grpc_query.go index 9cc9290e9..7d4f99c06 100644 --- a/x/evm/keeper/grpc_query.go +++ b/x/evm/keeper/grpc_query.go @@ -284,7 +284,7 @@ func (k *Keeper) EthCall( txConfig := statedb.NewEmptyTxConfig(gethcommon.BytesToHash(ctx.HeaderHash())) // pass false to not commit StateDB - res, _, err := k.ApplyEvmMsg(ctx, msg, nil, false, cfg, txConfig) + res, _, err := k.ApplyEvmMsg(ctx, msg, nil, false, cfg, txConfig, false) if err != nil { return nil, grpcstatus.Error(grpccodes.Internal, err.Error()) } @@ -422,7 +422,7 @@ func (k Keeper) EstimateGasForEvmCallType( WithTransientKVGasConfig(storetypes.GasConfig{}) } // pass false to not commit StateDB - rsp, _, err = k.ApplyEvmMsg(tmpCtx, msg, nil, false, cfg, txConfig) + rsp, _, err = k.ApplyEvmMsg(tmpCtx, msg, nil, false, cfg, txConfig, false) if err != nil { if errors.Is(err, core.ErrIntrinsicGas) { return true, nil, nil // Special case, raise gas limit @@ -518,7 +518,7 @@ func (k Keeper) TraceTx( ctx = ctx.WithGasMeter(eth.NewInfiniteGasMeterWithLimit(msg.Gas())). WithKVGasConfig(storetypes.GasConfig{}). WithTransientKVGasConfig(storetypes.GasConfig{}) - rsp, _, err := k.ApplyEvmMsg(ctx, msg, evm.NewNoOpTracer(), true, cfg, txConfig) + rsp, _, err := k.ApplyEvmMsg(ctx, msg, evm.NewNoOpTracer(), true, cfg, txConfig, false) if err != nil { continue } @@ -800,7 +800,7 @@ func (k *Keeper) TraceEthTxMsg( ctx = ctx.WithGasMeter(eth.NewInfiniteGasMeterWithLimit(msg.Gas())). WithKVGasConfig(storetypes.GasConfig{}). WithTransientKVGasConfig(storetypes.GasConfig{}) - res, _, err := k.ApplyEvmMsg(ctx, msg, tracer, commitMessage, cfg, txConfig) + res, _, err := k.ApplyEvmMsg(ctx, msg, tracer, commitMessage, cfg, txConfig, false) if err != nil { return nil, 0, grpcstatus.Error(grpccodes.Internal, err.Error()) } diff --git a/x/evm/keeper/keeper.go b/x/evm/keeper/keeper.go index 49ea0c9bf..e5ece3d4e 100644 --- a/x/evm/keeper/keeper.go +++ b/x/evm/keeper/keeper.go @@ -4,12 +4,12 @@ package keeper import ( "math/big" + "cosmossdk.io/math" "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/vm" gethparams "github.com/ethereum/go-ethereum/params" sdkerrors "cosmossdk.io/errors" - "cosmossdk.io/math" "github.com/cometbft/cometbft/libs/log" "github.com/cosmos/cosmos-sdk/codec" @@ -109,8 +109,11 @@ func (k *Keeper) AddToBlockGasUsed( return result, nil } -// GetMinGasMultiplier returns minimum gas multiplier. -func (k Keeper) GetMinGasMultiplier(ctx sdk.Context) math.LegacyDec { +// GetMinGasUsedMultiplier - value from 0 to 1 +// When executing evm msg, user specifies gasLimit. +// If the gasLimit is X times higher than the actual gasUsed then +// we update gasUsed = max(gasUsed, gasLimit * minGasUsedPct) +func (k Keeper) GetMinGasUsedMultiplier(ctx sdk.Context) math.LegacyDec { return math.LegacyNewDecWithPrec(50, 2) // 50% } diff --git a/x/evm/keeper/msg_server.go b/x/evm/keeper/msg_server.go index 89a249dd3..d1d368de1 100644 --- a/x/evm/keeper/msg_server.go +++ b/x/evm/keeper/msg_server.go @@ -20,9 +20,10 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/params" + "github.com/NibiruChain/nibiru/v2/x/evm/embeds" + "github.com/NibiruChain/nibiru/v2/eth" "github.com/NibiruChain/nibiru/v2/x/evm" - "github.com/NibiruChain/nibiru/v2/x/evm/embeds" "github.com/NibiruChain/nibiru/v2/x/evm/statedb" ) @@ -62,7 +63,7 @@ func (k *Keeper) EthereumTx( tmpCtx, commitCtx := ctx.CacheContext() // pass true to commit the StateDB - evmResp, _, err = k.ApplyEvmMsg(tmpCtx, evmMsg, nil, true, evmConfig, txConfig) + evmResp, _, err = k.ApplyEvmMsg(tmpCtx, evmMsg, nil, true, evmConfig, txConfig, false) if err != nil { // when a transaction contains multiple msg, as long as one of the msg fails // all gas will be deducted. so is not msg.Gas() @@ -234,18 +235,21 @@ func (k Keeper) GetHashFn(ctx sdk.Context) vm.GetHashFunc { // 1. set up the initial access list // // # Tracer parameter -// // It should be a `vm.Tracer` object or nil, if pass `nil`, it'll create a default one based on keeper options. // // # Commit parameter -// // If commit is true, the `StateDB` will be committed, otherwise discarded. +// +// # fullRefundLeftoverGas parameter +// For internal calls like funtokens, user does not specify gas limit explicitly. +// In this case we don't apply any caps for refund and refund 100% func (k *Keeper) ApplyEvmMsg(ctx sdk.Context, msg core.Message, tracer vm.EVMLogger, commit bool, evmConfig *statedb.EVMConfig, txConfig statedb.TxConfig, + fullRefundLeftoverGas bool, ) (resp *evm.MsgEthereumTxResponse, evmObj *vm.EVM, err error) { var ( ret []byte // return bytes from evm execution @@ -328,21 +332,6 @@ func (k *Keeper) ApplyEvmMsg(ctx sdk.Context, ) } - // After EIP-3529: refunds are capped to gasUsed / 5 - refundQuotient := params.RefundQuotientEIP3529 - - // calculate gas refund - if msg.Gas() < leftoverGas { - return nil, evmObj, errors.Wrap(evm.ErrGasOverflow, "apply message") - } - // refund gas - temporaryGasUsed := msg.Gas() - leftoverGas - refund := GasToRefund(stateDB.GetRefund(), temporaryGasUsed, refundQuotient) - - // update leftoverGas and temporaryGasUsed with refund amount - leftoverGas += refund - temporaryGasUsed -= refund - // EVM execution error needs to be available for the JSON-RPC client var vmError string if vmErr != nil { @@ -355,19 +344,35 @@ func (k *Keeper) ApplyEvmMsg(ctx sdk.Context, return nil, evmObj, fmt.Errorf("failed to commit stateDB: %w", err) } } + // Rare case of uint64 gas overflow + if msg.Gas() < leftoverGas { + return nil, evmObj, errors.Wrap(evm.ErrGasOverflow, "apply message") + } - gasLimit := math.LegacyNewDec(int64(msg.Gas())) - minGasMultiplier := k.GetMinGasMultiplier(ctx) - minimumGasUsed := gasLimit.Mul(minGasMultiplier) + // GAS REFUND + // If msg.Gas() > gasUsed, we need to refund extra gas. + // leftoverGas = amount of extra (not used) gas. + // If the msg comes from user, we apply refundQuotient capping the refund to 20% of used gas + // If msg is internal (funtoken), we refund 100% - if !minimumGasUsed.TruncateInt().IsUint64() { - return nil, evmObj, errors.Wrapf(evm.ErrGasOverflow, "minimumGasUsed(%s) is not a uint64", minimumGasUsed.TruncateInt().String()) + refundQuotient := params.RefundQuotientEIP3529 // EIP-3529: refunds are capped to gasUsed / 5 + minGasUsedPct := k.GetMinGasUsedMultiplier(ctx) // Evmos invention: https://github.com/evmos/ethermint/issues/1085 + if fullRefundLeftoverGas { + refundQuotient = 1 // 100% refund + minGasUsedPct = math.LegacyZeroDec() // no minimum, get the actual gasUsed value } + temporaryGasUsed := msg.Gas() - leftoverGas + refund := GasToRefund(stateDB.GetRefund(), temporaryGasUsed, refundQuotient) + // update leftoverGas and temporaryGasUsed with refund amount + leftoverGas += refund + temporaryGasUsed -= refund if msg.Gas() < leftoverGas { return nil, evmObj, errors.Wrapf(evm.ErrGasOverflow, "message gas limit < leftover gas (%d < %d)", msg.Gas(), leftoverGas) } + // Min gas used is a % of gasLimit + minimumGasUsed := math.LegacyNewDec(int64(msg.Gas())).Mul(minGasUsedPct) gasUsed := math.LegacyMaxDec(minimumGasUsed, math.LegacyNewDec(int64(temporaryGasUsed))).TruncateInt().Uint64() // This resulting "leftoverGas" is used by the tracer. This happens as a @@ -521,6 +526,7 @@ func (k Keeper) convertCoinNativeCoin( evm.EVM_MODULE_ADDRESS, &erc20Addr, true, + Erc20GasLimitExecute, "mint", recipient, coin.Amount.BigInt(), diff --git a/x/evm/precompile/funtoken.go b/x/evm/precompile/funtoken.go index 5c585c2e9..a4062a3bf 100644 --- a/x/evm/precompile/funtoken.go +++ b/x/evm/precompile/funtoken.go @@ -28,6 +28,18 @@ var _ vm.PrecompiledContract = (*precompileFunToken)(nil) // using the ERC20's `FunToken` mapping. var PrecompileAddr_FunToken = gethcommon.HexToAddress("0x0000000000000000000000000000000000000800") +const ( + // FunTokenGasLimitBankSend consists of gas for 3 calls: + // 1. transfer erc20 from sender to module + // ~60_000 gas for regular erc20 transfer (our own ERC20Minter contract) + // could be higher for user created contracts, let's cap with 200_000 + // 2. mint native coin (made from erc20) or burn erc20 token (made from coin) + // ~60_000 gas for either mint or burn + // 3. send from module to account: + // ~65_000 gas (bank send) + FunTokenGasLimitBankSend uint64 = 400_000 +) + func (p precompileFunToken) Address() gethcommon.Address { return PrecompileAddr_FunToken } @@ -59,6 +71,9 @@ func (p precompileFunToken) Run( return nil, err } + // Resets the gas meter to parent one after precompile execution and gracefully handles "out of gas" + defer ReturnToParentGasMeter(start.Ctx, contract, start.parentGasMeter, &err)() + method := start.Method switch PrecompileMethod(method.Name) { case FunTokenMethod_BankSend: @@ -72,6 +87,13 @@ func (p precompileFunToken) Run( if err != nil { return nil, err } + + // Gas consumed by a local gas meter + gasUsed := start.Ctx.GasMeter().GasConsumed() + if !contract.UseGas(gasUsed) { + return nil, vm.ErrOutOfGas + } + // Dirty journal entries in `StateDB` must be committed return bz, start.StateDB.Commit() } diff --git a/x/evm/precompile/funtoken_test.go b/x/evm/precompile/funtoken_test.go index dd5176fb3..4f5492a8b 100644 --- a/x/evm/precompile/funtoken_test.go +++ b/x/evm/precompile/funtoken_test.go @@ -8,6 +8,8 @@ import ( gethcommon "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/suite" + "github.com/NibiruChain/nibiru/v2/x/evm/keeper" + "github.com/NibiruChain/nibiru/v2/eth" "github.com/NibiruChain/nibiru/v2/x/common/testutil" "github.com/NibiruChain/nibiru/v2/x/common/testutil/testapp" @@ -25,6 +27,16 @@ func TestSuite(t *testing.T) { type FuntokenSuite struct { suite.Suite + deps evmtest.TestDeps + funtoken evm.FunToken +} + +func (s *FuntokenSuite) SetupSuite() { + s.deps = evmtest.NewTestDeps() + + s.T().Log("Create FunToken from coin") + bankDenom := "unibi" + s.funtoken = evmtest.CreateFunTokenForBankCoin(&s.deps, bankDenom, &s.Suite) } func (s *FuntokenSuite) TestFailToPackABI() { @@ -78,12 +90,8 @@ func (s *FuntokenSuite) TestFailToPackABI() { } func (s *FuntokenSuite) TestHappyPath() { - deps := evmtest.NewTestDeps() - - s.T().Log("Create FunToken mapping and ERC20") - bankDenom := "unibi" - funtoken := evmtest.CreateFunTokenForBankCoin(&deps, bankDenom, &s.Suite) - erc20 := funtoken.Erc20Addr.Address + deps := s.deps + erc20 := s.funtoken.Erc20Addr.Address s.T().Log("Balances of the ERC20 should start empty") evmtest.AssertERC20BalanceEqual(s.T(), deps, erc20, deps.Sender.EthAddr, big.NewInt(0)) @@ -93,14 +101,14 @@ func (s *FuntokenSuite) TestHappyPath() { deps.App.BankKeeper, deps.Ctx, deps.Sender.NibiruAddr, - sdk.NewCoins(sdk.NewCoin(bankDenom, sdk.NewInt(69_420))), + sdk.NewCoins(sdk.NewCoin(s.funtoken.BankDenom, sdk.NewInt(69_420))), )) _, err := deps.EvmKeeper.ConvertCoinToEvm( sdk.WrapSDKContext(deps.Ctx), &evm.MsgConvertCoinToEvm{ Sender: deps.Sender.NibiruAddr.String(), - BankCoin: sdk.NewCoin(bankDenom, sdk.NewInt(69_420)), + BankCoin: sdk.NewCoin(s.funtoken.BankDenom, sdk.NewInt(69_420)), ToEthAddr: eth.EIP55Addr{ Address: deps.Sender.EthAddr, }, @@ -113,7 +121,7 @@ func (s *FuntokenSuite) TestHappyPath() { input, err := embeds.SmartContract_ERC20Minter.ABI.Pack("mint", deps.Sender.EthAddr, big.NewInt(69_420)) s.NoError(err) _, _, err = deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &erc20, true, input, + deps.Ctx, deps.Sender.EthAddr, &erc20, true, input, keeper.Erc20GasLimitQuery, ) s.ErrorContains(err, "Ownable: caller is not the owner") } @@ -121,23 +129,91 @@ func (s *FuntokenSuite) TestHappyPath() { randomAcc := testutil.AccAddress() s.T().Log("Send using precompile") - amtToSend := int64(420) - callArgs := []any{erc20, big.NewInt(amtToSend), randomAcc.String()} - input, err := embeds.SmartContract_FunToken.ABI.Pack(string(precompile.FunTokenMethod_BankSend), callArgs...) - s.NoError(err) - - _, resp, err := evmtest.CallContractTx( - &deps, - precompile.PrecompileAddr_FunToken, - input, - deps.Sender, + { + amtToSend := int64(420) + callArgs := []any{erc20, big.NewInt(amtToSend), randomAcc.String()} + input, err := embeds.SmartContract_FunToken.ABI.Pack(string(precompile.FunTokenMethod_BankSend), callArgs...) + s.NoError(err) + + _, resp, err := evmtest.CallContractTx( + &deps, + precompile.PrecompileAddr_FunToken, + input, + deps.Sender, + ) + s.Require().NoError(err) + s.Require().Empty(resp.VmError) + + evmtest.AssertERC20BalanceEqual(s.T(), deps, erc20, deps.Sender.EthAddr, big.NewInt(69_000)) + evmtest.AssertERC20BalanceEqual(s.T(), deps, erc20, evm.EVM_MODULE_ADDRESS, big.NewInt(0)) + s.Equal(sdk.NewInt(420).String(), + deps.App.BankKeeper.GetBalance(deps.Ctx, randomAcc, s.funtoken.BankDenom).Amount.String(), + ) + } +} + +func (s *FuntokenSuite) TestPrecompileLocalGas() { + deps := s.deps + randomAcc := testutil.AccAddress() + + deployResp, err := evmtest.DeployContract( + &deps, embeds.SmartContract_TestFunTokenPrecompileLocalGas, + s.funtoken.Erc20Addr.Address, ) s.Require().NoError(err) - s.Require().Empty(resp.VmError) + contractAddr := deployResp.ContractAddr - evmtest.AssertERC20BalanceEqual(s.T(), deps, erc20, deps.Sender.EthAddr, big.NewInt(69_000)) - evmtest.AssertERC20BalanceEqual(s.T(), deps, erc20, evm.EVM_MODULE_ADDRESS, big.NewInt(0)) - s.Equal(sdk.NewInt(420).String(), - deps.App.BankKeeper.GetBalance(deps.Ctx, randomAcc, funtoken.BankDenom).Amount.String(), + s.T().Log("Fund sender's wallet") + s.Require().NoError(testapp.FundAccount( + deps.App.BankKeeper, + deps.Ctx, + deps.Sender.NibiruAddr, + sdk.NewCoins(sdk.NewCoin(s.funtoken.BankDenom, sdk.NewInt(1000))), + )) + + s.T().Log("Fund contract with erc20 coins") + _, err = deps.EvmKeeper.ConvertCoinToEvm( + sdk.WrapSDKContext(deps.Ctx), + &evm.MsgConvertCoinToEvm{ + Sender: deps.Sender.NibiruAddr.String(), + BankCoin: sdk.NewCoin(s.funtoken.BankDenom, sdk.NewInt(1000)), + ToEthAddr: eth.EIP55Addr{ + Address: contractAddr, + }, + }, ) + s.Require().NoError(err) + + s.deps.ResetGasMeter() + + s.T().Log("Happy: callBankSend with default gas") + _, err = deps.EvmKeeper.CallContract( + deps.Ctx, + embeds.SmartContract_TestFunTokenPrecompileLocalGas.ABI, + deps.Sender.EthAddr, + &contractAddr, + true, + precompile.FunTokenGasLimitBankSend, + "callBankSend", + big.NewInt(1), + randomAcc.String(), + ) + s.Require().NoError(err) + + s.deps.ResetGasMeter() + + s.T().Log("Happy: callBankSend with local gas - sufficient gas amount") + _, err = deps.EvmKeeper.CallContract( + deps.Ctx, + embeds.SmartContract_TestFunTokenPrecompileLocalGas.ABI, + deps.Sender.EthAddr, + &contractAddr, + true, + precompile.FunTokenGasLimitBankSend, // gasLimit for the entire call + "callBankSendLocalGas", + big.NewInt(1), // erc20 amount + randomAcc.String(), // to + big.NewInt(int64(precompile.FunTokenGasLimitBankSend)), // customGas + ) + s.Require().NoError(err) } diff --git a/x/evm/precompile/oracle.go b/x/evm/precompile/oracle.go index fb0b2981b..f5d7bb118 100644 --- a/x/evm/precompile/oracle.go +++ b/x/evm/precompile/oracle.go @@ -38,11 +38,14 @@ func (p precompileOracle) Run( defer func() { err = ErrPrecompileRun(err, p) }() - res, err := OnRunStart(evm, contract, embeds.SmartContract_Oracle.ABI) + start, err := OnRunStart(evm, contract, embeds.SmartContract_Oracle.ABI) if err != nil { return nil, err } - method, args, ctx := res.Method, res.Args, res.Ctx + method, args, ctx := start.Method, start.Args, start.Ctx + + // Resets the gas meter to parent one after precompile execution and gracefully handles "out of gas" + defer ReturnToParentGasMeter(start.Ctx, contract, start.parentGasMeter, &err)() switch PrecompileMethod(method.Name) { case OracleMethod_queryExchangeRate: @@ -52,6 +55,12 @@ func (p precompileOracle) Run( return } + // Gas consumed by a local gas meter + gasUsed := start.Ctx.GasMeter().GasConsumed() + if !contract.UseGas(gasUsed) { + return nil, vm.ErrOutOfGas + } + return } diff --git a/x/evm/precompile/oracle_test.go b/x/evm/precompile/oracle_test.go index efab80118..c90b5329c 100644 --- a/x/evm/precompile/oracle_test.go +++ b/x/evm/precompile/oracle_test.go @@ -12,6 +12,8 @@ import ( "github.com/NibiruChain/nibiru/v2/x/evm/precompile" ) +const OracleGasLimitQuery = 100_000 + func (s *OracleSuite) TestOracle_FailToPackABI() { testcases := []struct { name string @@ -59,7 +61,12 @@ func (s *OracleSuite) TestOracle_HappyPath() { input, err := embeds.SmartContract_Oracle.ABI.Pack("queryExchangeRate", "unibi:uusd") s.NoError(err) resp, _, err := deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &precompile.PrecompileAddr_Oracle, true, input, + deps.Ctx, + deps.Sender.EthAddr, + &precompile.PrecompileAddr_Oracle, + false, + input, + OracleGasLimitQuery, ) s.NoError(err) diff --git a/x/evm/precompile/precompile.go b/x/evm/precompile/precompile.go index a6bbfefc4..02ae636af 100644 --- a/x/evm/precompile/precompile.go +++ b/x/evm/precompile/precompile.go @@ -19,6 +19,7 @@ import ( "github.com/NibiruChain/collections" store "github.com/cosmos/cosmos-sdk/store/types" + storetypes "github.com/cosmos/cosmos-sdk/store/types" gethabi "github.com/ethereum/go-ethereum/accounts/abi" gethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/vm" @@ -147,6 +148,8 @@ type OnRunStartResult struct { Method *gethabi.Method StateDB *statedb.StateDB + + parentGasMeter sdk.GasMeter } // OnRunStart prepares the execution environment for a precompiled contract call. @@ -193,11 +196,19 @@ func OnRunStart( return res, fmt.Errorf("error committing dirty journal entries: %w", err) } + // Temporarily switching to a local gas meter to enforce gas limit check for a precompile + // returning parent gas meter after execution or failure + parentGasMeter := ctx.GasMeter() + ctx = ctx.WithGasMeter(storetypes.NewGasMeter(contract.Gas)). + WithKVGasConfig(storetypes.GasConfig{}). + WithTransientKVGasConfig(storetypes.GasConfig{}) + return OnRunStartResult{ - Args: args, - Ctx: ctx, - Method: method, - StateDB: stateDB, + Args: args, + Ctx: ctx, + Method: method, + StateDB: stateDB, + parentGasMeter: parentGasMeter, }, nil } @@ -212,3 +223,25 @@ var precompileMethodIsTxMap map[PrecompileMethod]bool = map[PrecompileMethod]boo OracleMethod_queryExchangeRate: false, } + +// ReturnToParentGasMeter resets the ctx.GasMeter back to a parent GasMeter before precompile execution. +// Additionally, handles the out of gas panic by resetting the gas meter and returning an error. +// This is used in order to avoid panics and to allow for the EVM to continue cleanup if the tx or query run out of gas. +func ReturnToParentGasMeter(ctx sdk.Context, contract *vm.Contract, parentGasMeter sdk.GasMeter, err *error) func() { + return func() { + if r := recover(); r != nil { + switch r.(type) { + case storetypes.ErrorOutOfGas: + _ = contract.UseGas(ctx.GasMeter().GasConsumed()) + + *err = vm.ErrOutOfGas + default: + panic(r) + } + } + // Back to parent ctx gas meter (before entering precompile) + ctx = ctx.WithGasMeter(parentGasMeter). + WithKVGasConfig(storetypes.GasConfig{}). + WithTransientKVGasConfig(storetypes.GasConfig{}) + } +} diff --git a/x/evm/precompile/test/export.go b/x/evm/precompile/test/export.go index 966dd3359..45a251a4d 100644 --- a/x/evm/precompile/test/export.go +++ b/x/evm/precompile/test/export.go @@ -21,6 +21,12 @@ import ( "github.com/NibiruChain/nibiru/v2/x/evm/statedb" ) +// rough gas limits for wasm execution - used in tests only +const ( + WasmGasLimitInstantiate uint64 = 1_000_000 + WasmGasLimitExecute uint64 = 10_000_000 +) + // SetupWasmContracts stores all Wasm bytecode and has the "deps.Sender" // instantiate each Wasm contract using the precompile. func SetupWasmContracts(deps *evmtest.TestDeps, s *suite.Suite) ( @@ -71,7 +77,12 @@ func SetupWasmContracts(deps *evmtest.TestDeps, s *suite.Suite) ( s.Require().NoError(err) ethTxResp, evmObj, err := deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &precompile.PrecompileAddr_Wasm, true, input, + deps.Ctx, + deps.Sender.EthAddr, + &precompile.PrecompileAddr_Wasm, + true, + input, + WasmGasLimitInstantiate, ) s.Require().NoError(err) s.Require().NotEmpty(ethTxResp.Ret) @@ -186,7 +197,12 @@ func AssertWasmCounterState( s.Require().NoError(err) ethTxResp, evmObj, err := deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &precompile.PrecompileAddr_Wasm, true, input, + deps.Ctx, + deps.Sender.EthAddr, + &precompile.PrecompileAddr_Wasm, + true, + input, + WasmGasLimitInstantiate, ) s.Require().NoError(err) s.Require().NotEmpty(ethTxResp.Ret) @@ -307,8 +323,15 @@ func IncrementWasmCounterWithExecuteMulti( ) s.Require().NoError(err) + deps.ResetGasMeter() + ethTxResp, evmObj, err := deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &precompile.PrecompileAddr_Wasm, true, input, + deps.Ctx, + deps.Sender.EthAddr, + &precompile.PrecompileAddr_Wasm, + true, + input, + WasmGasLimitExecute, ) s.Require().NoError(err) s.Require().NotEmpty(ethTxResp.Ret) diff --git a/x/evm/precompile/wasm.go b/x/evm/precompile/wasm.go index 10817c673..a8fe80962 100644 --- a/x/evm/precompile/wasm.go +++ b/x/evm/precompile/wasm.go @@ -42,6 +42,9 @@ func (p precompileWasm) Run( } method := start.Method + // Resets the gas meter to parent one after precompile execution and gracefully handles "out of gas" + defer ReturnToParentGasMeter(start.Ctx, contract, start.parentGasMeter, &err)() + switch PrecompileMethod(method.Name) { case WasmMethod_execute: bz, err = p.execute(start, contract.CallerAddress, readonly) @@ -63,6 +66,12 @@ func (p precompileWasm) Run( return nil, err } + // Gas consumed by a local gas meter + gasUsed := start.Ctx.GasMeter().GasConsumed() + if !contract.UseGas(gasUsed) { + return nil, vm.ErrOutOfGas + } + // Dirty journal entries in `StateDB` must be committed return bz, start.StateDB.Commit() } diff --git a/x/evm/precompile/wasm_test.go b/x/evm/precompile/wasm_test.go index d796f8b89..f122625de 100644 --- a/x/evm/precompile/wasm_test.go +++ b/x/evm/precompile/wasm_test.go @@ -18,6 +18,12 @@ import ( "github.com/stretchr/testify/suite" ) +// rough gas limits for wasm execution - used in tests only +const ( + WasmGasLimitQuery uint64 = 200_000 + WasmGasLimitExecute uint64 = 1_000_000 +) + type WasmSuite struct { suite.Suite } @@ -53,7 +59,12 @@ func (s *WasmSuite) TestExecuteHappy() { s.Require().NoError(err) ethTxResp, _, err := deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &precompile.PrecompileAddr_Wasm, true, input, + deps.Ctx, + deps.Sender.EthAddr, + &precompile.PrecompileAddr_Wasm, + true, + input, + WasmGasLimitExecute, ) s.Require().NoError(err) s.Require().NotEmpty(ethTxResp.Ret) @@ -82,7 +93,12 @@ func (s *WasmSuite) TestExecuteHappy() { ) s.Require().NoError(err) ethTxResp, _, err = deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &precompile.PrecompileAddr_Wasm, true, input, + deps.Ctx, + deps.Sender.EthAddr, + &precompile.PrecompileAddr_Wasm, + true, + input, + WasmGasLimitExecute, ) s.Require().NoError(err) s.Require().NotEmpty(ethTxResp.Ret) @@ -136,8 +152,15 @@ func (s *WasmSuite) assertWasmCounterStateRaw( ) s.Require().NoError(err) + deps.ResetGasMeter() + ethTxResp, _, err := deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &precompile.PrecompileAddr_Wasm, true, input, + deps.Ctx, + deps.Sender.EthAddr, + &precompile.PrecompileAddr_Wasm, + true, + input, + WasmGasLimitQuery, ) s.Require().NoError(err) s.Require().NotEmpty(ethTxResp.Ret) @@ -307,7 +330,12 @@ func (s *WasmSuite) TestSadArgsExecute() { s.Require().NoError(err) ethTxResp, _, err := deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &precompile.PrecompileAddr_Wasm, true, input, + deps.Ctx, + deps.Sender.EthAddr, + &precompile.PrecompileAddr_Wasm, + true, + input, + WasmGasLimitExecute, ) s.Require().ErrorContains(err, tc.wantError, "ethTxResp %v", ethTxResp) }) diff --git a/x/evm/statedb/journal_test.go b/x/evm/statedb/journal_test.go index 5863face5..a8185097a 100644 --- a/x/evm/statedb/journal_test.go +++ b/x/evm/statedb/journal_test.go @@ -9,6 +9,8 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" "github.com/ethereum/go-ethereum/core/vm" + "github.com/NibiruChain/nibiru/v2/x/evm/keeper" + serverconfig "github.com/NibiruChain/nibiru/v2/app/server/config" "github.com/NibiruChain/nibiru/v2/x/common/testutil/testapp" "github.com/NibiruChain/nibiru/v2/x/evm" @@ -65,7 +67,12 @@ func (s *Suite) TestPrecompileSnapshots() { input, err := deps.EvmKeeper.ERC20().ABI.Pack("mint", to, amount) s.Require().NoError(err) _, evmObj, err := deps.EvmKeeper.CallContractWithInput( - deps.Ctx, deps.Sender.EthAddr, &contract, true, input, + deps.Ctx, + deps.Sender.EthAddr, + &contract, + true, + input, + keeper.Erc20GasLimitExecute, ) s.Require().NoError(err)