Skip to content

Commit 8d2d440

Browse files
committed
fix(ethrpc): report zero native balance and gas
The EVM facade reported a fake 1000-coin native balance and a 1 gwei gas price so MetaMask's `balance >= value + gasLimit*gasPrice` pre-flight check would pass. The fake balance is confusing — users with no native coin see 1000. There is no native gas token, so report 0. Since the facade only accepts zero-value ERC-20 transfers, MetaMask's check collapses to `0 >= 0` as long as gas is also 0, so transfers still go through. Gas price and the cosmetic gas limit are fixed values, not deployment knobs, so hardcode them in code and drop them from config: - native balance: always 0 (token.Native.GetBalance) - gas price: always 0 (eth_gasPrice, maxPriorityFeePerGas, baseFeePerGas, effectiveGasPrice) - gas limit: service.DefaultGasLimit (21000), cosmetic only The relayer's ethereum.gas_limit (real on-chain gas) is unaffected.
1 parent 9a5ccbd commit 8d2d440

14 files changed

Lines changed: 67 additions & 157 deletions

config.e2e-local.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,11 @@ token:
8787
symbol: "USDCx"
8888
decimals: 6
8989
instrument_id: "USDCx"
90-
native_balance_wei: "1000000000000000000000"
9190

9291
# Ethereum JSON-RPC facade (MetaMask compatibility)
9392
eth_rpc:
9493
enabled: true
9594
chain_id: 31337
96-
gas_price_wei: "1000000000"
97-
gas_limit: 21000
98-
native_balance_wei: "1000000000000000000000"
9995
request_timeout: "30s"
10096

10197
jwks:

