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
95 changes: 48 additions & 47 deletions src/services/chain-adapter/evm/asset-service.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,47 @@
/**
* EVM Asset Service
*
* Provides balance queries for EVM chains via walletapi.
*
* Provides balance queries for EVM chains via public JSON-RPC endpoints.
*/

import type { ChainConfig } from '@/services/chain-config'
import type { IAssetService, Address, Balance, TokenMetadata } from '../types'
import { Amount } from '@/types/amount'
import { ChainServiceError, ChainErrorCodes } from '../types'

/** Default public RPC endpoints for EVM chains */
const DEFAULT_RPC_URLS: Record<string, string> = {
ethereum: 'https://ethereum-rpc.publicnode.com',
binance: 'https://bsc-rpc.publicnode.com',
}

interface JsonRpcResponse<T> {
jsonrpc: string
id: number
result?: T
error?: { code: number; message: string }
}

export class EvmAssetService implements IAssetService {
private readonly config: ChainConfig
private readonly apiUrl: string
private readonly apiPath: string
private readonly rpcUrl: string

constructor(config: ChainConfig) {
this.config = config
this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info'
this.apiPath = config.api?.path ?? config.id
this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['ethereum']!
}

private async fetch<T>(endpoint: string, body?: unknown): Promise<T> {
const url = `${this.apiUrl}/wallet/${this.apiPath}${endpoint}`
const init: RequestInit = body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: { method: 'GET' }
const response = await fetch(url, init)
private async rpc<T>(method: string, params: unknown[]): Promise<T> {
const response = await fetch(this.rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method,
params,
id: 1,
}),
})

