Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions config.e2e-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,11 @@ token:
symbol: "USDCx"
decimals: 6
instrument_id: "USDCx"
native_balance_wei: "1000000000000000000000"

# Ethereum JSON-RPC facade (MetaMask compatibility)
eth_rpc:
enabled: true
chain_id: 31337
gas_price_wei: "1000000000"
gas_limit: 21000
native_balance_wei: "1000000000000000000000"
request_timeout: "30s"

jwks:
Expand Down
2 changes: 1 addition & 1 deletion pkg/app/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ func initServices(
if cfg.EthRPC.Enabled {
m := ethrpcminer.New(
evmStore,
cfg.EthRPC.ChainID, cfg.EthRPC.GasLimit,
cfg.EthRPC.ChainID, ethrpc.DefaultGasLimit,
cfg.EthRPC.MinerMaxTxsPerBlock, cfg.EthRPC.MinerInterval,
ethrpcminer.NewMetrics(reg),
logger,
Expand Down
13 changes: 0 additions & 13 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,22 +125,9 @@ func TestLoadAPIServer_AppliesDefaults(t *testing.T) {
t.Fatalf("auth.expiry_leeway default mismatch: got %s", cfg.Canton.Ledger.Auth.ExpiryLeeway)
}

if cfg.Token.NativeBalanceWei != "1000000000000000000000" {
t.Fatalf("token.native_balance_wei default mismatch: got %q", cfg.Token.NativeBalanceWei)
}

if cfg.EthRPC.Enabled {
t.Fatal("eth_rpc.enabled default mismatch: expected false")
}
if cfg.EthRPC.GasPriceWei != "1000000000" {
t.Fatalf("eth_rpc.gas_price_wei default mismatch: got %q", cfg.EthRPC.GasPriceWei)
}
if cfg.EthRPC.GasLimit != 21000 {
t.Fatalf("eth_rpc.gas_limit default mismatch: got %d", cfg.EthRPC.GasLimit)
}
if cfg.EthRPC.NativeBalanceWei != "1000000000000000000000" {
t.Fatalf("eth_rpc.native_balance_wei default mismatch: got %q", cfg.EthRPC.NativeBalanceWei)
}
if cfg.EthRPC.RequestTimeout != 30*time.Second {
t.Fatalf("eth_rpc.request_timeout default mismatch: got %s", cfg.EthRPC.RequestTimeout)
}
Expand Down
4 changes: 0 additions & 4 deletions pkg/config/defaults/config.api-server.docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,11 @@ token:
symbol: "USDCx"
decimals: 6
instrument_id: "USDCx"
native_balance_wei: "1000000000000000000000"

# Ethereum JSON-RPC facade (MetaMask compatibility)
eth_rpc:
enabled: true
chain_id: 31337 # Anvil local chain ID
gas_price_wei: "1000000000"
gas_limit: 21000
native_balance_wei: "1000000000000000000000"
request_timeout: "30s"

# JWKS endpoint for JWT validation (optional - if not using EVM signatures)
Expand Down
4 changes: 0 additions & 4 deletions pkg/config/defaults/config.api-server.local-devnet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,11 @@ token:
symbol: "USDCx"
decimals: 6
instrument_id: "USDCx"
native_balance_wei: "1000000000000000000000"

# Ethereum JSON-RPC facade (MetaMask compatibility)
eth_rpc:
enabled: true
chain_id: 1155111101 # Custom: Sepolia (11155111) + 01 suffix for Canton local
gas_price_wei: "1000000000"
gas_limit: 21000
native_balance_wei: "1000000000000000000000"
request_timeout: "60s"

jwks:
Expand Down
4 changes: 0 additions & 4 deletions pkg/config/defaults/config.api-server.mainnet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,10 @@ token:
symbol: "USDCx"
decimals: 6
instrument_id: "USDCx"
native_balance_wei: "1000000000000000000000"

eth_rpc:
enabled: true
chain_id: 1337
gas_price_wei: "1000000000"
gas_limit: 21000
native_balance_wei: "1000000000000000000000"
request_timeout: "60s"

jwks:
Expand Down
3 changes: 0 additions & 3 deletions pkg/ethrpc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ import (
type Config struct {
Enabled bool `yaml:"enabled" default:"false"`
ChainID uint64 `yaml:"chain_id" validate:"required_if=Enabled true"`
GasPriceWei string `yaml:"gas_price_wei" default:"1000000000"`
GasLimit uint64 `yaml:"gas_limit" default:"21000"`
NativeBalanceWei string `yaml:"native_balance_wei" default:"1000000000000000000000"`
RequestTimeout time.Duration `yaml:"request_timeout" default:"30s"`
MinerInterval time.Duration `yaml:"miner_interval" default:"2s"`
MinerMaxTxsPerBlock int `yaml:"miner_max_txs_per_block" default:"500"`
Expand Down
42 changes: 23 additions & 19 deletions pkg/ethrpc/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,14 @@ type ethService struct {
}

const (
decimalStringBase = 10
defaultGasPriceWeiInt64 = int64(1_000_000_000)
defaultGasPriceWeiUint64 = uint64(1_000_000_000)
functionSelectorSize = 4
topicSizeBytes = 32
confirmationBufferBlocks = uint64(12)
syntheticBlockTimeSeconds = uint64(12)
// DefaultGasLimit is the cosmetic gas limit the facade reports (eth_estimateGas,
// block gasLimit, tx gas, synthetic block gasUsed). The facade executes
// transfers on Canton rather than an EVM, so gas is never metered — and the
// gas price is fixed at 0 — making this value purely informational for wallets.
DefaultGasLimit = 21_000
)

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

func (s *ethService) GasPrice(_ context.Context) (*hexutil.Big, error) {
gasPrice := new(big.Int)
if _, ok := gasPrice.SetString(s.cfg.GasPriceWei, decimalStringBase); !ok {
return nil, apperr.GeneralError(fmt.Errorf("invalid gas price wei: %q", s.cfg.GasPriceWei))
}

return (*hexutil.Big)(gasPrice), nil
// GasPrice always reports 0, and so do maxPriorityFeePerGas, block
// baseFeePerGas, and receipt effectiveGasPrice. The facade never charges gas,
// and MetaMask's client-side pre-flight check is
// `balance >= value + gasLimit*gasPrice`. Since eth_getBalance reports a zero
// native balance and this facade only accepts zero-value ERC-20 transfers, the
// check collapses to `0 >= 0` only while gas is 0 — any non-zero gas price would
// make MetaMask reject transfers with "insufficient funds for gas". Gas is
// therefore fixed at 0 in code rather than exposed as config.
func (*ethService) GasPrice(_ context.Context) (*hexutil.Big, error) {
return (*hexutil.Big)(big.NewInt(0)), nil
}

func (*ethService) MaxPriorityFeePerGas(_ context.Context) (*hexutil.Big, error) {
return (*hexutil.Big)(big.NewInt(defaultGasPriceWeiInt64)), nil
return (*hexutil.Big)(big.NewInt(0)), nil
}

func (s *ethService) EstimateGas(_ context.Context, _ *ethrpc.CallArgs) (hexutil.Uint64, error) {
return hexutil.Uint64(s.cfg.GasLimit), nil
func (*ethService) EstimateGas(_ context.Context, _ *ethrpc.CallArgs) (hexutil.Uint64, error) {
return hexutil.Uint64(DefaultGasLimit), nil
}

func (s *ethService) GetBalance(ctx context.Context, address common.Address) (*hexutil.Big, error) {
Expand Down Expand Up @@ -328,7 +332,7 @@ func (s *ethService) GetTransactionReceipt(ctx context.Context, hash common.Hash
Logs: logs,
LogsBloom: bloom,
Status: hexutil.Uint64(row.Status),
EffectiveGasPrice: hexutil.Uint64(defaultGasPriceWeiUint64),
EffectiveGasPrice: hexutil.Uint64(0),
Type: hexutil.Uint64(2),
// RevertReason is omitted when empty (omitempty), so successful
// receipts keep the standard JSON shape.
Expand All @@ -350,7 +354,7 @@ func (s *ethService) GetTransactionByHash(ctx context.Context, hash common.Hash)
blockHash := common.BytesToHash(row.BlockHash)
blockNum := hexutil.Uint64(row.BlockNumber)
txIndex := hexutil.Uint(row.TxIndex)
gasPrice := big.NewInt(defaultGasPriceWeiInt64)
gasPrice := big.NewInt(0)

return &ethrpc.RPCTransaction{
Hash: hash,
Expand All @@ -362,7 +366,7 @@ func (s *ethService) GetTransactionByHash(ctx context.Context, hash common.Hash)
To: &to,
Value: (*hexutil.Big)(big.NewInt(0)),
GasPrice: (*hexutil.Big)(gasPrice),
Gas: hexutil.Uint64(s.cfg.GasLimit),
Gas: hexutil.Uint64(DefaultGasLimit),
Input: row.Input,
Type: hexutil.Uint64(2),
ChainID: (*hexutil.Big)(new(big.Int).Set(s.chainID)),
Expand Down Expand Up @@ -557,12 +561,12 @@ func (s *ethService) GetBlockByNumber(ctx context.Context, block ethrpc.BlockNum
TotalDifficulty: (*hexutil.Big)(big.NewInt(0)),
ExtraData: []byte{},
Size: hexutil.Uint64(0),
GasLimit: hexutil.Uint64(s.cfg.GasLimit),
GasLimit: hexutil.Uint64(DefaultGasLimit),
GasUsed: hexutil.Uint64(0),
Timestamp: hexutil.Uint64(blockNum * syntheticBlockTimeSeconds),
Transactions: []any{},
Uncles: []common.Hash{},
BaseFeePerGas: (*hexutil.Big)(big.NewInt(defaultGasPriceWeiInt64)),
BaseFeePerGas: (*hexutil.Big)(big.NewInt(0)),
}, nil
}

Expand Down
57 changes: 26 additions & 31 deletions pkg/ethrpc/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,8 @@ import (
// defaultCfg returns a minimal EthRPCConfig suitable for unit tests.
func defaultCfg() *ethrpc.Config {
return &ethrpc.Config{
ChainID: 31337,
GasPriceWei: "1000000000",
GasLimit: 21000,
RequestTimeout: 30 * time.Second,
NativeBalanceWei: "1000000000000000000000",
ChainID: 31337,
RequestTimeout: 30 * time.Second,
}
}

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

// ─── GasPrice ─────────────────────────────────────────────────────────────────

// Gas is fixed at 0 in code (not configurable). See TestService_ZeroGas for the
// MetaMask-compatibility rationale.
func TestService_GasPrice(t *testing.T) {
t.Run("valid config returns configured price", func(t *testing.T) {
svc := newSvc(t, defaultCfg(), nil, nil)

got, err := svc.GasPrice(context.Background())
require.NoError(t, err)
assert.Equal(t, big.NewInt(1_000_000_000), got.ToInt())
})

t.Run("non-numeric wei string returns error", func(t *testing.T) {
cfg := defaultCfg()
cfg.GasPriceWei = "not-a-number"
svc := newSvc(t, cfg, nil, nil)

_, err := svc.GasPrice(context.Background())
require.Error(t, err)
assert.True(t, apperr.Is(err, apperr.CategoryGeneralError))
})
got, err := newSvc(t, defaultCfg(), nil, nil).GasPrice(context.Background())
require.NoError(t, err)
assert.Equal(t, big.NewInt(0), got.ToInt())
}

// ─── MaxPriorityFeePerGas ─────────────────────────────────────────────────────

func TestService_MaxPriorityFeePerGas(t *testing.T) {
svc := newSvc(t, defaultCfg(), nil, nil)
got, err := newSvc(t, defaultCfg(), nil, nil).MaxPriorityFeePerGas(context.Background())
require.NoError(t, err)
assert.Equal(t, big.NewInt(0), got.ToInt())
}

got, err := svc.MaxPriorityFeePerGas(context.Background())
// ─── Zero gas (MetaMask compatibility) ────────────────────────────────────────

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

// ─── EstimateGas ──────────────────────────────────────────────────────────────

func TestService_EstimateGas(t *testing.T) {
cfg := defaultCfg()
cfg.GasLimit = 50_000
svc := newSvc(t, cfg, nil, nil)

got, err := svc.EstimateGas(context.Background(), nil)
got, err := newSvc(t, defaultCfg(), nil, nil).EstimateGas(context.Background(), nil)
require.NoError(t, err)
assert.Equal(t, hexutil.Uint64(50_000), got)
assert.Equal(t, hexutil.Uint64(service.DefaultGasLimit), got)
}

// ─── GetBalance ───────────────────────────────────────────────────────────────
Expand Down
8 changes: 3 additions & 5 deletions pkg/token/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@ type ERC20Token struct {

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

// NewConfig creates a token Config.
func NewConfig(nativeBalanceWei string) *Config {
func NewConfig() *Config {
return &Config{
NativeBalanceWei: nativeBalanceWei,
SupportedTokens: make(map[common.Address]ERC20Token),
SupportedTokens: make(map[common.Address]ERC20Token),
}
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/token/erc20_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ var (
// ─── Shared helpers ───────────────────────────────────────────────────────────

func newCfg() *token.Config {
cfg := token.NewConfig("5000000000000000000") // 5 ETH in wei
cfg := token.NewConfig()
cfg.AddToken(promptAddr, token.ERC20Token{Name: "Prompt Token", Symbol: "PROMPT", Decimals: 18, InstrumentID: "PROMPT"})
cfg.AddToken(demoAddr, token.ERC20Token{Name: "Demo Token", Symbol: "DEMO", Decimals: 18, InstrumentID: "DEMO"})
return cfg
Expand Down
18 changes: 8 additions & 10 deletions pkg/token/native.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,19 @@ type nativeImpl struct {
svc *Service
}

const decimalBase = 10

// NewNative creates a Native implementation.
func NewNative(svc *Service) Native {
return &nativeImpl{svc: svc}
}

func (n *nativeImpl) GetBalance(ctx context.Context, address common.Address) (big.Int, error) {
// TODO: This logic is confusing - either return not supported or implement it.
isRegistered, err := n.svc.isUserRegistered(ctx, address)
if err != nil || !isRegistered {
return big.Int{}, err
}
bal, _ := new(big.Int).SetString(n.svc.cfg.NativeBalanceWei, decimalBase)
return *bal, nil
// GetBalance always reports a zero native balance. The native coin is synthetic
// — there is no real gas token — so 0 is the honest value and avoids showing a
// confusing fake balance in MetaMask. Gas is also fixed at 0 (see the ethrpc
// service), so MetaMask's `balance >= value + gasLimit*gasPrice` pre-flight
// check still passes as `0 >= 0` for the zero-value ERC-20 transfers this facade
// supports.
func (*nativeImpl) GetBalance(_ context.Context, _ common.Address) (big.Int, error) {
return big.Int{}, nil
}

func (*nativeImpl) Transfer(_ context.Context, _, _ common.Address, _ big.Int) error {
Expand Down
Loading
Loading