pkg/app/api/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ func initServices(
239239
if cfg.EthRPC.Enabled {
240240
m := ethrpcminer.New(
241241
evmStore,
242-
cfg.EthRPC.ChainID, cfg.EthRPC.GasLimit,
242+
cfg.EthRPC.ChainID, ethrpc.DefaultGasLimit,
243243
cfg.EthRPC.MinerMaxTxsPerBlock, cfg.EthRPC.MinerInterval,
244244
ethrpcminer.NewMetrics(reg),
245245
logger,

pkg/config/config_test.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,22 +125,9 @@ func TestLoadAPIServer_AppliesDefaults(t *testing.T) {
125125
t.Fatalf("auth.expiry_leeway default mismatch: got %s", cfg.Canton.Ledger.Auth.ExpiryLeeway)
126126
}
127127

128-
if cfg.Token.NativeBalanceWei != "1000000000000000000000" {
129-
t.Fatalf("token.native_balance_wei default mismatch: got %q", cfg.Token.NativeBalanceWei)
130-
}
131-
132128
if cfg.EthRPC.Enabled {
133129
t.Fatal("eth_rpc.enabled default mismatch: expected false")
134130
}
135-
if cfg.EthRPC.GasPriceWei != "1000000000" {
136-
t.Fatalf("eth_rpc.gas_price_wei default mismatch: got %q", cfg.EthRPC.GasPriceWei)
137-
}
138-
if cfg.EthRPC.GasLimit != 21000 {
139-
t.Fatalf("eth_rpc.gas_limit default mismatch: got %d", cfg.EthRPC.GasLimit)
140-
}
141-
if cfg.EthRPC.NativeBalanceWei != "1000000000000000000000" {
142-
t.Fatalf("eth_rpc.native_balance_wei default mismatch: got %q", cfg.EthRPC.NativeBalanceWei)
143-
}
144131
if cfg.EthRPC.RequestTimeout != 30*time.Second {
145132
t.Fatalf("eth_rpc.request_timeout default mismatch: got %s", cfg.EthRPC.RequestTimeout)
146133
}

pkg/config/defaults/config.api-server.docker.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,11 @@ token:
9191
symbol: "USDCx"
9292
decimals: 6
9393
instrument_id: "USDCx"
94-
native_balance_wei: "1000000000000000000000"
9594

9695
# Ethereum JSON-RPC facade (MetaMask compatibility)
9796
eth_rpc:
9897
enabled: true
9998
chain_id: 31337 # Anvil local chain ID
100-
gas_price_wei: "1000000000"
101-
gas_limit: 21000
102-
native_balance_wei: "1000000000000000000000"
10399
request_timeout: "30s"
104100

105101
# JWKS endpoint for JWT validation (optional - if not using EVM signatures)

pkg/config/defaults/config.api-server.local-devnet.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,11 @@ token:
8484
symbol: "USDCx"
8585
decimals: 6
8686
instrument_id: "USDCx"
87-
native_balance_wei: "1000000000000000000000"
8887

8988
# Ethereum JSON-RPC facade (MetaMask compatibility)
9089
eth_rpc:
9190
enabled: true
9291
chain_id: 1155111101 # Custom: Sepolia (11155111) + 01 suffix for Canton local
93-
gas_price_wei: "1000000000"
94-
gas_limit: 21000
95-
native_balance_wei: "1000000000000000000000"
9692
request_timeout: "60s"
9793

9894
jwks:

pkg/config/defaults/config.api-server.mainnet.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,10 @@ token:
6767
symbol: "USDCx"
6868
decimals: 6
6969
instrument_id: "USDCx"
70-
native_balance_wei: "1000000000000000000000"
7170

7271
eth_rpc:
7372
enabled: true
7473
chain_id: 1337
75-
gas_price_wei: "1000000000"
76-
gas_limit: 21000
77-
native_balance_wei: "1000000000000000000000"
7874
request_timeout: "60s"
7975

8076
jwks:

pkg/ethrpc/config.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@ import (
1010
type Config struct {
1111
Enabled bool `yaml:"enabled" default:"false"`
1212
ChainID uint64 `yaml:"chain_id" validate:"required_if=Enabled true"`
13-
GasPriceWei string `yaml:"gas_price_wei" default:"1000000000"`
14-
GasLimit uint64 `yaml:"gas_limit" default:"21000"`
15-
NativeBalanceWei string `yaml:"native_balance_wei" default:"1000000000000000000000"`
1613
RequestTimeout time.Duration `yaml:"request_timeout" default:"30s"`
1714
MinerInterval time.Duration `yaml:"miner_interval" default:"2s"`
1815
MinerMaxTxsPerBlock int `yaml:"miner_max_txs_per_block" default:"500"`

pkg/ethrpc/service/service.go

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,14 @@ type ethService struct {
8383
}
8484

8585
const (
86-
decimalStringBase = 10
87-
defaultGasPriceWeiInt64 = int64(1_000_000_000)
88-
defaultGasPriceWeiUint64 = uint64(1_000_000_000)
8986
functionSelectorSize = 4
90-
topicSizeBytes = 32
9187
confirmationBufferBlocks = uint64(12)
9288
syntheticBlockTimeSeconds = uint64(12)
89+
// DefaultGasLimit is the cosmetic gas limit the facade reports (eth_estimateGas,
90+
// block gasLimit, tx gas, synthetic block gasUsed). The facade executes
91+
// transfers on Canton rather than an EVM, so gas is never metered — and the
92+
// gas price is fixed at 0 — making this value purely informational for wallets.
93+
DefaultGasLimit = 21_000
9394
)
9495

9596
// NewService creates a new ethService.
@@ -140,21 +141,24 @@ func (s *ethService) BlockNumber(ctx context.Context) (hexutil.Uint64, error) {
140141
return hexutil.Uint64(max(baseBlock, timeBasedBlocks)), nil
141142
}
142143

143-
func (s *ethService) GasPrice(_ context.Context) (*hexutil.Big, error) {
144-
gasPrice := new(big.Int)
145-
if _, ok := gasPrice.SetString(s.cfg.GasPriceWei, decimalStringBase); !ok {
146-
return nil, apperr.GeneralError(fmt.Errorf("invalid gas price wei: %q", s.cfg.GasPriceWei))
147-
}
148-
149-
return (*hexutil.Big)(gasPrice), nil
144+
// GasPrice always reports 0, and so do maxPriorityFeePerGas, block
145+
// baseFeePerGas, and receipt effectiveGasPrice. The facade never charges gas,
146+
// and MetaMask's client-side pre-flight check is
147+
// `balance >= value + gasLimit*gasPrice`. Since eth_getBalance reports a zero
148+
// native balance and this facade only accepts zero-value ERC-20 transfers, the
149+
// check collapses to `0 >= 0` only while gas is 0 — any non-zero gas price would
150+
// make MetaMask reject transfers with "insufficient funds for gas". Gas is
151+
// therefore fixed at 0 in code rather than exposed as config.
152+
func (*ethService) GasPrice(_ context.Context) (*hexutil.Big, error) {
153+
return (*hexutil.Big)(big.NewInt(0)), nil
150154
}
151155

152156
func (*ethService) MaxPriorityFeePerGas(_ context.Context) (*hexutil.Big, error) {
153-
return (*hexutil.Big)(big.NewInt(defaultGasPriceWeiInt64)), nil
157+
return (*hexutil.Big)(big.NewInt(0)), nil
154158
}
155159

156-
func (s *ethService) EstimateGas(_ context.Context, _ *ethrpc.CallArgs) (hexutil.Uint64, error) {
157-
return hexutil.Uint64(s.cfg.GasLimit), nil
160+
func (*ethService) EstimateGas(_ context.Context, _ *ethrpc.CallArgs) (hexutil.Uint64, error) {
161+
return hexutil.Uint64(DefaultGasLimit), nil
158162
}
159163

160164
func (s *ethService) GetBalance(ctx context.Context, address common.Address) (*hexutil.Big, error) {
@@ -328,7 +332,7 @@ func (s *ethService) GetTransactionReceipt(ctx context.Context, hash common.Hash
328332
Logs: logs,
329333
LogsBloom: bloom,
330334
Status: hexutil.Uint64(row.Status),
331-
EffectiveGasPrice: hexutil.Uint64(defaultGasPriceWeiUint64),
335+
EffectiveGasPrice: hexutil.Uint64(0),
332336
Type: hexutil.Uint64(2),
333337
// RevertReason is omitted when empty (omitempty), so successful
334338
// receipts keep the standard JSON shape.
@@ -350,7 +354,7 @@ func (s *ethService) GetTransactionByHash(ctx context.Context, hash common.Hash)
350354
blockHash := common.BytesToHash(row.BlockHash)
351355
blockNum := hexutil.Uint64(row.BlockNumber)
352356
txIndex := hexutil.Uint(row.TxIndex)
353-
gasPrice := big.NewInt(defaultGasPriceWeiInt64)
357+
gasPrice := big.NewInt(0)
354358

355359
return &ethrpc.RPCTransaction{
356360
Hash: hash,
@@ -362,7 +366,7 @@ func (s *ethService) GetTransactionByHash(ctx context.Context, hash common.Hash)
362366
To: &to,
363367
Value: (*hexutil.Big)(big.NewInt(0)),
364368
GasPrice: (*hexutil.Big)(gasPrice),
365-
Gas: hexutil.Uint64(s.cfg.GasLimit),
369+
Gas: hexutil.Uint64(DefaultGasLimit),
366370
Input: row.Input,
367371
Type: hexutil.Uint64(2),
368372
ChainID: (*hexutil.Big)(new(big.Int).Set(s.chainID)),
@@ -557,12 +561,12 @@ func (s *ethService) GetBlockByNumber(ctx context.Context, block ethrpc.BlockNum
557561
TotalDifficulty: (*hexutil.Big)(big.NewInt(0)),
558562
ExtraData: []byte{},
559563
Size: hexutil.Uint64(0),
560-
GasLimit: hexutil.Uint64(s.cfg.GasLimit),
564+
GasLimit: hexutil.Uint64(DefaultGasLimit),
561565
GasUsed: hexutil.Uint64(0),
562566
Timestamp: hexutil.Uint64(blockNum * syntheticBlockTimeSeconds),
563567
Transactions: []any{},
564568
Uncles: []common.Hash{},
565-
BaseFeePerGas: (*hexutil.Big)(big.NewInt(defaultGasPriceWeiInt64)),
569+
BaseFeePerGas: (*hexutil.Big)(big.NewInt(0)),
566570
}, nil
567571
}
568572

pkg/ethrpc/service/service_test.go

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,8 @@ import (
2424
// defaultCfg returns a minimal EthRPCConfig suitable for unit tests.
2525
func defaultCfg() *ethrpc.Config {
2626
return &ethrpc.Config{
27-
ChainID: 31337,
28-
GasPriceWei: "1000000000",
29-
GasLimit: 21000,
30-
RequestTimeout: 30 * time.Second,
31-
NativeBalanceWei: "1000000000000000000000",
27+
ChainID: 31337,
28+
RequestTimeout: 30 * time.Second,
3229
}
3330
}
3431

@@ -73,46 +70,44 @@ func TestService_BlockNumber(t *testing.T) {
7370

7471
// ─── GasPrice ─────────────────────────────────────────────────────────────────
7572

73+
// Gas is fixed at 0 in code (not configurable). See TestService_ZeroGas for the
74+
// MetaMask-compatibility rationale.
7675
func TestService_GasPrice(t *testing.T) {
77-
t.Run("valid config returns configured price", func(t *testing.T) {
78-
svc := newSvc(t, defaultCfg(), nil, nil)
79-
80-
got, err := svc.GasPrice(context.Background())
81-
require.NoError(t, err)
82-
assert.Equal(t, big.NewInt(1_000_000_000), got.ToInt())
83-
})
84-
85-
t.Run("non-numeric wei string returns error", func(t *testing.T) {
86-
cfg := defaultCfg()
87-
cfg.GasPriceWei = "not-a-number"
88-
svc := newSvc(t, cfg, nil, nil)
89-
90-
_, err := svc.GasPrice(context.Background())
91-
require.Error(t, err)
92-
assert.True(t, apperr.Is(err, apperr.CategoryGeneralError))
93-
})
76+
got, err := newSvc(t, defaultCfg(), nil, nil).GasPrice(context.Background())
77+
require.NoError(t, err)
78+
assert.Equal(t, big.NewInt(0), got.ToInt())
9479
}
9580

9681
// ─── MaxPriorityFeePerGas ─────────────────────────────────────────────────────
9782

9883
func TestService_MaxPriorityFeePerGas(t *testing.T) {
99-
svc := newSvc(t, defaultCfg(), nil, nil)
84+
got, err := newSvc(t, defaultCfg(), nil, nil).MaxPriorityFeePerGas(context.Background())
85+
require.NoError(t, err)
86+
assert.Equal(t, big.NewInt(0), got.ToInt())
87+
}
10088

101-
got, err := svc.MaxPriorityFeePerGas(context.Background())
89+
// ─── Zero gas (MetaMask compatibility) ────────────────────────────────────────
90+
91+
// TestService_ZeroGas locks in the MetaMask-compatibility contract: gas is fixed
92+
// at 0 across every fee surface a wallet reads, so a zero native balance
93+
// satisfies MetaMask's `balance >= value + gas*price` pre-flight check for the
94+
// zero-value ERC-20 transfers this facade accepts. A non-zero gas price would
95+
// make MetaMask reject transfers with "insufficient funds for gas".
96+
func TestService_ZeroGas(t *testing.T) {
97+
blockNum := hexutil.Uint64(42)
98+
got, err := newSvc(t, defaultCfg(), nil, nil).
99+
GetBlockByNumber(context.Background(), ethrpc.BlockNumberOrHash{BlockNumber: &blockNum}, false)
102100
require.NoError(t, err)
103-
assert.Equal(t, big.NewInt(1_000_000_000), got.ToInt())
101+
require.NotNil(t, got)
102+
assert.Equal(t, big.NewInt(0), got.BaseFeePerGas.ToInt())
104103
}
105104

106105
// ─── EstimateGas ──────────────────────────────────────────────────────────────
107106

108107
func TestService_EstimateGas(t *testing.T) {
109-
cfg := defaultCfg()
110-
cfg.GasLimit = 50_000
111-
svc := newSvc(t, cfg, nil, nil)
112-
113-
got, err := svc.EstimateGas(context.Background(), nil)
108+
got, err := newSvc(t, defaultCfg(), nil, nil).EstimateGas(context.Background(), nil)
114109
require.NoError(t, err)
115-
assert.Equal(t, hexutil.Uint64(50_000), got)
110+
assert.Equal(t, hexutil.Uint64(service.DefaultGasLimit), got)
116111
}
117112

118113
// ─── GetBalance ───────────────────────────────────────────────────────────────

pkg/token/config.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,13 @@ type ERC20Token struct {
1818

1919
// Config holds token metadata indexed by contract address.
2020
type Config struct {
21-
SupportedTokens map[common.Address]ERC20Token `yaml:"supported_tokens" validate:"required,min=1"`
22-
NativeBalanceWei string `yaml:"native_balance_wei" default:"1000000000000000000000"`
21+
SupportedTokens map[common.Address]ERC20Token `yaml:"supported_tokens" validate:"required,min=1"`
2322
}
2423

2524
// NewConfig creates a token Config.
26-
func NewConfig(nativeBalanceWei string) *Config {
25+
func NewConfig() *Config {
2726
return &Config{
28-
NativeBalanceWei: nativeBalanceWei,
29-
SupportedTokens: make(map[common.Address]ERC20Token),
27+
SupportedTokens: make(map[common.Address]ERC20Token),
3028
}
3129
}
3230

0 commit comments

Comments
 (0)