From 56171772b140579d885b19f24c12b9f7ae8c1c7c Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 11:39:38 +0530 Subject: [PATCH 1/8] fix: include OP-Stack L1 data fee in outbound GasFeeUsed accounting (F-2026-18145) --- universalClient/chains/evm/event_confirmer.go | 3 +- universalClient/chains/evm/l1fee_test.go | 105 ++++++++++++++++++ universalClient/chains/evm/rpc_client.go | 20 ++++ universalClient/chains/evm/tx_builder.go | 21 +++- 4 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 universalClient/chains/evm/l1fee_test.go diff --git a/universalClient/chains/evm/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index c3003904..cc880abd 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -178,7 +178,8 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { } gasUsed := new(big.Int).SetUint64(receipt.GasUsed) gasPrice := tx.GasPrice() - gasFeeUsed := new(big.Int).Mul(gasUsed, gasPrice).String() + execFee := new(big.Int).Mul(gasUsed, gasPrice) + gasFeeUsed := withL1Fee(ctx, ec.rpcClient, hash, execFee).String() // Unmarshal, set GasFeeUsed, re-marshal var outboundEvent chaincommon.OutboundEvent diff --git a/universalClient/chains/evm/l1fee_test.go b/universalClient/chains/evm/l1fee_test.go new file mode 100644 index 00000000..3c68a857 --- /dev/null +++ b/universalClient/chains/evm/l1fee_test.go @@ -0,0 +1,105 @@ +package evm + +import ( + "context" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rpcServer serves eth_chainId plus caller-supplied receipt and tx JSON. +func rpcServer(t *testing.T, receiptJSON, txJSON string) *RPCClient { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := make([]byte, r.ContentLength) + r.Body.Read(body) + switch { + case strings.Contains(string(body), "eth_chainId"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0xaa36a7"}`)) // 11155111 + case strings.Contains(string(body), "eth_getTransactionReceipt"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":` + receiptJSON + `}`)) + case strings.Contains(string(body), "eth_getTransactionByHash"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":` + txJSON + `}`)) + default: + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + t.Cleanup(server.Close) + + rc, err := NewRPCClient([]string{server.URL}, 11155111, zerolog.Nop()) + require.NoError(t, err) + t.Cleanup(func() { rc.Close() }) + return rc +} + +func receiptJSON(txHash string, l1FeeField string) string { + return `{"transactionHash":"` + txHash + `",` + + `"blockHash":"0x2222222222222222222222222222222222222222222222222222222222222222",` + + `"blockNumber":"0x1","transactionIndex":"0x0","cumulativeGasUsed":"0x5208",` + + `"gasUsed":"0x5208","status":"0x1","contractAddress":null,"logs":[],` + + `"logsBloom":"0x` + strings.Repeat("0", 512) + `",` + l1FeeField + `"type":"0x0"}` +} + +func TestGetL1Fee(t *testing.T) { + hash := ethcommon.HexToHash("0xabc") + + t.Run("parses OP l1Fee", func(t *testing.T) { + rc := rpcServer(t, receiptJSON(hash.Hex(), `"l1Fee":"0x5208",`), `null`) + got, err := rc.GetL1Fee(context.Background(), hash) + require.NoError(t, err) + assert.Equal(t, int64(0x5208), got.Int64()) + }) + + t.Run("returns 0 when field absent (non-OP)", func(t *testing.T) { + rc := rpcServer(t, receiptJSON(hash.Hex(), ``), `null`) + got, err := rc.GetL1Fee(context.Background(), hash) + require.NoError(t, err) + assert.Equal(t, int64(0), got.Int64()) + }) +} + +// A nonzero l1Fee must be added to GasFeeUsed so the core refund (gasFee − +// GasFeeUsed) shrinks by exactly that amount. +func TestGetGasFeeUsed_IncludesL1Fee(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + chainID := big.NewInt(11155111) + gasPrice := big.NewInt(20_000_000_000) + to := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + signedTx, err := types.SignTx( + types.NewTransaction(0, to, big.NewInt(0), 21000, gasPrice, nil), + types.NewEIP155Signer(chainID), key, + ) + require.NoError(t, err) + txJSON, err := signedTx.MarshalJSON() + require.NoError(t, err) + + execFee := new(big.Int).Mul(big.NewInt(21000), gasPrice) // gasUsed 0x5208 = 21000 + + t.Run("OP destination adds l1Fee", func(t *testing.T) { + rc := rpcServer(t, receiptJSON(signedTx.Hash().Hex(), `"l1Fee":"0x5208",`), string(txJSON)) + tb := &TxBuilder{rpcClient: rc, chainID: "eip155:11155111", chainIDInt: 11155111, logger: zerolog.Nop()} + got, err := tb.GetGasFeeUsed(context.Background(), signedTx.Hash().Hex()) + require.NoError(t, err) + want := new(big.Int).Add(execFee, big.NewInt(0x5208)) + assert.Equal(t, want.String(), got) + }) + + t.Run("non-OP destination is execution fee only", func(t *testing.T) { + rc := rpcServer(t, receiptJSON(signedTx.Hash().Hex(), ``), string(txJSON)) + tb := &TxBuilder{rpcClient: rc, chainID: "eip155:11155111", chainIDInt: 11155111, logger: zerolog.Nop()} + got, err := tb.GetGasFeeUsed(context.Background(), signedTx.Hash().Hex()) + require.NoError(t, err) + assert.Equal(t, execFee.String(), got) + }) +} diff --git a/universalClient/chains/evm/rpc_client.go b/universalClient/chains/evm/rpc_client.go index b8c83d04..7d1e4a84 100644 --- a/universalClient/chains/evm/rpc_client.go +++ b/universalClient/chains/evm/rpc_client.go @@ -10,6 +10,7 @@ import ( "github.com/ethereum/go-ethereum" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/rs/zerolog" @@ -204,6 +205,25 @@ func (rc *RPCClient) GetTransactionReceipt(ctx context.Context, txHash ethcommon return receipt, err } +// GetL1Fee returns the OP-Stack L1 data fee charged for an included transaction, +// read from the receipt's l1Fee field. Returns 0 for chains that do not populate +// it (non-OP chains), so callers can add it unconditionally. +func (rc *RPCClient) GetL1Fee(ctx context.Context, txHash ethcommon.Hash) (*big.Int, error) { + var raw struct { + L1Fee *hexutil.Big `json:"l1Fee"` + } + err := rc.executeWithFailover(ctx, "get_l1_fee", func(client *ethclient.Client) error { + return client.Client().CallContext(ctx, &raw, "eth_getTransactionReceipt", txHash) + }) + if err != nil { + return nil, err + } + if raw.L1Fee == nil { + return big.NewInt(0), nil + } + return (*big.Int)(raw.L1Fee), nil +} + // GetTransactionByHash returns a transaction by its hash. func (rc *RPCClient) GetTransactionByHash(ctx context.Context, txHash ethcommon.Hash) (*types.Transaction, bool, error) { var tx *types.Transaction diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index f55873ff..794569db 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -449,9 +449,9 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, } -// GetGasFeeUsed returns the gas fee used by a transaction on the EVM chain. -// Fetches the receipt for gasUsed and the transaction for gasPrice, then returns -// gasUsed * gasPrice as a decimal string. Returns "0" if not found. +// GetGasFeeUsed returns the gas fee used by a transaction on the EVM chain: +// L2 execution (gasUsed * gasPrice) plus the OP-Stack L1 data fee (0 on non-OP +// chains). Returns "0" if not found. func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, error) { hash := ethcommon.HexToHash(txHash) receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, hash) @@ -470,8 +470,19 @@ func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, return "0", nil } - gasFeeUsed := new(big.Int).Mul(gasUsed, gasPrice) - return gasFeeUsed.String(), nil + execFee := new(big.Int).Mul(gasUsed, gasPrice) + return withL1Fee(ctx, tb.rpcClient, hash, execFee).String(), nil +} + +// withL1Fee returns execFee plus the OP-Stack L1 data fee for txHash. On a +// failed L1-fee lookup it falls back to execFee so accounting never blocks on a +// transient RPC error (non-OP chains report 0 and are unaffected). +func withL1Fee(ctx context.Context, rc *RPCClient, txHash ethcommon.Hash, execFee *big.Int) *big.Int { + l1Fee, err := rc.GetL1Fee(ctx, txHash) + if err != nil { + return execFee + } + return new(big.Int).Add(execFee, l1Fee) } // GetFundMigrationSigningRequest builds a native token transfer for fund migration, From 0b17eb37538e31230090e70024fab836c09de6bf Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 11:51:29 +0530 Subject: [PATCH 2/8] refactor: read L2+L1 gas fee from a single receipt call (F-2026-18145) --- universalClient/chains/evm/event_confirmer.go | 14 ++-- universalClient/chains/evm/l1fee_test.go | 84 ++++++++----------- universalClient/chains/evm/rpc_client.go | 23 +++-- universalClient/chains/evm/tx_builder.go | 33 +------- 4 files changed, 57 insertions(+), 97 deletions(-) diff --git a/universalClient/chains/evm/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index cc880abd..b52262e9 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "math/big" "strings" "sync" "time" @@ -167,19 +166,16 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { // For outbound events, enrich with gas fee before confirming if event.Type == store.EventTypeOutbound { - tx, _, txErr := ec.rpcClient.GetTransactionByHash(ctx, hash) - if txErr != nil { + feeUsed, feeErr := ec.rpcClient.GetReceiptGasFee(ctx, hash) + if feeErr != nil { ec.logger.Warn(). - Err(txErr). + Err(feeErr). Str("event_id", event.EventID). Str("tx_hash", txHash). - Msg("failed to fetch transaction for gas fee, skipping confirmation") + Msg("failed to fetch gas fee, skipping confirmation") continue } - gasUsed := new(big.Int).SetUint64(receipt.GasUsed) - gasPrice := tx.GasPrice() - execFee := new(big.Int).Mul(gasUsed, gasPrice) - gasFeeUsed := withL1Fee(ctx, ec.rpcClient, hash, execFee).String() + gasFeeUsed := feeUsed.String() // Unmarshal, set GasFeeUsed, re-marshal var outboundEvent chaincommon.OutboundEvent diff --git a/universalClient/chains/evm/l1fee_test.go b/universalClient/chains/evm/l1fee_test.go index 3c68a857..fff2f4b9 100644 --- a/universalClient/chains/evm/l1fee_test.go +++ b/universalClient/chains/evm/l1fee_test.go @@ -9,15 +9,13 @@ import ( "testing" ethcommon "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// rpcServer serves eth_chainId plus caller-supplied receipt and tx JSON. -func rpcServer(t *testing.T, receiptJSON, txJSON string) *RPCClient { +// receiptRPC serves eth_chainId plus a single receipt for any receipt lookup. +func receiptRPC(t *testing.T, receiptJSON string) *RPCClient { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -28,8 +26,6 @@ func rpcServer(t *testing.T, receiptJSON, txJSON string) *RPCClient { w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0xaa36a7"}`)) // 11155111 case strings.Contains(string(body), "eth_getTransactionReceipt"): w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":` + receiptJSON + `}`)) - case strings.Contains(string(body), "eth_getTransactionByHash"): - w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":` + txJSON + `}`)) default: w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) } @@ -42,64 +38,50 @@ func rpcServer(t *testing.T, receiptJSON, txJSON string) *RPCClient { return rc } -func receiptJSON(txHash string, l1FeeField string) string { - return `{"transactionHash":"` + txHash + `",` + - `"blockHash":"0x2222222222222222222222222222222222222222222222222222222222222222",` + +// receipt builds a minimal receipt JSON with gasUsed 0x5208 (21000) and +// effectiveGasPrice 0x4a817c800 (20 gwei); l1FeeField is "" for non-OP chains. +func receipt(l1FeeField string) string { + return `{"transactionHash":"0xabc","blockHash":"0x2222222222222222222222222222222222222222222222222222222222222222",` + `"blockNumber":"0x1","transactionIndex":"0x0","cumulativeGasUsed":"0x5208",` + - `"gasUsed":"0x5208","status":"0x1","contractAddress":null,"logs":[],` + - `"logsBloom":"0x` + strings.Repeat("0", 512) + `",` + l1FeeField + `"type":"0x0"}` + `"gasUsed":"0x5208","effectiveGasPrice":"0x4a817c800","status":"0x1","contractAddress":null,` + + `"logs":[],"logsBloom":"0x` + strings.Repeat("0", 512) + `",` + l1FeeField + `"type":"0x0"}` } -func TestGetL1Fee(t *testing.T) { +// A nonzero l1Fee must be added to GasFeeUsed so the core refund (gasFee − +// GasFeeUsed) shrinks by exactly that amount. Both values come from one receipt. +func TestGetReceiptGasFee(t *testing.T) { hash := ethcommon.HexToHash("0xabc") + execFee := new(big.Int).Mul(big.NewInt(21000), big.NewInt(20_000_000_000)) // gasUsed * effectiveGasPrice + + t.Run("OP destination adds l1Fee", func(t *testing.T) { + rc := receiptRPC(t, receipt(`"l1Fee":"0x5208",`)) + got, err := rc.GetReceiptGasFee(context.Background(), hash) + require.NoError(t, err) + assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)), got) + }) - t.Run("parses OP l1Fee", func(t *testing.T) { - rc := rpcServer(t, receiptJSON(hash.Hex(), `"l1Fee":"0x5208",`), `null`) - got, err := rc.GetL1Fee(context.Background(), hash) + t.Run("non-OP destination is execution fee only", func(t *testing.T) { + rc := receiptRPC(t, receipt(``)) + got, err := rc.GetReceiptGasFee(context.Background(), hash) require.NoError(t, err) - assert.Equal(t, int64(0x5208), got.Int64()) + assert.Equal(t, execFee, got) }) - t.Run("returns 0 when field absent (non-OP)", func(t *testing.T) { - rc := rpcServer(t, receiptJSON(hash.Hex(), ``), `null`) - got, err := rc.GetL1Fee(context.Background(), hash) + t.Run("missing receipt returns 0", func(t *testing.T) { + rc := receiptRPC(t, `null`) + got, err := rc.GetReceiptGasFee(context.Background(), hash) require.NoError(t, err) assert.Equal(t, int64(0), got.Int64()) }) } -// A nonzero l1Fee must be added to GasFeeUsed so the core refund (gasFee − -// GasFeeUsed) shrinks by exactly that amount. +// GetGasFeeUsed (revert/resolver path) delegates to the same single-call helper. func TestGetGasFeeUsed_IncludesL1Fee(t *testing.T) { - key, err := crypto.GenerateKey() - require.NoError(t, err) - chainID := big.NewInt(11155111) - gasPrice := big.NewInt(20_000_000_000) - to := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") - signedTx, err := types.SignTx( - types.NewTransaction(0, to, big.NewInt(0), 21000, gasPrice, nil), - types.NewEIP155Signer(chainID), key, - ) - require.NoError(t, err) - txJSON, err := signedTx.MarshalJSON() - require.NoError(t, err) + execFee := new(big.Int).Mul(big.NewInt(21000), big.NewInt(20_000_000_000)) - execFee := new(big.Int).Mul(big.NewInt(21000), gasPrice) // gasUsed 0x5208 = 21000 - - t.Run("OP destination adds l1Fee", func(t *testing.T) { - rc := rpcServer(t, receiptJSON(signedTx.Hash().Hex(), `"l1Fee":"0x5208",`), string(txJSON)) - tb := &TxBuilder{rpcClient: rc, chainID: "eip155:11155111", chainIDInt: 11155111, logger: zerolog.Nop()} - got, err := tb.GetGasFeeUsed(context.Background(), signedTx.Hash().Hex()) - require.NoError(t, err) - want := new(big.Int).Add(execFee, big.NewInt(0x5208)) - assert.Equal(t, want.String(), got) - }) - - t.Run("non-OP destination is execution fee only", func(t *testing.T) { - rc := rpcServer(t, receiptJSON(signedTx.Hash().Hex(), ``), string(txJSON)) - tb := &TxBuilder{rpcClient: rc, chainID: "eip155:11155111", chainIDInt: 11155111, logger: zerolog.Nop()} - got, err := tb.GetGasFeeUsed(context.Background(), signedTx.Hash().Hex()) - require.NoError(t, err) - assert.Equal(t, execFee.String(), got) - }) + rc := receiptRPC(t, receipt(`"l1Fee":"0x5208",`)) + tb := &TxBuilder{rpcClient: rc, chainID: "eip155:11155111", chainIDInt: 11155111, logger: zerolog.Nop()} + got, err := tb.GetGasFeeUsed(context.Background(), "0xabc") + require.NoError(t, err) + assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)).String(), got) } diff --git a/universalClient/chains/evm/rpc_client.go b/universalClient/chains/evm/rpc_client.go index 7d1e4a84..5c22a33b 100644 --- a/universalClient/chains/evm/rpc_client.go +++ b/universalClient/chains/evm/rpc_client.go @@ -205,23 +205,30 @@ func (rc *RPCClient) GetTransactionReceipt(ctx context.Context, txHash ethcommon return receipt, err } -// GetL1Fee returns the OP-Stack L1 data fee charged for an included transaction, -// read from the receipt's l1Fee field. Returns 0 for chains that do not populate -// it (non-OP chains), so callers can add it unconditionally. -func (rc *RPCClient) GetL1Fee(ctx context.Context, txHash ethcommon.Hash) (*big.Int, error) { +// GetReceiptGasFee returns the full destination cost of an included transaction +// from a single receipt read: L2 execution (gasUsed * effectiveGasPrice) plus the +// OP-Stack L1 data fee (l1Fee, 0 on non-OP chains). Returns 0 if the tx is not +// found or the receipt lacks the required fields. +func (rc *RPCClient) GetReceiptGasFee(ctx context.Context, txHash ethcommon.Hash) (*big.Int, error) { var raw struct { - L1Fee *hexutil.Big `json:"l1Fee"` + GasUsed *hexutil.Uint64 `json:"gasUsed"` + EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` + L1Fee *hexutil.Big `json:"l1Fee"` } - err := rc.executeWithFailover(ctx, "get_l1_fee", func(client *ethclient.Client) error { + err := rc.executeWithFailover(ctx, "get_transaction_receipt", func(client *ethclient.Client) error { return client.Client().CallContext(ctx, &raw, "eth_getTransactionReceipt", txHash) }) if err != nil { return nil, err } - if raw.L1Fee == nil { + if raw.GasUsed == nil || raw.EffectiveGasPrice == nil { return big.NewInt(0), nil } - return (*big.Int)(raw.L1Fee), nil + fee := new(big.Int).Mul(new(big.Int).SetUint64(uint64(*raw.GasUsed)), (*big.Int)(raw.EffectiveGasPrice)) + if raw.L1Fee != nil { + fee.Add(fee, (*big.Int)(raw.L1Fee)) + } + return fee, nil } // GetTransactionByHash returns a transaction by its hash. diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index 794569db..25bd8078 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -450,39 +450,14 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, // GetGasFeeUsed returns the gas fee used by a transaction on the EVM chain: -// L2 execution (gasUsed * gasPrice) plus the OP-Stack L1 data fee (0 on non-OP -// chains). Returns "0" if not found. +// L2 execution (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee +// (0 on non-OP chains). Returns "0" if not found. func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, error) { - hash := ethcommon.HexToHash(txHash) - receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, hash) + fee, err := tb.rpcClient.GetReceiptGasFee(ctx, ethcommon.HexToHash(txHash)) if err != nil { return "0", nil } - - tx, _, err := tb.rpcClient.GetTransactionByHash(ctx, hash) - if err != nil { - return "0", nil - } - - gasUsed := new(big.Int).SetUint64(receipt.GasUsed) - gasPrice := tx.GasPrice() - if gasPrice == nil || gasPrice.Sign() == 0 { - return "0", nil - } - - execFee := new(big.Int).Mul(gasUsed, gasPrice) - return withL1Fee(ctx, tb.rpcClient, hash, execFee).String(), nil -} - -// withL1Fee returns execFee plus the OP-Stack L1 data fee for txHash. On a -// failed L1-fee lookup it falls back to execFee so accounting never blocks on a -// transient RPC error (non-OP chains report 0 and are unaffected). -func withL1Fee(ctx context.Context, rc *RPCClient, txHash ethcommon.Hash, execFee *big.Int) *big.Int { - l1Fee, err := rc.GetL1Fee(ctx, txHash) - if err != nil { - return execFee - } - return new(big.Int).Add(execFee, l1Fee) + return fee.String(), nil } // GetFundMigrationSigningRequest builds a native token transfer for fund migration, From 00c410de6f5fba7f2995553743023e1fe010797e Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 11:58:15 +0530 Subject: [PATCH 3/8] refactor: single GetReceipt method with GasFee helper, drop typed receipt fetch (F-2026-18145) --- universalClient/chains/evm/event_confirmer.go | 17 ++---- universalClient/chains/evm/l1fee_test.go | 20 ++++--- universalClient/chains/evm/rpc_client.go | 58 +++++++++++++------ universalClient/chains/evm/tx_builder.go | 12 ++-- 4 files changed, 60 insertions(+), 47 deletions(-) diff --git a/universalClient/chains/evm/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index b52262e9..64eebdb1 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -138,8 +138,8 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { // Get transaction receipt hash := ethcommon.HexToHash(txHash) - receipt, err := ec.rpcClient.GetTransactionReceipt(ctx, hash) - if err != nil { + receipt, err := ec.rpcClient.GetReceipt(ctx, hash) + if err != nil || receipt == nil { // Transaction not found or not yet mined - skip continue } @@ -159,23 +159,14 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { // Check if transaction is confirmed based on confirmation type requiredConfirmations := ec.getRequiredConfirmations(event.ConfirmationType) - confirmations := latestBlock - receipt.BlockNumber.Uint64() + 1 + confirmations := latestBlock - receipt.BlockNumber + 1 if confirmations >= requiredConfirmations { var rowsAffected int64 // For outbound events, enrich with gas fee before confirming if event.Type == store.EventTypeOutbound { - feeUsed, feeErr := ec.rpcClient.GetReceiptGasFee(ctx, hash) - if feeErr != nil { - ec.logger.Warn(). - Err(feeErr). - Str("event_id", event.EventID). - Str("tx_hash", txHash). - Msg("failed to fetch gas fee, skipping confirmation") - continue - } - gasFeeUsed := feeUsed.String() + gasFeeUsed := receipt.GasFee().String() // Unmarshal, set GasFeeUsed, re-marshal var outboundEvent chaincommon.OutboundEvent diff --git a/universalClient/chains/evm/l1fee_test.go b/universalClient/chains/evm/l1fee_test.go index fff2f4b9..adf094e8 100644 --- a/universalClient/chains/evm/l1fee_test.go +++ b/universalClient/chains/evm/l1fee_test.go @@ -47,31 +47,33 @@ func receipt(l1FeeField string) string { `"logs":[],"logsBloom":"0x` + strings.Repeat("0", 512) + `",` + l1FeeField + `"type":"0x0"}` } -// A nonzero l1Fee must be added to GasFeeUsed so the core refund (gasFee − +// A nonzero l1Fee must be added to GasFee so the core refund (gasFee − // GasFeeUsed) shrinks by exactly that amount. Both values come from one receipt. -func TestGetReceiptGasFee(t *testing.T) { +func TestReceiptGasFee(t *testing.T) { hash := ethcommon.HexToHash("0xabc") execFee := new(big.Int).Mul(big.NewInt(21000), big.NewInt(20_000_000_000)) // gasUsed * effectiveGasPrice t.Run("OP destination adds l1Fee", func(t *testing.T) { rc := receiptRPC(t, receipt(`"l1Fee":"0x5208",`)) - got, err := rc.GetReceiptGasFee(context.Background(), hash) + r, err := rc.GetReceipt(context.Background(), hash) require.NoError(t, err) - assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)), got) + require.NotNil(t, r) + assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)), r.GasFee()) }) t.Run("non-OP destination is execution fee only", func(t *testing.T) { rc := receiptRPC(t, receipt(``)) - got, err := rc.GetReceiptGasFee(context.Background(), hash) + r, err := rc.GetReceipt(context.Background(), hash) require.NoError(t, err) - assert.Equal(t, execFee, got) + require.NotNil(t, r) + assert.Equal(t, execFee, r.GasFee()) }) - t.Run("missing receipt returns 0", func(t *testing.T) { + t.Run("missing receipt returns nil", func(t *testing.T) { rc := receiptRPC(t, `null`) - got, err := rc.GetReceiptGasFee(context.Background(), hash) + r, err := rc.GetReceipt(context.Background(), hash) require.NoError(t, err) - assert.Equal(t, int64(0), got.Int64()) + assert.Nil(t, r) }) } diff --git a/universalClient/chains/evm/rpc_client.go b/universalClient/chains/evm/rpc_client.go index 5c22a33b..0d905c2a 100644 --- a/universalClient/chains/evm/rpc_client.go +++ b/universalClient/chains/evm/rpc_client.go @@ -194,23 +194,30 @@ func (rc *RPCClient) FilterLogs(ctx context.Context, query ethereum.FilterQuery) return logs, err } -// GetTransactionReceipt fetches a transaction receipt -func (rc *RPCClient) GetTransactionReceipt(ctx context.Context, txHash ethcommon.Hash) (*types.Receipt, error) { - var receipt *types.Receipt - err := rc.executeWithFailover(ctx, "get_transaction_receipt", func(client *ethclient.Client) error { - var innerErr error - receipt, innerErr = client.TransactionReceipt(ctx, txHash) - return innerErr - }) - return receipt, err +// Receipt holds the transaction-receipt fields the universal client needs, +// including the OP-Stack L1 data fee that go-ethereum's typed receipt omits. +type Receipt struct { + Status uint64 + BlockNumber uint64 + GasUsed uint64 + EffectiveGasPrice *big.Int + L1Fee *big.Int // OP-Stack L1 data fee; 0 on non-OP chains +} + +// GasFee returns the full destination cost of the transaction: L2 execution +// (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee. +func (r *Receipt) GasFee() *big.Int { + fee := new(big.Int).Mul(new(big.Int).SetUint64(r.GasUsed), r.EffectiveGasPrice) + return fee.Add(fee, r.L1Fee) } -// GetReceiptGasFee returns the full destination cost of an included transaction -// from a single receipt read: L2 execution (gasUsed * effectiveGasPrice) plus the -// OP-Stack L1 data fee (l1Fee, 0 on non-OP chains). Returns 0 if the tx is not -// found or the receipt lacks the required fields. -func (rc *RPCClient) GetReceiptGasFee(ctx context.Context, txHash ethcommon.Hash) (*big.Int, error) { +// GetReceipt fetches a transaction receipt in a single raw call, reading the +// OP-Stack l1Fee alongside the standard fields. Returns (nil, nil) if the tx is +// not found (receipt is null). +func (rc *RPCClient) GetReceipt(ctx context.Context, txHash ethcommon.Hash) (*Receipt, error) { var raw struct { + Status *hexutil.Uint64 `json:"status"` + BlockNumber *hexutil.Big `json:"blockNumber"` GasUsed *hexutil.Uint64 `json:"gasUsed"` EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` L1Fee *hexutil.Big `json:"l1Fee"` @@ -221,14 +228,27 @@ func (rc *RPCClient) GetReceiptGasFee(ctx context.Context, txHash ethcommon.Hash if err != nil { return nil, err } - if raw.GasUsed == nil || raw.EffectiveGasPrice == nil { - return big.NewInt(0), nil + if raw.GasUsed == nil { + return nil, nil // not found + } + r := &Receipt{ + GasUsed: uint64(*raw.GasUsed), + EffectiveGasPrice: big.NewInt(0), + L1Fee: big.NewInt(0), + } + if raw.Status != nil { + r.Status = uint64(*raw.Status) + } + if raw.BlockNumber != nil { + r.BlockNumber = (*big.Int)(raw.BlockNumber).Uint64() + } + if raw.EffectiveGasPrice != nil { + r.EffectiveGasPrice = (*big.Int)(raw.EffectiveGasPrice) } - fee := new(big.Int).Mul(new(big.Int).SetUint64(uint64(*raw.GasUsed)), (*big.Int)(raw.EffectiveGasPrice)) if raw.L1Fee != nil { - fee.Add(fee, (*big.Int)(raw.L1Fee)) + r.L1Fee = (*big.Int)(raw.L1Fee) } - return fee, nil + return r, nil } // GetTransactionByHash returns a transaction by its hash. diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index 25bd8078..37a706b4 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -249,12 +249,12 @@ func (tb *TxBuilder) BroadcastOutboundSigningRequest( // VerifyBroadcastedTx checks the status of a broadcasted transaction on the EVM chain. func (tb *TxBuilder) VerifyBroadcastedTx(ctx context.Context, txHash string) (found bool, blockHeight uint64, confirmations uint64, status uint8, err error) { hash := ethcommon.HexToHash(txHash) - receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, hash) - if err != nil { + receipt, err := tb.rpcClient.GetReceipt(ctx, hash) + if err != nil || receipt == nil { return false, 0, 0, 0, nil } - receiptBlock := receipt.BlockNumber.Uint64() + receiptBlock := receipt.BlockNumber var confs uint64 latestBlock, err := tb.rpcClient.GetLatestBlock(ctx) @@ -453,11 +453,11 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, // L2 execution (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee // (0 on non-OP chains). Returns "0" if not found. func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, error) { - fee, err := tb.rpcClient.GetReceiptGasFee(ctx, ethcommon.HexToHash(txHash)) - if err != nil { + receipt, err := tb.rpcClient.GetReceipt(ctx, ethcommon.HexToHash(txHash)) + if err != nil || receipt == nil { return "0", nil } - return fee.String(), nil + return receipt.GasFee().String(), nil } // GetFundMigrationSigningRequest builds a native token transfer for fund migration, From 8330a43d38cd0f59f9d587ce01bb6d4d0e6d4931 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 12:00:00 +0530 Subject: [PATCH 4/8] refactor: keep GetTransactionReceipt name, move gas-fee helper out of rpc_client (F-2026-18145) --- universalClient/chains/evm/event_confirmer.go | 4 ++-- universalClient/chains/evm/l1fee_test.go | 10 +++++----- universalClient/chains/evm/rpc_client.go | 15 ++++----------- universalClient/chains/evm/tx_builder.go | 13 ++++++++++--- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/universalClient/chains/evm/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index 64eebdb1..37053c0e 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -138,7 +138,7 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { // Get transaction receipt hash := ethcommon.HexToHash(txHash) - receipt, err := ec.rpcClient.GetReceipt(ctx, hash) + receipt, err := ec.rpcClient.GetTransactionReceipt(ctx, hash) if err != nil || receipt == nil { // Transaction not found or not yet mined - skip continue @@ -166,7 +166,7 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { // For outbound events, enrich with gas fee before confirming if event.Type == store.EventTypeOutbound { - gasFeeUsed := receipt.GasFee().String() + gasFeeUsed := receiptGasFee(receipt).String() // Unmarshal, set GasFeeUsed, re-marshal var outboundEvent chaincommon.OutboundEvent diff --git a/universalClient/chains/evm/l1fee_test.go b/universalClient/chains/evm/l1fee_test.go index adf094e8..f7cde880 100644 --- a/universalClient/chains/evm/l1fee_test.go +++ b/universalClient/chains/evm/l1fee_test.go @@ -55,23 +55,23 @@ func TestReceiptGasFee(t *testing.T) { t.Run("OP destination adds l1Fee", func(t *testing.T) { rc := receiptRPC(t, receipt(`"l1Fee":"0x5208",`)) - r, err := rc.GetReceipt(context.Background(), hash) + r, err := rc.GetTransactionReceipt(context.Background(), hash) require.NoError(t, err) require.NotNil(t, r) - assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)), r.GasFee()) + assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)), receiptGasFee(r)) }) t.Run("non-OP destination is execution fee only", func(t *testing.T) { rc := receiptRPC(t, receipt(``)) - r, err := rc.GetReceipt(context.Background(), hash) + r, err := rc.GetTransactionReceipt(context.Background(), hash) require.NoError(t, err) require.NotNil(t, r) - assert.Equal(t, execFee, r.GasFee()) + assert.Equal(t, execFee, receiptGasFee(r)) }) t.Run("missing receipt returns nil", func(t *testing.T) { rc := receiptRPC(t, `null`) - r, err := rc.GetReceipt(context.Background(), hash) + r, err := rc.GetTransactionReceipt(context.Background(), hash) require.NoError(t, err) assert.Nil(t, r) }) diff --git a/universalClient/chains/evm/rpc_client.go b/universalClient/chains/evm/rpc_client.go index 0d905c2a..437e8989 100644 --- a/universalClient/chains/evm/rpc_client.go +++ b/universalClient/chains/evm/rpc_client.go @@ -204,17 +204,10 @@ type Receipt struct { L1Fee *big.Int // OP-Stack L1 data fee; 0 on non-OP chains } -// GasFee returns the full destination cost of the transaction: L2 execution -// (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee. -func (r *Receipt) GasFee() *big.Int { - fee := new(big.Int).Mul(new(big.Int).SetUint64(r.GasUsed), r.EffectiveGasPrice) - return fee.Add(fee, r.L1Fee) -} - -// GetReceipt fetches a transaction receipt in a single raw call, reading the -// OP-Stack l1Fee alongside the standard fields. Returns (nil, nil) if the tx is -// not found (receipt is null). -func (rc *RPCClient) GetReceipt(ctx context.Context, txHash ethcommon.Hash) (*Receipt, error) { +// GetTransactionReceipt fetches a transaction receipt in a single raw call, +// reading the OP-Stack l1Fee alongside the standard fields. Returns (nil, nil) +// if the tx is not found (receipt is null). +func (rc *RPCClient) GetTransactionReceipt(ctx context.Context, txHash ethcommon.Hash) (*Receipt, error) { var raw struct { Status *hexutil.Uint64 `json:"status"` BlockNumber *hexutil.Big `json:"blockNumber"` diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index 37a706b4..94affe0c 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -249,7 +249,7 @@ func (tb *TxBuilder) BroadcastOutboundSigningRequest( // VerifyBroadcastedTx checks the status of a broadcasted transaction on the EVM chain. func (tb *TxBuilder) VerifyBroadcastedTx(ctx context.Context, txHash string) (found bool, blockHeight uint64, confirmations uint64, status uint8, err error) { hash := ethcommon.HexToHash(txHash) - receipt, err := tb.rpcClient.GetReceipt(ctx, hash) + receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, hash) if err != nil || receipt == nil { return false, 0, 0, 0, nil } @@ -453,11 +453,18 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, // L2 execution (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee // (0 on non-OP chains). Returns "0" if not found. func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, error) { - receipt, err := tb.rpcClient.GetReceipt(ctx, ethcommon.HexToHash(txHash)) + receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, ethcommon.HexToHash(txHash)) if err != nil || receipt == nil { return "0", nil } - return receipt.GasFee().String(), nil + return receiptGasFee(receipt).String(), nil +} + +// receiptGasFee returns the full destination cost of an included tx: L2 execution +// (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee. +func receiptGasFee(r *Receipt) *big.Int { + fee := new(big.Int).Mul(new(big.Int).SetUint64(r.GasUsed), r.EffectiveGasPrice) + return fee.Add(fee, r.L1Fee) } // GetFundMigrationSigningRequest builds a native token transfer for fund migration, From fbf0920563a1fe205e3274dbf20e6fbb1a825003 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 12:12:58 +0530 Subject: [PATCH 5/8] fix: source gas price from tx, not receipt effectiveGasPrice, for fee accounting (F-2026-18145) --- universalClient/chains/evm/event_confirmer.go | 13 +++- universalClient/chains/evm/l1fee_test.go | 59 +++++++++++++++---- universalClient/chains/evm/rpc_client.go | 26 ++++---- universalClient/chains/evm/tx_builder.go | 23 +++++--- 4 files changed, 84 insertions(+), 37 deletions(-) diff --git a/universalClient/chains/evm/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index 37053c0e..b06d0d53 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -166,7 +166,16 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { // For outbound events, enrich with gas fee before confirming if event.Type == store.EventTypeOutbound { - gasFeeUsed := receiptGasFee(receipt).String() + tx, _, txErr := ec.rpcClient.GetTransactionByHash(ctx, hash) + if txErr != nil { + ec.logger.Warn(). + Err(txErr). + Str("event_id", event.EventID). + Str("tx_hash", txHash). + Msg("failed to fetch transaction for gas fee, skipping confirmation") + continue + } + gasFeeUsedStr := gasFeeUsed(receipt.GasUsed, tx.GasPrice(), receipt.L1Fee).String() // Unmarshal, set GasFeeUsed, re-marshal var outboundEvent chaincommon.OutboundEvent @@ -177,7 +186,7 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { Msg("failed to unmarshal outbound event data") continue } - outboundEvent.GasFeeUsed = gasFeeUsed + outboundEvent.GasFeeUsed = gasFeeUsedStr updatedData, marshalErr := json.Marshal(outboundEvent) if marshalErr != nil { diff --git a/universalClient/chains/evm/l1fee_test.go b/universalClient/chains/evm/l1fee_test.go index f7cde880..2c70d38a 100644 --- a/universalClient/chains/evm/l1fee_test.go +++ b/universalClient/chains/evm/l1fee_test.go @@ -9,6 +9,8 @@ import ( "testing" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,27 +40,28 @@ func receiptRPC(t *testing.T, receiptJSON string) *RPCClient { return rc } -// receipt builds a minimal receipt JSON with gasUsed 0x5208 (21000) and -// effectiveGasPrice 0x4a817c800 (20 gwei); l1FeeField is "" for non-OP chains. +// receipt builds a minimal receipt JSON with gasUsed 0x5208 (21000); +// l1FeeField is "" for non-OP chains. func receipt(l1FeeField string) string { return `{"transactionHash":"0xabc","blockHash":"0x2222222222222222222222222222222222222222222222222222222222222222",` + `"blockNumber":"0x1","transactionIndex":"0x0","cumulativeGasUsed":"0x5208",` + - `"gasUsed":"0x5208","effectiveGasPrice":"0x4a817c800","status":"0x1","contractAddress":null,` + + `"gasUsed":"0x5208","status":"0x1","contractAddress":null,` + `"logs":[],"logsBloom":"0x` + strings.Repeat("0", 512) + `",` + l1FeeField + `"type":"0x0"}` } -// A nonzero l1Fee must be added to GasFee so the core refund (gasFee − -// GasFeeUsed) shrinks by exactly that amount. Both values come from one receipt. -func TestReceiptGasFee(t *testing.T) { +// A nonzero l1Fee must be added to GasFeeUsed so the core refund (gasFee − +// GasFeeUsed) shrinks by exactly that amount. Gas price is sourced from the tx. +func TestGasFeeUsed(t *testing.T) { hash := ethcommon.HexToHash("0xabc") - execFee := new(big.Int).Mul(big.NewInt(21000), big.NewInt(20_000_000_000)) // gasUsed * effectiveGasPrice + gasPrice := big.NewInt(20_000_000_000) + execFee := new(big.Int).Mul(big.NewInt(21000), gasPrice) // gasUsed * gasPrice t.Run("OP destination adds l1Fee", func(t *testing.T) { rc := receiptRPC(t, receipt(`"l1Fee":"0x5208",`)) r, err := rc.GetTransactionReceipt(context.Background(), hash) require.NoError(t, err) require.NotNil(t, r) - assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)), receiptGasFee(r)) + assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)), gasFeeUsed(r.GasUsed, gasPrice, r.L1Fee)) }) t.Run("non-OP destination is execution fee only", func(t *testing.T) { @@ -66,7 +69,7 @@ func TestReceiptGasFee(t *testing.T) { r, err := rc.GetTransactionReceipt(context.Background(), hash) require.NoError(t, err) require.NotNil(t, r) - assert.Equal(t, execFee, receiptGasFee(r)) + assert.Equal(t, execFee, gasFeeUsed(r.GasUsed, gasPrice, r.L1Fee)) }) t.Run("missing receipt returns nil", func(t *testing.T) { @@ -79,11 +82,43 @@ func TestReceiptGasFee(t *testing.T) { // GetGasFeeUsed (revert/resolver path) delegates to the same single-call helper. func TestGetGasFeeUsed_IncludesL1Fee(t *testing.T) { - execFee := new(big.Int).Mul(big.NewInt(21000), big.NewInt(20_000_000_000)) + gasPrice := big.NewInt(20_000_000_000) + execFee := new(big.Int).Mul(big.NewInt(21000), gasPrice) + + // Sign a legacy tx so GetGasFeeUsed can source gasPrice from it. + key, err := crypto.GenerateKey() + require.NoError(t, err) + signedTx, err := types.SignTx( + types.NewTransaction(0, ethcommon.HexToAddress("0x2"), big.NewInt(0), 21000, gasPrice, nil), + types.NewEIP155Signer(big.NewInt(11155111)), key, + ) + require.NoError(t, err) + txJSON, err := signedTx.MarshalJSON() + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := make([]byte, r.ContentLength) + r.Body.Read(body) + switch { + case strings.Contains(string(body), "eth_chainId"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0xaa36a7"}`)) + case strings.Contains(string(body), "eth_getTransactionReceipt"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":` + receipt(`"l1Fee":"0x5208",`) + `}`)) + case strings.Contains(string(body), "eth_getTransactionByHash"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":` + string(txJSON) + `}`)) + default: + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + defer server.Close() + + rc, err := NewRPCClient([]string{server.URL}, 11155111, zerolog.Nop()) + require.NoError(t, err) + defer rc.Close() - rc := receiptRPC(t, receipt(`"l1Fee":"0x5208",`)) tb := &TxBuilder{rpcClient: rc, chainID: "eip155:11155111", chainIDInt: 11155111, logger: zerolog.Nop()} - got, err := tb.GetGasFeeUsed(context.Background(), "0xabc") + got, err := tb.GetGasFeeUsed(context.Background(), signedTx.Hash().Hex()) require.NoError(t, err) assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)).String(), got) } diff --git a/universalClient/chains/evm/rpc_client.go b/universalClient/chains/evm/rpc_client.go index 437e8989..2b71c5a9 100644 --- a/universalClient/chains/evm/rpc_client.go +++ b/universalClient/chains/evm/rpc_client.go @@ -197,11 +197,10 @@ func (rc *RPCClient) FilterLogs(ctx context.Context, query ethereum.FilterQuery) // Receipt holds the transaction-receipt fields the universal client needs, // including the OP-Stack L1 data fee that go-ethereum's typed receipt omits. type Receipt struct { - Status uint64 - BlockNumber uint64 - GasUsed uint64 - EffectiveGasPrice *big.Int - L1Fee *big.Int // OP-Stack L1 data fee; 0 on non-OP chains + Status uint64 + BlockNumber uint64 + GasUsed uint64 + L1Fee *big.Int // OP-Stack L1 data fee; 0 on non-OP chains } // GetTransactionReceipt fetches a transaction receipt in a single raw call, @@ -209,11 +208,10 @@ type Receipt struct { // if the tx is not found (receipt is null). func (rc *RPCClient) GetTransactionReceipt(ctx context.Context, txHash ethcommon.Hash) (*Receipt, error) { var raw struct { - Status *hexutil.Uint64 `json:"status"` - BlockNumber *hexutil.Big `json:"blockNumber"` - GasUsed *hexutil.Uint64 `json:"gasUsed"` - EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` - L1Fee *hexutil.Big `json:"l1Fee"` + Status *hexutil.Uint64 `json:"status"` + BlockNumber *hexutil.Big `json:"blockNumber"` + GasUsed *hexutil.Uint64 `json:"gasUsed"` + L1Fee *hexutil.Big `json:"l1Fee"` } err := rc.executeWithFailover(ctx, "get_transaction_receipt", func(client *ethclient.Client) error { return client.Client().CallContext(ctx, &raw, "eth_getTransactionReceipt", txHash) @@ -225,9 +223,8 @@ func (rc *RPCClient) GetTransactionReceipt(ctx context.Context, txHash ethcommon return nil, nil // not found } r := &Receipt{ - GasUsed: uint64(*raw.GasUsed), - EffectiveGasPrice: big.NewInt(0), - L1Fee: big.NewInt(0), + GasUsed: uint64(*raw.GasUsed), + L1Fee: big.NewInt(0), } if raw.Status != nil { r.Status = uint64(*raw.Status) @@ -235,9 +232,6 @@ func (rc *RPCClient) GetTransactionReceipt(ctx context.Context, txHash ethcommon if raw.BlockNumber != nil { r.BlockNumber = (*big.Int)(raw.BlockNumber).Uint64() } - if raw.EffectiveGasPrice != nil { - r.EffectiveGasPrice = (*big.Int)(raw.EffectiveGasPrice) - } if raw.L1Fee != nil { r.L1Fee = (*big.Int)(raw.L1Fee) } diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index 94affe0c..834d8852 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -453,18 +453,27 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, // L2 execution (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee // (0 on non-OP chains). Returns "0" if not found. func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, error) { - receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, ethcommon.HexToHash(txHash)) + hash := ethcommon.HexToHash(txHash) + receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, hash) if err != nil || receipt == nil { return "0", nil } - return receiptGasFee(receipt).String(), nil + tx, _, err := tb.rpcClient.GetTransactionByHash(ctx, hash) + if err != nil { + return "0", nil + } + gasPrice := tx.GasPrice() + if gasPrice == nil || gasPrice.Sign() == 0 { + return "0", nil + } + return gasFeeUsed(receipt.GasUsed, gasPrice, receipt.L1Fee).String(), nil } -// receiptGasFee returns the full destination cost of an included tx: L2 execution -// (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee. -func receiptGasFee(r *Receipt) *big.Int { - fee := new(big.Int).Mul(new(big.Int).SetUint64(r.GasUsed), r.EffectiveGasPrice) - return fee.Add(fee, r.L1Fee) +// gasFeeUsed returns the full destination cost of an included tx: L2 execution +// (gasUsed * gasPrice) plus the OP-Stack L1 data fee. +func gasFeeUsed(gasUsed uint64, gasPrice, l1Fee *big.Int) *big.Int { + fee := new(big.Int).Mul(new(big.Int).SetUint64(gasUsed), gasPrice) + return fee.Add(fee, l1Fee) } // GetFundMigrationSigningRequest builds a native token transfer for fund migration, From 19332ee321ef4967a725e5fc560e3548dcd3f5d2 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 12:30:57 +0530 Subject: [PATCH 6/8] fix: use receipt effectiveGasPrice for gas fee, guard missing field, drop tx fetch (F-2026-18145) --- universalClient/chains/evm/event_confirmer.go | 10 +- universalClient/chains/evm/l1fee_test.go | 105 ++++++++---------- universalClient/chains/evm/rpc_client.go | 21 ++-- universalClient/chains/evm/tx_builder.go | 19 +--- 4 files changed, 68 insertions(+), 87 deletions(-) diff --git a/universalClient/chains/evm/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index b06d0d53..e43f1532 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -166,16 +166,16 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { // For outbound events, enrich with gas fee before confirming if event.Type == store.EventTypeOutbound { - tx, _, txErr := ec.rpcClient.GetTransactionByHash(ctx, hash) - if txErr != nil { + if receipt.EffectiveGasPrice == nil { + // Receipt omitted effectiveGasPrice; skip rather than record a + // gas fee missing its L2 execution component. Retried next poll. ec.logger.Warn(). - Err(txErr). Str("event_id", event.EventID). Str("tx_hash", txHash). - Msg("failed to fetch transaction for gas fee, skipping confirmation") + Msg("receipt missing effectiveGasPrice, skipping confirmation") continue } - gasFeeUsedStr := gasFeeUsed(receipt.GasUsed, tx.GasPrice(), receipt.L1Fee).String() + gasFeeUsedStr := gasFeeUsed(receipt.GasUsed, receipt.EffectiveGasPrice, receipt.L1Fee).String() // Unmarshal, set GasFeeUsed, re-marshal var outboundEvent chaincommon.OutboundEvent diff --git a/universalClient/chains/evm/l1fee_test.go b/universalClient/chains/evm/l1fee_test.go index 2c70d38a..83242452 100644 --- a/universalClient/chains/evm/l1fee_test.go +++ b/universalClient/chains/evm/l1fee_test.go @@ -9,8 +9,6 @@ import ( "testing" ethcommon "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -40,85 +38,70 @@ func receiptRPC(t *testing.T, receiptJSON string) *RPCClient { return rc } -// receipt builds a minimal receipt JSON with gasUsed 0x5208 (21000); +// receipt builds a minimal receipt JSON. gasUsed 0x5208 (21000), +// effectiveGasPrice 0x4a817c800 (20 gwei) unless withEffPrice is false; // l1FeeField is "" for non-OP chains. -func receipt(l1FeeField string) string { +func receipt(l1FeeField string, withEffPrice bool) string { + eff := "" + if withEffPrice { + eff = `"effectiveGasPrice":"0x4a817c800",` + } return `{"transactionHash":"0xabc","blockHash":"0x2222222222222222222222222222222222222222222222222222222222222222",` + `"blockNumber":"0x1","transactionIndex":"0x0","cumulativeGasUsed":"0x5208",` + - `"gasUsed":"0x5208","status":"0x1","contractAddress":null,` + + `"gasUsed":"0x5208",` + eff + `"status":"0x1","contractAddress":null,` + `"logs":[],"logsBloom":"0x` + strings.Repeat("0", 512) + `",` + l1FeeField + `"type":"0x0"}` } // A nonzero l1Fee must be added to GasFeeUsed so the core refund (gasFee − -// GasFeeUsed) shrinks by exactly that amount. Gas price is sourced from the tx. -func TestGasFeeUsed(t *testing.T) { - hash := ethcommon.HexToHash("0xabc") - gasPrice := big.NewInt(20_000_000_000) - execFee := new(big.Int).Mul(big.NewInt(21000), gasPrice) // gasUsed * gasPrice +// GasFeeUsed) shrinks by exactly that amount; both come from one receipt read. +func TestGetGasFeeUsed(t *testing.T) { + execFee := new(big.Int).Mul(big.NewInt(21000), big.NewInt(20_000_000_000)) // gasUsed * effectiveGasPrice + tb := func(rc *RPCClient) *TxBuilder { + return &TxBuilder{rpcClient: rc, chainID: "eip155:11155111", chainIDInt: 11155111, logger: zerolog.Nop()} + } t.Run("OP destination adds l1Fee", func(t *testing.T) { - rc := receiptRPC(t, receipt(`"l1Fee":"0x5208",`)) - r, err := rc.GetTransactionReceipt(context.Background(), hash) + got, err := tb(receiptRPC(t, receipt(`"l1Fee":"0x5208",`, true))).GetGasFeeUsed(context.Background(), "0xabc") require.NoError(t, err) - require.NotNil(t, r) - assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)), gasFeeUsed(r.GasUsed, gasPrice, r.L1Fee)) + assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)).String(), got) }) t.Run("non-OP destination is execution fee only", func(t *testing.T) { - rc := receiptRPC(t, receipt(``)) - r, err := rc.GetTransactionReceipt(context.Background(), hash) + got, err := tb(receiptRPC(t, receipt(``, true))).GetGasFeeUsed(context.Background(), "0xabc") require.NoError(t, err) - require.NotNil(t, r) - assert.Equal(t, execFee, gasFeeUsed(r.GasUsed, gasPrice, r.L1Fee)) + assert.Equal(t, execFee.String(), got) }) - t.Run("missing receipt returns nil", func(t *testing.T) { - rc := receiptRPC(t, `null`) - r, err := rc.GetTransactionReceipt(context.Background(), hash) + t.Run("missing effectiveGasPrice returns 0, not L2-less fee", func(t *testing.T) { + got, err := tb(receiptRPC(t, receipt(`"l1Fee":"0x5208",`, false))).GetGasFeeUsed(context.Background(), "0xabc") require.NoError(t, err) - assert.Nil(t, r) + assert.Equal(t, "0", got) }) -} -// GetGasFeeUsed (revert/resolver path) delegates to the same single-call helper. -func TestGetGasFeeUsed_IncludesL1Fee(t *testing.T) { - gasPrice := big.NewInt(20_000_000_000) - execFee := new(big.Int).Mul(big.NewInt(21000), gasPrice) - - // Sign a legacy tx so GetGasFeeUsed can source gasPrice from it. - key, err := crypto.GenerateKey() - require.NoError(t, err) - signedTx, err := types.SignTx( - types.NewTransaction(0, ethcommon.HexToAddress("0x2"), big.NewInt(0), 21000, gasPrice, nil), - types.NewEIP155Signer(big.NewInt(11155111)), key, - ) - require.NoError(t, err) - txJSON, err := signedTx.MarshalJSON() - require.NoError(t, err) + t.Run("missing receipt returns 0", func(t *testing.T) { + got, err := tb(receiptRPC(t, `null`)).GetGasFeeUsed(context.Background(), "0xabc") + require.NoError(t, err) + assert.Equal(t, "0", got) + }) +} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - body := make([]byte, r.ContentLength) - r.Body.Read(body) - switch { - case strings.Contains(string(body), "eth_chainId"): - w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0xaa36a7"}`)) - case strings.Contains(string(body), "eth_getTransactionReceipt"): - w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":` + receipt(`"l1Fee":"0x5208",`) + `}`)) - case strings.Contains(string(body), "eth_getTransactionByHash"): - w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":` + string(txJSON) + `}`)) - default: - w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) - } - })) - defer server.Close() +// GetTransactionReceipt surfaces effectiveGasPrice (nil when absent) and l1Fee. +func TestGetTransactionReceipt_Fields(t *testing.T) { + hash := ethcommon.HexToHash("0xabc") - rc, err := NewRPCClient([]string{server.URL}, 11155111, zerolog.Nop()) - require.NoError(t, err) - defer rc.Close() + t.Run("effectiveGasPrice and l1Fee parsed", func(t *testing.T) { + r, err := receiptRPC(t, receipt(`"l1Fee":"0x5208",`, true)).GetTransactionReceipt(context.Background(), hash) + require.NoError(t, err) + require.NotNil(t, r) + assert.Equal(t, int64(20_000_000_000), r.EffectiveGasPrice.Int64()) + assert.Equal(t, int64(0x5208), r.L1Fee.Int64()) + }) - tb := &TxBuilder{rpcClient: rc, chainID: "eip155:11155111", chainIDInt: 11155111, logger: zerolog.Nop()} - got, err := tb.GetGasFeeUsed(context.Background(), signedTx.Hash().Hex()) - require.NoError(t, err) - assert.Equal(t, new(big.Int).Add(execFee, big.NewInt(0x5208)).String(), got) + t.Run("nil effectiveGasPrice when absent", func(t *testing.T) { + r, err := receiptRPC(t, receipt(``, false)).GetTransactionReceipt(context.Background(), hash) + require.NoError(t, err) + require.NotNil(t, r) + assert.Nil(t, r.EffectiveGasPrice) + assert.Equal(t, int64(0), r.L1Fee.Int64()) + }) } diff --git a/universalClient/chains/evm/rpc_client.go b/universalClient/chains/evm/rpc_client.go index 2b71c5a9..ab908667 100644 --- a/universalClient/chains/evm/rpc_client.go +++ b/universalClient/chains/evm/rpc_client.go @@ -197,10 +197,11 @@ func (rc *RPCClient) FilterLogs(ctx context.Context, query ethereum.FilterQuery) // Receipt holds the transaction-receipt fields the universal client needs, // including the OP-Stack L1 data fee that go-ethereum's typed receipt omits. type Receipt struct { - Status uint64 - BlockNumber uint64 - GasUsed uint64 - L1Fee *big.Int // OP-Stack L1 data fee; 0 on non-OP chains + Status uint64 + BlockNumber uint64 + GasUsed uint64 + EffectiveGasPrice *big.Int // nil if the receipt omits the field (pre-London / non-compliant RPC) + L1Fee *big.Int // OP-Stack L1 data fee; 0 on non-OP chains } // GetTransactionReceipt fetches a transaction receipt in a single raw call, @@ -208,10 +209,11 @@ type Receipt struct { // if the tx is not found (receipt is null). func (rc *RPCClient) GetTransactionReceipt(ctx context.Context, txHash ethcommon.Hash) (*Receipt, error) { var raw struct { - Status *hexutil.Uint64 `json:"status"` - BlockNumber *hexutil.Big `json:"blockNumber"` - GasUsed *hexutil.Uint64 `json:"gasUsed"` - L1Fee *hexutil.Big `json:"l1Fee"` + Status *hexutil.Uint64 `json:"status"` + BlockNumber *hexutil.Big `json:"blockNumber"` + GasUsed *hexutil.Uint64 `json:"gasUsed"` + EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` + L1Fee *hexutil.Big `json:"l1Fee"` } err := rc.executeWithFailover(ctx, "get_transaction_receipt", func(client *ethclient.Client) error { return client.Client().CallContext(ctx, &raw, "eth_getTransactionReceipt", txHash) @@ -232,6 +234,9 @@ func (rc *RPCClient) GetTransactionReceipt(ctx context.Context, txHash ethcommon if raw.BlockNumber != nil { r.BlockNumber = (*big.Int)(raw.BlockNumber).Uint64() } + if raw.EffectiveGasPrice != nil { + r.EffectiveGasPrice = (*big.Int)(raw.EffectiveGasPrice) + } if raw.L1Fee != nil { r.L1Fee = (*big.Int)(raw.L1Fee) } diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index 834d8852..d6da3f44 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -453,24 +453,17 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, // L2 execution (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee // (0 on non-OP chains). Returns "0" if not found. func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, error) { - hash := ethcommon.HexToHash(txHash) - receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, hash) - if err != nil || receipt == nil { - return "0", nil - } - tx, _, err := tb.rpcClient.GetTransactionByHash(ctx, hash) - if err != nil { - return "0", nil - } - gasPrice := tx.GasPrice() - if gasPrice == nil || gasPrice.Sign() == 0 { + receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, ethcommon.HexToHash(txHash)) + // EffectiveGasPrice nil means the receipt omitted it; return "0" rather than + // silently under-report the L2 execution fee as zero. + if err != nil || receipt == nil || receipt.EffectiveGasPrice == nil { return "0", nil } - return gasFeeUsed(receipt.GasUsed, gasPrice, receipt.L1Fee).String(), nil + return gasFeeUsed(receipt.GasUsed, receipt.EffectiveGasPrice, receipt.L1Fee).String(), nil } // gasFeeUsed returns the full destination cost of an included tx: L2 execution -// (gasUsed * gasPrice) plus the OP-Stack L1 data fee. +// (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee. func gasFeeUsed(gasUsed uint64, gasPrice, l1Fee *big.Int) *big.Int { fee := new(big.Int).Mul(new(big.Int).SetUint64(gasUsed), gasPrice) return fee.Add(fee, l1Fee) From bf153052b04ddc261c6164d276bf4babeabae679 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 12:38:30 +0530 Subject: [PATCH 7/8] test: add skipped live-RPC gas-fee check for Sepolia and Base Sepolia (F-2026-18145) --- universalClient/chains/evm/l1fee_test.go | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/universalClient/chains/evm/l1fee_test.go b/universalClient/chains/evm/l1fee_test.go index 83242452..23d1514e 100644 --- a/universalClient/chains/evm/l1fee_test.go +++ b/universalClient/chains/evm/l1fee_test.go @@ -5,6 +5,7 @@ import ( "math/big" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -105,3 +106,65 @@ func TestGetTransactionReceipt_Fields(t *testing.T) { assert.Equal(t, int64(0), r.L1Fee.Int64()) }) } + +// TestLive_GasFeeUsed exercises the real fetch + fee computation against public +// RPCs for two known txs (one non-OP, one OP). Skipped by default; run with: +// +// RUN_LIVE_RPC_TESTS=1 go test ./universalClient/chains/evm/ -run TestLive_GasFeeUsed -v +func TestLive_GasFeeUsed(t *testing.T) { + if os.Getenv("RUN_LIVE_RPC_TESTS") != "1" { + t.Skip("set RUN_LIVE_RPC_TESTS=1 to run live RPC test") + } + + cases := []struct { + name string + rpcURL string + chainID int64 + txHash string + wantFee string // gasUsed*effectiveGasPrice + l1Fee + wantL1 string + }{ + { + name: "Ethereum Sepolia (non-OP, l1Fee=0)", + rpcURL: "https://ethereum-sepolia-rpc.publicnode.com", + chainID: 11155111, + txHash: "0x489fb72d961e9bd69983fdaa52f0c9113705330f2e4bf4ac3fc46e1fb2977f08", + wantFee: "170830319373250", + wantL1: "0", + }, + { + name: "Base Sepolia (OP, nonzero l1Fee)", + rpcURL: "https://sepolia.base.org", + chainID: 84532, + txHash: "0x7b961e5cfbb6f8ddced1a0694773290ddb0d32caaaf8494f850d7ad07ddc0c30", + wantFee: "1032488370864", + wantL1: "14015970864", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rc, err := NewRPCClient([]string{tc.rpcURL}, tc.chainID, zerolog.Nop()) + require.NoError(t, err) + defer rc.Close() + + receipt, err := rc.GetTransactionReceipt(context.Background(), ethcommon.HexToHash(tc.txHash)) + require.NoError(t, err) + require.NotNil(t, receipt, "tx not found on chain") + require.NotNil(t, receipt.EffectiveGasPrice, "receipt missing effectiveGasPrice") + + fee := gasFeeUsed(receipt.GasUsed, receipt.EffectiveGasPrice, receipt.L1Fee) + t.Logf("gasUsed=%d effectiveGasPrice=%s l1Fee=%s => GasFeeUsed=%s", + receipt.GasUsed, receipt.EffectiveGasPrice, receipt.L1Fee, fee) + + assert.Equal(t, tc.wantL1, receipt.L1Fee.String(), "l1Fee") + assert.Equal(t, tc.wantFee, fee.String(), "GasFeeUsed") + + // Full path through the TxBuilder entrypoint. + tb := &TxBuilder{rpcClient: rc, chainIDInt: tc.chainID, logger: zerolog.Nop()} + got, err := tb.GetGasFeeUsed(context.Background(), tc.txHash) + require.NoError(t, err) + assert.Equal(t, tc.wantFee, got) + }) + } +} From 68df7b2f352de8b037081bba0b32c128096073b6 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 17 Aug 2026 12:51:36 +0530 Subject: [PATCH 8/8] fix: error instead of zero gas fee when receipt fee cannot be determined (F-2026-18145) --- universalClient/chains/common/types.go | 5 +++-- universalClient/chains/evm/l1fee_test.go | 18 ++++++++++-------- universalClient/chains/evm/tx_builder.go | 15 ++++++++++----- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/universalClient/chains/common/types.go b/universalClient/chains/common/types.go index 98a3b85c..aa44e582 100644 --- a/universalClient/chains/common/types.go +++ b/universalClient/chains/common/types.go @@ -78,9 +78,10 @@ type TxBuilder interface { IsAlreadyExecuted(ctx context.Context, txID string) (executed bool, queryBlockTime int64, err error) // GetGasFeeUsed returns the gas fee used by a transaction on the destination chain. - // EVM: fetches receipt and returns gasUsed * effectiveGasPrice as decimal string. + // EVM: gasUsed * effectiveGasPrice + OP-Stack l1Fee, as a decimal string; errors + // when the fee cannot be determined so callers retry instead of recording an + // under-reported fee. // SVM: returns "0" (gas accounting is handled via vault gasFee reimbursement). - // Returns "0" if the transaction is not found. GetGasFeeUsed(ctx context.Context, txHash string) (string, error) // GetFundMigrationSigningRequest builds a native token transfer for fund migration, diff --git a/universalClient/chains/evm/l1fee_test.go b/universalClient/chains/evm/l1fee_test.go index 23d1514e..e680bc58 100644 --- a/universalClient/chains/evm/l1fee_test.go +++ b/universalClient/chains/evm/l1fee_test.go @@ -73,16 +73,18 @@ func TestGetGasFeeUsed(t *testing.T) { assert.Equal(t, execFee.String(), got) }) - t.Run("missing effectiveGasPrice returns 0, not L2-less fee", func(t *testing.T) { - got, err := tb(receiptRPC(t, receipt(`"l1Fee":"0x5208",`, false))).GetGasFeeUsed(context.Background(), "0xabc") - require.NoError(t, err) - assert.Equal(t, "0", got) + // Errors rather than "0": a zero fee here would make core refund the full + // gasFee, the same over-refund this fix removes. Callers retry instead. + t.Run("missing effectiveGasPrice errors", func(t *testing.T) { + _, err := tb(receiptRPC(t, receipt(`"l1Fee":"0x5208",`, false))).GetGasFeeUsed(context.Background(), "0xabc") + require.Error(t, err) + assert.Contains(t, err.Error(), "missing effectiveGasPrice") }) - t.Run("missing receipt returns 0", func(t *testing.T) { - got, err := tb(receiptRPC(t, `null`)).GetGasFeeUsed(context.Background(), "0xabc") - require.NoError(t, err) - assert.Equal(t, "0", got) + t.Run("missing receipt errors", func(t *testing.T) { + _, err := tb(receiptRPC(t, `null`)).GetGasFeeUsed(context.Background(), "0xabc") + require.Error(t, err) + assert.Contains(t, err.Error(), "receipt not found") }) } diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index d6da3f44..f5a0114e 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -451,13 +451,18 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, // GetGasFeeUsed returns the gas fee used by a transaction on the EVM chain: // L2 execution (gasUsed * effectiveGasPrice) plus the OP-Stack L1 data fee -// (0 on non-OP chains). Returns "0" if not found. +// (0 on non-OP chains). Errors when the fee cannot be determined so callers +// retry rather than record an under-reported fee. func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, error) { receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, ethcommon.HexToHash(txHash)) - // EffectiveGasPrice nil means the receipt omitted it; return "0" rather than - // silently under-report the L2 execution fee as zero. - if err != nil || receipt == nil || receipt.EffectiveGasPrice == nil { - return "0", nil + if err != nil { + return "", fmt.Errorf("failed to fetch receipt for %s: %w", txHash, err) + } + if receipt == nil { + return "", fmt.Errorf("receipt not found for %s", txHash) + } + if receipt.EffectiveGasPrice == nil { + return "", fmt.Errorf("receipt for %s missing effectiveGasPrice", txHash) } return gasFeeUsed(receipt.GasUsed, receipt.EffectiveGasPrice, receipt.L1Fee).String(), nil }