From 25905fbb8c1605713eb146db7c11033152ca48fe Mon Sep 17 00:00:00 2001 From: Kartik Bhat Date: Tue, 26 Mar 2024 15:35:07 -0400 Subject: [PATCH] Oracle Precompile (#1445) * Oracle Precompile * Update abi oracle * Update tests * Update tests * Update abi + tests * integration test query * Precompile integration tests * Update type * Add more loggign * lint * Add more checks * update tests --- app/app.go | 1 + contracts/test/EVMPrecompileTester.js | 44 +++++ contracts/test/query_oracle_data.sh | 5 + integration_test/evm_module/hardhat_test.yaml | 1 + precompiles/common/expected_keepers.go | 6 + precompiles/oracle/Oracle.sol | 30 ++++ precompiles/oracle/abi.json | 1 + precompiles/oracle/oracle.go | 158 ++++++++++++++++++ precompiles/oracle/oracle_test.go | 134 +++++++++++++++ precompiles/setup.go | 7 + 10 files changed, 387 insertions(+) create mode 100644 contracts/test/query_oracle_data.sh create mode 100644 precompiles/oracle/Oracle.sol create mode 100644 precompiles/oracle/abi.json create mode 100644 precompiles/oracle/oracle.go create mode 100644 precompiles/oracle/oracle_test.go diff --git a/app/app.go b/app/app.go index 66437303a5..9039c32435 100644 --- a/app/app.go +++ b/app/app.go @@ -663,6 +663,7 @@ func New( stakingkeeper.NewMsgServerImpl(app.StakingKeeper), app.GovKeeper, app.DistrKeeper, + app.OracleKeeper, ); err != nil { panic(err) } diff --git a/contracts/test/EVMPrecompileTester.js b/contracts/test/EVMPrecompileTester.js index 4409bac621..d7dd7d66e4 100644 --- a/contracts/test/EVMPrecompileTester.js +++ b/contracts/test/EVMPrecompileTester.js @@ -173,6 +173,50 @@ describe("EVM Test", function () { // TODO: Add staking query precompile here }); }); + + describe("EVM Oracle Precompile Tester", function () { + const OraclePrecompileContract = '0x0000000000000000000000000000000000001008'; + before(async function() { + const exchangeRatesContent = readDeploymentOutput('oracle_exchange_rates.json'); + const twapsContent = readDeploymentOutput('oracle_twaps.json'); + + exchangeRatesJSON = JSON.parse(exchangeRatesContent).denom_oracle_exchange_rate_pairs; + twapsJSON = JSON.parse(twapsContent).oracle_twaps; + + const [signer, _] = await ethers.getSigners(); + owner = await signer.getAddress(); + + const contractABIPath = path.join(__dirname, '../../precompiles/oracle/abi.json'); + const contractABI = require(contractABIPath); + // Get a contract instance + oracle = new ethers.Contract(OraclePrecompileContract, contractABI, signer); + }); + + it("Oracle Exchange Rates", async function () { + const exchangeRates = await oracle.getExchangeRates(); + const exchangeRatesLen = exchangeRatesJSON.length; + expect(exchangeRates.length).to.equal(exchangeRatesLen); + + for (let i = 0; i < exchangeRatesLen; i++) { + expect(exchangeRates[i].denom).to.equal(exchangeRatesJSON[i].denom); + expect(exchangeRates[i].oracleExchangeRateVal.exchangeRate).to.be.a('string').and.to.not.be.empty; + expect(exchangeRates[i].oracleExchangeRateVal.exchangeRate).to.be.a('string').and.to.not.be.empty; + expect(exchangeRates[i].oracleExchangeRateVal.lastUpdateTimestamp).to.exist.and.to.be.gt(0); + } + }); + + it("Oracle Twaps", async function () { + const twaps = await oracle.getOracleTwaps(3600); + const twapsLen = twapsJSON.length + expect(twaps.length).to.equal(twapsLen); + + for (let i = 0; i < twapsLen; i++) { + expect(twaps[i].denom).to.equal(twapsJSON[i].denom); + expect(twaps[i].twap).to.be.a('string').and.to.not.be.empty; + expect(twaps[i].lookbackSeconds).to.exist.and.to.be.gt(0); + } + }); + }); }); }); diff --git a/contracts/test/query_oracle_data.sh b/contracts/test/query_oracle_data.sh new file mode 100644 index 0000000000..7531f6fe46 --- /dev/null +++ b/contracts/test/query_oracle_data.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +# This script is used to query oracle exchange rates and twap +seid q oracle exchange-rates -o json > contracts/oracle_exchange_rates.json +seid q oracle twaps 3600 -o json > contracts/oracle_twaps.json diff --git a/integration_test/evm_module/hardhat_test.yaml b/integration_test/evm_module/hardhat_test.yaml index 81ddc4791f..c03595b736 100644 --- a/integration_test/evm_module/hardhat_test.yaml +++ b/integration_test/evm_module/hardhat_test.yaml @@ -9,6 +9,7 @@ - cmd: bash contracts/test/deploy_atom_erc20.sh - cmd: bash contracts/test/get_validator_address.sh - cmd: bash contracts/test/send_gov_proposal.sh + - cmd: bash contracts/test/query_oracle_data.sh verifiers: - type: eval expr: RESULT == "0x1" diff --git a/precompiles/common/expected_keepers.go b/precompiles/common/expected_keepers.go index 3c7ecda104..65087402e0 100644 --- a/precompiles/common/expected_keepers.go +++ b/precompiles/common/expected_keepers.go @@ -8,6 +8,7 @@ import ( govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" "github.com/ethereum/go-ethereum/common" + oracletypes "github.com/sei-protocol/sei-chain/x/oracle/types" ) type BankKeeper interface { @@ -31,6 +32,11 @@ type EVMKeeper interface { GetBaseDenom(ctx sdk.Context) string } +type OracleKeeper interface { + IterateBaseExchangeRates(ctx sdk.Context, handler func(denom string, exchangeRate oracletypes.OracleExchangeRate) (stop bool)) + CalculateTwaps(ctx sdk.Context, lookbackSeconds uint64) (oracletypes.OracleTwaps, error) +} + type WasmdKeeper interface { Instantiate(ctx sdk.Context, codeID uint64, creator, admin sdk.AccAddress, initMsg []byte, label string, deposit sdk.Coins) (sdk.AccAddress, []byte, error) Execute(ctx sdk.Context, contractAddress sdk.AccAddress, caller sdk.AccAddress, msg []byte, coins sdk.Coins) ([]byte, error) diff --git a/precompiles/oracle/Oracle.sol b/precompiles/oracle/Oracle.sol new file mode 100644 index 0000000000..5de419d194 --- /dev/null +++ b/precompiles/oracle/Oracle.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +address constant ORACLE_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000001008; + +IOracle constant ORACLE_CONTRACT = IOracle(ORACLE_PRECOMPILE_ADDRESS); + +interface IOracle { + // Queries + function getExchangeRates() external view returns (DenomOracleExchangeRatePair[] memory); + function getOracleTwaps(uint64 lookback_seconds) external view returns (OracleTwap[] memory); + + // Structs + struct OracleExchangeRate { + string exchangeRate; + string lastUpdate; + int64 lastUpdateTimestamp; + } + + struct DenomOracleExchangeRatePair { + string denom; + OracleExchangeRate oracleExchangeRateVal; + } + + struct OracleTwap { + string denom; + string twap; + int64 lookbackSeconds; + } +} diff --git a/precompiles/oracle/abi.json b/precompiles/oracle/abi.json new file mode 100644 index 0000000000..1bb91d96aa --- /dev/null +++ b/precompiles/oracle/abi.json @@ -0,0 +1 @@ +[{"inputs":[],"name":"getExchangeRates","outputs":[{"components":[{"internalType":"string","name":"denom","type":"string"},{"components":[{"internalType":"string","name":"exchangeRate","type":"string"},{"internalType":"string","name":"lastUpdate","type":"string"},{"internalType":"int64","name":"lastUpdateTimestamp","type":"int64"}],"internalType":"struct IOracle.OracleExchangeRate","name":"oracleExchangeRateVal","type":"tuple"}],"internalType":"struct IOracle.DenomOracleExchangeRatePair[]","name":"","type":"tuple[]"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint64","name":"lookback_seconds","type":"uint64"}],"name":"getOracleTwaps","outputs":[{"components":[{"internalType":"string","name":"denom","type":"string"},{"internalType":"string","name":"twap","type":"string"},{"internalType":"int64","name":"lookbackSeconds","type":"int64"}],"internalType":"struct IOracle.OracleTwap[]","name":"","type":"tuple[]"}],"stateMutability":"view","type":"function"}] \ No newline at end of file diff --git a/precompiles/oracle/oracle.go b/precompiles/oracle/oracle.go new file mode 100644 index 0000000000..fbe18402e4 --- /dev/null +++ b/precompiles/oracle/oracle.go @@ -0,0 +1,158 @@ +package oracle + +import ( + "bytes" + "embed" + "math/big" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/vm" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + "github.com/sei-protocol/sei-chain/x/oracle/types" +) + +const ( + GetExchangeRatesMethod = "getExchangeRates" + GetOracleTwapsMethod = "getOracleTwaps" +) + +const ( + OracleAddress = "0x0000000000000000000000000000000000001008" +) + +var _ vm.PrecompiledContract = &Precompile{} + +// Embed abi json file to the executable binary. Needed when importing as dependency. +// +//go:embed abi.json +var f embed.FS + +func GetABI() abi.ABI { + abiBz, err := f.ReadFile("abi.json") + if err != nil { + panic(err) + } + + newAbi, err := abi.JSON(bytes.NewReader(abiBz)) + if err != nil { + panic(err) + } + return newAbi +} + +type Precompile struct { + pcommon.Precompile + evmKeeper pcommon.EVMKeeper + oracleKeeper pcommon.OracleKeeper + address common.Address + + GetExchangeRatesId []byte + GetOracleTwapsId []byte +} + +// Define types which deviate slightly from cosmos types (ExchangeRate string vs sdk.Dec) +type OracleExchangeRate struct { + ExchangeRate string `json:"exchangeRate"` + LastUpdate string `json:"lastUpdate"` + LastUpdateTimestamp int64 `json:"lastUpdateTimestamp"` +} + +type DenomOracleExchangeRatePair struct { + Denom string `json:"denom"` + OracleExchangeRateVal OracleExchangeRate `json:"oracleExchangeRateVal"` +} + +type OracleTwap struct { + Denom string `json:"denom"` + Twap string `json:"twap"` + LookbackSeconds int64 `json:"lookbackSeconds"` +} + +func NewPrecompile(oracleKeeper pcommon.OracleKeeper, evmKeeper pcommon.EVMKeeper) (*Precompile, error) { + newAbi := GetABI() + + p := &Precompile{ + Precompile: pcommon.Precompile{ABI: newAbi}, + evmKeeper: evmKeeper, + address: common.HexToAddress(OracleAddress), + oracleKeeper: oracleKeeper, + } + + for name, m := range newAbi.Methods { + switch name { + case GetExchangeRatesMethod: + p.GetExchangeRatesId = m.ID + case GetOracleTwapsMethod: + p.GetOracleTwapsId = m.ID + } + } + + return p, nil +} + +// RequiredGas returns the required bare minimum gas to execute the precompile. +func (p Precompile) RequiredGas(input []byte) uint64 { + methodID := input[:4] + + method, err := p.ABI.MethodById(methodID) + if err != nil { + // This should never happen since this method is going to fail during Run + return 0 + } + + return p.Precompile.RequiredGas(input, p.IsTransaction(method.Name)) +} + +func (p Precompile) Address() common.Address { + return p.address +} + +func (p Precompile) Run(evm *vm.EVM, _ common.Address, input []byte, value *big.Int) (bz []byte, err error) { + ctx, method, args, err := p.Prepare(evm, input) + if err != nil { + return nil, err + } + + switch method.Name { + case GetExchangeRatesMethod: + return p.getExchangeRates(ctx, method, args, value) + case GetOracleTwapsMethod: + return p.getOracleTwaps(ctx, method, args, value) + } + return +} + +func (p Precompile) getExchangeRates(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, error) { + pcommon.AssertNonPayable(value) + pcommon.AssertArgsLength(args, 0) + exchangeRates := []DenomOracleExchangeRatePair{} + p.oracleKeeper.IterateBaseExchangeRates(ctx, func(denom string, rate types.OracleExchangeRate) (stop bool) { + exchangeRates = append(exchangeRates, DenomOracleExchangeRatePair{Denom: denom, OracleExchangeRateVal: OracleExchangeRate{ExchangeRate: rate.ExchangeRate.String(), LastUpdate: rate.LastUpdate.String(), LastUpdateTimestamp: rate.LastUpdateTimestamp}}) + return false + }) + + return method.Outputs.Pack(exchangeRates) +} + +func (p Precompile) getOracleTwaps(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, error) { + pcommon.AssertNonPayable(value) + pcommon.AssertArgsLength(args, 1) + lookbackSeconds := args[0].(uint64) + twaps, err := p.oracleKeeper.CalculateTwaps(ctx, lookbackSeconds) + if err != nil { + return nil, err + } + // Convert twap to string + oracleTwaps := make([]OracleTwap, 0, len(twaps)) + for _, twap := range twaps { + oracleTwaps = append(oracleTwaps, OracleTwap{Denom: twap.Denom, Twap: twap.Twap.String(), LookbackSeconds: twap.LookbackSeconds}) + } + return method.Outputs.Pack(oracleTwaps) +} + +func (Precompile) IsTransaction(string) bool { + return false +} diff --git a/precompiles/oracle/oracle_test.go b/precompiles/oracle/oracle_test.go new file mode 100644 index 0000000000..7fdddece4d --- /dev/null +++ b/precompiles/oracle/oracle_test.go @@ -0,0 +1,134 @@ +package oracle_test + +import ( + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/sei-protocol/sei-chain/precompiles/oracle" + testkeeper "github.com/sei-protocol/sei-chain/testutil/keeper" + "github.com/sei-protocol/sei-chain/x/evm/state" + "github.com/sei-protocol/sei-chain/x/oracle/types" + "github.com/sei-protocol/sei-chain/x/oracle/utils" + "github.com/stretchr/testify/require" + tmtypes "github.com/tendermint/tendermint/proto/tendermint/types" +) + +func TestGetExchangeRate(t *testing.T) { + testApp := testkeeper.EVMTestApp + rate := sdk.NewDec(1700) + ctx := testApp.NewContext(false, tmtypes.Header{}).WithBlockHeight(2) + testApp.OracleKeeper.SetBaseExchangeRate(ctx, utils.MicroAtomDenom, rate) + k := &testApp.EvmKeeper + + // Setup sender addresses and environment + privKey := testkeeper.MockPrivateKey() + senderAddr, senderEVMAddr := testkeeper.PrivateKeyToAddresses(privKey) + k.SetAddressMapping(ctx, senderAddr, senderEVMAddr) + statedb := state.NewDBImpl(ctx, k, true) + evm := vm.EVM{ + StateDB: statedb, + TxContext: vm.TxContext{Origin: senderEVMAddr}, + } + + p, err := oracle.NewPrecompile(testApp.OracleKeeper, k) + require.Nil(t, err) + + query, err := p.ABI.MethodById(p.GetExchangeRatesId) + require.Nil(t, err) + precompileRes, err := p.Run(&evm, common.Address{}, p.GetExchangeRatesId, nil) + require.Nil(t, err) + exchangeRates, err := query.Outputs.Unpack(precompileRes) + require.Nil(t, err) + require.Equal(t, 1, len(exchangeRates)) + + // TODO: Use type assertion for nested struct + require.Equal(t, []struct { + Denom string `json:"denom"` + OracleExchangeRateVal struct { + ExchangeRate string `json:"exchangeRate"` + LastUpdate string `json:"lastUpdate"` + LastUpdateTimestamp int64 `json:"lastUpdateTimestamp"` + } `json:"oracleExchangeRateVal"` + }{ + { + Denom: "uatom", + OracleExchangeRateVal: struct { + ExchangeRate string `json:"exchangeRate"` + LastUpdate string `json:"lastUpdate"` + LastUpdateTimestamp int64 `json:"lastUpdateTimestamp"` + }{ + ExchangeRate: "1700.000000000000000000", + LastUpdate: "2", + LastUpdateTimestamp: -62135596800000, + }, + }, + }, exchangeRates[0]) +} + +func TestGetOracleTwaps(t *testing.T) { + testApp := testkeeper.EVMTestApp + ctx := testApp.NewContext(false, tmtypes.Header{}).WithBlockTime(time.Unix(5400, 0)) + + priceSnapshots := types.PriceSnapshots{ + types.NewPriceSnapshot(types.PriceSnapshotItems{ + types.NewPriceSnapshotItem(utils.MicroEthDenom, types.OracleExchangeRate{ + ExchangeRate: sdk.NewDec(10), + LastUpdate: sdk.NewInt(3600), + }), + }, 3600), + types.NewPriceSnapshot(types.PriceSnapshotItems{ + types.NewPriceSnapshotItem(utils.MicroEthDenom, types.OracleExchangeRate{ + ExchangeRate: sdk.NewDec(20), + LastUpdate: sdk.NewInt(4500), + }), + }, 4500), + } + for _, snap := range priceSnapshots { + testApp.OracleKeeper.SetPriceSnapshot(ctx, snap) + } + + k := &testApp.EvmKeeper + defaults := types.DefaultParams() + testApp.OracleKeeper.SetParams(ctx, defaults) + for _, denom := range defaults.Whitelist { + testApp.OracleKeeper.SetVoteTarget(ctx, denom.Name) + } + + // Setup sender addresses and environment + privKey := testkeeper.MockPrivateKey() + senderAddr, senderEVMAddr := testkeeper.PrivateKeyToAddresses(privKey) + k.SetAddressMapping(ctx, senderAddr, senderEVMAddr) + statedb := state.NewDBImpl(ctx, k, true) + evm := vm.EVM{ + StateDB: statedb, + TxContext: vm.TxContext{Origin: senderEVMAddr}, + } + + p, err := oracle.NewPrecompile(testApp.OracleKeeper, k) + require.Nil(t, err) + + query, err := p.ABI.MethodById(p.GetOracleTwapsId) + require.Nil(t, err) + args, err := query.Inputs.Pack(uint64(3600)) + require.Nil(t, err) + precompileRes, err := p.Run(&evm, common.Address{}, append(p.GetOracleTwapsId, args...), nil) + require.Nil(t, err) + twap, err := query.Outputs.Unpack(precompileRes) + require.Nil(t, err) + require.Equal(t, 1, len(twap)) + + require.Equal(t, []struct { + Denom string `json:"denom"` + Twap string `json:"twap"` + LookbackSeconds int64 `json:"lookbackSeconds"` + }{ + { + Denom: "ueth", + Twap: "15.000000000000000000", + LookbackSeconds: 1800, + }, + }, twap[0]) +} diff --git a/precompiles/setup.go b/precompiles/setup.go index 820d7519a5..b4d72e116b 100644 --- a/precompiles/setup.go +++ b/precompiles/setup.go @@ -11,6 +11,7 @@ import ( "github.com/sei-protocol/sei-chain/precompiles/distribution" "github.com/sei-protocol/sei-chain/precompiles/gov" "github.com/sei-protocol/sei-chain/precompiles/json" + "github.com/sei-protocol/sei-chain/precompiles/oracle" "github.com/sei-protocol/sei-chain/precompiles/staking" "github.com/sei-protocol/sei-chain/precompiles/wasmd" ) @@ -26,6 +27,7 @@ func InitializePrecompiles( stakingKeeper common.StakingKeeper, govKeeper common.GovKeeper, distrKeeper common.DistributionKeeper, + oracleKeeper common.OracleKeeper, ) error { SetupMtx.Lock() defer SetupMtx.Unlock() @@ -67,6 +69,11 @@ func InitializePrecompiles( return err } addPrecompileToVM(distrp, distrp.Address()) + oraclep, err := oracle.NewPrecompile(oracleKeeper, evmKeeper) + if err != nil { + return err + } + addPrecompileToVM(oraclep, oraclep.Address()) Initialized = true return nil }