From 56cb97fda147e65964f68c09d756e64ce2ed21fb Mon Sep 17 00:00:00 2001 From: Maximilian Langenfeld <15726643+ezdac@users.noreply.github.com> Date: Wed, 15 May 2024 11:17:22 +0200 Subject: [PATCH] `eth_estimateGas` CIP-64 and CIP-66 compatibility (#91) * rpc: include feeCurrency in transaction-args This commit fixes wrong gas calculation in `eth_estimateGas` calls, when the additional `feeCurrency` parameter is used. The TransactionArgs struct is used in transaction related endpoints like `eth_sendTransaction`, `eth_signTransaction`, `eth_estimateGas` and many more. CIP-64 and CIP-66 transaction types make use of an additional transaction parameter `feeCurrency` and some client libraries are already sending this in the RPC request body, however the remote procedures omitted this during unmarshaling and the value was never passed to the EVM. Now the TransactionArgs struct includes an optional FeeCurrency field for correct unmarshaling, and the field is passed along downstream when constructing EVM messages out of the struct. This e.g. allows gas estimation to consider the different intrinsic gas for transactions paid in non-native token. * Rename celoapi file * Add Backend wrapper for Celo functionality * Make transaction-args CIP-64/66 compatible * Make eth_estimateGas CIP64 and CIP66 compatible * Move error message inside function --- core/state_transition.go | 12 ++- e2e_test/js-tests/test_viem_tx.mjs | 17 ++++ eth/backend.go | 5 +- eth/gasestimator/gasestimator.go | 36 ++++++-- eth/tracers/api.go | 2 +- graphql/graphql.go | 2 +- graphql/graphql_test.go | 4 +- graphql/service.go | 6 +- internal/celoapi/api.go | 97 +++++++++++++++++++++ internal/celoapi/backend.go | 106 +++++++++++------------ internal/ethapi/api.go | 54 +++++++++--- internal/ethapi/api_test.go | 52 ++++++++++- internal/ethapi/backend.go | 14 ++- internal/ethapi/transaction_args.go | 66 ++++++++++++-- internal/ethapi/transaction_args_test.go | 33 ++++++- tests/testdata | 2 +- 16 files changed, 409 insertions(+), 99 deletions(-) create mode 100644 internal/celoapi/api.go diff --git a/core/state_transition.go b/core/state_transition.go index d77945c319..3b11ac7168 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -183,6 +183,8 @@ type Message struct { // `nil` corresponds to CELO (native currency). // All other values should correspond to ERC20 contract addresses. FeeCurrency *common.Address + + MaxFeeInFeeCurrency *big.Int // MaxFeeInFeeCurrency is the maximum fee that can be charged in the fee currency. } // TransactionToMessage converts a transaction into a Message. @@ -206,7 +208,8 @@ func TransactionToMessage(tx *types.Transaction, s types.Signer, baseFee *big.In BlobHashes: tx.BlobHashes(), BlobGasFeeCap: tx.BlobGasFeeCap(), - FeeCurrency: tx.FeeCurrency(), + FeeCurrency: tx.FeeCurrency(), + MaxFeeInFeeCurrency: nil, // Will only be set once CIP-66 is implemented } // If baseFee provided, set gasPrice to effectiveGasPrice. if baseFee != nil { @@ -224,6 +227,13 @@ func TransactionToMessage(tx *types.Transaction, s types.Signer, baseFee *big.In return msg, err } +// IsFeeCurrencyDenominated returns whether the gas-price related +// fields are denominated in a given fee currency or in the native token. +// This effectively is only true for CIP-64 transactions. +func (msg *Message) IsFeeCurrencyDenominated() bool { + return msg.FeeCurrency != nil && msg.MaxFeeInFeeCurrency == nil +} + // ApplyMessage computes the new state by applying the given message // against the old state within the environment. // diff --git a/e2e_test/js-tests/test_viem_tx.mjs b/e2e_test/js-tests/test_viem_tx.mjs index 16a46f7fd7..6211cca91c 100644 --- a/e2e_test/js-tests/test_viem_tx.mjs +++ b/e2e_test/js-tests/test_viem_tx.mjs @@ -181,6 +181,23 @@ describe("viem send tx", () => { assert.equal(request.maxPriorityFeePerGas, fees.maxPriorityFeePerGas); }).timeout(10_000); + it("send fee currency with gas estimation tx and check receipt", async () => { + const request = await walletClient.prepareTransactionRequest({ + account, + to: "0x00000000000000000000000000000000DeaDBeef", + value: 2, + feeCurrency: process.env.FEE_CURRENCY, + maxFeePerGas: 2000000000n, + maxPriorityFeePerGas: 0n, + }); + const signature = await walletClient.signTransaction(request); + const hash = await walletClient.sendRawTransaction({ + serializedTransaction: signature, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + assert.equal(receipt.status, "success", "receipt status 'failure'"); + }).timeout(10_000); + it("send overlapping nonce tx in different currencies", async () => { const priceBump = 1.1; const rate = 2; diff --git a/eth/backend.go b/eth/backend.go index ef6b71989d..80c0e9970b 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -366,7 +366,8 @@ func makeExtraData(extra []byte) []byte { // APIs return the collection of RPC services the ethereum package offers. // NOTE, some of these services probably need to be moved to somewhere else. func (s *Ethereum) APIs() []rpc.API { - apis := ethapi.GetAPIs(s.APIBackend) + celoBackend := celoapi.NewCeloAPIBackend(s.APIBackend) + apis := ethapi.GetAPIs(celoBackend) // Append any APIs exposed explicitly by the consensus engine apis = append(apis, s.engine.APIs(s.BlockChain())...) @@ -397,7 +398,7 @@ func (s *Ethereum) APIs() []rpc.API { // on the eth namespace, this will overwrite the original procedures. { Namespace: "eth", - Service: celoapi.NewCeloAPI(s, s.APIBackend), + Service: celoapi.NewCeloAPI(s, celoBackend), }, }...) } diff --git a/eth/gasestimator/gasestimator.go b/eth/gasestimator/gasestimator.go index a6c9d635d8..06a596bfa4 100644 --- a/eth/gasestimator/gasestimator.go +++ b/eth/gasestimator/gasestimator.go @@ -20,10 +20,11 @@ import ( "context" "errors" "fmt" - "math" "math/big" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/exchange" + "github.com/ethereum/go-ethereum/common/math" "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/types" @@ -49,7 +50,7 @@ type Options struct { // Estimate returns the lowest possible gas limit that allows the transaction to // run successfully with the provided context options. It returns an error if the // transaction would always revert, or if there are unexpected failures. -func Estimate(ctx context.Context, call *core.Message, opts *Options, gasCap uint64) (uint64, []byte, error) { +func Estimate(ctx context.Context, call *core.Message, opts *Options, gasCap uint64, exchangeRates common.ExchangeRates, balance *big.Int) (uint64, []byte, error) { // Binary search the gas limit, as it may need to be higher than the amount used var ( lo uint64 // lowest-known gas limit where tx execution fails @@ -71,14 +72,29 @@ func Estimate(ctx context.Context, call *core.Message, opts *Options, gasCap uin } // Recap the highest gas limit with account's available balance. if feeCap.BitLen() != 0 { - balance := opts.State.GetBalance(call.From).ToBig() - available := balance - if call.Value != nil { - if call.Value.Cmp(available) >= 0 { - return 0, nil, core.ErrInsufficientFundsForTransfer + if call.FeeCurrency != nil { + if !call.IsFeeCurrencyDenominated() { + // CIP-66, prices are given in native token. + // We need to check the allowance in the converted feeCurrency + var err error + feeCap, err = exchange.ConvertCeloToCurrency(exchangeRates, call.FeeCurrency, feeCap) + if err != nil { + return 0, nil, err + } + } + } else { + if call.Value != nil { + if call.Value.Cmp(available) >= 0 { + return 0, nil, core.ErrInsufficientFundsForTransfer + } + available.Sub(available, call.Value) } - available.Sub(available, call.Value) + } + + // cap the available by the maxFeeInFeeCurrency + if call.MaxFeeInFeeCurrency != nil { + available = math.BigMin(available, call.MaxFeeInFeeCurrency) } allowance := new(big.Int).Div(available, feeCap) @@ -89,7 +105,9 @@ func Estimate(ctx context.Context, call *core.Message, opts *Options, gasCap uin transfer = new(big.Int) } log.Debug("Gas estimation capped by limited funds", "original", hi, "balance", balance, - "sent", transfer, "maxFeePerGas", feeCap, "fundable", allowance) + "sent", transfer, "maxFeePerGas", feeCap, "fundable", allowance, + "feeCurrency", call.FeeCurrency, "maxFeeInFeeCurrency", call.MaxFeeInFeeCurrency, + ) hi = allowance.Uint64() } } diff --git a/eth/tracers/api.go b/eth/tracers/api.go index 021eaf1a4a..5d0702b81f 100644 --- a/eth/tracers/api.go +++ b/eth/tracers/api.go @@ -966,7 +966,7 @@ func (api *API) TraceCall(ctx context.Context, args ethapi.TransactionArgs, bloc config.BlockOverrides.Apply(&vmctx) } // Execute the trace - msg, err := args.ToMessage(api.backend.RPCGasCap(), vmctx.BaseFee) + msg, err := args.ToMessage(api.backend.RPCGasCap(), vmctx.BaseFee, vmctx.ExchangeRates) if err != nil { return nil, err } diff --git a/graphql/graphql.go b/graphql/graphql.go index c2807131d8..e380290119 100644 --- a/graphql/graphql.go +++ b/graphql/graphql.go @@ -1282,7 +1282,7 @@ func (p *Pending) EstimateGas(ctx context.Context, args struct { // Resolver is the top-level object in the GraphQL hierarchy. type Resolver struct { - backend ethapi.Backend + backend ethapi.CeloBackend filterSystem *filters.FilterSystem } diff --git a/graphql/graphql_test.go b/graphql/graphql_test.go index 1dda102058..d6bf4b49a9 100644 --- a/graphql/graphql_test.go +++ b/graphql/graphql_test.go @@ -38,6 +38,7 @@ import ( "github.com/ethereum/go-ethereum/eth" "github.com/ethereum/go-ethereum/eth/ethconfig" "github.com/ethereum/go-ethereum/eth/filters" + "github.com/ethereum/go-ethereum/internal/celoapi" "github.com/ethereum/go-ethereum/node" "github.com/ethereum/go-ethereum/params" @@ -477,7 +478,8 @@ func newGQLService(t *testing.T, stack *node.Node, shanghai bool, gspec *core.Ge } // Set up handler filterSystem := filters.NewFilterSystem(ethBackend.APIBackend, filters.Config{}) - handler, err := newHandler(stack, ethBackend.APIBackend, filterSystem, []string{}, []string{}) + celoBackend := celoapi.NewCeloAPIBackend(ethBackend.APIBackend) + handler, err := newHandler(stack, celoBackend, filterSystem, []string{}, []string{}) if err != nil { t.Fatalf("could not create graphql service: %v", err) } diff --git a/graphql/service.go b/graphql/service.go index 584165bdb8..43271537e1 100644 --- a/graphql/service.go +++ b/graphql/service.go @@ -25,6 +25,7 @@ import ( "time" "github.com/ethereum/go-ethereum/eth/filters" + "github.com/ethereum/go-ethereum/internal/celoapi" "github.com/ethereum/go-ethereum/internal/ethapi" "github.com/ethereum/go-ethereum/node" "github.com/ethereum/go-ethereum/rpc" @@ -107,13 +108,14 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // New constructs a new GraphQL service instance. func New(stack *node.Node, backend ethapi.Backend, filterSystem *filters.FilterSystem, cors, vhosts []string) error { - _, err := newHandler(stack, backend, filterSystem, cors, vhosts) + celoBackend := celoapi.NewCeloAPIBackend(backend) + _, err := newHandler(stack, celoBackend, filterSystem, cors, vhosts) return err } // newHandler returns a new `http.Handler` that will answer GraphQL queries. // It additionally exports an interactive query browser on the / endpoint. -func newHandler(stack *node.Node, backend ethapi.Backend, filterSystem *filters.FilterSystem, cors, vhosts []string) (*handler, error) { +func newHandler(stack *node.Node, backend ethapi.CeloBackend, filterSystem *filters.FilterSystem, cors, vhosts []string) (*handler, error) { q := Resolver{backend, filterSystem} s, err := graphql.ParseSchema(schema, &q) diff --git a/internal/celoapi/api.go b/internal/celoapi/api.go new file mode 100644 index 0000000000..73279b98bf --- /dev/null +++ b/internal/celoapi/api.go @@ -0,0 +1,97 @@ +package celoapi + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/exchange" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/contracts" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/internal/ethapi" +) + +type Ethereum interface { + BlockChain() *core.BlockChain +} + +type CeloAPI struct { + ethAPI *ethapi.EthereumAPI + eth Ethereum +} + +func NewCeloAPI(e Ethereum, b ethapi.CeloBackend) *CeloAPI { + return &CeloAPI{ + ethAPI: ethapi.NewEthereumAPI(b), + eth: e, + } +} + +func (c *CeloAPI) convertedCurrencyValue(v *hexutil.Big, feeCurrency *common.Address) (*hexutil.Big, error) { + if feeCurrency != nil { + convertedTipCap, err := c.convertCeloToCurrency(v.ToInt(), feeCurrency) + if err != nil { + return nil, fmt.Errorf("convert to feeCurrency: %w", err) + } + v = (*hexutil.Big)(convertedTipCap) + } + return v, nil +} + +func (c *CeloAPI) celoBackendCurrentState() (*contracts.CeloBackend, error) { + state, err := c.eth.BlockChain().State() + if err != nil { + return nil, fmt.Errorf("retrieve HEAD blockchain state': %w", err) + } + + cb := &contracts.CeloBackend{ + ChainConfig: c.eth.BlockChain().Config(), + State: state, + } + return cb, nil +} + +func (c *CeloAPI) convertCeloToCurrency(nativePrice *big.Int, feeCurrency *common.Address) (*big.Int, error) { + cb, err := c.celoBackendCurrentState() + if err != nil { + return nil, err + } + er, err := contracts.GetExchangeRates(cb) + if err != nil { + return nil, fmt.Errorf("retrieve exchange rates from current state: %w", err) + } + return exchange.ConvertCeloToCurrency(er, feeCurrency, nativePrice) +} + +// GasPrice wraps the original JSON RPC `eth_gasPrice` and adds an additional +// optional parameter `feeCurrency` for fee-currency conversion. +// When `feeCurrency` is not given, then the original JSON RPC method is called without conversion. +func (c *CeloAPI) GasPrice(ctx context.Context, feeCurrency *common.Address) (*hexutil.Big, error) { + tipcap, err := c.ethAPI.GasPrice(ctx) + if err != nil { + return nil, err + } + // Between the call to `ethapi.GasPrice` and the call to fetch and convert the rates, + // there is a chance of a state-change. This means that gas-price suggestion is calculated + // based on state of block x, while the currency conversion could be calculated based on block + // x+1. + // However, a similar race condition is present in the `ethapi.GasPrice` method itself. + return c.convertedCurrencyValue(tipcap, feeCurrency) +} + +// MaxPriorityFeePerGas wraps the original JSON RPC `eth_maxPriorityFeePerGas` and adds an additional +// optional parameter `feeCurrency` for fee-currency conversion. +// When `feeCurrency` is not given, then the original JSON RPC method is called without conversion. +func (c *CeloAPI) MaxPriorityFeePerGas(ctx context.Context, feeCurrency *common.Address) (*hexutil.Big, error) { + tipcap, err := c.ethAPI.MaxPriorityFeePerGas(ctx) + if err != nil { + return nil, err + } + // Between the call to `ethapi.MaxPriorityFeePerGas` and the call to fetch and convert the rates, + // there is a chance of a state-change. This means that gas-price suggestion is calculated + // based on state of block x, while the currency conversion could be calculated based on block + // x+1. + return c.convertedCurrencyValue(tipcap, feeCurrency) +} diff --git a/internal/celoapi/backend.go b/internal/celoapi/backend.go index f04111e141..89aa0b845a 100644 --- a/internal/celoapi/backend.go +++ b/internal/celoapi/backend.go @@ -7,91 +7,87 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/exchange" - "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/common/lru" "github.com/ethereum/go-ethereum/contracts" - "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/internal/ethapi" + "github.com/ethereum/go-ethereum/rpc" ) -type Ethereum interface { - BlockChain() *core.BlockChain +func NewCeloAPIBackend(b ethapi.Backend) *CeloAPIBackend { + return &CeloAPIBackend{ + Backend: b, + exchangeRatesCache: lru.NewCache[common.Hash, common.ExchangeRates](128), + } } -type CeloAPI struct { - ethAPI *ethapi.EthereumAPI - eth Ethereum -} +// CeloAPIBackend is a wrapper for the ethapi.Backend, that provides additional Celo specific +// functionality. CeloAPIBackend is mainly passed to the JSON RPC services and provides +// an easy way to make extra functionality available in the service internal methods without +// having to change their call signature significantly. +// CeloAPIBackend keeps a threadsafe LRU cache of block-hash to exchange rates for that block. +// Cache invalidation is only a problem when an already existing blocks' hash +// doesn't change, but the rates change. That shouldn't be possible, since changing the rates +// requires different transaction hashes / state and thus a different block hash. +// If the previous rates change during a reorg, the previous block hash should also change +// and with it the new block's hash. +// Stale branches cache values will get evicted eventually. +type CeloAPIBackend struct { + ethapi.Backend -func NewCeloAPI(e Ethereum, b ethapi.Backend) *CeloAPI { - return &CeloAPI{ - ethAPI: ethapi.NewEthereumAPI(b), - eth: e, - } + exchangeRatesCache *lru.Cache[common.Hash, common.ExchangeRates] } -func (c *CeloAPI) convertedCurrencyValue(v *hexutil.Big, feeCurrency *common.Address) (*hexutil.Big, error) { - if feeCurrency != nil { - convertedTipCap, err := c.convertGoldToCurrency(v.ToInt(), feeCurrency) - if err != nil { - return nil, fmt.Errorf("convert to feeCurrency: %w", err) - } - v = (*hexutil.Big)(convertedTipCap) +func (b *CeloAPIBackend) getContractCaller(ctx context.Context, atBlock common.Hash) (*contracts.CeloBackend, error) { + state, _, err := b.Backend.StateAndHeaderByNumberOrHash( + ctx, + rpc.BlockNumberOrHashWithHash(atBlock, false), + ) + if err != nil { + return nil, fmt.Errorf("retrieve state for block hash %s: %w", atBlock.String(), err) } - return v, nil + return &contracts.CeloBackend{ + ChainConfig: b.Backend.ChainConfig(), + State: state, + }, nil } -func (c *CeloAPI) celoBackendCurrentState() (*contracts.CeloBackend, error) { - state, err := c.eth.BlockChain().State() +func (b *CeloAPIBackend) GetFeeBalance(ctx context.Context, atBlock common.Hash, account common.Address, feeCurrency *common.Address) (*big.Int, error) { + cb, err := b.getContractCaller(ctx, atBlock) if err != nil { - return nil, fmt.Errorf("retrieve HEAD blockchain state': %w", err) - } - - cb := &contracts.CeloBackend{ - ChainConfig: c.eth.BlockChain().Config(), - State: state, + return nil, err } - return cb, nil + return contracts.GetFeeBalance(cb, account, feeCurrency), nil } -func (c *CeloAPI) convertGoldToCurrency(nativePrice *big.Int, feeCurrency *common.Address) (*big.Int, error) { - cb, err := c.celoBackendCurrentState() +func (b *CeloAPIBackend) GetExchangeRates(ctx context.Context, atBlock common.Hash) (common.ExchangeRates, error) { + cachedRates, ok := b.exchangeRatesCache.Get(atBlock) + if ok { + return cachedRates, nil + } + cb, err := b.getContractCaller(ctx, atBlock) if err != nil { return nil, err } er, err := contracts.GetExchangeRates(cb) if err != nil { - return nil, fmt.Errorf("retrieve exchange rates from current state: %w", err) + return nil, err } - return exchange.ConvertCeloToCurrency(er, feeCurrency, nativePrice) + b.exchangeRatesCache.Add(atBlock, er) + return er, nil } -// GasPrice wraps the original JSON RPC `eth_gasPrice` and adds an additional -// optional parameter `feeCurrency` for fee-currency conversion. -// When `feeCurrency` is not given, then the original JSON RPC method is called without conversion. -func (c *CeloAPI) GasPrice(ctx context.Context, feeCurrency *common.Address) (*hexutil.Big, error) { - tipcap, err := c.ethAPI.GasPrice(ctx) +func (b *CeloAPIBackend) ConvertToCurrency(ctx context.Context, atBlock common.Hash, value *big.Int, fromFeeCurrency *common.Address) (*big.Int, error) { + er, err := b.GetExchangeRates(ctx, atBlock) if err != nil { return nil, err } - // Between the call to `ethapi.GasPrice` and the call to fetch and convert the rates, - // there is a chance of a state-change. This means that gas-price suggestion is calculated - // based on state of block x, while the currency conversion could be calculated based on block - // x+1. - // However, a similar race condition is present in the `ethapi.GasPrice` method itself. - return c.convertedCurrencyValue(tipcap, feeCurrency) + return exchange.ConvertCeloToCurrency(er, fromFeeCurrency, value) } -// MaxPriorityFeePerGas wraps the original JSON RPC `eth_maxPriorityFeePerGas` and adds an additional -// optional parameter `feeCurrency` for fee-currency conversion. -// When `feeCurrency` is not given, then the original JSON RPC method is called without conversion. -func (c *CeloAPI) MaxPriorityFeePerGas(ctx context.Context, feeCurrency *common.Address) (*hexutil.Big, error) { - tipcap, err := c.ethAPI.MaxPriorityFeePerGas(ctx) +func (b *CeloAPIBackend) ConvertToGold(ctx context.Context, atBlock common.Hash, value *big.Int, toFeeCurrency *common.Address) (*big.Int, error) { + er, err := b.GetExchangeRates(ctx, atBlock) if err != nil { return nil, err } - // Between the call to `ethapi.MaxPriorityFeePerGas` and the call to fetch and convert the rates, - // there is a chance of a state-change. This means that gas-price suggestion is calculated - // based on state of block x, while the currency conversion could be calculated based on block - // x+1. - return c.convertedCurrencyValue(tipcap, feeCurrency) + return exchange.ConvertCurrencyToCelo(er, value, toFeeCurrency) } diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index cc5b575ed7..6bb1f80bbe 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -57,6 +57,7 @@ import ( const estimateGasErrorRatio = 0.015 var errBlobTxNotSupported = errors.New("signing blob transactions not supported") +var emptyExchangeRates = make(common.ExchangeRates) // EthereumAPI provides an API to access Ethereum related information. type EthereumAPI struct { @@ -286,11 +287,11 @@ func (s *EthereumAccountAPI) Accounts() []common.Address { type PersonalAccountAPI struct { am *accounts.Manager nonceLock *AddrLocker - b Backend + b CeloBackend } // NewPersonalAccountAPI creates a new PersonalAccountAPI. -func NewPersonalAccountAPI(b Backend, nonceLock *AddrLocker) *PersonalAccountAPI { +func NewPersonalAccountAPI(b CeloBackend, nonceLock *AddrLocker) *PersonalAccountAPI { return &PersonalAccountAPI{ am: b.AccountManager(), nonceLock: nonceLock, @@ -620,11 +621,11 @@ func (s *PersonalAccountAPI) Unpair(ctx context.Context, url string, pin string) // BlockChainAPI provides an API to access Ethereum blockchain data. type BlockChainAPI struct { - b Backend + b CeloBackend } // NewBlockChainAPI creates a new Ethereum blockchain API. -func NewBlockChainAPI(b Backend) *BlockChainAPI { +func NewBlockChainAPI(b CeloBackend) *BlockChainAPI { return &BlockChainAPI{b} } @@ -1182,7 +1183,7 @@ func doCall(ctx context.Context, b Backend, args TransactionArgs, state *state.S if blockOverrides != nil { blockOverrides.Apply(&blockCtx) } - msg, err := args.ToMessage(globalGasCap, blockCtx.BaseFee) + msg, err := args.ToMessage(globalGasCap, blockCtx.BaseFee, blockCtx.ExchangeRates) if err != nil { return nil, err } @@ -1212,7 +1213,7 @@ func doCall(ctx context.Context, b Backend, args TransactionArgs, state *state.S return result, nil } -func DoCall(ctx context.Context, b Backend, args TransactionArgs, blockNrOrHash rpc.BlockNumberOrHash, overrides *StateOverride, blockOverrides *BlockOverrides, timeout time.Duration, globalGasCap uint64) (*core.ExecutionResult, error) { +func DoCall(ctx context.Context, b CeloBackend, args TransactionArgs, blockNrOrHash rpc.BlockNumberOrHash, overrides *StateOverride, blockOverrides *BlockOverrides, timeout time.Duration, globalGasCap uint64) (*core.ExecutionResult, error) { defer func(start time.Time) { log.Debug("Executing EVM call finished", "runtime", time.Since(start)) }(time.Now()) state, header, err := b.StateAndHeaderByNumberOrHash(ctx, blockNrOrHash) @@ -1268,7 +1269,7 @@ func (s *BlockChainAPI) Call(ctx context.Context, args TransactionArgs, blockNrO // successfully at block `blockNrOrHash`. It returns error if the transaction would revert, or if // there are unexpected failures. The gas limit is capped by both `args.Gas` (if non-nil & // non-zero) and `gasCap` (if non-zero). -func DoEstimateGas(ctx context.Context, b Backend, args TransactionArgs, blockNrOrHash rpc.BlockNumberOrHash, overrides *StateOverride, gasCap uint64) (hexutil.Uint64, error) { +func DoEstimateGas(ctx context.Context, b CeloBackend, args TransactionArgs, blockNrOrHash rpc.BlockNumberOrHash, overrides *StateOverride, gasCap uint64) (hexutil.Uint64, error) { // Retrieve the base state and mutate it with any overrides state, header, err := b.StateAndHeaderByNumberOrHash(ctx, blockNrOrHash) if state == nil || err != nil { @@ -1285,12 +1286,27 @@ func DoEstimateGas(ctx context.Context, b Backend, args TransactionArgs, blockNr State: state, ErrorRatio: estimateGasErrorRatio, } + // Celo specific: get exchange rates if fee currency is specified + exchangeRates := emptyExchangeRates + if args.FeeCurrency != nil { + // It is debatable whether we should use Hash or ParentHash here. Usually, + // user would probably like the recent rates after the block, so we use Hash. + exchangeRates, err = b.GetExchangeRates(ctx, header.Hash()) + if err != nil { + return 0, fmt.Errorf("get exchange rates for block: %v err: %w", header.Hash(), err) + } + } // Run the gas estimation andwrap any revertals into a custom return - call, err := args.ToMessage(gasCap, header.BaseFee) + call, err := args.ToMessage(gasCap, header.BaseFee, exchangeRates) + if err != nil { + return 0, err + } + // Celo specific: get balance + balance, err := b.GetFeeBalance(ctx, opts.Header.Hash(), call.From, args.FeeCurrency) if err != nil { return 0, err } - estimate, revert, err := gasestimator.Estimate(ctx, call, opts, gasCap) + estimate, revert, err := gasestimator.Estimate(ctx, call, opts, gasCap, exchangeRates, balance) if err != nil { if len(revert) > 0 { return 0, newRevertError(revert) @@ -1667,7 +1683,7 @@ func (s *BlockChainAPI) CreateAccessList(ctx context.Context, args TransactionAr // AccessList creates an access list for the given transaction. // If the accesslist creation fails an error is returned. // If the transaction itself fails, an vmErr is returned. -func AccessList(ctx context.Context, b Backend, blockNrOrHash rpc.BlockNumberOrHash, args TransactionArgs) (acl types.AccessList, gasUsed uint64, vmErr error, err error) { +func AccessList(ctx context.Context, b CeloBackend, blockNrOrHash rpc.BlockNumberOrHash, args TransactionArgs) (acl types.AccessList, gasUsed uint64, vmErr error, err error) { // Retrieve the execution context db, header, err := b.StateAndHeaderByNumberOrHash(ctx, blockNrOrHash) if db == nil || err != nil { @@ -1702,7 +1718,19 @@ func AccessList(ctx context.Context, b Backend, blockNrOrHash rpc.BlockNumberOrH statedb := db.Copy() // Set the accesslist to the last al args.AccessList = &accessList - msg, err := args.ToMessage(b.RPCGasCap(), header.BaseFee) + baseFee := header.BaseFee + + exchangeRates := emptyExchangeRates + if args.FeeCurrency != nil { + // Always use the header's parent here, since we want to create the list at the + // queried block, but want to use the exchange rates before (at the beginning of) + // the queried block + exchangeRates, err = b.GetExchangeRates(ctx, header.ParentHash) + if err != nil { + return nil, 0, nil, fmt.Errorf("get exchange rates for block: %v err: %w", header.Hash(), err) + } + } + msg, err := args.ToMessage(b.RPCGasCap(), baseFee, exchangeRates) if err != nil { return nil, 0, nil, err } @@ -1724,13 +1752,13 @@ func AccessList(ctx context.Context, b Backend, blockNrOrHash rpc.BlockNumberOrH // TransactionAPI exposes methods for reading and creating transaction data. type TransactionAPI struct { - b Backend + b CeloBackend nonceLock *AddrLocker signer types.Signer } // NewTransactionAPI creates a new RPC service with methods for interacting with transactions. -func NewTransactionAPI(b Backend, nonceLock *AddrLocker) *TransactionAPI { +func NewTransactionAPI(b CeloBackend, nonceLock *AddrLocker) *TransactionAPI { // The signer used by the API should always be the 'latest' known one because we expect // signers to be backwards-compatible with old transactions. signer := types.LatestSigner(b.ChainConfig()) diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go index 6d2bf372d3..7b47b0a079 100644 --- a/internal/ethapi/api_test.go +++ b/internal/ethapi/api_test.go @@ -584,6 +584,50 @@ func newTestAccountManager(t *testing.T) (*accounts.Manager, accounts.Account) { return am, acc } +var errCeloNotImplemented error = errors.New("Celo backend test functionality not implemented") + +type celoTestBackend struct { + *testBackend +} + +func (c *celoTestBackend) GetFeeBalance(ctx context.Context, atBlock common.Hash, account common.Address, feeCurrency *common.Address) (*big.Int, error) { + if feeCurrency == nil { + header, err := c.HeaderByHash(ctx, atBlock) + if err != nil { + return nil, fmt.Errorf("retrieve header by hash in testBackend: %w", err) + } + + state, _, err := c.StateAndHeaderByNumber(ctx, rpc.BlockNumber(header.Number.Int64())) + if err != nil { + return nil, err + } + return state.GetBalance(account).ToBig(), nil + } + // Celo specific backend features are currently not tested + return nil, errCeloNotImplemented +} + +func (c *celoTestBackend) GetExchangeRates(ctx context.Context, atBlock common.Hash) (common.ExchangeRates, error) { + var er common.ExchangeRates + return er, nil +} + +func (c *celoTestBackend) ConvertToCurrency(ctx context.Context, atBlock common.Hash, value *big.Int, feeCurrency *common.Address) (*big.Int, error) { + if feeCurrency == nil { + return value, nil + } + // Celo specific backend features are currently not tested + return nil, errCeloNotImplemented +} + +func (c *celoTestBackend) ConvertToGold(ctx context.Context, atBlock common.Hash, value *big.Int, feeCurrency *common.Address) (*big.Int, error) { + if feeCurrency == nil { + return value, nil + } + // Celo specific backend features are currently not tested + return nil, errCeloNotImplemented +} + type testBackend struct { db ethdb.Database chain *core.BlockChain @@ -592,7 +636,7 @@ type testBackend struct { acc accounts.Account } -func newTestBackend(t *testing.T, n int, gspec *core.Genesis, engine consensus.Engine, generator func(i int, b *core.BlockGen)) *testBackend { +func newTestBackend(t *testing.T, n int, gspec *core.Genesis, engine consensus.Engine, generator func(i int, b *core.BlockGen)) *celoTestBackend { var ( cacheConfig = &core.CacheConfig{ TrieCleanLimit: 256, @@ -616,7 +660,9 @@ func newTestBackend(t *testing.T, n int, gspec *core.Genesis, engine consensus.E } backend := &testBackend{db: db, chain: chain, accman: accman, acc: acc} - return backend + return &celoTestBackend{ + testBackend: backend, + } } func (b *testBackend) setPendingBlock(block *types.Block) { @@ -1953,7 +1999,7 @@ func TestRPCGetBlockOrHeader(t *testing.T) { } } -func setupReceiptBackend(t *testing.T, genBlocks int) (*testBackend, []common.Hash) { +func setupReceiptBackend(t *testing.T, genBlocks int) (*celoTestBackend, []common.Hash) { config := *params.MergedTestChainConfig var ( acc1Key, _ = crypto.HexToECDSA("8a1f9a8f95be41cd7ccb6168179afb4504aefe388d1e14474d32c45c72ce7b7a") diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index 7c0e655d20..9c0f972bfb 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -37,8 +37,16 @@ import ( "github.com/ethereum/go-ethereum/rpc" ) -// Backend interface provides the common API services (that are provided by -// both full and light clients) with access to necessary functions. +type CeloBackend interface { + Backend + + GetFeeBalance(ctx context.Context, atBlock common.Hash, account common.Address, feeCurrency *common.Address) (*big.Int, error) + GetExchangeRates(ctx context.Context, atBlock common.Hash) (common.ExchangeRates, error) + ConvertToCurrency(ctx context.Context, atBlock common.Hash, value *big.Int, feeCurrency *common.Address) (*big.Int, error) + ConvertToGold(ctx context.Context, atBlock common.Hash, value *big.Int, feeCurrency *common.Address) (*big.Int, error) +} + +// Backend interface provides the common API services (that are provided by both full and light clients) with access to necessary functions. type Backend interface { // General Ethereum API SyncProgress() ethereum.SyncProgress @@ -101,7 +109,7 @@ type Backend interface { ServiceFilter(ctx context.Context, session *bloombits.MatcherSession) } -func GetAPIs(apiBackend Backend) []rpc.API { +func GetAPIs(apiBackend CeloBackend) []rpc.API { nonceLock := new(AddrLocker) return []rpc.API{ { diff --git a/internal/ethapi/transaction_args.go b/internal/ethapi/transaction_args.go index bae1c68641..9ac030246f 100644 --- a/internal/ethapi/transaction_args.go +++ b/internal/ethapi/transaction_args.go @@ -25,6 +25,7 @@ import ( "math/big" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/exchange" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/common/math" "github.com/ethereum/go-ethereum/consensus/misc/eip4844" @@ -74,6 +75,12 @@ type TransactionArgs struct { // This configures whether blobs are allowed to be passed. blobSidecarAllowed bool + // Celo specific + + // CIP-64, CIP-66 + FeeCurrency *common.Address `json:"feeCurrency,omitempty"` + // CIP-66 + MaxFeeInFeeCurrency *hexutil.Big `json:"maxFeeInFeeCurrency,omitempty"` } // from retrieves the transaction sender address. @@ -96,7 +103,7 @@ func (args *TransactionArgs) data() []byte { } // setDefaults fills in default values for unspecified tx fields. -func (args *TransactionArgs) setDefaults(ctx context.Context, b Backend, skipGasEstimation bool) error { +func (args *TransactionArgs) setDefaults(ctx context.Context, b CeloBackend, skipGasEstimation bool) error { if err := args.setBlobTxSidecar(ctx, b); err != nil { return err } @@ -158,6 +165,9 @@ func (args *TransactionArgs) setDefaults(ctx context.Context, b Backend, skipGas AccessList: args.AccessList, BlobFeeCap: args.BlobFeeCap, BlobHashes: args.BlobHashes, + + FeeCurrency: args.FeeCurrency, + MaxFeeInFeeCurrency: args.MaxFeeInFeeCurrency, } latestBlockNr := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) estimated, err := DoEstimateGas(ctx, b, callArgs, latestBlockNr, nil, b.RPCGasCap()) @@ -183,7 +193,7 @@ func (args *TransactionArgs) setDefaults(ctx context.Context, b Backend, skipGas } // setFeeDefaults fills in default fee values for unspecified tx fields. -func (args *TransactionArgs) setFeeDefaults(ctx context.Context, b Backend) error { +func (args *TransactionArgs) setFeeDefaults(ctx context.Context, b CeloBackend) error { head := b.CurrentHeader() // Sanity check the EIP-4844 fee parameters. if args.BlobFeeCap != nil && args.BlobFeeCap.ToInt().Sign() == 0 { @@ -237,13 +247,19 @@ func (args *TransactionArgs) setFeeDefaults(ctx context.Context, b Backend) erro if err != nil { return err } + if args.IsFeeCurrencyDenominated() { + price, err = b.ConvertToCurrency(ctx, head.Hash(), price, args.FeeCurrency) + if err != nil { + return fmt.Errorf("can't convert suggested gasTipCap to fee-currency: %w", err) + } + } args.GasPrice = (*hexutil.Big)(price) } return nil } // setCancunFeeDefaults fills in reasonable default fee values for unspecified fields. -func (args *TransactionArgs) setCancunFeeDefaults(ctx context.Context, head *types.Header, b Backend) error { +func (args *TransactionArgs) setCancunFeeDefaults(ctx context.Context, head *types.Header, b CeloBackend) error { // Set maxFeePerBlobGas if it is missing. if args.BlobHashes != nil && args.BlobFeeCap == nil { var excessBlobGas uint64 @@ -252,6 +268,15 @@ func (args *TransactionArgs) setCancunFeeDefaults(ctx context.Context, head *typ } // ExcessBlobGas must be set for a Cancun block. blobBaseFee := eip4844.CalcBlobFee(excessBlobGas) + if args.IsFeeCurrencyDenominated() { + // wether the blob-fee will be used like that in Cel2 or not, + // at least this keeps it consistent with the rest of the gas-fees + var err error + blobBaseFee, err = b.ConvertToCurrency(ctx, head.Hash(), blobBaseFee, args.FeeCurrency) + if err != nil { + return fmt.Errorf("can't convert blob-fee to fee-currency: %w", err) + } + } // Set the max fee to be 2 times larger than the previous block's blob base fee. // The additional slack allows the tx to not become invalidated if the base // fee is rising. @@ -262,13 +287,19 @@ func (args *TransactionArgs) setCancunFeeDefaults(ctx context.Context, head *typ } // setLondonFeeDefaults fills in reasonable default fee values for unspecified fields. -func (args *TransactionArgs) setLondonFeeDefaults(ctx context.Context, head *types.Header, b Backend) error { +func (args *TransactionArgs) setLondonFeeDefaults(ctx context.Context, head *types.Header, b CeloBackend) error { // Set maxPriorityFeePerGas if it is missing. if args.MaxPriorityFeePerGas == nil { tip, err := b.SuggestGasTipCap(ctx) if err != nil { return err } + if args.IsFeeCurrencyDenominated() { + tip, err = b.ConvertToCurrency(ctx, head.Hash(), tip, args.FeeCurrency) + if err != nil { + return fmt.Errorf("can't convert suggested gasTipCap to fee-currency: %w", err) + } + } args.MaxPriorityFeePerGas = (*hexutil.Big)(tip) } // Set maxFeePerGas if it is missing. @@ -276,9 +307,17 @@ func (args *TransactionArgs) setLondonFeeDefaults(ctx context.Context, head *typ // Set the max fee to be 2 times larger than the previous block's base fee. // The additional slack allows the tx to not become invalidated if the base // fee is rising. + baseFee := head.BaseFee + if args.IsFeeCurrencyDenominated() { + var err error + baseFee, err = b.ConvertToCurrency(ctx, head.Hash(), baseFee, args.FeeCurrency) + if err != nil { + return fmt.Errorf("can't convert base-fee to fee-currency: %w", err) + } + } val := new(big.Int).Add( args.MaxPriorityFeePerGas.ToInt(), - new(big.Int).Mul(head.BaseFee, big.NewInt(2)), + new(big.Int).Mul(baseFee, big.NewInt(2)), ) args.MaxFeePerGas = (*hexutil.Big)(val) } @@ -367,7 +406,7 @@ func (args *TransactionArgs) setBlobTxSidecar(ctx context.Context, b Backend) er // ToMessage converts the transaction arguments to the Message type used by the // core evm. This method is used in calls and traces that do not require a real // live transaction. -func (args *TransactionArgs) ToMessage(globalGasCap uint64, baseFee *big.Int) (*core.Message, error) { +func (args *TransactionArgs) ToMessage(globalGasCap uint64, baseFee *big.Int, exchangeRates common.ExchangeRates) (*core.Message, error) { // Reject invalid combinations of pre- and post-1559 fee styles if args.GasPrice != nil && (args.MaxFeePerGas != nil || args.MaxPriorityFeePerGas != nil) { return nil, errors.New("both gasPrice and (maxFeePerGas or maxPriorityFeePerGas) specified") @@ -419,6 +458,13 @@ func (args *TransactionArgs) ToMessage(globalGasCap uint64, baseFee *big.Int) (* // Backfill the legacy gasPrice for EVM execution, unless we're all zeroes gasPrice = new(big.Int) if gasFeeCap.BitLen() > 0 || gasTipCap.BitLen() > 0 { + if args.IsFeeCurrencyDenominated() { + var err error + baseFee, err = exchange.ConvertCeloToCurrency(exchangeRates, args.FeeCurrency, baseFee) + if err != nil { + return nil, fmt.Errorf("can't convert base-fee to fee-currency: %w", err) + } + } gasPrice = math.BigMin(new(big.Int).Add(gasTipCap, baseFee), gasFeeCap) } } @@ -450,6 +496,7 @@ func (args *TransactionArgs) ToMessage(globalGasCap uint64, baseFee *big.Int) (* BlobGasFeeCap: blobFeeCap, BlobHashes: args.BlobHashes, SkipAccountChecks: true, + FeeCurrency: args.FeeCurrency, } return msg, nil } @@ -531,3 +578,10 @@ func (args *TransactionArgs) toTransaction() *types.Transaction { func (args *TransactionArgs) IsEIP4844() bool { return args.BlobHashes != nil || args.BlobFeeCap != nil } + +// IsFeeCurrencyDenominated returns whether the gas-price related +// fields are denominated in a given fee currency or in the native token. +// This effectively is only true for CIP-64 transactions. +func (args *TransactionArgs) IsFeeCurrencyDenominated() bool { + return args.FeeCurrency != nil && args.MaxFeeInFeeCurrency == nil +} diff --git a/internal/ethapi/transaction_args_test.go b/internal/ethapi/transaction_args_test.go index f98683baca..31dc6bdd10 100644 --- a/internal/ethapi/transaction_args_test.go +++ b/internal/ethapi/transaction_args_test.go @@ -51,7 +51,7 @@ func TestSetFeeDefaults(t *testing.T) { } var ( - b = newBackendMock() + b = newCeloBackendMock() zero = (*hexutil.Big)(big.NewInt(0)) fortytwo = (*hexutil.Big)(big.NewInt(42)) maxFee = (*hexutil.Big)(new(big.Int).Add(new(big.Int).Mul(b.current.BaseFee, big.NewInt(2)), fortytwo.ToInt())) @@ -254,6 +254,37 @@ func TestSetFeeDefaults(t *testing.T) { } } +type celoBackendMock struct { + *backendMock +} + +func newCeloBackendMock() *celoBackendMock { + return &celoBackendMock{ + backendMock: newBackendMock(), + } +} + +func (c *celoBackendMock) GetFeeBalance(ctx context.Context, atBlock common.Hash, account common.Address, feeCurrency *common.Address) (*big.Int, error) { + // Celo specific backend features are currently not tested + return nil, errCeloNotImplemented +} + +func (c *celoBackendMock) GetExchangeRates(ctx context.Context, atBlock common.Hash) (common.ExchangeRates, error) { + var er common.ExchangeRates + // Celo specific backend features are currently not tested + return er, errCeloNotImplemented +} + +func (c *celoBackendMock) ConvertToCurrency(ctx context.Context, atBlock common.Hash, value *big.Int, fromFeeCurrency *common.Address) (*big.Int, error) { + // Celo specific backend features are currently not tested + return nil, errCeloNotImplemented +} + +func (c *celoBackendMock) ConvertToGold(ctx context.Context, atBlock common.Hash, value *big.Int, toFeeCurrency *common.Address) (*big.Int, error) { + // Celo specific backend features are currently not tested + return nil, errCeloNotImplemented +} + type backendMock struct { current *types.Header config *params.ChainConfig diff --git a/tests/testdata b/tests/testdata index fa51c5c164..ee3fa4c86d 160000 --- a/tests/testdata +++ b/tests/testdata @@ -1 +1 @@ -Subproject commit fa51c5c164f79140730ccb8fe26a46c3d3994338 +Subproject commit ee3fa4c86d05f99f2717f83a6ad08008490ddf07