Skip to content

Commit

Permalink
eth_estimateGas CIP-64 and CIP-66 compatibility (#91)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ezdac authored and karlb committed Aug 20, 2024
1 parent cd67dcf commit 475e34e
Show file tree
Hide file tree
Showing 15 changed files with 410 additions and 98 deletions.
12 changes: 11 additions & 1 deletion core/state_transition.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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.
//
Expand Down
17 changes: 17 additions & 0 deletions e2e_test/js-tests/test_viem_tx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions eth/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())...)
Expand Down Expand Up @@ -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),
},
}...)
}
Expand Down
36 changes: 27 additions & 9 deletions eth/gasestimator/gasestimator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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()
}
}
Expand Down
2 changes: 1 addition & 1 deletion eth/tracers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 1 addition & 1 deletion graphql/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion graphql/graphql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 4 additions & 2 deletions graphql/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
97 changes: 97 additions & 0 deletions internal/celoapi/api.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 475e34e

Please sign in to comment.