if (!response.ok) {
throw new ChainServiceError(
Expand All @@ -34,26 +50,23 @@ export class EvmAssetService implements IAssetService {
)
}

const json = await response.json() as { success: boolean; result?: T; error?: { message: string } }
if (!json.success) {
throw new ChainServiceError(
ChainErrorCodes.NETWORK_ERROR,
json.error?.message ?? 'API request failed',
)
const json = (await response.json()) as JsonRpcResponse<T>
if (json.error) {
throw new ChainServiceError(ChainErrorCodes.NETWORK_ERROR, json.error.message)
}

return json.result as T
}

async getNativeBalance(address: Address): Promise<Balance> {
try {
const result = await this.fetch<{ balance: string }>('/address/balance', { address })
const hexBalance = await this.rpc<string>('eth_getBalance', [address, 'latest'])
const balance = BigInt(hexBalance).toString()
return {
amount: Amount.fromRaw(result.balance, this.config.decimals, this.config.symbol),
amount: Amount.fromRaw(balance, this.config.decimals, this.config.symbol),
symbol: this.config.symbol,
}
} catch (error) {
if (error instanceof ChainServiceError) throw error
} catch {
return {
amount: Amount.fromRaw('0', this.config.decimals, this.config.symbol),
symbol: this.config.symbol,
Expand All @@ -63,13 +76,13 @@ export class EvmAssetService implements IAssetService {

async getTokenBalance(address: Address, tokenAddress: Address): Promise<Balance> {
try {
const result = await this.fetch<{ balance: string; decimals: number; symbol: string }>(
'/token/balance',
{ address, tokenAddress },
)
// ERC20 balanceOf(address) selector: 0x70a08231
const data = `0x70a08231000000000000000000000000${address.slice(2).toLowerCase()}`
const hexBalance = await this.rpc<string>('eth_call', [{ to: tokenAddress, data }, 'latest'])
const balance = BigInt(hexBalance).toString()
return {
amount: Amount.fromRaw(result.balance, result.decimals, result.symbol),
symbol: result.symbol,
amount: Amount.fromRaw(balance, 18, 'TOKEN'),
symbol: 'TOKEN',
}
} catch {
return {
Expand All @@ -80,30 +93,18 @@ export class EvmAssetService implements IAssetService {
}

async getTokenBalances(address: Address): Promise<Balance[]> {
// Return only native balance for now
const nativeBalance = await this.getNativeBalance(address)
return [nativeBalance]
}

async getTokenMetadata(tokenAddress: Address): Promise<TokenMetadata> {
try {
const result = await this.fetch<{ name: string; symbol: string; decimals: number }>(
'/token/info',
{ tokenAddress },
)
return {
address: tokenAddress,
name: result.name,
symbol: result.symbol,
decimals: result.decimals,
}
} catch {
return {
address: tokenAddress,
name: 'Unknown Token',
symbol: 'UNKNOWN',
decimals: 18,
}
// ERC20 metadata queries would require multiple eth_call requests
// Return minimal info for now
return {
address: tokenAddress,
name: 'Unknown Token',
symbol: 'UNKNOWN',
decimals: 18,
}
}
}
53 changes: 35 additions & 18 deletions src/services/chain-adapter/evm/chain-service.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,47 @@
/**
* EVM Chain Service
*
* Provides chain info and gas price queries via public JSON-RPC endpoints.
*/

import type { ChainConfig } from '@/services/chain-config'
import type { IChainService, ChainInfo, GasPrice, HealthStatus } from '../types'
import { Amount } from '@/types/amount'
import { ChainServiceError, ChainErrorCodes } from '../types'

/** Default public RPC endpoints for EVM chains */
const DEFAULT_RPC_URLS: Record<string, string> = {
ethereum: 'https://ethereum-rpc.publicnode.com',
binance: 'https://bsc-rpc.publicnode.com',
}

interface JsonRpcResponse<T> {
jsonrpc: string
id: number
result?: T
error?: { code: number; message: string }
}

export class EvmChainService implements IChainService {
private readonly config: ChainConfig
private readonly apiUrl: string
private readonly apiPath: string
private readonly rpcUrl: string

constructor(config: ChainConfig) {
this.config = config
this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info'
this.apiPath = config.api?.path ?? config.id
this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['ethereum']!
}

private async fetch<T>(endpoint: string): Promise<T> {
const url = `${this.apiUrl}/wallet/${this.apiPath}${endpoint}`
const response = await fetch(url)
private async rpc<T>(method: string, params: unknown[] = []): Promise<T> {
const response = await fetch(this.rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method,
params,
id: 1,
}),
})

if (!response.ok) {
throw new ChainServiceError(
Expand All @@ -29,12 +50,9 @@ export class EvmChainService implements IChainService {
)
}

const json = await response.json() as { success: boolean; result?: T; error?: { message: string } }
if (!json.success) {
throw new ChainServiceError(
ChainErrorCodes.NETWORK_ERROR,
json.error?.message ?? 'API request failed',
)
const json = (await response.json()) as JsonRpcResponse<T>
if (json.error) {
throw new ChainServiceError(ChainErrorCodes.NETWORK_ERROR, json.error.message)
}

return json.result as T
Expand All @@ -54,23 +72,22 @@ export class EvmChainService implements IChainService {

async getBlockHeight(): Promise<bigint> {
try {
const result = await this.fetch<{ height: number }>('/lastblock')
return BigInt(result.height)
const hexHeight = await this.rpc<string>('eth_blockNumber')
return BigInt(hexHeight)
} catch {
return 0n
}
}

async getGasPrice(): Promise<GasPrice> {
try {
const result = await this.fetch<{ gasPrice: string; baseFee?: string }>('/gasprice')
const gasPrice = Amount.fromRaw(result.gasPrice, 9, 'Gwei') // Gas price in Gwei
const hexGasPrice = await this.rpc<string>('eth_gasPrice')
const gasPrice = Amount.fromRaw(BigInt(hexGasPrice).toString(), 9, 'Gwei')

return {
slow: gasPrice,
standard: gasPrice,
fast: gasPrice,
baseFee: result.baseFee ? Amount.fromRaw(result.baseFee, 9, 'Gwei') : undefined,
lastUpdated: Date.now(),
}
} catch {
Expand Down