Skip to content
Merged
5 changes: 3 additions & 2 deletions universalClient/chains/common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 8 additions & 11 deletions universalClient/chains/evm/event_confirmer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"math/big"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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 {
Expand Down
172 changes: 172 additions & 0 deletions universalClient/chains/evm/l1fee_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
53 changes: 46 additions & 7 deletions universalClient/chains/evm/rpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
38 changes: 19 additions & 19 deletions universalClient/chains/evm/tx_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading