From 475e34e16c2a1ac51beda506a4f6e33aa095b7dd 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 | 55 +++++++++--- internal/ethapi/api_test.go | 52 ++++++++++- internal/ethapi/backend.go | 14 ++- internal/ethapi/transaction_args.go | 67 ++++++++++++-- internal/ethapi/transaction_args_test.go | 33 ++++++- 15 files changed, 410 insertions(+), 98 deletions(-) create mode 100644 internal/celoapi/api.go diff --git a/core/state_transition.go b/core/state_transition.go index 437b250b49..3cbcad9d23 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -184,6 +184,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. @@ -207,7 +209,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 { @@ -225,6 +228,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 1fb3ec53e1..37971e1e9e 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -373,7 +373,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())...) @@ -401,7 +402,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 fbcdbc8b35..a5626f0beb 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) } if opts.Config.IsCancun(opts.Header.Number, opts.Header.Time) && len(call.BlobHashes) > 0 { blobGasPerBlob := new(big.Int).SetInt64(params.BlobTxBlobGasPerBlob) @@ -99,7 +115,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 606cb3ef78..04b80269eb 100644 --- a/eth/tracers/api.go +++ b/eth/tracers/api.go @@ -994,7 +994,7 @@ func (api *API) TraceCall(ctx context.Context, args ethapi.TransactionArgs, bloc return nil, err } var ( - msg = args.ToMessage(vmctx.BaseFee) + msg = args.ToMessage(vmctx.BaseFee, vmctx.ExchangeRates) tx = args.ToTransaction() traceConfig *TraceConfig ) diff --git a/graphql/graphql.go b/graphql/graphql.go index 6672c99a99..b888d7bb4f 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 f3f9d1778a..6a1c432338 100644 --- a/graphql/graphql_test.go +++ b/graphql/graphql_test.go @@ -39,6 +39,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" @@ -479,7 +480,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 8ec2b25d47..b9ff9361ad 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -58,6 +58,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 { @@ -303,11 +304,11 @@ func (api *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, @@ -637,11 +638,11 @@ func (api *PersonalAccountAPI) Unpair(ctx context.Context, url string, pin strin // 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} } @@ -1202,7 +1203,7 @@ func doCall(ctx context.Context, b Backend, args TransactionArgs, state *state.S if err := args.CallDefaults(globalGasCap, blockCtx.BaseFee, b.ChainConfig().ChainID); err != nil { return nil, err } - msg := args.ToMessage(blockCtx.BaseFee) + msg := args.ToMessage(blockCtx.BaseFee, blockCtx.ExchangeRates) evm := b.GetEVM(ctx, msg, state, header, &vm.Config{NoBaseFee: true}, &blockCtx) // Wait for the context to be done and cancel the evm. Even if the @@ -1229,7 +1230,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) @@ -1285,7 +1286,7 @@ func (api *BlockChainAPI) Call(ctx context.Context, args TransactionArgs, blockN // 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 { @@ -1310,10 +1311,28 @@ func DoEstimateGas(ctx context.Context, b Backend, args TransactionArgs, blockNr if err := args.CallDefaults(gasCap, header.BaseFee, b.ChainConfig().ChainID); err != nil { return 0, err } - call := args.ToMessage(header.BaseFee) + + // 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) + } + } + + call := args.ToMessage(header.BaseFee, exchangeRates) + + // Celo specific: get balance + balance, err := b.GetFeeBalance(ctx, opts.Header.Hash(), call.From, args.FeeCurrency) + if err != nil { + return 0, err + } // Run the gas estimation and wrap any revertals into a custom return - 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) @@ -1690,7 +1709,7 @@ func (api *BlockChainAPI) CreateAccessList(ctx context.Context, args Transaction // 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 { @@ -1728,7 +1747,17 @@ func AccessList(ctx context.Context, b Backend, blockNrOrHash rpc.BlockNumberOrH statedb := db.Copy() // Set the accesslist to the last al args.AccessList = &accessList - msg := args.ToMessage(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 := args.ToMessage(header.BaseFee, exchangeRates) // Apply the transaction with the access list tracer tracer := logger.NewAccessListTracer(accessList, args.from(), to, precompiles) @@ -1747,13 +1776,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 6b9ffd3100..4ed8a30354 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) { @@ -1990,7 +2036,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 7265bed8a0..a8f13ae94b 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 f199f9d912..b6a7e7c17f 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); 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) } @@ -421,7 +460,7 @@ func (args *TransactionArgs) CallDefaults(globalGasCap uint64, baseFee *big.Int, // core evm. This method is used in calls and traces that do not require a real // live transaction. // Assumes that fields are not nil, i.e. setDefaults or CallDefaults has been called. -func (args *TransactionArgs) ToMessage(baseFee *big.Int) *core.Message { +func (args *TransactionArgs) ToMessage(baseFee *big.Int, exchangeRates common.ExchangeRates) *core.Message { var ( gasPrice *big.Int gasFeeCap *big.Int @@ -443,6 +482,14 @@ func (args *TransactionArgs) ToMessage(baseFee *big.Int) *core.Message { // 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 { + log.Error("can't convert base-fee to fee-currency", "err", err) + baseFee = common.Big1 + } + } gasPrice = math.BigMin(new(big.Int).Add(gasTipCap, baseFee), gasFeeCap) } } @@ -464,6 +511,7 @@ func (args *TransactionArgs) ToMessage(baseFee *big.Int) *core.Message { BlobGasFeeCap: (*big.Int)(args.BlobFeeCap), BlobHashes: args.BlobHashes, SkipAccountChecks: true, + FeeCurrency: args.FeeCurrency, } } @@ -544,3 +592,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 79fe9a4257..ef242672e7 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