diff --git a/CHANGELOG.md b/CHANGELOG.md index 50af1cb18..5f754fc93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,7 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#2003](https://github.com/NibiruChain/nibiru/pull/2003) - fix(evm): fix FunToken conversions between Cosmos and EVM - [#2004](https://github.com/NibiruChain/nibiru/pull/2004) - refactor(evm)!: replace `HexAddr` with `EIP55Addr` - [#2006](https://github.com/NibiruChain/nibiru/pull/2006) - test(evm): e2e tests for eth_* endpoints -- [#2008](https://github.com/NibiruChain/nibiru/pull/2008) - refactor(evm): clean up precompile setups +- [#2008](https://github.com/NibiruChain/nibiru/pull/2008) - refactor(evm): clean up precompile setups - [#2013](https://github.com/NibiruChain/nibiru/pull/2013) - chore(evm): Set appropriate gas value for the required gas of the "IFunToken.sol" precompile. - [#2014](https://github.com/NibiruChain/nibiru/pull/2014) - feat(evm): Emit block bloom event in EndBlock hook. - [#2017](https://github.com/NibiruChain/nibiru/pull/2017) - fix(evm): Fix DynamicFeeTx gas cap parameters @@ -119,13 +119,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#2023](https://github.com/NibiruChain/nibiru/pull/2023) - fix(evm)!: adjusted generation and parsing of the block bloom events - [#2030](https://github.com/NibiruChain/nibiru/pull/2030) - refactor(eth/rpc): Delete unused code and improve logging in the eth and debug namespaces - [#2031](https://github.com/NibiruChain/nibiru/pull/2031) - fix(evm): debug calls with custom tracer and tracer options -- [#2032](https://github.com/NibiruChain/nibiru/pull/2032) - feat(evm): ante handler to prohibit authz grant evm messages +- [#2032](https://github.com/NibiruChain/nibiru/pull/2032) - feat(evm): ante handler to prohibit authz grant evm messages - [#2039](https://github.com/NibiruChain/nibiru/pull/2039) - refactor(rpc-backend): remove unnecessary interface code - [#2044](https://github.com/NibiruChain/nibiru/pull/2044) - feat(evm): evm tx indexer service implemented - [#2045](https://github.com/NibiruChain/nibiru/pull/2045) - test(evm): backend tests with test network and real txs - [#2053](https://github.com/NibiruChain/nibiru/pull/2053) - refactor(evm): converted untyped event to typed and cleaned up - [#2054](https://github.com/NibiruChain/nibiru/pull/2054) - feat(evm-precompile): Precompile for one-way EVM calls to invoke/execute Wasm contracts. - [#2060](https://github.com/NibiruChain/nibiru/pull/2060) - fix(evm-precompiles): add assertNumArgs validation +- [#2056](https://github.com/NibiruChain/nibiru/pull/2056) - feat(evm): add oracle precompile #### Dapp modules: perp, spot, oracle, etc diff --git a/x/evm/embeds/artifacts/contracts/IOracle.sol/IOracle.json b/x/evm/embeds/artifacts/contracts/IOracle.sol/IOracle.json new file mode 100644 index 000000000..39638bdae --- /dev/null +++ b/x/evm/embeds/artifacts/contracts/IOracle.sol/IOracle.json @@ -0,0 +1,30 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "IOracle", + "sourceName": "contracts/IOracle.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "string", + "name": "pair", + "type": "string" + } + ], + "name": "queryExchangeRate", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "0x", + "deployedBytecode": "0x", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/x/evm/embeds/contracts/IOracle.sol b/x/evm/embeds/contracts/IOracle.sol new file mode 100644 index 000000000..7cb0a820a --- /dev/null +++ b/x/evm/embeds/contracts/IOracle.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.19; + +/// @notice Oracle interface for querying exchange rates +interface IOracle { + /// @notice Queries the exchange rate for a given pair + /// @param pair The asset pair to query. For example, "ubtc:uusd" is the + /// USD price of BTC and "unibi:uusd" is the USD price of NIBI. + /// @return The exchange rate (a decimal value) as a string. + /// @dev This function is view-only and does not modify state. + function queryExchangeRate(string memory pair) external view returns (string memory); +} + +address constant ORACLE_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000801; + +IOracle constant ORACLE_GATEWAY = IOracle(ORACLE_PRECOMPILE_ADDRESS); diff --git a/x/evm/embeds/embeds.go b/x/evm/embeds/embeds.go index 0103b78c1..b2535040c 100644 --- a/x/evm/embeds/embeds.go +++ b/x/evm/embeds/embeds.go @@ -17,6 +17,8 @@ import ( var ( //go:embed artifacts/contracts/ERC20Minter.sol/ERC20Minter.json erc20MinterContractJSON []byte + //go:embed artifacts/contracts/IOracle.sol/IOracle.json + oracleContractJSON []byte //go:embed artifacts/contracts/FunToken.sol/IFunToken.json funtokenPrecompileJSON []byte //go:embed artifacts/contracts/Wasm.sol/IWasm.json @@ -48,7 +50,10 @@ var ( Name: "Wasm.sol", EmbedJSON: wasmPrecompileJSON, } - + SmartContract_Oracle = CompiledEvmContract{ + Name: "Oracle.sol", + EmbedJSON: oracleContractJSON, + } SmartContract_TestERC20 = CompiledEvmContract{ Name: "TestERC20.sol", EmbedJSON: testErc20Json, @@ -59,6 +64,7 @@ func init() { SmartContract_ERC20Minter.MustLoad() SmartContract_FunToken.MustLoad() SmartContract_Wasm.MustLoad() + SmartContract_Oracle.MustLoad() SmartContract_TestERC20.MustLoad() } diff --git a/x/evm/precompile/oracle.go b/x/evm/precompile/oracle.go new file mode 100644 index 000000000..7c3a57af5 --- /dev/null +++ b/x/evm/precompile/oracle.go @@ -0,0 +1,127 @@ +package precompile + +import ( + "fmt" + "reflect" + + 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" + gethparams "github.com/ethereum/go-ethereum/params" + + "github.com/NibiruChain/nibiru/v2/app/keepers" + "github.com/NibiruChain/nibiru/v2/x/common/asset" + "github.com/NibiruChain/nibiru/v2/x/evm/embeds" + "github.com/NibiruChain/nibiru/v2/x/evm/statedb" + oraclekeeper "github.com/NibiruChain/nibiru/v2/x/oracle/keeper" +) + +var _ vm.PrecompiledContract = (*precompileOracle)(nil) + +// Precompile address for "Oracle.sol", the contract that enables queries for exchange rates +var PrecompileAddr_Oracle = gethcommon.HexToAddress("0x0000000000000000000000000000000000000801") + +func (p precompileOracle) Address() gethcommon.Address { + return PrecompileAddr_Oracle +} + +func (p precompileOracle) RequiredGas(input []byte) (gasPrice uint64) { + // Since [gethparams.TxGas] is the cost per (Ethereum) transaction that does not create + // a contract, it's value can be used to derive an appropriate value for the precompile call. + return gethparams.TxGas +} + +const ( + OracleMethod_QueryExchangeRate OracleMethod = "queryExchangeRate" +) + +type OracleMethod string + +// Run runs the precompiled contract +func (p precompileOracle) 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) + } + }() + + // 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() + + method, args, err := DecomposeInput(embeds.SmartContract_Oracle.ABI, contract.Input) + if err != nil { + return nil, err + } + + switch OracleMethod(method.Name) { + case OracleMethod_QueryExchangeRate: + bz, err = p.queryExchangeRate(ctx, method, args, readonly) + default: + err = fmt.Errorf("invalid method called with name \"%s\"", method.Name) + return + } + + return +} + +func PrecompileOracle(keepers keepers.PublicKeepers) vm.PrecompiledContract { + return precompileOracle{ + oracleKeeper: keepers.OracleKeeper, + } +} + +type precompileOracle struct { + oracleKeeper oraclekeeper.Keeper +} + +func (p precompileOracle) queryExchangeRate( + ctx sdk.Context, + method *gethabi.Method, + args []interface{}, + readOnly bool, +) (bz []byte, err error) { + pair, err := p.decomposeQueryExchangeRateArgs(args) + if err != nil { + return nil, err + } + assetPair, err := asset.TryNewPair(pair) + if err != nil { + return nil, err + } + + price, err := p.oracleKeeper.GetExchangeRate(ctx, assetPair) + if err != nil { + return nil, err + } + + return method.Outputs.Pack(price.String()) +} + +func (p precompileOracle) decomposeQueryExchangeRateArgs(args []any) ( + pair string, + err error, +) { + if len(args) != 1 { + err = fmt.Errorf("expected 3 arguments but got %d", len(args)) + return + } + + pair, ok := args[0].(string) + if !ok { + err = ErrArgTypeValidation("string pair", args[0]) + return + } + + return pair, nil +} diff --git a/x/evm/precompile/oracle_test.go b/x/evm/precompile/oracle_test.go new file mode 100644 index 000000000..4d8d0116e --- /dev/null +++ b/x/evm/precompile/oracle_test.go @@ -0,0 +1,82 @@ +package precompile_test + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/suite" + + "github.com/NibiruChain/nibiru/v2/x/evm/embeds" + "github.com/NibiruChain/nibiru/v2/x/evm/evmtest" + "github.com/NibiruChain/nibiru/v2/x/evm/precompile" +) + +func (s *OracleSuite) TestOracle_FailToPackABI() { + testcases := []struct { + name string + methodName string + callArgs []any + wantError string + }{ + { + name: "wrong amount of call args", + methodName: string(precompile.OracleMethod_QueryExchangeRate), + callArgs: []any{"nonsense", "args here", "to see if", "precompile is", "called"}, + wantError: "argument count mismatch: got 5 for 1", + }, + { + name: "wrong type for pair", + methodName: string(precompile.OracleMethod_QueryExchangeRate), + callArgs: []any{common.HexToAddress("0x7D4B7B8CA7E1a24928Bb96D59249c7a5bd1DfBe6")}, + wantError: "abi: cannot use array as type string as argument", + }, + { + name: "invalid method name", + methodName: "foo", + callArgs: []any{"ubtc:uusdc"}, + wantError: "method 'foo' not found", + }, + } + + abi := embeds.SmartContract_Oracle.ABI + + for _, tc := range testcases { + s.Run(tc.name, func() { + input, err := abi.Pack(tc.methodName, tc.callArgs...) + s.ErrorContains(err, tc.wantError) + s.Nil(input) + }) + } +} + +func (s *OracleSuite) TestOracle_HappyPath() { + deps := evmtest.NewTestDeps() + + s.T().Log("Query exchange rate") + { + deps.App.OracleKeeper.SetPrice(deps.Ctx, "unibi:uusd", sdk.MustNewDecFromStr("0.067")) + 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, + ) + s.NoError(err) + + // Check the response + out, err := embeds.SmartContract_Oracle.ABI.Unpack(string(precompile.OracleMethod_QueryExchangeRate), resp.Ret) + s.NoError(err) + + // Check the response + s.Equal("0.067000000000000000", out[0].(string)) + } +} + +type OracleSuite struct { + suite.Suite +} + +// TestPrecompileSuite: Runs all the tests in the suite. +func TestOracleSuite(t *testing.T) { + suite.Run(t, new(OracleSuite)) +} diff --git a/x/evm/precompile/precompile.go b/x/evm/precompile/precompile.go index 6bba3145e..38a8744c1 100644 --- a/x/evm/precompile/precompile.go +++ b/x/evm/precompile/precompile.go @@ -49,6 +49,7 @@ func InitPrecompiles( for _, precompileSetupFn := range []func(k keepers.PublicKeepers) vm.PrecompiledContract{ PrecompileFunToken, PrecompileWasm, + PrecompileOracle, } { pc := precompileSetupFn(k) precompiles[pc.Address()] = pc