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