Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(evm): add oracle precompile #2056

Merged
merged 12 commits into from
Oct 3, 2024
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -119,10 +119,11 @@ 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
- [#2056](https://github.com/NibiruChain/nibiru/pull/2056) - feat(evm): add oracle precompile

#### Dapp modules: perp, spot, oracle, etc

Expand Down
30 changes: 30 additions & 0 deletions x/evm/embeds/artifacts/contracts/IOracle.sol/IOracle.json
Original file line number Diff line number Diff line change
@@ -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": "nonpayable",
"type": "function"
}
],
"bytecode": "0x",
"deployedBytecode": "0x",
"linkReferences": {},
"deployedLinkReferences": {}
}
13 changes: 13 additions & 0 deletions x/evm/embeds/contracts/IOracle.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;

/// @dev Oracle interface for querying exchange rates
interface IOracle {
/// @dev queryExchangeRate queries the exchange rate for a given pair
/// @param pair the pair to query
k-yang marked this conversation as resolved.
Show resolved Hide resolved
function queryExchangeRate(string memory pair) external returns (string memory);
k-yang marked this conversation as resolved.
Show resolved Hide resolved
}
Unique-Divine marked this conversation as resolved.
Show resolved Hide resolved

address constant ORACLE_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000801;

IOracle constant ORACLE_GATEWAY = IOracle(ORACLE_PRECOMPILE_ADDRESS);
8 changes: 7 additions & 1 deletion x/evm/embeds/embeds.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ var (
erc20MinterContractJSON []byte
//go:embed artifacts/contracts/IFunToken.sol/IFunToken.json
funtokenContractJSON []byte
//go:embed artifacts/contracts/IOracle.sol/IOracle.json
oracleContractJSON []byte
//go:embed artifacts/contracts/TestERC20.sol/TestERC20.json
testErc20Json []byte
)
Expand All @@ -38,7 +40,10 @@ var (
Name: "FunToken.sol",
EmbedJSON: funtokenContractJSON,
}

SmartContract_Oracle = CompiledEvmContract{
Name: "Oracle.sol",
EmbedJSON: oracleContractJSON,
}
SmartContract_TestERC20 = CompiledEvmContract{
Name: "TestERC20.sol",
EmbedJSON: testErc20Json,
Expand All @@ -48,6 +53,7 @@ var (
func init() {
SmartContract_ERC20Minter.MustLoad()
SmartContract_FunToken.MustLoad()
SmartContract_Oracle.MustLoad()
SmartContract_TestERC20.MustLoad()
}

Expand Down
130 changes: 130 additions & 0 deletions x/evm/precompile/oracle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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
}
Comment on lines +29 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider refining the gas calculation in RequiredGas method

The RequiredGas method currently returns a constant gethparams.TxGas, which is a fixed cost for transactions that do not create contracts. To ensure accurate gas estimation for this precompile, consider calculating the gas based on the actual computational complexity of the queryExchangeRate method.


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:
// TODO: UD-DEBUG: Test that calling non-method on the right address does
// nothing.
bz, err = p.queryExchangeRate(ctx, method, args, readonly)
default:
// TODO: UD-DEBUG: test invalid method called
k-yang marked this conversation as resolved.
Show resolved Hide resolved
err = fmt.Errorf("invalid method called with name \"%s\"", method.Name)
return
Comment on lines +71 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance the error message for unsupported methods

The error message "invalid method called with name \"%s\"" could be improved to provide more guidance to the user. Consider listing the supported methods in the error to aid in debugging.

Apply this diff to enhance the error message:

-		err = fmt.Errorf("invalid method called with name \"%s\"", method.Name)
+		err = fmt.Errorf("invalid method \"%s\" called. Supported methods: \"%s\"", method.Name, OracleMethod_QueryExchangeRate)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
err = fmt.Errorf("invalid method called with name \"%s\"", method.Name)
return
err = fmt.Errorf("invalid method \"%s\" called. Supported methods: \"%s\"", method.Name, OracleMethod_QueryExchangeRate)
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
}
Comment on lines +95 to +106
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Wrap errors with context in queryExchangeRate method

To improve debuggability, consider wrapping the errors returned from called functions with additional context. This provides clearer information about where and why a failure occurred.

Apply this diff to wrap the errors:

      pair, err := p.decomposeQueryExchangeRateArgs(args)
      if err != nil {
-         return nil, err
+         return nil, fmt.Errorf("failed to decompose arguments: %w", err)
      }

      assetPair, err := asset.TryNewPair(pair)
      if err != nil {
-         return nil, err
+         return nil, fmt.Errorf("invalid asset pair \"%s\": %w", pair, err)
      }

      price, err := p.oracleKeeper.GetExchangeRate(ctx, assetPair)
      if err != nil {
-         return nil, err
+         return nil, fmt.Errorf("failed to get exchange rate for \"%s\": %w", assetPair, err)
      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
if err != nil {
return nil, fmt.Errorf("failed to decompose arguments: %w", err)
}
assetPair, err := asset.TryNewPair(pair)
if err != nil {
return nil, fmt.Errorf("invalid asset pair \"%s\": %w", pair, err)
}
price, err := p.oracleKeeper.GetExchangeRate(ctx, assetPair)
if err != nil {
return nil, fmt.Errorf("failed to get exchange rate for \"%s\": %w", assetPair, 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))
k-yang marked this conversation as resolved.
Show resolved Hide resolved
return
}

pair, ok := args[0].(string)
if !ok {
err = fmt.Errorf("type validation for failed for (address erc20) argument")
Unique-Divine marked this conversation as resolved.
Show resolved Hide resolved
k-yang marked this conversation as resolved.
Show resolved Hide resolved
return
}

return pair, nil
}
82 changes: 82 additions & 0 deletions x/evm/precompile/oracle_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
1 change: 1 addition & 0 deletions x/evm/precompile/precompile.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func InitPrecompiles(
// Custom precompiles
for _, precompileSetupFn := range []func(k keepers.PublicKeepers) vm.PrecompiledContract{
PrecompileFunToken,
PrecompileOracle,
} {
pc := precompileSetupFn(k)
precompiles[pc.Address()] = pc
Expand Down
Loading