diff --git a/e2e_test/js-tests/package.json b/e2e_test/js-tests/package.json index 2b8de2061e..1867f08694 100644 --- a/e2e_test/js-tests/package.json +++ b/e2e_test/js-tests/package.json @@ -1,17 +1,17 @@ { - "name": "js-tests", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "", - "license": "MIT", - "dependencies": { - "chai": "^5.0.0", - "ethers": "^6.10.0", - "mocha": "^10.2.0", - "viem": "^2.9.6" - } + "name": "js-tests", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "MIT", + "dependencies": { + "chai": "^5.0.0", + "ethers": "^6.10.0", + "mocha": "^10.2.0", + "viem": "^2.9.6" + } } diff --git a/e2e_test/js-tests/test_viem_tx.mjs b/e2e_test/js-tests/test_viem_tx.mjs index 34e94f1bee..16a46f7fd7 100644 --- a/e2e_test/js-tests/test_viem_tx.mjs +++ b/e2e_test/js-tests/test_viem_tx.mjs @@ -142,6 +142,45 @@ describe("viem send tx", () => { assert.equal(receipt.status, "success", "receipt status 'failure'"); }).timeout(10_000); + it("test gas price difference for fee currency", async () => { + const request = await walletClient.prepareTransactionRequest({ + account, + to: "0x00000000000000000000000000000000DeaDBeef", + value: 2, + gas: 90000, + feeCurrency: process.env.FEE_CURRENCY, + }); + + const gasPriceNative = await publicClient.getGasPrice({}); + var maxPriorityFeePerGasNative = + await publicClient.estimateMaxPriorityFeePerGas({}); + const block = await publicClient.getBlock({}); + assert.equal( + BigInt(block.baseFeePerGas) + maxPriorityFeePerGasNative, + gasPriceNative, + ); + + // viem's getGasPrice does not expose additional request parameters, + // but Celo's override 'chain.fees.estimateFeesPerGas' action does. + // this will call the eth_gasPrice and eth_maxPriorityFeePerGas methods + // with the additional feeCurrency parameter internally + var fees = await publicClient.estimateFeesPerGas({ + type: "eip1559", + request: { + feeCurrency: process.env.FEE_CURRENCY, + }, + }); + // first check that the fee currency denominated gas price + // converts properly to the native gas price + assert.equal(fees.maxFeePerGas, gasPriceNative * 2n); + assert.equal(fees.maxPriorityFeePerGas, maxPriorityFeePerGasNative * 2n); + + // check that the prepared transaction request uses the + // converted gas price internally + assert.equal(request.maxFeePerGas, fees.maxFeePerGas); + assert.equal(request.maxPriorityFeePerGas, fees.maxPriorityFeePerGas); + }).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 89e85c2a39..1fb3ec53e1 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -47,6 +47,7 @@ import ( "github.com/ethereum/go-ethereum/eth/tracers" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/event" + "github.com/ethereum/go-ethereum/internal/celoapi" "github.com/ethereum/go-ethereum/internal/ethapi" "github.com/ethereum/go-ethereum/internal/shutdowncheck" "github.com/ethereum/go-ethereum/log" @@ -395,6 +396,13 @@ func (s *Ethereum) APIs() []rpc.API { Namespace: "net", Service: s.netRPCService, }, + // CELO specific API backend. + // For methods in the backend that are already defined (match by name) + // on the eth namespace, this will overwrite the original procedures. + { + Namespace: "eth", + Service: celoapi.NewCeloAPI(s, s.APIBackend), + }, }...) } diff --git a/internal/celoapi/backend.go b/internal/celoapi/backend.go new file mode 100644 index 0000000000..f04111e141 --- /dev/null +++ b/internal/celoapi/backend.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.Backend) *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.convertGoldToCurrency(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) convertGoldToCurrency(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) +}