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/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index c3003904..e43f1532 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" @@ -140,7 +139,7 @@ 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 { + if err != nil || receipt == nil { // Transaction not found or not yet mined - skip continue } @@ -160,25 +159,23 @@ 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 { - 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 } - gasUsed := new(big.Int).SetUint64(receipt.GasUsed) - gasPrice := tx.GasPrice() - gasFeeUsed := new(big.Int).Mul(gasUsed, gasPrice).String() + gasFeeUsedStr := gasFeeUsed(receipt.GasUsed, receipt.EffectiveGasPrice, receipt.L1Fee).String() // Unmarshal, set GasFeeUsed, re-marshal var outboundEvent chaincommon.OutboundEvent @@ -189,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 new file mode 100644 index 00000000..e680bc58 --- /dev/null +++ b/universalClient/chains/evm/l1fee_test.go @@ -0,0 +1,172 @@ +package evm + +import ( + "context" + "math/big" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 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") + 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 + `}`)) + 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 +} + +// 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, withEffPrice bool) string { + eff := "" + if withEffPrice { + eff = `"effectiveGasPrice":"0x4a817c800",` + } + return `{"transactionHash":"0xabc","blockHash":"0x2222222222222222222222222222222222222222222222222222222222222222",` + + `"blockNumber":"0x1","transactionIndex":"0x0","cumulativeGasUsed":"0x5208",` + + `"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; 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) { + got, err := tb(receiptRPC(t, receipt(`"l1Fee":"0x5208",`, true))).GetGasFeeUsed(context.Background(), "0xabc") + require.NoError(t, err) + 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) { + got, err := tb(receiptRPC(t, receipt(``, true))).GetGasFeeUsed(context.Background(), "0xabc") + require.NoError(t, err) + assert.Equal(t, execFee.String(), 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 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") + }) +} + +// GetTransactionReceipt surfaces effectiveGasPrice (nil when absent) and l1Fee. +func TestGetTransactionReceipt_Fields(t *testing.T) { + hash := ethcommon.HexToHash("0xabc") + + 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()) + }) + + 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()) + }) +} + +// 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) + }) + } +} diff --git a/universalClient/chains/evm/rpc_client.go b/universalClient/chains/evm/rpc_client.go index b8c83d04..ab908667 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" @@ -193,15 +194,53 @@ 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 +// 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 // 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, +// 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"` + 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 { - var innerErr error - receipt, innerErr = client.TransactionReceipt(ctx, txHash) - return innerErr + return client.Client().CallContext(ctx, &raw, "eth_getTransactionReceipt", txHash) }) - return receipt, err + if err != nil { + return nil, err + } + if raw.GasUsed == nil { + return nil, nil // not found + } + r := &Receipt{ + GasUsed: uint64(*raw.GasUsed), + 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) + } + if raw.L1Fee != nil { + r.L1Fee = (*big.Int)(raw.L1Fee) + } + 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 f55873ff..f5a0114e 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -250,11 +250,11 @@ func (tb *TxBuilder) BroadcastOutboundSigningRequest( 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 { + 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) @@ -449,29 +449,29 @@ 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 * effectiveGasPrice) plus the OP-Stack L1 data fee +// (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) { - hash := ethcommon.HexToHash(txHash) - receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, hash) + receipt, err := tb.rpcClient.GetTransactionReceipt(ctx, ethcommon.HexToHash(txHash)) if err != nil { - return "0", nil + return "", fmt.Errorf("failed to fetch receipt for %s: %w", txHash, err) } - - tx, _, err := tb.rpcClient.GetTransactionByHash(ctx, hash) - if err != nil { - return "0", nil + if receipt == nil { + return "", fmt.Errorf("receipt not found for %s", txHash) } - - gasUsed := new(big.Int).SetUint64(receipt.GasUsed) - gasPrice := tx.GasPrice() - if gasPrice == nil || gasPrice.Sign() == 0 { - return "0", nil + if receipt.EffectiveGasPrice == nil { + return "", fmt.Errorf("receipt for %s missing effectiveGasPrice", txHash) } + return gasFeeUsed(receipt.GasUsed, receipt.EffectiveGasPrice, receipt.L1Fee).String(), nil +} - gasFeeUsed := new(big.Int).Mul(gasUsed, gasPrice) - return gasFeeUsed.String(), nil +// gasFeeUsed returns the full destination cost of an included tx: L2 execution +// (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) } // GetFundMigrationSigningRequest builds a native token transfer for fund migration,