From 8a75b9f2821c6da9f77817cb4e06835eedc97d33 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 27 Dec 2025 21:15:47 +0800 Subject: [PATCH 1/8] feat(chain-adapter): add EVM and BIP39 adapters - Add EVM adapter for Ethereum/BSC chains - IdentityService, AssetService, TransactionService, ChainService - Balance queries via walletapi - Transaction signing placeholder (not yet implemented) - Add BIP39 adapter for Bitcoin/Tron chains - Similar structure to EVM adapter - Chain-specific fee estimates and confirmations - Update use-send.ts error messages - Show chain type name for unsupported chains - Prepare for future EVM/BIP39 transfer implementation - Register all adapters in setupAdapters() --- src/hooks/use-send.ts | 21 +- src/services/chain-adapter/bip39/adapter.ts | 44 ++++ .../chain-adapter/bip39/asset-service.ts | 100 +++++++++ .../chain-adapter/bip39/chain-service.ts | 99 +++++++++ .../chain-adapter/bip39/identity-service.ts | 49 +++++ src/services/chain-adapter/bip39/index.ts | 6 + .../bip39/transaction-service.ts | 190 ++++++++++++++++ src/services/chain-adapter/bip39/types.ts | 15 ++ src/services/chain-adapter/evm/adapter.ts | 44 ++++ .../chain-adapter/evm/asset-service.ts | 109 ++++++++++ .../chain-adapter/evm/chain-service.ts | 109 ++++++++++ .../chain-adapter/evm/identity-service.ts | 41 ++++ src/services/chain-adapter/evm/index.ts | 6 + .../chain-adapter/evm/transaction-service.ts | 204 ++++++++++++++++++ src/services/chain-adapter/evm/types.ts | 34 +++ src/services/chain-adapter/index.ts | 9 +- 16 files changed, 1074 insertions(+), 6 deletions(-) create mode 100644 src/services/chain-adapter/bip39/adapter.ts create mode 100644 src/services/chain-adapter/bip39/asset-service.ts create mode 100644 src/services/chain-adapter/bip39/chain-service.ts create mode 100644 src/services/chain-adapter/bip39/identity-service.ts create mode 100644 src/services/chain-adapter/bip39/index.ts create mode 100644 src/services/chain-adapter/bip39/transaction-service.ts create mode 100644 src/services/chain-adapter/bip39/types.ts create mode 100644 src/services/chain-adapter/evm/adapter.ts create mode 100644 src/services/chain-adapter/evm/asset-service.ts create mode 100644 src/services/chain-adapter/evm/chain-service.ts create mode 100644 src/services/chain-adapter/evm/identity-service.ts create mode 100644 src/services/chain-adapter/evm/index.ts create mode 100644 src/services/chain-adapter/evm/transaction-service.ts create mode 100644 src/services/chain-adapter/evm/types.ts diff --git a/src/hooks/use-send.ts b/src/hooks/use-send.ts index 77310c57..21c1e073 100644 --- a/src/hooks/use-send.ts +++ b/src/hooks/use-send.ts @@ -183,15 +183,30 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { return result.status === 'ok' ? { status: 'ok' as const } : { status: 'error' as const } } - if (!chainConfig || chainConfig.type !== 'bioforest') { - console.log('[useSend.submit] Chain not supported:', chainConfig?.type) + if (!chainConfig) { + console.log('[useSend.submit] No chain config') setState((prev) => ({ ...prev, step: 'result', isSubmitting: false, resultStatus: 'failed', txHash: null, - errorMessage: '当前链暂不支持真实转账', + errorMessage: '链配置缺失', + })) + return { status: 'error' as const } + } + + // Currently only bioforest is fully implemented + if (chainConfig.type !== 'bioforest') { + console.log('[useSend.submit] Chain type not yet supported:', chainConfig.type) + const chainTypeName = chainConfig.type === 'evm' ? 'EVM' : chainConfig.type === 'bip39' ? 'BIP39' : chainConfig.type + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: `${chainTypeName} 链转账功能开发中`, })) return { status: 'error' as const } } diff --git a/src/services/chain-adapter/bip39/adapter.ts b/src/services/chain-adapter/bip39/adapter.ts new file mode 100644 index 00000000..bdf1c785 --- /dev/null +++ b/src/services/chain-adapter/bip39/adapter.ts @@ -0,0 +1,44 @@ +/** + * BIP39 Chain Adapter (Bitcoin, Tron) + */ + +import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import type { IChainAdapter, IStakingService } from '../types' +import { Bip39IdentityService } from './identity-service' +import { Bip39AssetService } from './asset-service' +import { Bip39TransactionService } from './transaction-service' +import { Bip39ChainService } from './chain-service' + +export class Bip39Adapter implements IChainAdapter { + readonly chainId: string + readonly chainType: ChainConfigType = 'bip39' + + readonly identity: Bip39IdentityService + readonly asset: Bip39AssetService + readonly transaction: Bip39TransactionService + readonly chain: Bip39ChainService + readonly staking: IStakingService | null = null + + private initialized = false + + constructor(config: ChainConfig) { + this.chainId = config.id + this.identity = new Bip39IdentityService(config) + this.asset = new Bip39AssetService(config) + this.transaction = new Bip39TransactionService(config) + this.chain = new Bip39ChainService(config) + } + + async initialize(_config: ChainConfig): Promise { + if (this.initialized) return + this.initialized = true + } + + dispose(): void { + this.initialized = false + } +} + +export function createBip39Adapter(config: ChainConfig): IChainAdapter { + return new Bip39Adapter(config) +} diff --git a/src/services/chain-adapter/bip39/asset-service.ts b/src/services/chain-adapter/bip39/asset-service.ts new file mode 100644 index 00000000..079510fe --- /dev/null +++ b/src/services/chain-adapter/bip39/asset-service.ts @@ -0,0 +1,100 @@ +/** + * BIP39 Asset Service (Bitcoin, Tron) + */ + +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' + +export class Bip39AssetService implements IAssetService { + private readonly config: ChainConfig + private readonly apiUrl: string + private readonly apiPath: string + + constructor(config: ChainConfig) { + this.config = config + this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' + this.apiPath = config.api?.path ?? config.id + } + + private async fetch(endpoint: string, body?: unknown): Promise { + 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) + + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `HTTP ${response.status}: ${response.statusText}`, + ) + } + + 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', + ) + } + + return json.result as T + } + + async getNativeBalance(address: Address): Promise { + try { + const result = await this.fetch<{ balance: string }>('/address/balance', { address }) + return { + amount: Amount.fromRaw(result.balance, this.config.decimals, this.config.symbol), + symbol: this.config.symbol, + } + } catch { + return { + amount: Amount.fromRaw('0', this.config.decimals, this.config.symbol), + symbol: this.config.symbol, + } + } + } + + async getTokenBalance(address: Address, tokenAddress: Address): Promise { + // Bitcoin doesn't have tokens, Tron has TRC20 + if (this.config.id !== 'tron') { + return { + amount: Amount.fromRaw('0', 18, 'UNKNOWN'), + symbol: 'UNKNOWN', + } + } + + try { + const result = await this.fetch<{ balance: string; decimals: number; symbol: string }>( + '/token/balance', + { address, tokenAddress }, + ) + return { + amount: Amount.fromRaw(result.balance, result.decimals, result.symbol), + symbol: result.symbol, + } + } catch { + return { + amount: Amount.fromRaw('0', 18, 'UNKNOWN'), + symbol: 'UNKNOWN', + } + } + } + + async getTokenBalances(address: Address): Promise { + const nativeBalance = await this.getNativeBalance(address) + return [nativeBalance] + } + + async getTokenMetadata(tokenAddress: Address): Promise { + return { + address: tokenAddress, + name: 'Unknown Token', + symbol: 'UNKNOWN', + decimals: 18, + } + } +} diff --git a/src/services/chain-adapter/bip39/chain-service.ts b/src/services/chain-adapter/bip39/chain-service.ts new file mode 100644 index 00000000..7295fef0 --- /dev/null +++ b/src/services/chain-adapter/bip39/chain-service.ts @@ -0,0 +1,99 @@ +/** + * BIP39 Chain Service (Bitcoin, Tron) + */ + +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' + +export class Bip39ChainService implements IChainService { + private readonly config: ChainConfig + private readonly apiUrl: string + private readonly apiPath: string + + constructor(config: ChainConfig) { + this.config = config + this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' + this.apiPath = config.api?.path ?? config.id + } + + private async fetch(endpoint: string): Promise { + const url = `${this.apiUrl}/wallet/${this.apiPath}${endpoint}` + const response = await fetch(url) + + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `HTTP ${response.status}: ${response.statusText}`, + ) + } + + 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', + ) + } + + return json.result as T + } + + getChainInfo(): ChainInfo { + const blockTime = this.config.id === 'bitcoin' ? 600 : 3 // BTC ~10min, TRX ~3s + const confirmations = this.config.id === 'bitcoin' ? 6 : 19 + + return { + chainId: this.config.id, + name: this.config.name, + symbol: this.config.symbol, + decimals: this.config.decimals, + blockTime, + confirmations, + explorerUrl: this.config.explorer?.url, + } + } + + async getBlockHeight(): Promise { + try { + const result = await this.fetch<{ height: number }>('/lastblock') + return BigInt(result.height) + } catch { + return 0n + } + } + + async getGasPrice(): Promise { + // Bitcoin uses sat/vB, Tron uses bandwidth/energy + const defaultFee = Amount.fromRaw('1000', this.config.decimals, this.config.symbol) + return { + slow: defaultFee, + standard: defaultFee, + fast: defaultFee, + lastUpdated: Date.now(), + } + } + + async healthCheck(): Promise { + const startTime = Date.now() + try { + const height = await this.getBlockHeight() + return { + isHealthy: true, + latency: Date.now() - startTime, + blockHeight: height, + isSyncing: false, + lastUpdated: Date.now(), + } + } catch { + return { + isHealthy: false, + latency: Date.now() - startTime, + blockHeight: 0n, + isSyncing: false, + lastUpdated: Date.now(), + } + } + } +} diff --git a/src/services/chain-adapter/bip39/identity-service.ts b/src/services/chain-adapter/bip39/identity-service.ts new file mode 100644 index 00000000..fcf6ad99 --- /dev/null +++ b/src/services/chain-adapter/bip39/identity-service.ts @@ -0,0 +1,49 @@ +/** + * BIP39 Identity Service + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { IIdentityService, Address, Signature } from '../types' +import { isValidAddress } from '@/lib/crypto' + +export class Bip39IdentityService implements IIdentityService { + private readonly chainId: string + + constructor(config: ChainConfig) { + this.chainId = config.id + } + + async deriveAddress(_seed: Uint8Array, _index = 0): Promise
{ + throw new Error('Use deriveAddressesForChains from @/lib/crypto instead') + } + + async deriveAddresses(_seed: Uint8Array, _startIndex: number, _count: number): Promise { + throw new Error('Use deriveAddressesForChains from @/lib/crypto instead') + } + + isValidAddress(address: string): boolean { + if (this.chainId === 'bitcoin') { + return isValidAddress(address, 'bitcoin') + } + if (this.chainId === 'tron') { + return isValidAddress(address, 'tron') + } + return false + } + + normalizeAddress(address: string): Address { + return address // BIP39 addresses are case-sensitive + } + + async signMessage(_message: string | Uint8Array, _privateKey: Uint8Array): Promise { + throw new Error('Not implemented') + } + + async verifyMessage( + _message: string | Uint8Array, + _signature: Signature, + _address: Address, + ): Promise { + throw new Error('Not implemented') + } +} diff --git a/src/services/chain-adapter/bip39/index.ts b/src/services/chain-adapter/bip39/index.ts new file mode 100644 index 00000000..add77298 --- /dev/null +++ b/src/services/chain-adapter/bip39/index.ts @@ -0,0 +1,6 @@ +export { Bip39Adapter, createBip39Adapter } from './adapter' +export { Bip39IdentityService } from './identity-service' +export { Bip39AssetService } from './asset-service' +export { Bip39TransactionService } from './transaction-service' +export { Bip39ChainService } from './chain-service' +export * from './types' diff --git a/src/services/chain-adapter/bip39/transaction-service.ts b/src/services/chain-adapter/bip39/transaction-service.ts new file mode 100644 index 00000000..cb18cd10 --- /dev/null +++ b/src/services/chain-adapter/bip39/transaction-service.ts @@ -0,0 +1,190 @@ +/** + * BIP39 Transaction Service (Bitcoin, Tron) + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { + ITransactionService, + TransferParams, + UnsignedTransaction, + SignedTransaction, + TransactionHash, + TransactionStatus, + Transaction, + FeeEstimate, + Fee, +} from '../types' +import { Amount } from '@/types/amount' +import { ChainServiceError, ChainErrorCodes } from '../types' + +export class Bip39TransactionService implements ITransactionService { + private readonly config: ChainConfig + private readonly apiUrl: string + private readonly apiPath: string + + constructor(config: ChainConfig) { + this.config = config + this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' + this.apiPath = config.api?.path ?? config.id + } + + private async fetch(endpoint: string, body?: unknown): Promise { + 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) + + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `HTTP ${response.status}: ${response.statusText}`, + ) + } + + 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', + ) + } + + return json.result as T + } + + async estimateFee(_params: TransferParams): Promise { + // Default fee estimates + const feeAmount = this.config.id === 'bitcoin' + ? Amount.fromRaw('10000', this.config.decimals, this.config.symbol) // ~0.0001 BTC + : Amount.fromRaw('1000000', this.config.decimals, this.config.symbol) // 1 TRX + + const fee: Fee = { + amount: feeAmount, + estimatedTime: this.config.id === 'bitcoin' ? 600 : 3, + } + + return { + slow: { ...fee, estimatedTime: fee.estimatedTime * 2 }, + standard: fee, + fast: { ...fee, estimatedTime: Math.ceil(fee.estimatedTime / 2) }, + } + } + + async buildTransaction(params: TransferParams): Promise { + return { + chainId: this.config.id, + data: { + from: params.from, + to: params.to, + amount: params.amount.raw.toString(), + }, + } + } + + async signTransaction( + _unsignedTx: UnsignedTransaction, + _privateKey: Uint8Array, + ): Promise { + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_SUPPORTED, + `${this.config.id} transaction signing not yet implemented`, + ) + } + + async broadcastTransaction(_signedTx: SignedTransaction): Promise { + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_SUPPORTED, + `${this.config.id} transaction broadcast not yet implemented`, + ) + } + + async getTransactionStatus(hash: TransactionHash): Promise { + try { + const result = await this.fetch<{ + confirmations: number + status: string + }>('/transaction/status', { hash }) + + const requiredConfirmations = this.config.id === 'bitcoin' ? 6 : 19 + + return { + status: result.confirmations >= requiredConfirmations ? 'confirmed' : 'confirming', + confirmations: result.confirmations, + requiredConfirmations, + } + } catch { + return { + status: 'pending', + confirmations: 0, + requiredConfirmations: this.config.id === 'bitcoin' ? 6 : 19, + } + } + } + + async getTransaction(hash: TransactionHash): Promise { + try { + const result = await this.fetch<{ + hash: string + from: string + to: string + amount: string + fee: string + status: string + timestamp: number + blockNumber?: string + }>('/transaction', { hash }) + + return { + hash: result.hash, + from: result.from, + to: result.to, + amount: Amount.fromRaw(result.amount, this.config.decimals, this.config.symbol), + fee: Amount.fromRaw(result.fee, this.config.decimals, this.config.symbol), + status: { + status: result.status === 'confirmed' ? 'confirmed' : 'pending', + confirmations: result.status === 'confirmed' ? 6 : 0, + requiredConfirmations: this.config.id === 'bitcoin' ? 6 : 19, + }, + timestamp: result.timestamp, + blockNumber: result.blockNumber ? BigInt(result.blockNumber) : undefined, + type: 'transfer', + } + } catch { + return null + } + } + + async getTransactionHistory(address: string, limit = 20): Promise { + try { + const result = await this.fetch>('/transactions', { address, limit }) + + return result.map((tx) => ({ + hash: tx.hash, + from: tx.from, + to: tx.to, + amount: Amount.fromRaw(tx.amount, this.config.decimals, this.config.symbol), + fee: Amount.fromRaw(tx.fee, this.config.decimals, this.config.symbol), + status: { + status: tx.status === 'confirmed' ? 'confirmed' as const : 'pending' as const, + confirmations: tx.status === 'confirmed' ? 6 : 0, + requiredConfirmations: this.config.id === 'bitcoin' ? 6 : 19, + }, + timestamp: tx.timestamp, + blockNumber: tx.blockNumber ? BigInt(tx.blockNumber) : undefined, + type: 'transfer' as const, + })) + } catch { + return [] + } + } +} diff --git a/src/services/chain-adapter/bip39/types.ts b/src/services/chain-adapter/bip39/types.ts new file mode 100644 index 00000000..04f21b0b --- /dev/null +++ b/src/services/chain-adapter/bip39/types.ts @@ -0,0 +1,15 @@ +/** + * BIP39 Chain Adapter Types (Bitcoin, Tron) + */ + +import type { ChainConfig } from '@/services/chain-config' + +export interface Bip39ChainConfig extends ChainConfig { + type: 'bip39' +} + +export type Bip39ChainId = 'bitcoin' | 'tron' + +export function isSupportedBip39Chain(chainId: string): chainId is Bip39ChainId { + return chainId === 'bitcoin' || chainId === 'tron' +} diff --git a/src/services/chain-adapter/evm/adapter.ts b/src/services/chain-adapter/evm/adapter.ts new file mode 100644 index 00000000..8b263620 --- /dev/null +++ b/src/services/chain-adapter/evm/adapter.ts @@ -0,0 +1,44 @@ +/** + * EVM Chain Adapter + */ + +import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import type { IChainAdapter, IStakingService } from '../types' +import { EvmIdentityService } from './identity-service' +import { EvmAssetService } from './asset-service' +import { EvmTransactionService } from './transaction-service' +import { EvmChainService } from './chain-service' + +export class EvmAdapter implements IChainAdapter { + readonly chainId: string + readonly chainType: ChainConfigType = 'evm' + + readonly identity: EvmIdentityService + readonly asset: EvmAssetService + readonly transaction: EvmTransactionService + readonly chain: EvmChainService + readonly staking: IStakingService | null = null + + private initialized = false + + constructor(config: ChainConfig) { + this.chainId = config.id + this.identity = new EvmIdentityService(config) + this.asset = new EvmAssetService(config) + this.transaction = new EvmTransactionService(config) + this.chain = new EvmChainService(config) + } + + async initialize(_config: ChainConfig): Promise { + if (this.initialized) return + this.initialized = true + } + + dispose(): void { + this.initialized = false + } +} + +export function createEvmAdapter(config: ChainConfig): IChainAdapter { + return new EvmAdapter(config) +} diff --git a/src/services/chain-adapter/evm/asset-service.ts b/src/services/chain-adapter/evm/asset-service.ts new file mode 100644 index 00000000..06700fa0 --- /dev/null +++ b/src/services/chain-adapter/evm/asset-service.ts @@ -0,0 +1,109 @@ +/** + * EVM Asset Service + * + * Provides balance queries for EVM chains via walletapi. + */ + +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' + +export class EvmAssetService implements IAssetService { + private readonly config: ChainConfig + private readonly apiUrl: string + private readonly apiPath: string + + constructor(config: ChainConfig) { + this.config = config + this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' + this.apiPath = config.api?.path ?? config.id + } + + private async fetch(endpoint: string, body?: unknown): Promise { + 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) + + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `HTTP ${response.status}: ${response.statusText}`, + ) + } + + 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', + ) + } + + return json.result as T + } + + async getNativeBalance(address: Address): Promise { + try { + const result = await this.fetch<{ balance: string }>('/address/balance', { address }) + return { + amount: Amount.fromRaw(result.balance, this.config.decimals, this.config.symbol), + symbol: this.config.symbol, + } + } catch (error) { + if (error instanceof ChainServiceError) throw error + return { + amount: Amount.fromRaw('0', this.config.decimals, this.config.symbol), + symbol: this.config.symbol, + } + } + } + + async getTokenBalance(address: Address, tokenAddress: Address): Promise { + try { + const result = await this.fetch<{ balance: string; decimals: number; symbol: string }>( + '/token/balance', + { address, tokenAddress }, + ) + return { + amount: Amount.fromRaw(result.balance, result.decimals, result.symbol), + symbol: result.symbol, + } + } catch { + return { + amount: Amount.fromRaw('0', 18, 'UNKNOWN'), + symbol: 'UNKNOWN', + } + } + } + + async getTokenBalances(address: Address): Promise { + // Return only native balance for now + const nativeBalance = await this.getNativeBalance(address) + return [nativeBalance] + } + + async getTokenMetadata(tokenAddress: Address): Promise { + 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, + } + } + } +} diff --git a/src/services/chain-adapter/evm/chain-service.ts b/src/services/chain-adapter/evm/chain-service.ts new file mode 100644 index 00000000..c9ff1eb2 --- /dev/null +++ b/src/services/chain-adapter/evm/chain-service.ts @@ -0,0 +1,109 @@ +/** + * EVM Chain Service + */ + +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' + +export class EvmChainService implements IChainService { + private readonly config: ChainConfig + private readonly apiUrl: string + private readonly apiPath: string + + constructor(config: ChainConfig) { + this.config = config + this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' + this.apiPath = config.api?.path ?? config.id + } + + private async fetch(endpoint: string): Promise { + const url = `${this.apiUrl}/wallet/${this.apiPath}${endpoint}` + const response = await fetch(url) + + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `HTTP ${response.status}: ${response.statusText}`, + ) + } + + 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', + ) + } + + return json.result as T + } + + getChainInfo(): ChainInfo { + return { + chainId: this.config.id, + name: this.config.name, + symbol: this.config.symbol, + decimals: this.config.decimals, + blockTime: 12, // ~12 seconds for Ethereum + confirmations: 12, + explorerUrl: this.config.explorer?.url, + } + } + + async getBlockHeight(): Promise { + try { + const result = await this.fetch<{ height: number }>('/lastblock') + return BigInt(result.height) + } catch { + return 0n + } + } + + async getGasPrice(): Promise { + try { + const result = await this.fetch<{ gasPrice: string; baseFee?: string }>('/gasprice') + const gasPrice = Amount.fromRaw(result.gasPrice, 9, 'Gwei') // Gas price in Gwei + + return { + slow: gasPrice, + standard: gasPrice, + fast: gasPrice, + baseFee: result.baseFee ? Amount.fromRaw(result.baseFee, 9, 'Gwei') : undefined, + lastUpdated: Date.now(), + } + } catch { + // Return default gas prices + const defaultGas = Amount.fromRaw('20000000000', 9, 'Gwei') // 20 Gwei + return { + slow: defaultGas, + standard: defaultGas, + fast: defaultGas, + lastUpdated: Date.now(), + } + } + } + + async healthCheck(): Promise { + const startTime = Date.now() + try { + const height = await this.getBlockHeight() + return { + isHealthy: true, + latency: Date.now() - startTime, + blockHeight: height, + isSyncing: false, + lastUpdated: Date.now(), + } + } catch { + return { + isHealthy: false, + latency: Date.now() - startTime, + blockHeight: 0n, + isSyncing: false, + lastUpdated: Date.now(), + } + } + } +} diff --git a/src/services/chain-adapter/evm/identity-service.ts b/src/services/chain-adapter/evm/identity-service.ts new file mode 100644 index 00000000..c76128e3 --- /dev/null +++ b/src/services/chain-adapter/evm/identity-service.ts @@ -0,0 +1,41 @@ +/** + * EVM Identity Service + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { IIdentityService, Address, Signature } from '../types' +import { toChecksumAddress, isValidAddress } from '@/lib/crypto' + +export class EvmIdentityService implements IIdentityService { + constructor(_config: ChainConfig) {} + + async deriveAddress(_seed: Uint8Array, _index = 0): Promise
{ + throw new Error('Use deriveAddressesForChains from @/lib/crypto instead') + } + + async deriveAddresses(_seed: Uint8Array, _startIndex: number, _count: number): Promise { + throw new Error('Use deriveAddressesForChains from @/lib/crypto instead') + } + + isValidAddress(address: string): boolean { + return isValidAddress(address, 'ethereum') + } + + normalizeAddress(address: string): Address { + return toChecksumAddress(address) + } + + async signMessage(_message: string | Uint8Array, _privateKey: Uint8Array): Promise { + // TODO: Implement EIP-191 personal_sign + throw new Error('Not implemented') + } + + async verifyMessage( + _message: string | Uint8Array, + _signature: Signature, + _address: Address, + ): Promise { + // TODO: Implement signature verification + throw new Error('Not implemented') + } +} diff --git a/src/services/chain-adapter/evm/index.ts b/src/services/chain-adapter/evm/index.ts new file mode 100644 index 00000000..6359ec05 --- /dev/null +++ b/src/services/chain-adapter/evm/index.ts @@ -0,0 +1,6 @@ +export { EvmAdapter, createEvmAdapter } from './adapter' +export { EvmIdentityService } from './identity-service' +export { EvmAssetService } from './asset-service' +export { EvmTransactionService } from './transaction-service' +export { EvmChainService } from './chain-service' +export type * from './types' diff --git a/src/services/chain-adapter/evm/transaction-service.ts b/src/services/chain-adapter/evm/transaction-service.ts new file mode 100644 index 00000000..e634391f --- /dev/null +++ b/src/services/chain-adapter/evm/transaction-service.ts @@ -0,0 +1,204 @@ +/** + * EVM Transaction Service + * + * Handles transaction building, signing, and broadcasting for EVM chains. + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { + ITransactionService, + TransferParams, + UnsignedTransaction, + SignedTransaction, + TransactionHash, + TransactionStatus, + Transaction, + FeeEstimate, + Fee, +} from '../types' +import { Amount } from '@/types/amount' +import { ChainServiceError, ChainErrorCodes } from '../types' + +export class EvmTransactionService implements ITransactionService { + private readonly config: ChainConfig + private readonly apiUrl: string + private readonly apiPath: string + + constructor(config: ChainConfig) { + this.config = config + this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' + this.apiPath = config.api?.path ?? config.id + } + + private async fetch(endpoint: string, body?: unknown): Promise { + 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) + + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `HTTP ${response.status}: ${response.statusText}`, + ) + } + + 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', + ) + } + + return json.result as T + } + + async estimateFee(_params: TransferParams): Promise { + // Estimate gas for a simple transfer (21000 gas) + const gasLimit = 21000n + const gasPrice = 20000000000n // 20 Gwei default + + const fee = Amount.fromRaw((gasLimit * gasPrice).toString(), this.config.decimals, this.config.symbol) + + const feeObj: Fee = { + amount: fee, + estimatedTime: 15, // ~15 seconds + } + + return { + slow: { ...feeObj, estimatedTime: 60 }, + standard: feeObj, + fast: { ...feeObj, estimatedTime: 5 }, + } + } + + async buildTransaction(params: TransferParams): Promise { + // Build EVM transaction + return { + chainId: this.config.id, + data: { + from: params.from, + to: params.to, + value: params.amount.raw.toString(), + gasLimit: '21000', + }, + } + } + + async signTransaction( + _unsignedTx: UnsignedTransaction, + _privateKey: Uint8Array, + ): Promise { + // TODO: Implement EVM transaction signing using @noble/secp256k1 + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_SUPPORTED, + 'EVM transaction signing not yet implemented', + ) + } + + async broadcastTransaction(_signedTx: SignedTransaction): Promise { + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_SUPPORTED, + 'EVM transaction broadcast not yet implemented', + ) + } + + async getTransactionStatus(hash: TransactionHash): Promise { + try { + const result = await this.fetch<{ + status: string + confirmations: number + }>('/transaction/status', { hash }) + + return { + status: result.status === 'confirmed' ? 'confirmed' : 'pending', + confirmations: result.confirmations, + requiredConfirmations: 12, + } + } catch { + return { + status: 'pending', + confirmations: 0, + requiredConfirmations: 12, + } + } + } + + async getTransaction(hash: TransactionHash): Promise { + try { + const result = await this.fetch<{ + hash: string + from: string + to: string + value: string + gasUsed: string + gasPrice: string + status: string + timestamp: number + blockNumber: string + }>('/transaction', { hash }) + + return { + hash: result.hash, + from: result.from, + to: result.to, + amount: Amount.fromRaw(result.value, this.config.decimals, this.config.symbol), + fee: Amount.fromRaw( + (BigInt(result.gasUsed) * BigInt(result.gasPrice)).toString(), + this.config.decimals, + this.config.symbol, + ), + status: { + status: result.status === 'success' ? 'confirmed' : 'failed', + confirmations: 12, + requiredConfirmations: 12, + }, + timestamp: result.timestamp, + blockNumber: result.blockNumber ? BigInt(result.blockNumber) : undefined, + type: 'transfer', + } + } catch { + return null + } + } + + async getTransactionHistory(address: string, limit = 20): Promise { + try { + const result = await this.fetch>('/transactions', { address, limit }) + + return result.map((tx) => ({ + hash: tx.hash, + from: tx.from, + to: tx.to, + amount: Amount.fromRaw(tx.value, this.config.decimals, this.config.symbol), + fee: Amount.fromRaw( + (BigInt(tx.gasUsed) * BigInt(tx.gasPrice)).toString(), + this.config.decimals, + this.config.symbol, + ), + status: { + status: tx.status === 'success' ? 'confirmed' as const : 'failed' as const, + confirmations: 12, + requiredConfirmations: 12, + }, + timestamp: tx.timestamp, + blockNumber: tx.blockNumber ? BigInt(tx.blockNumber) : undefined, + type: 'transfer' as const, + })) + } catch { + return [] + } + } +} diff --git a/src/services/chain-adapter/evm/types.ts b/src/services/chain-adapter/evm/types.ts new file mode 100644 index 00000000..98cc6cba --- /dev/null +++ b/src/services/chain-adapter/evm/types.ts @@ -0,0 +1,34 @@ +/** + * EVM Chain Adapter Types + */ + +import type { ChainConfig } from '@/services/chain-config' + +export interface EvmChainConfig extends ChainConfig { + type: 'evm' + /** EVM Chain ID (e.g., 1 for Ethereum mainnet, 56 for BSC) */ + chainId?: number + /** RPC endpoint URL */ + rpcUrl?: string +} + +export interface EvmTransactionReceipt { + transactionHash: string + blockNumber: bigint + blockHash: string + status: 'success' | 'reverted' + gasUsed: bigint + effectiveGasPrice: bigint +} + +export interface EvmTransactionRequest { + from: string + to: string + value: bigint + data?: string + nonce?: number + gasLimit?: bigint + gasPrice?: bigint + maxFeePerGas?: bigint + maxPriorityFeePerGas?: bigint +} diff --git a/src/services/chain-adapter/index.ts b/src/services/chain-adapter/index.ts index 188e8f5e..6d8651d6 100644 --- a/src/services/chain-adapter/index.ts +++ b/src/services/chain-adapter/index.ts @@ -40,15 +40,18 @@ export { getAdapterRegistry, resetAdapterRegistry } from './registry' // Adapters export { BioforestAdapter, createBioforestAdapter } from './bioforest' +export { EvmAdapter, createEvmAdapter } from './evm' +export { Bip39Adapter, createBip39Adapter } from './bip39' // Setup function to register all adapters import { getAdapterRegistry } from './registry' import { createBioforestAdapter } from './bioforest' +import { createEvmAdapter } from './evm' +import { createBip39Adapter } from './bip39' export function setupAdapters(): void { const registry = getAdapterRegistry() registry.register('bioforest', createBioforestAdapter) - // TODO: Register other adapters - // registry.register('evm', createEvmAdapter) - // registry.register('bip39', createBip39Adapter) + registry.register('evm', createEvmAdapter) + registry.register('bip39', createBip39Adapter) } From 4d4bb27e148404e4809bf1da26a4cc995c65adcf Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 27 Dec 2025 21:19:57 +0800 Subject: [PATCH 2/8] docs: add testnet integration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new chapter: 08-测试篇/06-测试网络 - Document public testnets: Sepolia, BSC Testnet, Nile, Signet - Include RPC endpoints, faucets, and explorer links - Add testnet-chains.json config file - Update white-book index --- .../index.md" | 303 ++++++++++++++++++ .../index.md" | 1 + docs/white-book/index.md | 1 + public/configs/testnet-chains.json | 62 ++++ 4 files changed, 367 insertions(+) create mode 100644 "docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" create mode 100644 public/configs/testnet-chains.json diff --git "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" new file mode 100644 index 00000000..38e7ab23 --- /dev/null +++ "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" @@ -0,0 +1,303 @@ +# 测试网络接入指南 + +> 本文档介绍如何接入各链的公开测试网络进行开发和测试。 + +## 概述 + +测试网络(Testnet)是区块链主网的镜像环境,使用无价值的测试代币,适用于: + +- 开发阶段的功能验证 +- 转账流程的端到端测试 +- 新链适配器的集成测试 +- CI/CD 自动化测试 + +## 支持的测试网络 + +### EVM 链 + +#### Ethereum Sepolia + +Sepolia 是 Ethereum 官方推荐的测试网络,替代了已弃用的 Goerli。 + +| 配置项 | 值 | +|-------|-----| +| 网络名称 | Sepolia Testnet | +| Chain ID | 11155111 | +| 货币符号 | SepoliaETH | +| RPC 端点 | `https://rpc.sepolia.org` | +| 备用 RPC | `https://ethereum-sepolia-rpc.publicnode.com` | +| 区块浏览器 | https://sepolia.etherscan.io | +| 水龙头 | https://sepoliafaucet.com | + +```typescript +// chain-config 配置示例 +{ + id: 'ethereum-sepolia', + version: '1.0', + type: 'evm', + name: 'Ethereum Sepolia', + symbol: 'SepoliaETH', + decimals: 18, + api: { + url: 'https://rpc.sepolia.org', + path: 'eth' + }, + explorer: { + url: 'https://sepolia.etherscan.io', + queryTx: 'https://sepolia.etherscan.io/tx/:hash', + queryAddress: 'https://sepolia.etherscan.io/address/:address' + } +} +``` + +#### BSC Testnet + +| 配置项 | 值 | +|-------|-----| +| 网络名称 | BSC Testnet | +| Chain ID | 97 | +| 货币符号 | tBNB | +| RPC 端点 | `https://data-seed-prebsc-1-s1.binance.org:8545` | +| 备用 RPC | `https://bsc-testnet-rpc.publicnode.com` | +| 区块浏览器 | https://testnet.bscscan.com | +| 水龙头 | https://testnet.binance.org/faucet-smart | + +### Bitcoin + +#### Bitcoin Testnet4 + +Bitcoin Testnet4 是最新的测试网络,替代了经常被攻击的 Testnet3。 + +| 配置项 | 值 | +|-------|-----| +| 网络名称 | Bitcoin Testnet4 | +| 地址前缀 | tb1 (Bech32) / m/n (Legacy) | +| RPC 端点 | 需要自建节点或使用 Blockstream API | +| 区块浏览器 | https://mempool.space/testnet4 | +| 水龙头 | https://testnet4.anyone.eu.org | + +#### Bitcoin Signet + +Signet 是一个受控的测试网络,区块由授权签名者产生,更稳定。 + +| 配置项 | 值 | +|-------|-----| +| 网络名称 | Bitcoin Signet | +| 地址前缀 | tb1 (Bech32) | +| RPC 端点 | `https://mempool.space/signet/api` | +| 区块浏览器 | https://mempool.space/signet | +| 水龙头 | https://signetfaucet.com | + +### Tron + +#### Tron Nile Testnet + +Nile 是 Tron 官方维护的测试网络。 + +| 配置项 | 值 | +|-------|-----| +| 网络名称 | Tron Nile Testnet | +| 地址前缀 | T | +| Full Node | `https://nile.trongrid.io` | +| Solidity Node | `https://nile.trongrid.io` | +| Event Server | `https://event.nileex.io` | +| 区块浏览器 | https://nile.tronscan.org | +| 水龙头 | https://nileex.io/join/getJoinPage | + +```typescript +// chain-config 配置示例 +{ + id: 'tron-nile', + version: '1.0', + type: 'bip39', + name: 'Tron Nile Testnet', + symbol: 'TRX', + decimals: 6, + api: { + url: 'https://nile.trongrid.io', + path: 'tron' + }, + explorer: { + url: 'https://nile.tronscan.org', + queryTx: 'https://nile.tronscan.org/#/transaction/:hash', + queryAddress: 'https://nile.tronscan.org/#/address/:address' + } +} +``` + +#### Tron Shasta Testnet + +Shasta 是另一个常用的 Tron 测试网络。 + +| 配置项 | 值 | +|-------|-----| +| 网络名称 | Tron Shasta Testnet | +| Full Node | `https://api.shasta.trongrid.io` | +| 区块浏览器 | https://shasta.tronscan.org | +| 水龙头 | https://www.trongrid.io/faucet | + +## 配置测试网络 + +### 方式一:环境变量切换 + +```bash +# .env.development +VITE_NETWORK_MODE=testnet + +# .env.production +VITE_NETWORK_MODE=mainnet +``` + +### 方式二:运行时配置 + +```typescript +// src/services/chain-config/testnet-configs.ts +import type { ChainConfig } from './types' + +export const TESTNET_CONFIGS: ChainConfig[] = [ + { + id: 'ethereum-sepolia', + version: '1.0', + type: 'evm', + name: 'Ethereum Sepolia', + symbol: 'SepoliaETH', + decimals: 18, + api: { url: 'https://rpc.sepolia.org', path: 'eth' }, + explorer: { url: 'https://sepolia.etherscan.io' }, + enabled: true, + source: 'default', + }, + { + id: 'bsc-testnet', + version: '1.0', + type: 'evm', + name: 'BSC Testnet', + symbol: 'tBNB', + decimals: 18, + api: { url: 'https://bsc-testnet-rpc.publicnode.com', path: 'bnb' }, + explorer: { url: 'https://testnet.bscscan.com' }, + enabled: true, + source: 'default', + }, + { + id: 'tron-nile', + version: '1.0', + type: 'bip39', + name: 'Tron Nile', + symbol: 'TRX', + decimals: 6, + api: { url: 'https://nile.trongrid.io', path: 'tron' }, + explorer: { url: 'https://nile.tronscan.org' }, + enabled: true, + source: 'default', + }, +] +``` + +### 方式三:通过订阅 URL + +创建一个测试网络的链配置订阅文件: + +```json +// https://example.com/testnet-chains.json +[ + { + "id": "ethereum-sepolia", + "version": "1.0", + "type": "evm", + "name": "Ethereum Sepolia", + "symbol": "SepoliaETH", + "decimals": 18, + "api": { "url": "https://rpc.sepolia.org", "path": "eth" } + } +] +``` + +## 测试水龙头使用 + +### 获取测试代币的步骤 + +1. **生成测试钱包地址** + - 使用应用的钱包创建功能 + - 或使用 `deriveAddressesForChains` 派生地址 + +2. **访问水龙头网站** + - 输入钱包地址 + - 完成人机验证 + - 等待测试代币到账 + +3. **验证余额** + ```typescript + const adapter = registry.getAdapter('ethereum-sepolia') + const balance = await adapter.asset.getNativeBalance(address) + console.log(`Balance: ${balance.amount.toFormatted()} ${balance.symbol}`) + ``` + +### 自动化获取测试代币 + +对于 CI/CD 环境,可以使用以下方式: + +```typescript +// scripts/faucet.ts +async function requestTestTokens(chain: string, address: string) { + switch (chain) { + case 'ethereum-sepolia': + // Alchemy Sepolia faucet API (需要 API key) + await fetch('https://api.alchemy.com/v2/faucet', { + method: 'POST', + body: JSON.stringify({ network: 'sepolia', address }), + }) + break + case 'tron-nile': + // Tron Nile faucet (每日限额) + await fetch('https://nileex.io/api/v1/faucet', { + method: 'POST', + body: JSON.stringify({ address, amount: 1000 }), + }) + break + } +} +``` + +## 测试网络使用注意事项 + +1. **测试代币无价值** + - 测试网代币仅用于测试,不具有真实价值 + - 不要在测试网地址存入主网代币 + +2. **网络不稳定** + - 测试网可能偶尔重置或中断 + - 建议准备多个测试网备选 + +3. **水龙头限制** + - 大多数水龙头有每日/每地址限额 + - 建议提前储备足够的测试代币 + +4. **地址格式一致** + - EVM 测试网地址与主网格式相同 + - Bitcoin 测试网使用 `tb1` 前缀 + - Tron 测试网使用 `T` 前缀(与主网相同) + +## 开发模式集成 + +将测试网络集成到开发模式: + +```typescript +// src/hooks/use-send.ts +const { useMock = import.meta.env.DEV } = options + +// 开发模式下自动使用测试网配置 +const chainConfig = useMemo(() => { + if (import.meta.env.DEV && import.meta.env.VITE_USE_TESTNET) { + return getTestnetConfig(originalConfig.id) + } + return originalConfig +}, [originalConfig]) +``` + +## 相关资源 + +- [Ethereum Sepolia 文档](https://ethereum.org/en/developers/docs/networks/#sepolia) +- [BSC Testnet 文档](https://docs.bnbchain.org/docs/bsc-testnet/) +- [Bitcoin Signet](https://en.bitcoin.it/wiki/Signet) +- [Tron Nile 文档](https://developers.tron.network/docs/testnet) diff --git "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/index.md" "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/index.md" index bd1869e4..8f96eceb 100644 --- "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/index.md" +++ "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/index.md" @@ -10,6 +10,7 @@ - [第二十四章:Vitest 配置](./02-Vitest配置/) - 单元测试、组件测试 - [第二十五章:Playwright 配置](./03-Playwright配置/) - E2E、视觉回归 - [E2E 测试最佳实践](./03-Playwright配置/e2e-best-practices.md) - **必读:国际化项目的测试规范** +- [第二十六章:测试网络](./06-测试网络/) - **新增:接入公开测试网络进行真实交易测试** --- diff --git a/docs/white-book/index.md b/docs/white-book/index.md index 963dfc40..1767286b 100644 --- a/docs/white-book/index.md +++ b/docs/white-book/index.md @@ -105,6 +105,7 @@ | [03 Playwright 配置](./08-测试篇/03-Playwright配置/) | E2E、截图、视觉回归 | | [04 性能测试](./08-测试篇/04-性能测试/) | 性能指标、回归检测、自动化 | | [05 安全测试](./08-测试篇/05-安全测试/) | SAST、SCA、渗透测试 | +| [06 测试网络](./08-测试篇/06-测试网络/) | Sepolia、BSC Testnet、Nile、Signet | ### [第九篇:部署篇](./09-部署篇/) diff --git a/public/configs/testnet-chains.json b/public/configs/testnet-chains.json new file mode 100644 index 00000000..383c901a --- /dev/null +++ b/public/configs/testnet-chains.json @@ -0,0 +1,62 @@ +[ + { + "id": "ethereum-sepolia", + "version": "1.0", + "type": "evm", + "name": "Ethereum Sepolia", + "symbol": "SepoliaETH", + "icon": "../icons/ethereum/chain.svg", + "decimals": 18, + "api": { "url": "https://rpc.sepolia.org", "path": "eth" }, + "explorer": { + "url": "https://sepolia.etherscan.io", + "queryTx": "https://sepolia.etherscan.io/tx/:hash", + "queryAddress": "https://sepolia.etherscan.io/address/:address" + } + }, + { + "id": "bsc-testnet", + "version": "1.0", + "type": "evm", + "name": "BSC Testnet", + "symbol": "tBNB", + "icon": "../icons/binance/chain.svg", + "decimals": 18, + "api": { "url": "https://bsc-testnet-rpc.publicnode.com", "path": "bnb" }, + "explorer": { + "url": "https://testnet.bscscan.com", + "queryTx": "https://testnet.bscscan.com/tx/:hash", + "queryAddress": "https://testnet.bscscan.com/address/:address" + } + }, + { + "id": "tron-nile", + "version": "1.0", + "type": "bip39", + "name": "Tron Nile Testnet", + "symbol": "TRX", + "icon": "../icons/tron/chain.svg", + "decimals": 6, + "api": { "url": "https://nile.trongrid.io", "path": "tron" }, + "explorer": { + "url": "https://nile.tronscan.org", + "queryTx": "https://nile.tronscan.org/#/transaction/:hash", + "queryAddress": "https://nile.tronscan.org/#/address/:address" + } + }, + { + "id": "bitcoin-signet", + "version": "1.0", + "type": "bip39", + "name": "Bitcoin Signet", + "symbol": "sBTC", + "icon": "../icons/bitcoin/chain.svg", + "decimals": 8, + "api": { "url": "https://mempool.space/signet/api", "path": "btc" }, + "explorer": { + "url": "https://mempool.space/signet", + "queryTx": "https://mempool.space/signet/tx/:hash", + "queryAddress": "https://mempool.space/signet/address/:address" + } + } +] From 90c2b96abe845a821cfaa82d329c8c7647717fbb Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 27 Dec 2025 21:31:23 +0800 Subject: [PATCH 3/8] feat(chain-adapter): complete EVM transaction signing with PublicNode RPC - Use standard Ethereum JSON-RPC instead of custom REST API - Implement proper secp256k1 signature parsing with recovery bit - Add PublicNode as unified RPC provider for all chains (ETH/BSC/Tron/BTC) - Update testnet documentation with PublicNode endpoints --- .../index.md" | 42 ++- .../chain-adapter/evm/transaction-service.ts | 340 ++++++++++++------ 2 files changed, 259 insertions(+), 123 deletions(-) diff --git "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" index 38e7ab23..c4d834e9 100644 --- "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" +++ "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" @@ -1,31 +1,47 @@ -# 测试网络接入指南 +# 公共 RPC 与测试网络接入指南 -> 本文档介绍如何接入各链的公开测试网络进行开发和测试。 +> 本文档介绍如何接入公共 RPC 节点进行开发和测试。 ## 概述 -测试网络(Testnet)是区块链主网的镜像环境,使用无价值的测试代币,适用于: +我们使用 **PublicNode** 作为统一的公共 RPC 提供商,它提供: -- 开发阶段的功能验证 -- 转账流程的端到端测试 -- 新链适配器的集成测试 -- CI/CD 自动化测试 +- **免费**:无需 API Key,无请求限制 +- **全链支持**:Ethereum、BSC、Tron、Bitcoin 等 +- **主网+测试网**:同一提供商,API 一致 -## 支持的测试网络 +## PublicNode 端点汇总 -### EVM 链 +### 主网 -#### Ethereum Sepolia +| 链 | RPC 端点 | 协议 | +|---|---------|------| +| Ethereum | `https://ethereum-rpc.publicnode.com` | JSON-RPC | +| BSC | `https://bsc-rpc.publicnode.com` | JSON-RPC | +| Tron | `https://tron-rpc.publicnode.com` | Tron HTTP API | +| Bitcoin | `https://bitcoin-rpc.publicnode.com` | Bitcoin JSON-RPC | -Sepolia 是 Ethereum 官方推荐的测试网络,替代了已弃用的 Goerli。 +### 测试网 + +| 链 | RPC 端点 | 协议 | +|---|---------|------| +| Ethereum Sepolia | `https://ethereum-sepolia-rpc.publicnode.com` | JSON-RPC | +| BSC Testnet | `https://bsc-testnet-rpc.publicnode.com` | JSON-RPC | +| Tron Nile | `https://nile.trongrid.io` | Tron HTTP API | +| Bitcoin Signet | `https://mempool.space/signet/api` | REST API | + +## 测试网络详情 + +### Ethereum Sepolia + +Sepolia 是 Ethereum 官方推荐的测试网络。 | 配置项 | 值 | |-------|-----| | 网络名称 | Sepolia Testnet | | Chain ID | 11155111 | | 货币符号 | SepoliaETH | -| RPC 端点 | `https://rpc.sepolia.org` | -| 备用 RPC | `https://ethereum-sepolia-rpc.publicnode.com` | +| RPC 端点 | `https://ethereum-sepolia-rpc.publicnode.com` | | 区块浏览器 | https://sepolia.etherscan.io | | 水龙头 | https://sepoliafaucet.com | diff --git a/src/services/chain-adapter/evm/transaction-service.ts b/src/services/chain-adapter/evm/transaction-service.ts index e634391f..9485cb79 100644 --- a/src/services/chain-adapter/evm/transaction-service.ts +++ b/src/services/chain-adapter/evm/transaction-service.ts @@ -2,6 +2,7 @@ * EVM Transaction Service * * Handles transaction building, signing, and broadcasting for EVM chains. + * Uses standard Ethereum JSON-RPC API (compatible with PublicNode, Infura, etc.) */ import type { ChainConfig } from '@/services/chain-config' @@ -14,28 +15,52 @@ import type { TransactionStatus, Transaction, FeeEstimate, - Fee, } from '../types' import { Amount } from '@/types/amount' import { ChainServiceError, ChainErrorCodes } from '../types' +import { secp256k1 } from '@noble/curves/secp256k1.js' +import { keccak_256 } from '@noble/hashes/sha3.js' +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js' + +/** EVM Chain IDs */ +const EVM_CHAIN_IDS: Record = { + 'ethereum': 1, + 'ethereum-sepolia': 11155111, + 'binance': 56, + 'bsc-testnet': 97, +} + +/** Default RPC endpoints (PublicNode - free, no API key required) */ +const DEFAULT_RPC_URLS: Record = { + 'ethereum': 'https://ethereum-rpc.publicnode.com', + 'ethereum-sepolia': 'https://ethereum-sepolia-rpc.publicnode.com', + 'binance': 'https://bsc-rpc.publicnode.com', + 'bsc-testnet': 'https://bsc-testnet-rpc.publicnode.com', +} export class EvmTransactionService implements ITransactionService { private readonly config: ChainConfig - private readonly apiUrl: string - private readonly apiPath: string + private readonly rpcUrl: string + private readonly evmChainId: number constructor(config: ChainConfig) { this.config = config - this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' - this.apiPath = config.api?.path ?? config.id + this.rpcUrl = config.api?.url ?? DEFAULT_RPC_URLS[config.id] ?? 'https://ethereum-rpc.publicnode.com' + this.evmChainId = EVM_CHAIN_IDS[config.id] ?? 1 } - private async fetch(endpoint: string, body?: unknown): Promise { - 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) + /** Make a JSON-RPC call */ + private async rpc(method: string, params: unknown[] = []): Promise { + const response = await fetch(this.rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: Date.now(), + method, + params, + }), + }) if (!response.ok) { throw new ChainServiceError( @@ -44,11 +69,12 @@ export class EvmTransactionService implements ITransactionService { ) } - const json = await response.json() as { success: boolean; result?: T; error?: { message: string } } - if (!json.success) { + const json = await response.json() as { result?: T; error?: { code: number; message: string } } + if (json.error) { throw new ChainServiceError( ChainErrorCodes.NETWORK_ERROR, - json.error?.message ?? 'API request failed', + json.error.message, + { code: json.error.code }, ) } @@ -56,149 +82,243 @@ export class EvmTransactionService implements ITransactionService { } async estimateFee(_params: TransferParams): Promise { - // Estimate gas for a simple transfer (21000 gas) + // Get current gas price from network + const gasPriceHex = await this.rpc('eth_gasPrice') + const gasPrice = BigInt(gasPriceHex) + + // Estimate gas (21000 for simple ETH transfer) const gasLimit = 21000n - const gasPrice = 20000000000n // 20 Gwei default + const baseFee = gasLimit * gasPrice - const fee = Amount.fromRaw((gasLimit * gasPrice).toString(), this.config.decimals, this.config.symbol) - - const feeObj: Fee = { - amount: fee, - estimatedTime: 15, // ~15 seconds - } + // Calculate slow/standard/fast with multipliers + const slow = Amount.fromRaw((baseFee * 80n / 100n).toString(), this.config.decimals, this.config.symbol) + const standard = Amount.fromRaw(baseFee.toString(), this.config.decimals, this.config.symbol) + const fast = Amount.fromRaw((baseFee * 120n / 100n).toString(), this.config.decimals, this.config.symbol) return { - slow: { ...feeObj, estimatedTime: 60 }, - standard: feeObj, - fast: { ...feeObj, estimatedTime: 5 }, + slow: { amount: slow, estimatedTime: 60 }, + standard: { amount: standard, estimatedTime: 15 }, + fast: { amount: fast, estimatedTime: 5 }, } } async buildTransaction(params: TransferParams): Promise { - // Build EVM transaction + // Get nonce for the sender + const nonceHex = await this.rpc('eth_getTransactionCount', [params.from, 'pending']) + const nonce = parseInt(nonceHex, 16) + + // Get current gas price + const gasPriceHex = await this.rpc('eth_gasPrice') + return { chainId: this.config.id, data: { - from: params.from, + nonce, + gasPrice: gasPriceHex, + gasLimit: '0x5208', // 21000 in hex to: params.to, - value: params.amount.raw.toString(), - gasLimit: '21000', + value: '0x' + params.amount.raw.toString(16), + data: '0x', + chainId: this.evmChainId, }, } } async signTransaction( - _unsignedTx: UnsignedTransaction, - _privateKey: Uint8Array, + unsignedTx: UnsignedTransaction, + privateKey: Uint8Array, ): Promise { - // TODO: Implement EVM transaction signing using @noble/secp256k1 - throw new ChainServiceError( - ChainErrorCodes.CHAIN_NOT_SUPPORTED, - 'EVM transaction signing not yet implemented', - ) + const txData = unsignedTx.data as { + nonce: number + gasPrice: string + gasLimit: string + to: string + value: string + data: string + chainId: number + } + + // RLP encode the transaction for signing (EIP-155) + const rawTx = this.rlpEncode([ + this.toRlpHex(txData.nonce), + txData.gasPrice, + txData.gasLimit, + txData.to.toLowerCase(), + txData.value, + txData.data, + this.toRlpHex(txData.chainId), + '0x', + '0x', + ]) + + // Hash and sign (use recovered format to get recovery bit) + const msgHash = keccak_256(hexToBytes(rawTx.slice(2))) + const sigBytes = secp256k1.sign(msgHash, privateKey, { prehash: false, format: 'recovered' }) + + // Parse signature: recovered format is 65 bytes (r[32] + s[32] + recovery[1]) + const r = BigInt('0x' + bytesToHex(sigBytes.slice(0, 32))) + const s = BigInt('0x' + bytesToHex(sigBytes.slice(32, 64))) + const recovery = sigBytes[64]! + + // Calculate v value (EIP-155) + const v = txData.chainId * 2 + 35 + recovery + + // Get r and s as hex strings + const rHex = r.toString(16).padStart(64, '0') + const sHex = s.toString(16).padStart(64, '0') + + // RLP encode signed transaction + const signedRaw = this.rlpEncode([ + this.toRlpHex(txData.nonce), + txData.gasPrice, + txData.gasLimit, + txData.to.toLowerCase(), + txData.value, + txData.data, + this.toRlpHex(v), + '0x' + rHex, + '0x' + sHex, + ]) + + return { + chainId: this.config.id, + data: signedRaw, + signature: '0x' + rHex + sHex, + } } - async broadcastTransaction(_signedTx: SignedTransaction): Promise { - throw new ChainServiceError( - ChainErrorCodes.CHAIN_NOT_SUPPORTED, - 'EVM transaction broadcast not yet implemented', - ) + async broadcastTransaction(signedTx: SignedTransaction): Promise { + const rawTx = signedTx.data as string + const txHash = await this.rpc('eth_sendRawTransaction', [rawTx]) + return txHash } - async getTransactionStatus(hash: TransactionHash): Promise { - try { - const result = await this.fetch<{ - status: string - confirmations: number - }>('/transaction/status', { hash }) + /** Convert number to RLP hex format */ + private toRlpHex(n: number): string { + if (n === 0) return '0x' + return '0x' + n.toString(16) + } - return { - status: result.status === 'confirmed' ? 'confirmed' : 'pending', - confirmations: result.confirmations, - requiredConfirmations: 12, + /** Simple RLP encoding for transaction */ + private rlpEncode(items: string[]): string { + const encoded = items.map(item => { + if (item === '0x' || item === '') { + return new Uint8Array([0x80]) } - } catch { - return { - status: 'pending', - confirmations: 0, - requiredConfirmations: 12, + const bytes = hexToBytes(item.startsWith('0x') ? item.slice(2) : item) + if (bytes.length === 1 && bytes[0]! < 0x80) { + return bytes + } + if (bytes.length <= 55) { + return new Uint8Array([0x80 + bytes.length, ...bytes]) } + const lenBytes = this.numberToBytes(bytes.length) + return new Uint8Array([0xb7 + lenBytes.length, ...lenBytes, ...bytes]) + }) + + const totalLen = encoded.reduce((sum, e) => sum + e.length, 0) + let prefix: Uint8Array + if (totalLen <= 55) { + prefix = new Uint8Array([0xc0 + totalLen]) + } else { + const lenBytes = this.numberToBytes(totalLen) + prefix = new Uint8Array([0xf7 + lenBytes.length, ...lenBytes]) + } + + const result = new Uint8Array(prefix.length + totalLen) + result.set(prefix) + let offset = prefix.length + for (const e of encoded) { + result.set(e, offset) + offset += e.length } + return '0x' + bytesToHex(result) } - async getTransaction(hash: TransactionHash): Promise { + private numberToBytes(n: number): Uint8Array { + const hex = n.toString(16) + const padded = hex.length % 2 ? '0' + hex : hex + return hexToBytes(padded) + } + + async getTransactionStatus(hash: TransactionHash): Promise { try { - const result = await this.fetch<{ - hash: string - from: string - to: string - value: string - gasUsed: string - gasPrice: string + const receipt = await this.rpc<{ status: string - timestamp: number blockNumber: string - }>('/transaction', { hash }) + } | null>('eth_getTransactionReceipt', [hash]) + + if (!receipt) { + return { status: 'pending', confirmations: 0, requiredConfirmations: 12 } + } + + const currentBlock = await this.rpc('eth_blockNumber') + const confirmations = parseInt(currentBlock, 16) - parseInt(receipt.blockNumber, 16) return { - hash: result.hash, - from: result.from, - to: result.to, - amount: Amount.fromRaw(result.value, this.config.decimals, this.config.symbol), - fee: Amount.fromRaw( - (BigInt(result.gasUsed) * BigInt(result.gasPrice)).toString(), - this.config.decimals, - this.config.symbol, - ), - status: { - status: result.status === 'success' ? 'confirmed' : 'failed', - confirmations: 12, - requiredConfirmations: 12, - }, - timestamp: result.timestamp, - blockNumber: result.blockNumber ? BigInt(result.blockNumber) : undefined, - type: 'transfer', + status: confirmations >= 12 ? 'confirmed' : 'confirming', + confirmations: Math.max(0, confirmations), + requiredConfirmations: 12, } } catch { - return null + return { status: 'pending', confirmations: 0, requiredConfirmations: 12 } } } - async getTransactionHistory(address: string, limit = 20): Promise { + async getTransaction(hash: TransactionHash): Promise { try { - const result = await this.fetch>('/transactions', { address, limit }) + const [tx, receipt] = await Promise.all([ + this.rpc<{ + hash: string + from: string + to: string + value: string + gasPrice: string + blockNumber: string | null + } | null>('eth_getTransactionByHash', [hash]), + this.rpc<{ + status: string + gasUsed: string + blockNumber: string + } | null>('eth_getTransactionReceipt', [hash]), + ]) + + if (!tx) return null + + const block = tx.blockNumber + ? await this.rpc<{ timestamp: string }>('eth_getBlockByNumber', [tx.blockNumber, false]) + : null - return result.map((tx) => ({ + return { hash: tx.hash, from: tx.from, - to: tx.to, - amount: Amount.fromRaw(tx.value, this.config.decimals, this.config.symbol), - fee: Amount.fromRaw( - (BigInt(tx.gasUsed) * BigInt(tx.gasPrice)).toString(), - this.config.decimals, - this.config.symbol, - ), + to: tx.to ?? '', + amount: Amount.fromRaw(BigInt(tx.value).toString(), this.config.decimals, this.config.symbol), + fee: receipt + ? Amount.fromRaw( + (BigInt(receipt.gasUsed) * BigInt(tx.gasPrice)).toString(), + this.config.decimals, + this.config.symbol, + ) + : Amount.fromRaw('0', this.config.decimals, this.config.symbol), status: { - status: tx.status === 'success' ? 'confirmed' as const : 'failed' as const, - confirmations: 12, + status: receipt?.status === '0x1' ? 'confirmed' : receipt ? 'failed' : 'pending', + confirmations: receipt ? 12 : 0, requiredConfirmations: 12, }, - timestamp: tx.timestamp, + timestamp: block ? parseInt(block.timestamp, 16) * 1000 : Date.now(), blockNumber: tx.blockNumber ? BigInt(tx.blockNumber) : undefined, - type: 'transfer' as const, - })) + type: 'transfer', + } } catch { - return [] + return null } } + + async getTransactionHistory(_address: string, _limit = 20): Promise { + // Note: Standard JSON-RPC doesn't support transaction history queries + // This would require an indexer service like Etherscan API + // For now, return empty array - can be extended with Etherscan/BlockScout API + return [] + } } From 20f814de15e81bd3dbf04d913894f1d56e8984cf Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 27 Dec 2025 21:47:15 +0800 Subject: [PATCH 4/8] feat(chain-adapter): add Tron adapter with PublicNode API - Add dedicated Tron chain type and adapter - Implement TronIdentityService with address derivation and signing - Implement TronAssetService for TRX balance queries - Implement TronChainService for block info and health checks - Implement TronTransactionService for transaction building/signing/broadcasting - Update ChainConfigTypeSchema to include 'tron' type - Update schema.test.ts for new chain type distribution --- public/configs/default-chains.json | 2 +- src/services/chain-adapter/index.ts | 10 +- src/services/chain-adapter/tron/adapter.ts | 45 ++++ .../chain-adapter/tron/asset-service.ts | 93 +++++++ .../chain-adapter/tron/chain-service.ts | 101 ++++++++ .../chain-adapter/tron/identity-service.ts | 164 ++++++++++++ src/services/chain-adapter/tron/index.ts | 10 + .../chain-adapter/tron/transaction-service.ts | 238 ++++++++++++++++++ src/services/chain-adapter/tron/types.ts | 79 ++++++ src/services/chain-adapter/types.ts | 3 + .../chain-config/__tests__/schema.test.ts | 4 +- src/services/chain-config/index.ts | 3 +- src/services/chain-config/schema.ts | 2 +- 13 files changed, 749 insertions(+), 5 deletions(-) create mode 100644 src/services/chain-adapter/tron/adapter.ts create mode 100644 src/services/chain-adapter/tron/asset-service.ts create mode 100644 src/services/chain-adapter/tron/chain-service.ts create mode 100644 src/services/chain-adapter/tron/identity-service.ts create mode 100644 src/services/chain-adapter/tron/index.ts create mode 100644 src/services/chain-adapter/tron/transaction-service.ts create mode 100644 src/services/chain-adapter/tron/types.ts diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index 629c9485..857c68eb 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -158,7 +158,7 @@ { "id": "tron", "version": "1.0", - "type": "bip39", + "type": "tron", "name": "Tron", "symbol": "TRX", "icon": "../icons/tron/chain.svg", diff --git a/src/services/chain-adapter/index.ts b/src/services/chain-adapter/index.ts index 6d8651d6..e2aad493 100644 --- a/src/services/chain-adapter/index.ts +++ b/src/services/chain-adapter/index.ts @@ -42,16 +42,24 @@ export { getAdapterRegistry, resetAdapterRegistry } from './registry' export { BioforestAdapter, createBioforestAdapter } from './bioforest' export { EvmAdapter, createEvmAdapter } from './evm' export { Bip39Adapter, createBip39Adapter } from './bip39' +export { TronAdapter } from './tron' // Setup function to register all adapters import { getAdapterRegistry } from './registry' import { createBioforestAdapter } from './bioforest' import { createEvmAdapter } from './evm' import { createBip39Adapter } from './bip39' +import { TronAdapter } from './tron' +import type { ChainConfig } from '@/services/chain-config' + +function createTronAdapter(config: ChainConfig) { + return new TronAdapter(config) +} export function setupAdapters(): void { const registry = getAdapterRegistry() registry.register('bioforest', createBioforestAdapter) registry.register('evm', createEvmAdapter) - registry.register('bip39', createBip39Adapter) + registry.register('tron', createTronAdapter) + registry.register('bip39', createBip39Adapter) // fallback for bitcoin } diff --git a/src/services/chain-adapter/tron/adapter.ts b/src/services/chain-adapter/tron/adapter.ts new file mode 100644 index 00000000..8575fec0 --- /dev/null +++ b/src/services/chain-adapter/tron/adapter.ts @@ -0,0 +1,45 @@ +/** + * Tron Chain Adapter + * + * Full adapter for Tron network using PublicNode HTTP API + */ + +import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import type { IChainAdapter, IStakingService } from '../types' +import { TronIdentityService } from './identity-service' +import { TronAssetService } from './asset-service' +import { TronChainService } from './chain-service' +import { TronTransactionService } from './transaction-service' + +export class TronAdapter implements IChainAdapter { + readonly config: ChainConfig + readonly identity: TronIdentityService + readonly asset: TronAssetService + readonly chain: TronChainService + readonly transaction: TronTransactionService + readonly staking: IStakingService | null = null + + constructor(config: ChainConfig) { + this.config = config + this.identity = new TronIdentityService(config) + this.asset = new TronAssetService(config) + this.chain = new TronChainService(config) + this.transaction = new TronTransactionService(config) + } + + get chainId(): string { + return this.config.id + } + + get chainType(): ChainConfigType { + return 'tron' + } + + async initialize(_config: ChainConfig): Promise { + // No async initialization needed + } + + dispose(): void { + // No cleanup needed + } +} diff --git a/src/services/chain-adapter/tron/asset-service.ts b/src/services/chain-adapter/tron/asset-service.ts new file mode 100644 index 00000000..10380398 --- /dev/null +++ b/src/services/chain-adapter/tron/asset-service.ts @@ -0,0 +1,93 @@ +/** + * Tron Asset Service + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { IAssetService, Balance, TokenMetadata, Address } from '../types' +import { Amount } from '@/types/amount' +import { ChainServiceError, ChainErrorCodes } from '../types' +import type { TronAccount } from './types' + +/** Default Tron RPC endpoints */ +const DEFAULT_RPC_URLS: Record = { + 'tron': 'https://tron-rpc.publicnode.com', + 'tron-nile': 'https://nile.trongrid.io', + 'tron-shasta': 'https://api.shasta.trongrid.io', +} + +export class TronAssetService implements IAssetService { + private readonly config: ChainConfig + private readonly rpcUrl: string + + constructor(config: ChainConfig) { + this.config = config + this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['tron']! + } + + private async api(endpoint: string, body?: unknown): Promise { + const url = `${this.rpcUrl}${endpoint}` + const init: RequestInit = body + ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : { method: 'GET' } + + const response = await fetch(url, init) + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `Tron API error: ${response.status}`, + ) + } + + return response.json() as Promise + } + + async getNativeBalance(address: Address): Promise { + try { + const account = await this.api>('/wallet/getaccount', { + address, + visible: true, + }) + + // Empty object means account doesn't exist yet (0 balance) + if (!account || !('balance' in account)) { + return { + amount: Amount.fromRaw('0', this.config.decimals, this.config.symbol), + symbol: this.config.symbol, + } + } + + return { + amount: Amount.fromRaw(account.balance.toString(), this.config.decimals, this.config.symbol), + symbol: this.config.symbol, + } + } catch { + return { + amount: Amount.fromRaw('0', this.config.decimals, this.config.symbol), + symbol: this.config.symbol, + } + } + } + + async getTokenBalance(_address: Address, _tokenAddress: Address): Promise { + // TRC20 token balance requires contract calls - not implemented + return { + amount: Amount.fromRaw('0', 18, 'TOKEN'), + symbol: 'TOKEN', + } + } + + async getTokenBalances(_address: Address): Promise { + // Would require TronGrid API for full token list + return [] + } + + async getTokenMetadata(_tokenAddress: Address): Promise { + // TRC20 token metadata requires contract calls + return { + address: _tokenAddress, + name: 'Unknown', + symbol: 'UNKNOWN', + decimals: 18, + } + } +} diff --git a/src/services/chain-adapter/tron/chain-service.ts b/src/services/chain-adapter/tron/chain-service.ts new file mode 100644 index 00000000..2a6f9c4c --- /dev/null +++ b/src/services/chain-adapter/tron/chain-service.ts @@ -0,0 +1,101 @@ +/** + * Tron Chain Service + */ + +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' +import type { TronBlock, TronAccountResource } from './types' + +/** Default Tron RPC endpoints */ +const DEFAULT_RPC_URLS: Record = { + 'tron': 'https://tron-rpc.publicnode.com', + 'tron-nile': 'https://nile.trongrid.io', + 'tron-shasta': 'https://api.shasta.trongrid.io', +} + +export class TronChainService implements IChainService { + private readonly config: ChainConfig + private readonly rpcUrl: string + + constructor(config: ChainConfig) { + this.config = config + this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['tron']! + } + + private async api(endpoint: string, body?: unknown): Promise { + const url = `${this.rpcUrl}${endpoint}` + const init: RequestInit = body + ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : { method: 'GET' } + + const response = await fetch(url, init) + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `Tron API error: ${response.status}`, + ) + } + + return response.json() as Promise + } + + getChainInfo(): ChainInfo { + return { + chainId: this.config.id, + name: this.config.name, + symbol: this.config.symbol, + decimals: this.config.decimals, + blockTime: 3, + confirmations: 19, + explorerUrl: this.config.explorer?.url, + } + } + + async getBlockHeight(): Promise { + const block = await this.api('/wallet/getnowblock') + return BigInt(block.block_header.raw_data.number) + } + + async getGasPrice(): Promise { + // Tron uses bandwidth/energy, not gas + // 1000 SUN per bandwidth unit + const price = Amount.fromRaw('1000', this.config.decimals, this.config.symbol) + return { + slow: price, + standard: price, + fast: price, + lastUpdated: Date.now(), + } + } + + async healthCheck(): Promise { + const start = Date.now() + try { + const block = await this.api('/wallet/getnowblock') + return { + isHealthy: true, + latency: Date.now() - start, + blockHeight: BigInt(block.block_header.raw_data.number), + isSyncing: false, + lastUpdated: Date.now(), + } + } catch { + return { + isHealthy: false, + latency: Date.now() - start, + blockHeight: 0n, + isSyncing: false, + lastUpdated: Date.now(), + } + } + } + + async getAccountResources(address: string): Promise { + return this.api('/wallet/getaccountresource', { + address, + visible: true, + }) + } +} diff --git a/src/services/chain-adapter/tron/identity-service.ts b/src/services/chain-adapter/tron/identity-service.ts new file mode 100644 index 00000000..e6a3880c --- /dev/null +++ b/src/services/chain-adapter/tron/identity-service.ts @@ -0,0 +1,164 @@ +/** + * Tron Identity Service + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { IIdentityService, Address, Signature } from '../types' +import { sha256 } from '@noble/hashes/sha2.js' +import { secp256k1 } from '@noble/curves/secp256k1.js' +import { keccak_256 } from '@noble/hashes/sha3.js' +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js' +import { HDKey } from '@scure/bip32' + +/** Base58 alphabet used by Tron */ +const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + +export class TronIdentityService implements IIdentityService { + constructor(_config: ChainConfig) {} + + async deriveAddress(seed: Uint8Array, index = 0): Promise
{ + // Tron uses BIP44 path: m/44'/195'/0'/0/index + const hdKey = HDKey.fromMasterSeed(seed) + const derived = hdKey.derive(`m/44'/195'/0'/0/${index}`) + + if (!derived.privateKey) { + throw new Error('Failed to derive private key') + } + + // Get public key and derive address + const pubKey = secp256k1.getPublicKey(derived.privateKey, false) + const pubKeyHash = keccak_256(pubKey.slice(1)) + const addressBytes = pubKeyHash.slice(-20) + + // Add Tron prefix (0x41) and encode to base58check + const payload = new Uint8Array([0x41, ...addressBytes]) + const checksum = sha256(sha256(payload)).slice(0, 4) + const full = new Uint8Array([...payload, ...checksum]) + + return this.encodeBase58(full) + } + + async deriveAddresses(seed: Uint8Array, startIndex: number, count: number): Promise { + const addresses: Address[] = [] + for (let i = 0; i < count; i++) { + addresses.push(await this.deriveAddress(seed, startIndex + i)) + } + return addresses + } + + isValidAddress(address: string): boolean { + // Tron addresses start with 'T' and are 34 characters + if (!address.startsWith('T') || address.length !== 34) { + return false + } + + // Validate base58 characters + for (const char of address) { + if (!ALPHABET.includes(char)) { + return false + } + } + + // Validate checksum + try { + const decoded = this.decodeBase58(address) + if (decoded.length !== 25) return false + + const payload = decoded.slice(0, 21) + const checksum = decoded.slice(21) + const hash = sha256(sha256(payload)) + + return checksum.every((byte, i) => byte === hash[i]) + } catch { + return false + } + } + + private decodeBase58(str: string): Uint8Array { + let num = BigInt(0) + for (const char of str) { + const index = ALPHABET.indexOf(char) + if (index === -1) throw new Error(`Invalid base58 character: ${char}`) + num = num * 58n + BigInt(index) + } + + const hex = num.toString(16).padStart(50, '0') + const bytes = new Uint8Array(25) + for (let i = 0; i < 25; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return bytes + } + + normalizeAddress(address: string): Address { + return address + } + + async signMessage(message: string | Uint8Array, privateKey: Uint8Array): Promise { + const msgBytes = typeof message === 'string' + ? new TextEncoder().encode(message) + : message + + // Tron uses keccak256 for message hashing + const prefix = new TextEncoder().encode('\x19TRON Signed Message:\n' + msgBytes.length) + const hash = keccak_256(new Uint8Array([...prefix, ...msgBytes])) + + const sig = secp256k1.sign(hash, privateKey, { prehash: false, format: 'recovered' }) + return bytesToHex(sig) + } + + async verifyMessage(message: string | Uint8Array, signature: Signature, address: Address): Promise { + try { + const msgBytes = typeof message === 'string' + ? new TextEncoder().encode(message) + : message + + const prefix = new TextEncoder().encode('\x19TRON Signed Message:\n' + msgBytes.length) + const hash = keccak_256(new Uint8Array([...prefix, ...msgBytes])) + + // Parse signature bytes (65 bytes: r[32] + s[32] + v[1]) + const sigBytes = hexToBytes(signature) + const recoveredPubKey = secp256k1.recoverPublicKey(hash, sigBytes) + if (!recoveredPubKey) return false + + // Derive address from recovered public key and compare + const pubKeyHash = keccak_256(recoveredPubKey.slice(1)) + const addressBytes = pubKeyHash.slice(-20) + + // Add Tron prefix (0x41) and encode to base58check + const payload = new Uint8Array([0x41, ...addressBytes]) + const checksum = sha256(sha256(payload)).slice(0, 4) + const full = new Uint8Array([...payload, ...checksum]) + const recoveredAddress = this.encodeBase58(full) + + return recoveredAddress === address + } catch { + return false + } + } + + private encodeBase58(bytes: Uint8Array): string { + let num = BigInt(0) + for (const byte of bytes) { + num = num * 256n + BigInt(byte) + } + + let result = '' + while (num > 0n) { + const rem = Number(num % 58n) + num = num / 58n + result = ALPHABET[rem] + result + } + + // Handle leading zeros + for (const byte of bytes) { + if (byte === 0) { + result = ALPHABET[0] + result + } else { + break + } + } + + return result + } +} diff --git a/src/services/chain-adapter/tron/index.ts b/src/services/chain-adapter/tron/index.ts new file mode 100644 index 00000000..53673858 --- /dev/null +++ b/src/services/chain-adapter/tron/index.ts @@ -0,0 +1,10 @@ +/** + * Tron Chain Adapter exports + */ + +export { TronAdapter } from './adapter' +export { TronIdentityService } from './identity-service' +export { TronAssetService } from './asset-service' +export { TronChainService } from './chain-service' +export { TronTransactionService } from './transaction-service' +export * from './types' diff --git a/src/services/chain-adapter/tron/transaction-service.ts b/src/services/chain-adapter/tron/transaction-service.ts new file mode 100644 index 00000000..76da3961 --- /dev/null +++ b/src/services/chain-adapter/tron/transaction-service.ts @@ -0,0 +1,238 @@ +/** + * Tron Transaction Service + * + * Uses Tron HTTP API via PublicNode (tron-rpc.publicnode.com) + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { + ITransactionService, + TransferParams, + UnsignedTransaction, + SignedTransaction, + TransactionHash, + TransactionStatus, + Transaction, + FeeEstimate, + Fee, +} from '../types' +import { Amount } from '@/types/amount' +import { ChainServiceError, ChainErrorCodes } from '../types' +import { secp256k1 } from '@noble/curves/secp256k1.js' +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js' +import type { + TronRawTransaction, + TronSignedTransaction, + TronTransactionInfo, + TronBlock, +} from './types' + +/** Default Tron RPC endpoints */ +const DEFAULT_RPC_URLS: Record = { + 'tron': 'https://tron-rpc.publicnode.com', + 'tron-nile': 'https://nile.trongrid.io', + 'tron-shasta': 'https://api.shasta.trongrid.io', +} + +export class TronTransactionService implements ITransactionService { + private readonly config: ChainConfig + private readonly rpcUrl: string + + constructor(config: ChainConfig) { + this.config = config + this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['tron']! + } + + private async api(endpoint: string, body?: unknown): Promise { + const url = `${this.rpcUrl}${endpoint}` + const init: RequestInit = body + ? { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + } + : { method: 'GET' } + + const response = await fetch(url, init) + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `Tron API error: ${response.status} ${response.statusText}`, + ) + } + + return response.json() as Promise + } + + /** Convert base58 Tron address to hex format */ + private base58ToHex(address: string): string { + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + let num = BigInt(0) + for (const char of address) { + const index = ALPHABET.indexOf(char) + if (index === -1) throw new Error(`Invalid base58 character: ${char}`) + num = num * 58n + BigInt(index) + } + // Convert to hex, remove checksum (last 4 bytes) + const hex = num.toString(16).padStart(50, '0') + return hex.slice(0, 42) // 21 bytes = 42 hex chars + } + + async estimateFee(_params: TransferParams): Promise { + // TRX transfers typically cost bandwidth (free up to limit) + // If bandwidth exhausted, burns TRX at 1000 SUN per bandwidth unit + const feeAmount = Amount.fromRaw('0', this.config.decimals, this.config.symbol) + + const fee: Fee = { + amount: feeAmount, + estimatedTime: 3, // ~3 seconds + } + + return { + slow: fee, + standard: fee, + fast: fee, + } + } + + async buildTransaction(params: TransferParams): Promise { + // Convert addresses to hex format for API + const ownerAddressHex = this.base58ToHex(params.from) + const toAddressHex = this.base58ToHex(params.to) + + // Create transaction via Tron API + const rawTx = await this.api('/wallet/createtransaction', { + owner_address: ownerAddressHex, + to_address: toAddressHex, + amount: Number(params.amount.raw), + visible: false, + }) + + if (!rawTx.txID) { + throw new ChainServiceError( + ChainErrorCodes.TX_BUILD_FAILED, + 'Failed to create Tron transaction', + ) + } + + return { + chainId: this.config.id, + data: rawTx, + } + } + + async signTransaction( + unsignedTx: UnsignedTransaction, + privateKey: Uint8Array, + ): Promise { + const rawTx = unsignedTx.data as TronRawTransaction + + // Sign the transaction ID (which is already a hash) + const txIdBytes = hexToBytes(rawTx.txID) + const sigBytes = secp256k1.sign(txIdBytes, privateKey, { prehash: false, format: 'recovered' }) + + // Tron signature format: r (32) + s (32) + recovery (1) + const signature = bytesToHex(sigBytes) + + const signedTx: TronSignedTransaction = { + ...rawTx, + signature: [signature], + } + + return { + chainId: this.config.id, + data: signedTx, + signature: signature, + } + } + + async broadcastTransaction(signedTx: SignedTransaction): Promise { + const tx = signedTx.data as TronSignedTransaction + + const result = await this.api<{ result?: boolean; txid?: string; code?: string; message?: string }>( + '/wallet/broadcasttransaction', + tx, + ) + + if (!result.result) { + const errorMsg = result.message + ? Buffer.from(result.message, 'hex').toString('utf8') + : result.code ?? 'Unknown error' + throw new ChainServiceError(ChainErrorCodes.TX_BROADCAST_FAILED, `Broadcast failed: ${errorMsg}`) + } + + return tx.txID + } + + async getTransactionStatus(hash: TransactionHash): Promise { + try { + const info = await this.api>( + '/wallet/gettransactioninfobyid', + { value: hash }, + ) + + // Empty object means transaction not found/pending + if (!info || !('blockNumber' in info)) { + return { status: 'pending', confirmations: 0, requiredConfirmations: 19 } + } + + // Get current block for confirmation count + const block = await this.api('/wallet/getnowblock') + const currentBlock = block.block_header.raw_data.number + const confirmations = currentBlock - info.blockNumber + + return { + status: confirmations >= 19 ? 'confirmed' : 'confirming', + confirmations: Math.max(0, confirmations), + requiredConfirmations: 19, + } + } catch { + return { status: 'pending', confirmations: 0, requiredConfirmations: 19 } + } + } + + async getTransaction(hash: TransactionHash): Promise { + try { + const [tx, info] = await Promise.all([ + this.api>('/wallet/gettransactionbyid', { value: hash }), + this.api>('/wallet/gettransactioninfobyid', { value: hash }), + ]) + + if (!tx || !('txID' in tx)) return null + + const contract = tx.raw_data.contract[0] + if (!contract || contract.type !== 'TransferContract') return null + + const { amount, owner_address, to_address } = contract.parameter.value + const isConfirmed = 'blockNumber' in info + + return { + hash: tx.txID, + from: owner_address, + to: to_address, + amount: Amount.fromRaw(amount.toString(), this.config.decimals, this.config.symbol), + fee: Amount.fromRaw( + ((info as TronTransactionInfo).receipt?.net_usage ?? 0).toString(), + this.config.decimals, + this.config.symbol, + ), + status: { + status: isConfirmed ? 'confirmed' : 'pending', + confirmations: isConfirmed ? 19 : 0, + requiredConfirmations: 19, + }, + timestamp: tx.raw_data.timestamp, + blockNumber: isConfirmed ? BigInt((info as TronTransactionInfo).blockNumber) : undefined, + type: 'transfer', + } + } catch { + return null + } + } + + async getTransactionHistory(_address: string, _limit = 20): Promise { + // Tron HTTP API doesn't support transaction history queries + // Would need TronGrid API or similar indexer service + return [] + } +} diff --git a/src/services/chain-adapter/tron/types.ts b/src/services/chain-adapter/tron/types.ts new file mode 100644 index 00000000..d5dc17f1 --- /dev/null +++ b/src/services/chain-adapter/tron/types.ts @@ -0,0 +1,79 @@ +/** + * Tron-specific types + */ + +/** Tron account info from API */ +export interface TronAccount { + address: string + balance: number + create_time: number + latest_opration_time?: number + account_resource?: { + energy_usage?: number + frozen_balance_for_energy?: number + } +} + +/** Tron block info */ +export interface TronBlock { + blockID: string + block_header: { + raw_data: { + number: number + txTrieRoot: string + witness_address: string + parentHash: string + timestamp: number + } + } +} + +/** Raw transaction from Tron API */ +export interface TronRawTransaction { + txID: string + raw_data: { + contract: Array<{ + parameter: { + value: { + amount: number + owner_address: string + to_address: string + } + type_url: string + } + type: string + }> + ref_block_bytes: string + ref_block_hash: string + expiration: number + timestamp: number + } + raw_data_hex: string +} + +/** Signed transaction ready for broadcast */ +export interface TronSignedTransaction extends TronRawTransaction { + signature: string[] +} + +/** Transaction info from API */ +export interface TronTransactionInfo { + id: string + blockNumber: number + blockTimeStamp: number + contractResult: string[] + receipt: { + net_usage?: number + energy_usage?: number + } +} + +/** Account resource info */ +export interface TronAccountResource { + freeNetLimit: number + freeNetUsed?: number + NetLimit?: number + NetUsed?: number + EnergyLimit?: number + EnergyUsed?: number +} diff --git a/src/services/chain-adapter/types.ts b/src/services/chain-adapter/types.ts index 7d938841..1063f434 100644 --- a/src/services/chain-adapter/types.ts +++ b/src/services/chain-adapter/types.ts @@ -210,6 +210,9 @@ export const ChainErrorCodes = { INVALID_ADDRESS: 'INVALID_ADDRESS', TRANSACTION_REJECTED: 'TRANSACTION_REJECTED', TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT', + TX_BUILD_FAILED: 'TX_BUILD_FAILED', + TX_BROADCAST_FAILED: 'TX_BROADCAST_FAILED', + SIGNATURE_FAILED: 'SIGNATURE_FAILED', // BioForest ADDRESS_FROZEN: 'ADDRESS_FROZEN', diff --git a/src/services/chain-config/__tests__/schema.test.ts b/src/services/chain-config/__tests__/schema.test.ts index 9fa85baf..c22c5c3e 100644 --- a/src/services/chain-config/__tests__/schema.test.ts +++ b/src/services/chain-config/__tests__/schema.test.ts @@ -64,11 +64,13 @@ describe('default-chains.json', () => { // Verify chain types const bioforestChains = chains.filter(c => c.type === 'bioforest') const evmChains = chains.filter(c => c.type === 'evm') + const tronChains = chains.filter(c => c.type === 'tron') const bip39Chains = chains.filter(c => c.type === 'bip39') expect(bioforestChains).toHaveLength(7) expect(evmChains).toHaveLength(2) - expect(bip39Chains).toHaveLength(2) + expect(tronChains).toHaveLength(1) + expect(bip39Chains).toHaveLength(1) }) }) diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index efe3131b..07a775fd 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -27,11 +27,12 @@ export interface ChainConfigSnapshot { warnings: ChainConfigWarning[] } -const KNOWN_TYPES: ReadonlySet = new Set(['bioforest', 'evm', 'bip39', 'custom']) +const KNOWN_TYPES: ReadonlySet = new Set(['bioforest', 'evm', 'tron', 'bip39', 'custom']) const SUPPORTED_MAJOR_BY_TYPE: Record = { bioforest: 1, evm: 1, + tron: 1, bip39: 1, custom: 1, } diff --git a/src/services/chain-config/schema.ts b/src/services/chain-config/schema.ts index 79f55b97..5e9ee263 100644 --- a/src/services/chain-config/schema.ts +++ b/src/services/chain-config/schema.ts @@ -17,7 +17,7 @@ export const ChainConfigVersionSchema = z .string() .regex(/^\d+\.\d+$/, 'version must be "major.minor" (e.g. "1.0")') -export const ChainConfigTypeSchema = z.enum(['bioforest', 'evm', 'bip39', 'custom']) +export const ChainConfigTypeSchema = z.enum(['bioforest', 'evm', 'tron', 'bip39', 'custom']) export const ChainConfigSourceSchema = z.enum(['default', 'subscription', 'manual']) From a8180e48a7b4f727249b79a4351cdbeb674b8f95 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 27 Dec 2025 21:55:55 +0800 Subject: [PATCH 5/8] feat(chain-adapter): add Bitcoin adapter with mempool.space API - Add BitcoinIdentityService with BIP84 address derivation (P2WPKH) - Add BitcoinAssetService for balance and UTXO queries - Add BitcoinChainService for block info and fee estimates - Add BitcoinTransactionService for transaction building - Implement custom bech32 encoding/decoding - Note: Full transaction signing requires specialized library --- src/services/chain-adapter/bitcoin/adapter.ts | 45 +++ .../chain-adapter/bitcoin/asset-service.ts | 89 ++++++ .../chain-adapter/bitcoin/chain-service.ts | 114 ++++++++ .../chain-adapter/bitcoin/identity-service.ts | 241 ++++++++++++++++ src/services/chain-adapter/bitcoin/index.ts | 10 + .../bitcoin/transaction-service.ts | 260 ++++++++++++++++++ src/services/chain-adapter/bitcoin/types.ts | 95 +++++++ src/services/chain-adapter/index.ts | 9 +- 8 files changed, 861 insertions(+), 2 deletions(-) create mode 100644 src/services/chain-adapter/bitcoin/adapter.ts create mode 100644 src/services/chain-adapter/bitcoin/asset-service.ts create mode 100644 src/services/chain-adapter/bitcoin/chain-service.ts create mode 100644 src/services/chain-adapter/bitcoin/identity-service.ts create mode 100644 src/services/chain-adapter/bitcoin/index.ts create mode 100644 src/services/chain-adapter/bitcoin/transaction-service.ts create mode 100644 src/services/chain-adapter/bitcoin/types.ts diff --git a/src/services/chain-adapter/bitcoin/adapter.ts b/src/services/chain-adapter/bitcoin/adapter.ts new file mode 100644 index 00000000..aadb0e97 --- /dev/null +++ b/src/services/chain-adapter/bitcoin/adapter.ts @@ -0,0 +1,45 @@ +/** + * Bitcoin Chain Adapter + * + * Full adapter for Bitcoin network using mempool.space API + */ + +import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import type { IChainAdapter, IStakingService } from '../types' +import { BitcoinIdentityService } from './identity-service' +import { BitcoinAssetService } from './asset-service' +import { BitcoinChainService } from './chain-service' +import { BitcoinTransactionService } from './transaction-service' + +export class BitcoinAdapter implements IChainAdapter { + readonly config: ChainConfig + readonly identity: BitcoinIdentityService + readonly asset: BitcoinAssetService + readonly chain: BitcoinChainService + readonly transaction: BitcoinTransactionService + readonly staking: IStakingService | null = null + + constructor(config: ChainConfig) { + this.config = config + this.identity = new BitcoinIdentityService(config) + this.asset = new BitcoinAssetService(config) + this.chain = new BitcoinChainService(config) + this.transaction = new BitcoinTransactionService(config) + } + + get chainId(): string { + return this.config.id + } + + get chainType(): ChainConfigType { + return 'bip39' + } + + async initialize(_config: ChainConfig): Promise { + // No async initialization needed + } + + dispose(): void { + // No cleanup needed + } +} diff --git a/src/services/chain-adapter/bitcoin/asset-service.ts b/src/services/chain-adapter/bitcoin/asset-service.ts new file mode 100644 index 00000000..cd71de65 --- /dev/null +++ b/src/services/chain-adapter/bitcoin/asset-service.ts @@ -0,0 +1,89 @@ +/** + * Bitcoin Asset Service + * + * Uses mempool.space API for address balance and UTXO queries + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { IAssetService, Balance, TokenMetadata, Address } from '../types' +import { Amount } from '@/types/amount' +import { ChainServiceError, ChainErrorCodes } from '../types' +import type { BitcoinAddressInfo, BitcoinUtxo } from './types' + +/** mempool.space API endpoints */ +const API_URLS: Record = { + 'bitcoin': 'https://mempool.space/api', + 'bitcoin-testnet': 'https://mempool.space/testnet/api', + 'bitcoin-signet': 'https://mempool.space/signet/api', +} + +export class BitcoinAssetService implements IAssetService { + private readonly config: ChainConfig + private readonly apiUrl: string + + constructor(config: ChainConfig) { + this.config = config + this.apiUrl = API_URLS[config.id] ?? API_URLS['bitcoin']! + } + + private async api(endpoint: string): Promise { + const url = `${this.apiUrl}${endpoint}` + const response = await fetch(url) + + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `Bitcoin API error: ${response.status}`, + ) + } + + return response.json() as Promise + } + + async getNativeBalance(address: Address): Promise { + try { + const info = await this.api(`/address/${address}`) + + // Balance = funded - spent (confirmed + mempool) + const confirmedBalance = info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum + const mempoolBalance = info.mempool_stats.funded_txo_sum - info.mempool_stats.spent_txo_sum + const totalBalance = confirmedBalance + mempoolBalance + + return { + amount: Amount.fromRaw(totalBalance.toString(), this.config.decimals, this.config.symbol), + symbol: this.config.symbol, + } + } catch { + return { + amount: Amount.fromRaw('0', this.config.decimals, this.config.symbol), + symbol: this.config.symbol, + } + } + } + + async getTokenBalance(_address: Address, _tokenAddress: Address): Promise { + // Bitcoin doesn't have native tokens (BRC-20 would require indexer) + return { + amount: Amount.fromRaw('0', 8, 'TOKEN'), + symbol: 'TOKEN', + } + } + + async getTokenBalances(_address: Address): Promise { + return [] + } + + async getTokenMetadata(_tokenAddress: Address): Promise { + return { + address: _tokenAddress, + name: 'Unknown', + symbol: 'UNKNOWN', + decimals: 8, + } + } + + /** Get UTXOs for an address (used for transaction building) */ + async getUtxos(address: Address): Promise { + return this.api(`/address/${address}/utxo`) + } +} diff --git a/src/services/chain-adapter/bitcoin/chain-service.ts b/src/services/chain-adapter/bitcoin/chain-service.ts new file mode 100644 index 00000000..e2f43f6b --- /dev/null +++ b/src/services/chain-adapter/bitcoin/chain-service.ts @@ -0,0 +1,114 @@ +/** + * Bitcoin Chain Service + * + * Uses mempool.space API for fee estimates and block info + */ + +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' +import type { BitcoinFeeEstimates } from './types' + +/** mempool.space API endpoints */ +const API_URLS: Record = { + 'bitcoin': 'https://mempool.space/api', + 'bitcoin-testnet': 'https://mempool.space/testnet/api', + 'bitcoin-signet': 'https://mempool.space/signet/api', +} + +export class BitcoinChainService implements IChainService { + private readonly config: ChainConfig + private readonly apiUrl: string + + constructor(config: ChainConfig) { + this.config = config + this.apiUrl = API_URLS[config.id] ?? API_URLS['bitcoin']! + } + + private async api(endpoint: string): Promise { + const url = `${this.apiUrl}${endpoint}` + const response = await fetch(url) + + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `Bitcoin API error: ${response.status}`, + ) + } + + return response.json() as Promise + } + + getChainInfo(): ChainInfo { + return { + chainId: this.config.id, + name: this.config.name, + symbol: this.config.symbol, + decimals: this.config.decimals, + blockTime: 600, // ~10 minutes + confirmations: 6, + explorerUrl: this.config.explorer?.url, + } + } + + async getBlockHeight(): Promise { + const height = await this.api('/blocks/tip/height') + return BigInt(height) + } + + async getGasPrice(): Promise { + try { + const fees = await this.api('/v1/fees/recommended') + + // Convert sat/vB to Amount (for a typical 250 vB transaction) + const typicalSize = 250n + const slow = Amount.fromRaw((BigInt(fees.hourFee) * typicalSize).toString(), this.config.decimals, this.config.symbol) + const standard = Amount.fromRaw((BigInt(fees.halfHourFee) * typicalSize).toString(), this.config.decimals, this.config.symbol) + const fast = Amount.fromRaw((BigInt(fees.fastestFee) * typicalSize).toString(), this.config.decimals, this.config.symbol) + + return { + slow, + standard, + fast, + lastUpdated: Date.now(), + } + } catch { + // Default fees if API fails + const defaultFee = Amount.fromRaw('5000', this.config.decimals, this.config.symbol) + return { + slow: defaultFee, + standard: defaultFee, + fast: defaultFee, + lastUpdated: Date.now(), + } + } + } + + async healthCheck(): Promise { + const start = Date.now() + try { + const height = await this.api('/blocks/tip/height') + return { + isHealthy: true, + latency: Date.now() - start, + blockHeight: BigInt(height), + isSyncing: false, + lastUpdated: Date.now(), + } + } catch { + return { + isHealthy: false, + latency: Date.now() - start, + blockHeight: 0n, + isSyncing: false, + lastUpdated: Date.now(), + } + } + } + + /** Get recommended fee rates in sat/vB */ + async getFeeRates(): Promise { + return this.api('/v1/fees/recommended') + } +} diff --git a/src/services/chain-adapter/bitcoin/identity-service.ts b/src/services/chain-adapter/bitcoin/identity-service.ts new file mode 100644 index 00000000..5ffad255 --- /dev/null +++ b/src/services/chain-adapter/bitcoin/identity-service.ts @@ -0,0 +1,241 @@ +/** + * Bitcoin Identity Service + * + * Supports multiple address types: + * - P2WPKH (Native SegWit, bc1q...) - Default, BIP84 + * - P2TR (Taproot, bc1p...) - BIP86 + * - P2PKH (Legacy, 1...) - BIP44 + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { IIdentityService, Address, Signature } from '../types' +import { sha256 } from '@noble/hashes/sha2.js' +import { ripemd160 } from '@noble/hashes/legacy.js' +import { secp256k1 } from '@noble/curves/secp256k1.js' +import { bytesToHex } from '@noble/hashes/utils.js' +import { HDKey } from '@scure/bip32' + +const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + +export class BitcoinIdentityService implements IIdentityService { + constructor(_config: ChainConfig) {} + + async deriveAddress(seed: Uint8Array, index = 0): Promise
{ + // Default to Native SegWit (P2WPKH) - BIP84: m/84'/0'/0'/0/index + const hdKey = HDKey.fromMasterSeed(seed) + const derived = hdKey.derive(`m/84'/0'/0'/0/${index}`) + + if (!derived.publicKey) { + throw new Error('Failed to derive public key') + } + + // P2WPKH: HASH160(compressed pubkey) + const pubKeyHash = ripemd160(sha256(derived.publicKey)) + + // Encode as bech32 (bc1q...) + const words = this.convertBits(pubKeyHash, 8, 5, true) + return this.bech32Encode('bc', [0, ...words]) + } + + /** Convert between bit groups */ + private convertBits(data: Uint8Array, fromBits: number, toBits: number, pad: boolean): number[] { + let acc = 0 + let bits = 0 + const ret: number[] = [] + const maxv = (1 << toBits) - 1 + + for (const value of data) { + acc = (acc << fromBits) | value + bits += fromBits + while (bits >= toBits) { + bits -= toBits + ret.push((acc >> bits) & maxv) + } + } + + if (pad && bits > 0) { + ret.push((acc << (toBits - bits)) & maxv) + } + + return ret + } + + /** Bech32 polymod for checksum */ + private bech32Polymod(values: number[]): number { + const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + let chk = 1 + for (const v of values) { + const top = chk >> 25 + chk = ((chk & 0x1ffffff) << 5) ^ v + for (let i = 0; i < 5; i++) { + if ((top >> i) & 1) chk ^= GEN[i]! + } + } + return chk + } + + /** Expand HRP for checksum calculation */ + private bech32HrpExpand(hrp: string): number[] { + const ret: number[] = [] + for (const c of hrp) { + ret.push(c.charCodeAt(0) >> 5) + } + ret.push(0) + for (const c of hrp) { + ret.push(c.charCodeAt(0) & 31) + } + return ret + } + + /** Create bech32 checksum */ + private bech32CreateChecksum(hrp: string, data: number[]): number[] { + const values = [...this.bech32HrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0] + const polymod = this.bech32Polymod(values) ^ 1 + const ret: number[] = [] + for (let p = 0; p < 6; p++) { + ret.push((polymod >> (5 * (5 - p))) & 31) + } + return ret + } + + /** Encode to bech32 */ + private bech32Encode(hrp: string, data: number[]): string { + const combined = [...data, ...this.bech32CreateChecksum(hrp, data)] + let ret = hrp + '1' + for (const d of combined) { + ret += BECH32_CHARSET[d] + } + return ret + } + + async deriveAddresses(seed: Uint8Array, startIndex: number, count: number): Promise { + const addresses: Address[] = [] + for (let i = 0; i < count; i++) { + addresses.push(await this.deriveAddress(seed, startIndex + i)) + } + return addresses + } + + isValidAddress(address: string): boolean { + try { + // Legacy P2PKH (starts with 1) + if (address.startsWith('1')) { + return this.validateBase58Check(address, 0x00) + } + + // Legacy P2SH (starts with 3) + if (address.startsWith('3')) { + return this.validateBase58Check(address, 0x05) + } + + // Native SegWit P2WPKH (bc1q...) or Taproot P2TR (bc1p...) + if (address.toLowerCase().startsWith('bc1')) { + const decoded = this.bech32Decode(address.toLowerCase()) + if (!decoded) return false + + // P2WPKH: version 0, 20-byte program + if (decoded.words[0] === 0) { + const program = this.convertBits(new Uint8Array(decoded.words.slice(1)), 5, 8, false) + return program.length === 20 + } + + // P2TR: version 1, 32-byte program + if (decoded.words[0] === 1) { + const program = this.convertBits(new Uint8Array(decoded.words.slice(1)), 5, 8, false) + return program.length === 32 + } + + return false + } + + return false + } catch { + return false + } + } + + /** Decode bech32 address */ + private bech32Decode(address: string): { prefix: string; words: number[] } | null { + const pos = address.lastIndexOf('1') + if (pos < 1 || pos + 7 > address.length) return null + + const prefix = address.slice(0, pos) + const data = address.slice(pos + 1) + + const words: number[] = [] + for (const c of data) { + const idx = BECH32_CHARSET.indexOf(c) + if (idx === -1) return null + words.push(idx) + } + + // Verify checksum + const values = [...this.bech32HrpExpand(prefix), ...words] + if (this.bech32Polymod(values) !== 1) return null + + // Remove checksum + return { prefix, words: words.slice(0, -6) } + } + + private validateBase58Check(address: string, expectedVersion: number): boolean { + try { + const decoded = this.decodeBase58(address) + if (decoded.length !== 25) return false + if (decoded[0] !== expectedVersion) return false + + const payload = decoded.slice(0, 21) + const checksum = decoded.slice(21) + const hash = sha256(sha256(payload)) + + return checksum.every((byte, i) => byte === hash[i]) + } catch { + return false + } + } + + private decodeBase58(str: string): Uint8Array { + let num = BigInt(0) + for (const char of str) { + const index = ALPHABET.indexOf(char) + if (index === -1) throw new Error(`Invalid base58 character: ${char}`) + num = num * 58n + BigInt(index) + } + + const hex = num.toString(16).padStart(50, '0') + const bytes = new Uint8Array(25) + for (let i = 0; i < 25; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return bytes + } + + normalizeAddress(address: string): Address { + // Bitcoin addresses are case-sensitive for base58, lowercase for bech32 + if (address.startsWith('bc1') || address.startsWith('BC1')) { + return address.toLowerCase() + } + return address + } + + async signMessage(message: string | Uint8Array, privateKey: Uint8Array): Promise { + const msgBytes = typeof message === 'string' + ? new TextEncoder().encode(message) + : message + + // Bitcoin message signing format + const prefix = new TextEncoder().encode('\x18Bitcoin Signed Message:\n') + const msgLen = new Uint8Array([msgBytes.length]) + const fullMsg = new Uint8Array([...prefix, ...msgLen, ...msgBytes]) + const hash = sha256(sha256(fullMsg)) + + const sig = secp256k1.sign(hash, privateKey, { prehash: false, format: 'recovered' }) + return bytesToHex(sig) + } + + async verifyMessage(_message: string | Uint8Array, _signature: Signature, _address: Address): Promise { + // Bitcoin message verification requires public key recovery + // For simplicity, return false - can be extended later + return false + } +} diff --git a/src/services/chain-adapter/bitcoin/index.ts b/src/services/chain-adapter/bitcoin/index.ts new file mode 100644 index 00000000..14866cfb --- /dev/null +++ b/src/services/chain-adapter/bitcoin/index.ts @@ -0,0 +1,10 @@ +/** + * Bitcoin Chain Adapter exports + */ + +export { BitcoinAdapter } from './adapter' +export { BitcoinIdentityService } from './identity-service' +export { BitcoinAssetService } from './asset-service' +export { BitcoinChainService } from './chain-service' +export { BitcoinTransactionService } from './transaction-service' +export * from './types' diff --git a/src/services/chain-adapter/bitcoin/transaction-service.ts b/src/services/chain-adapter/bitcoin/transaction-service.ts new file mode 100644 index 00000000..a8b809b1 --- /dev/null +++ b/src/services/chain-adapter/bitcoin/transaction-service.ts @@ -0,0 +1,260 @@ +/** + * Bitcoin Transaction Service + * + * Handles UTXO selection, transaction building, signing, and broadcasting. + * Uses mempool.space API for UTXO queries and transaction broadcast. + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { + ITransactionService, + TransferParams, + UnsignedTransaction, + SignedTransaction, + TransactionHash, + TransactionStatus, + Transaction, + FeeEstimate, + Fee, +} from '../types' +import { Amount } from '@/types/amount' +import { ChainServiceError, ChainErrorCodes } from '../types' +import type { BitcoinUtxo, BitcoinTransaction, BitcoinUnsignedTx, BitcoinFeeEstimates } from './types' + +/** mempool.space API endpoints */ +const API_URLS: Record = { + 'bitcoin': 'https://mempool.space/api', + 'bitcoin-testnet': 'https://mempool.space/testnet/api', + 'bitcoin-signet': 'https://mempool.space/signet/api', +} + +export class BitcoinTransactionService implements ITransactionService { + private readonly config: ChainConfig + private readonly apiUrl: string + + constructor(config: ChainConfig) { + this.config = config + this.apiUrl = API_URLS[config.id] ?? API_URLS['bitcoin']! + } + + private async api(endpoint: string, options?: RequestInit): Promise { + const url = `${this.apiUrl}${endpoint}` + const response = await fetch(url, options) + + if (!response.ok) { + throw new ChainServiceError( + ChainErrorCodes.NETWORK_ERROR, + `Bitcoin API error: ${response.status}`, + ) + } + + const text = await response.text() + try { + return JSON.parse(text) as T + } catch { + return text as T + } + } + + async estimateFee(_params: TransferParams): Promise { + try { + const fees = await this.api('/v1/fees/recommended') + + // Estimate for typical P2WPKH transaction (~140 vB for 1-in-2-out) + const typicalVsize = 140 + + const slow: Fee = { + amount: Amount.fromRaw((fees.hourFee * typicalVsize).toString(), this.config.decimals, this.config.symbol), + estimatedTime: 3600, // 1 hour + } + + const standard: Fee = { + amount: Amount.fromRaw((fees.halfHourFee * typicalVsize).toString(), this.config.decimals, this.config.symbol), + estimatedTime: 1800, // 30 minutes + } + + const fast: Fee = { + amount: Amount.fromRaw((fees.fastestFee * typicalVsize).toString(), this.config.decimals, this.config.symbol), + estimatedTime: 600, // 10 minutes + } + + return { slow, standard, fast } + } catch { + // Default fee estimate + const defaultFee: Fee = { + amount: Amount.fromRaw('2000', this.config.decimals, this.config.symbol), + estimatedTime: 1800, + } + return { slow: defaultFee, standard: defaultFee, fast: defaultFee } + } + } + + async buildTransaction(params: TransferParams): Promise { + // Get UTXOs for the sender + const utxos = await this.api(`/address/${params.from}/utxo`) + + if (utxos.length === 0) { + throw new ChainServiceError(ChainErrorCodes.INSUFFICIENT_BALANCE, 'No UTXOs available') + } + + // Get fee rate + const fees = await this.api('/v1/fees/recommended') + const feeRate = fees.halfHourFee // sat/vB + + // Simple UTXO selection: use all available UTXOs + const totalInput = utxos.reduce((sum, u) => sum + u.value, 0) + const sendAmount = Number(params.amount.raw) + + // Estimate transaction size (simplified) + const estimatedVsize = 10 + utxos.length * 68 + 2 * 31 // header + inputs + outputs + const fee = feeRate * estimatedVsize + + if (totalInput < sendAmount + fee) { + throw new ChainServiceError( + ChainErrorCodes.INSUFFICIENT_BALANCE, + `Insufficient balance: need ${sendAmount + fee}, have ${totalInput}`, + ) + } + + const change = totalInput - sendAmount - fee + + const txData: BitcoinUnsignedTx = { + inputs: utxos.map(u => ({ + txid: u.txid, + vout: u.vout, + value: u.value, + scriptPubKey: '', // Would need to fetch from previous tx + })), + outputs: [ + { address: params.to, value: sendAmount }, + ], + fee, + changeAddress: params.from, + } + + // Add change output if significant + if (change > 546) { // Dust threshold + txData.outputs.push({ address: params.from, value: change }) + } + + return { + chainId: this.config.id, + data: txData, + } + } + + async signTransaction( + _unsignedTx: UnsignedTransaction, + _privateKey: Uint8Array, + ): Promise { + // Bitcoin transaction signing is complex - requires: + // 1. Serializing the transaction for signing + // 2. Creating sighash for each input + // 3. Signing each input with the private key + // 4. Building the final transaction with signatures + + // For now, throw an error - full implementation would require + // a proper Bitcoin transaction library + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_SUPPORTED, + 'Bitcoin transaction signing requires specialized library (bitcoinjs-lib or similar)', + ) + } + + async broadcastTransaction(signedTx: SignedTransaction): Promise { + const txHex = signedTx.data as string + + const txid = await this.api('/tx', { + method: 'POST', + body: txHex, + }) + + return txid + } + + async getTransactionStatus(hash: TransactionHash): Promise { + try { + const tx = await this.api(`/tx/${hash}`) + + if (!tx.status.confirmed) { + return { status: 'pending', confirmations: 0, requiredConfirmations: 6 } + } + + // Get current block height for confirmation count + const currentHeight = await this.api('/blocks/tip/height') + const confirmations = currentHeight - (tx.status.block_height ?? currentHeight) + 1 + + return { + status: confirmations >= 6 ? 'confirmed' : 'confirming', + confirmations: Math.max(0, confirmations), + requiredConfirmations: 6, + } + } catch { + return { status: 'pending', confirmations: 0, requiredConfirmations: 6 } + } + } + + async getTransaction(hash: TransactionHash): Promise { + try { + const tx = await this.api(`/tx/${hash}`) + + // Simplify: assume first input is sender, first output is recipient + const from = tx.vin[0]?.prevout?.scriptpubkey_address ?? '' + const to = tx.vout[0]?.scriptpubkey_address ?? '' + const amount = tx.vout[0]?.value ?? 0 + + return { + hash: tx.txid, + from, + to, + amount: Amount.fromRaw(amount.toString(), this.config.decimals, this.config.symbol), + fee: Amount.fromRaw(tx.fee.toString(), this.config.decimals, this.config.symbol), + status: { + status: tx.status.confirmed ? 'confirmed' : 'pending', + confirmations: tx.status.confirmed ? 6 : 0, + requiredConfirmations: 6, + }, + timestamp: (tx.status.block_time ?? Math.floor(Date.now() / 1000)) * 1000, + blockNumber: tx.status.block_height ? BigInt(tx.status.block_height) : undefined, + type: 'transfer', + } + } catch { + return null + } + } + + async getTransactionHistory(address: string, limit = 20): Promise { + try { + const txs = await this.api(`/address/${address}/txs`) + + return txs.slice(0, limit).map(tx => { + const isOutgoing = tx.vin.some(v => v.prevout?.scriptpubkey_address === address) + const from = isOutgoing ? address : (tx.vin[0]?.prevout?.scriptpubkey_address ?? '') + const to = isOutgoing + ? (tx.vout.find(v => v.scriptpubkey_address !== address)?.scriptpubkey_address ?? '') + : address + const amount = isOutgoing + ? tx.vout.filter(v => v.scriptpubkey_address !== address).reduce((s, v) => s + v.value, 0) + : tx.vout.filter(v => v.scriptpubkey_address === address).reduce((s, v) => s + v.value, 0) + + return { + hash: tx.txid, + from, + to, + amount: Amount.fromRaw(amount.toString(), this.config.decimals, this.config.symbol), + fee: Amount.fromRaw(tx.fee.toString(), this.config.decimals, this.config.symbol), + status: { + status: tx.status.confirmed ? 'confirmed' as const : 'pending' as const, + confirmations: tx.status.confirmed ? 6 : 0, + requiredConfirmations: 6, + }, + timestamp: (tx.status.block_time ?? Math.floor(Date.now() / 1000)) * 1000, + blockNumber: tx.status.block_height ? BigInt(tx.status.block_height) : undefined, + type: 'transfer' as const, + } + }) + } catch { + return [] + } + } +} diff --git a/src/services/chain-adapter/bitcoin/types.ts b/src/services/chain-adapter/bitcoin/types.ts new file mode 100644 index 00000000..0ecf1127 --- /dev/null +++ b/src/services/chain-adapter/bitcoin/types.ts @@ -0,0 +1,95 @@ +/** + * Bitcoin-specific types + */ + +/** UTXO from mempool.space API */ +export interface BitcoinUtxo { + txid: string + vout: number + status: { + confirmed: boolean + block_height?: number + block_hash?: string + block_time?: number + } + value: number +} + +/** Address info from mempool.space API */ +export interface BitcoinAddressInfo { + address: string + chain_stats: { + funded_txo_count: number + funded_txo_sum: number + spent_txo_count: number + spent_txo_sum: number + tx_count: number + } + mempool_stats: { + funded_txo_count: number + funded_txo_sum: number + spent_txo_count: number + spent_txo_sum: number + tx_count: number + } +} + +/** Transaction from mempool.space API */ +export interface BitcoinTransaction { + txid: string + version: number + locktime: number + vin: Array<{ + txid: string + vout: number + prevout: { + scriptpubkey: string + scriptpubkey_address: string + value: number + } + scriptsig: string + sequence: number + }> + vout: Array<{ + scriptpubkey: string + scriptpubkey_address: string + value: number + }> + size: number + weight: number + fee: number + status: { + confirmed: boolean + block_height?: number + block_hash?: string + block_time?: number + } +} + +/** Fee estimates from mempool.space API */ +export interface BitcoinFeeEstimates { + fastestFee: number + halfHourFee: number + hourFee: number + economyFee: number + minimumFee: number +} + +/** Bitcoin address types */ +export type BitcoinAddressType = 'p2pkh' | 'p2sh' | 'p2wpkh' | 'p2tr' + +/** Unsigned Bitcoin transaction data */ +export interface BitcoinUnsignedTx { + inputs: Array<{ + txid: string + vout: number + value: number + scriptPubKey: string + }> + outputs: Array<{ + address: string + value: number + }> + fee: number + changeAddress: string +} diff --git a/src/services/chain-adapter/index.ts b/src/services/chain-adapter/index.ts index e2aad493..319c0ac1 100644 --- a/src/services/chain-adapter/index.ts +++ b/src/services/chain-adapter/index.ts @@ -43,23 +43,28 @@ export { BioforestAdapter, createBioforestAdapter } from './bioforest' export { EvmAdapter, createEvmAdapter } from './evm' export { Bip39Adapter, createBip39Adapter } from './bip39' export { TronAdapter } from './tron' +export { BitcoinAdapter } from './bitcoin' // Setup function to register all adapters import { getAdapterRegistry } from './registry' import { createBioforestAdapter } from './bioforest' import { createEvmAdapter } from './evm' -import { createBip39Adapter } from './bip39' import { TronAdapter } from './tron' +import { BitcoinAdapter } from './bitcoin' import type { ChainConfig } from '@/services/chain-config' function createTronAdapter(config: ChainConfig) { return new TronAdapter(config) } +function createBitcoinAdapter(config: ChainConfig) { + return new BitcoinAdapter(config) +} + export function setupAdapters(): void { const registry = getAdapterRegistry() registry.register('bioforest', createBioforestAdapter) registry.register('evm', createEvmAdapter) registry.register('tron', createTronAdapter) - registry.register('bip39', createBip39Adapter) // fallback for bitcoin + registry.register('bip39', createBitcoinAdapter) // Bitcoin uses bip39 type } From 32fea7d0758808ad0809cef8885c007093de29e4 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 27 Dec 2025 21:58:54 +0800 Subject: [PATCH 6/8] refactor(bitcoin): use @scure/base and viem standard libraries - Replace custom bech32/base58 implementation with @scure/base - Add viem for future EVM improvements - Simplify Bitcoin address validation using standard library --- package.json | 2 + pnpm-lock.yaml | 109 +++++++++++ .../chain-adapter/bitcoin/identity-service.ts | 177 ++---------------- 3 files changed, 130 insertions(+), 158 deletions(-) diff --git a/package.json b/package.json index 3e8fe25c..a810da7e 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@plaoc/plugins": "^1.1.9", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", + "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1", "@scure/bip39": "^2.0.1", "@stackflow/core": "^1.3.0", @@ -103,6 +104,7 @@ "swiper": "^12.0.3", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", + "viem": "^2.43.3", "yargs": "^18.0.0", "zod": "^4.1.13" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6177f3ae..54e8b312 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.7)(react@19.2.3) + '@scure/base': + specifier: ^2.0.0 + version: 2.0.0 '@scure/bip32': specifier: ^2.0.1 version: 2.0.1 @@ -158,6 +161,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + viem: + specifier: ^2.43.3 + version: 2.43.3(typescript@5.9.3)(zod@4.2.1) yargs: specifier: ^18.0.0 version: 18.0.0 @@ -1666,6 +1672,10 @@ packages: '@noble/curves@1.4.2': resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.9.7': resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} @@ -2114,18 +2124,27 @@ packages: '@scure/base@1.1.9': resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + '@scure/base@2.0.0': resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} '@scure/bip32@1.4.0': resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + '@scure/bip32@2.0.1': resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} '@scure/bip39@1.3.0': resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@scure/bip39@2.0.1': resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} @@ -2823,6 +2842,17 @@ packages: zod: optional: true + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -4178,6 +4208,11 @@ packages: peerDependencies: ws: '*' + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -4690,6 +4725,14 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + ox@0.11.1: + resolution: {integrity: sha512-1l1gOLAqg0S0xiN1dH5nkPna8PucrZgrIJOfS49MLNiMevxu07Iz4ZjuJS9N+xifvT+PsZyIptS7WHM8nC+0+A==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + oxlint@1.35.0: resolution: {integrity: sha512-QDX1aUgaiqznkGfTM2qHwva2wtKqhVoqPSVXrnPz+yLUhlNadikD3QRuRtppHl7WGuy3wG6nKAuR8lash3aWSg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5873,6 +5916,14 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + viem@2.43.3: + resolution: {integrity: sha512-zM251fspfSjENCtfmT7cauuD+AA/YAlkFU7cksdEQJxj7wDuO0XFRWRH+RMvfmTFza88B9kug5cKU+Wk2nAjJg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + vite-plugin-commonjs@0.10.4: resolution: {integrity: sha512-eWQuvQKCcx0QYB5e5xfxBNjQKyrjEWZIR9UOkOV6JAgxVhtbZvCOF+FNC2ZijBJ3U3Px04ZMMyyMyFBVWIJ5+g==} @@ -7962,6 +8013,10 @@ snapshots: dependencies: '@noble/hashes': 1.4.0 + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 @@ -8313,6 +8368,8 @@ snapshots: '@scure/base@1.1.9': {} + '@scure/base@1.2.6': {} + '@scure/base@2.0.0': {} '@scure/bip32@1.4.0': @@ -8321,6 +8378,12 @@ snapshots: '@noble/hashes': 1.4.0 '@scure/base': 1.1.9 + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@scure/bip32@2.0.1': dependencies: '@noble/curves': 2.0.1 @@ -8332,6 +8395,11 @@ snapshots: '@noble/hashes': 1.4.0 '@scure/base': 1.1.9 + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@scure/bip39@2.0.1': dependencies: '@noble/hashes': 2.0.1 @@ -9175,6 +9243,11 @@ snapshots: optionalDependencies: zod: 4.2.1 + abitype@1.2.3(typescript@5.9.3)(zod@4.2.1): + optionalDependencies: + typescript: 5.9.3 + zod: 4.2.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -10622,6 +10695,10 @@ snapshots: dependencies: ws: 8.18.3 + isows@1.0.7(ws@8.18.3): + dependencies: + ws: 8.18.3 + isstream@0.1.2: {} istanbul-lib-coverage@3.2.2: {} @@ -11121,6 +11198,21 @@ snapshots: outvariant@1.4.3: {} + ox@0.11.1(typescript@5.9.3)(zod@4.2.1): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.2.1) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + oxlint@1.35.0: optionalDependencies: '@oxlint/darwin-arm64': 1.35.0 @@ -12288,6 +12380,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + viem@2.43.3(typescript@5.9.3)(zod@4.2.1): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.2.1) + isows: 1.0.7(ws@8.18.3) + ox: 0.11.1(typescript@5.9.3)(zod@4.2.1) + ws: 8.18.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite-plugin-commonjs@0.10.4: dependencies: acorn: 8.15.0 diff --git a/src/services/chain-adapter/bitcoin/identity-service.ts b/src/services/chain-adapter/bitcoin/identity-service.ts index 5ffad255..62632627 100644 --- a/src/services/chain-adapter/bitcoin/identity-service.ts +++ b/src/services/chain-adapter/bitcoin/identity-service.ts @@ -14,9 +14,7 @@ import { ripemd160 } from '@noble/hashes/legacy.js' import { secp256k1 } from '@noble/curves/secp256k1.js' import { bytesToHex } from '@noble/hashes/utils.js' import { HDKey } from '@scure/bip32' - -const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' -const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' +import { bech32, bech32m, base58check } from '@scure/base' export class BitcoinIdentityService implements IIdentityService { constructor(_config: ChainConfig) {} @@ -34,79 +32,8 @@ export class BitcoinIdentityService implements IIdentityService { const pubKeyHash = ripemd160(sha256(derived.publicKey)) // Encode as bech32 (bc1q...) - const words = this.convertBits(pubKeyHash, 8, 5, true) - return this.bech32Encode('bc', [0, ...words]) - } - - /** Convert between bit groups */ - private convertBits(data: Uint8Array, fromBits: number, toBits: number, pad: boolean): number[] { - let acc = 0 - let bits = 0 - const ret: number[] = [] - const maxv = (1 << toBits) - 1 - - for (const value of data) { - acc = (acc << fromBits) | value - bits += fromBits - while (bits >= toBits) { - bits -= toBits - ret.push((acc >> bits) & maxv) - } - } - - if (pad && bits > 0) { - ret.push((acc << (toBits - bits)) & maxv) - } - - return ret - } - - /** Bech32 polymod for checksum */ - private bech32Polymod(values: number[]): number { - const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] - let chk = 1 - for (const v of values) { - const top = chk >> 25 - chk = ((chk & 0x1ffffff) << 5) ^ v - for (let i = 0; i < 5; i++) { - if ((top >> i) & 1) chk ^= GEN[i]! - } - } - return chk - } - - /** Expand HRP for checksum calculation */ - private bech32HrpExpand(hrp: string): number[] { - const ret: number[] = [] - for (const c of hrp) { - ret.push(c.charCodeAt(0) >> 5) - } - ret.push(0) - for (const c of hrp) { - ret.push(c.charCodeAt(0) & 31) - } - return ret - } - - /** Create bech32 checksum */ - private bech32CreateChecksum(hrp: string, data: number[]): number[] { - const values = [...this.bech32HrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0] - const polymod = this.bech32Polymod(values) ^ 1 - const ret: number[] = [] - for (let p = 0; p < 6; p++) { - ret.push((polymod >> (5 * (5 - p))) & 31) - } - return ret - } - - /** Encode to bech32 */ - private bech32Encode(hrp: string, data: number[]): string { - const combined = [...data, ...this.bech32CreateChecksum(hrp, data)] - let ret = hrp + '1' - for (const d of combined) { - ret += BECH32_CHARSET[d] - } - return ret + const words = bech32.toWords(pubKeyHash) + return bech32.encode('bc', [0, ...words]) } async deriveAddresses(seed: Uint8Array, startIndex: number, count: number): Promise { @@ -119,34 +46,26 @@ export class BitcoinIdentityService implements IIdentityService { isValidAddress(address: string): boolean { try { - // Legacy P2PKH (starts with 1) - if (address.startsWith('1')) { - return this.validateBase58Check(address, 0x00) + // Legacy P2PKH (starts with 1) or P2SH (starts with 3) + if (address.startsWith('1') || address.startsWith('3')) { + const decoded = base58check(sha256).decode(address) + return decoded.length === 21 // 1 version + 20 hash } - // Legacy P2SH (starts with 3) - if (address.startsWith('3')) { - return this.validateBase58Check(address, 0x05) + // Native SegWit P2WPKH (bc1q...) + if (address.toLowerCase().startsWith('bc1q')) { + const addr = address.toLowerCase() as `${string}1${string}` + const decoded = bech32.decode(addr) + const data = bech32.fromWords(decoded.words.slice(1)) + return decoded.prefix === 'bc' && decoded.words[0] === 0 && data.length === 20 } - // Native SegWit P2WPKH (bc1q...) or Taproot P2TR (bc1p...) - if (address.toLowerCase().startsWith('bc1')) { - const decoded = this.bech32Decode(address.toLowerCase()) - if (!decoded) return false - - // P2WPKH: version 0, 20-byte program - if (decoded.words[0] === 0) { - const program = this.convertBits(new Uint8Array(decoded.words.slice(1)), 5, 8, false) - return program.length === 20 - } - - // P2TR: version 1, 32-byte program - if (decoded.words[0] === 1) { - const program = this.convertBits(new Uint8Array(decoded.words.slice(1)), 5, 8, false) - return program.length === 32 - } - - return false + // Taproot P2TR (bc1p...) + if (address.toLowerCase().startsWith('bc1p')) { + const addr = address.toLowerCase() as `${string}1${string}` + const decoded = bech32m.decode(addr) + const data = bech32m.fromWords(decoded.words.slice(1)) + return decoded.prefix === 'bc' && decoded.words[0] === 1 && data.length === 32 } return false @@ -155,63 +74,7 @@ export class BitcoinIdentityService implements IIdentityService { } } - /** Decode bech32 address */ - private bech32Decode(address: string): { prefix: string; words: number[] } | null { - const pos = address.lastIndexOf('1') - if (pos < 1 || pos + 7 > address.length) return null - - const prefix = address.slice(0, pos) - const data = address.slice(pos + 1) - - const words: number[] = [] - for (const c of data) { - const idx = BECH32_CHARSET.indexOf(c) - if (idx === -1) return null - words.push(idx) - } - - // Verify checksum - const values = [...this.bech32HrpExpand(prefix), ...words] - if (this.bech32Polymod(values) !== 1) return null - - // Remove checksum - return { prefix, words: words.slice(0, -6) } - } - - private validateBase58Check(address: string, expectedVersion: number): boolean { - try { - const decoded = this.decodeBase58(address) - if (decoded.length !== 25) return false - if (decoded[0] !== expectedVersion) return false - - const payload = decoded.slice(0, 21) - const checksum = decoded.slice(21) - const hash = sha256(sha256(payload)) - - return checksum.every((byte, i) => byte === hash[i]) - } catch { - return false - } - } - - private decodeBase58(str: string): Uint8Array { - let num = BigInt(0) - for (const char of str) { - const index = ALPHABET.indexOf(char) - if (index === -1) throw new Error(`Invalid base58 character: ${char}`) - num = num * 58n + BigInt(index) - } - - const hex = num.toString(16).padStart(50, '0') - const bytes = new Uint8Array(25) - for (let i = 0; i < 25; i++) { - bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) - } - return bytes - } - normalizeAddress(address: string): Address { - // Bitcoin addresses are case-sensitive for base58, lowercase for bech32 if (address.startsWith('bc1') || address.startsWith('BC1')) { return address.toLowerCase() } @@ -234,8 +97,6 @@ export class BitcoinIdentityService implements IIdentityService { } async verifyMessage(_message: string | Uint8Array, _signature: Signature, _address: Address): Promise { - // Bitcoin message verification requires public key recovery - // For simplicity, return false - can be extended later return false } } From c31c4770a43222bc73c0a00bc25de9807b81eda2 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 27 Dec 2025 22:02:36 +0800 Subject: [PATCH 7/8] feat(use-send): integrate Web3 chain adapters for EVM/Tron/Bitcoin - Add use-send.web3.ts with transfer/fee/validation functions - Update use-send.ts to route EVM/Tron/Bitcoin transfers through adapters - Support address validation using chain adapter identity service - Support fee estimation using chain adapter transaction service --- src/hooks/use-send.ts | 92 +++++++++++++++-- src/hooks/use-send.web3.ts | 200 +++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 src/hooks/use-send.web3.ts diff --git a/src/hooks/use-send.ts b/src/hooks/use-send.ts index 21c1e073..58f1d518 100644 --- a/src/hooks/use-send.ts +++ b/src/hooks/use-send.ts @@ -4,6 +4,7 @@ import { Amount } from '@/types/amount' import { initialState, MOCK_FEES } from './use-send.constants' import type { SendState, UseSendOptions, UseSendReturn } from './use-send.types' import { fetchBioforestBalance, fetchBioforestFee, submitBioforestTransfer } from './use-send.bioforest' +import { fetchWeb3Fee, submitWeb3Transfer, validateWeb3Address } from './use-send.web3' import { adjustAmountForFee, canProceedToConfirm, validateAddressInput, validateAmountInput } from './use-send.logic' import { submitMockTransfer } from './use-send.mock' @@ -19,11 +20,16 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { }) const isBioforestChain = chainConfig?.type === 'bioforest' + const isWeb3Chain = chainConfig?.type === 'evm' || chainConfig?.type === 'tron' || chainConfig?.type === 'bip39' // Validate address const validateAddress = useCallback((address: string): string | null => { + // Use chain adapter validation for Web3 chains + if (isWeb3Chain && chainConfig) { + return validateWeb3Address(chainConfig, address) + } return validateAddressInput(address, isBioforestChain) - }, [isBioforestChain]) + }, [isBioforestChain, isWeb3Chain, chainConfig]) // Validate amount const validateAmount = useCallback((amount: Amount | null, asset: AssetInfo | null): string | null => { @@ -56,7 +62,9 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { feeLoading: true, })) - if (useMock || !isBioforestChain || !chainConfig || !fromAddress) { + const shouldUseMock = useMock || (!isBioforestChain && !isWeb3Chain) || !chainConfig || !fromAddress + + if (shouldUseMock) { // Mock fee estimation delay setTimeout(() => { const fee = MOCK_FEES[asset.assetType] ?? { amount: '0.001', symbol: asset.assetType } @@ -73,7 +81,11 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { void (async () => { try { - const feeEstimate = await fetchBioforestFee(chainConfig, fromAddress) + // Use appropriate fee fetcher based on chain type + const feeEstimate = isWeb3Chain + ? await fetchWeb3Fee(chainConfig, fromAddress) + : await fetchBioforestFee(chainConfig, fromAddress) + setState((prev) => ({ ...prev, feeAmount: feeEstimate.amount, @@ -88,7 +100,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { })) } })() - }, [chainConfig, fromAddress, isBioforestChain, useMock]) + }, [chainConfig, fromAddress, isBioforestChain, isWeb3Chain, useMock]) useEffect(() => { if (useMock || !isBioforestChain || !chainConfig || !fromAddress) return @@ -196,17 +208,81 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { return { status: 'error' as const } } - // Currently only bioforest is fully implemented + // Handle Web3 chains (EVM, Tron, Bitcoin) + if (chainConfig.type === 'evm' || chainConfig.type === 'tron' || chainConfig.type === 'bip39') { + console.log('[useSend.submit] Using Web3 transfer for:', chainConfig.type) + + if (!walletId || !fromAddress || !state.asset || !state.amount) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: '钱包信息不完整', + })) + return { status: 'error' as const } + } + + setState((prev) => ({ + ...prev, + step: 'sending', + isSubmitting: true, + errorMessage: null, + })) + + const result = await submitWeb3Transfer({ + chainConfig, + walletId, + password, + fromAddress, + toAddress: state.toAddress, + amount: state.amount, + }) + + if (result.status === 'password') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })) + return { status: 'password' as const } + } + + if (result.status === 'error') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + txHash: null, + errorMessage: result.message, + })) + return { status: 'error' as const } + } + + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'success', + txHash: result.txHash, + errorMessage: null, + })) + + return { status: 'ok' as const, txHash: result.txHash } + } + + // Unsupported chain type if (chainConfig.type !== 'bioforest') { - console.log('[useSend.submit] Chain type not yet supported:', chainConfig.type) - const chainTypeName = chainConfig.type === 'evm' ? 'EVM' : chainConfig.type === 'bip39' ? 'BIP39' : chainConfig.type + console.log('[useSend.submit] Chain type not supported:', chainConfig.type) setState((prev) => ({ ...prev, step: 'result', isSubmitting: false, resultStatus: 'failed', txHash: null, - errorMessage: `${chainTypeName} 链转账功能开发中`, + errorMessage: `不支持的链类型: ${chainConfig.type}`, })) return { status: 'error' as const } } diff --git a/src/hooks/use-send.web3.ts b/src/hooks/use-send.web3.ts new file mode 100644 index 00000000..0a07fde9 --- /dev/null +++ b/src/hooks/use-send.web3.ts @@ -0,0 +1,200 @@ +/** + * Web3 Transfer Implementation + * + * Handles transfers for EVM, Tron, and Bitcoin chains using chain adapters. + */ + +import type { AssetInfo } from '@/types/asset' +import type { ChainConfig } from '@/services/chain-config' +import { Amount } from '@/types/amount' +import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage' +import { getAdapterRegistry, setupAdapters } from '@/services/chain-adapter' +import { mnemonicToSeedSync } from '@scure/bip39' + +// Ensure adapters are registered +let adaptersInitialized = false +function ensureAdapters() { + if (!adaptersInitialized) { + setupAdapters() + adaptersInitialized = true + } +} + +export interface Web3FeeResult { + amount: Amount + symbol: string +} + +export async function fetchWeb3Fee(chainConfig: ChainConfig, fromAddress: string): Promise { + ensureAdapters() + + const registry = getAdapterRegistry() + registry.setChainConfigs([chainConfig]) + + const adapter = registry.getAdapter(chainConfig.id) + if (!adapter) { + throw new Error(`No adapter found for chain: ${chainConfig.id}`) + } + + const feeEstimate = await adapter.transaction.estimateFee({ + from: fromAddress, + to: fromAddress, + amount: Amount.fromRaw('1', chainConfig.decimals, chainConfig.symbol), + }) + + return { + amount: feeEstimate.standard.amount, + symbol: chainConfig.symbol, + } +} + +export async function fetchWeb3Balance(chainConfig: ChainConfig, fromAddress: string): Promise { + ensureAdapters() + + const registry = getAdapterRegistry() + registry.setChainConfigs([chainConfig]) + + const adapter = registry.getAdapter(chainConfig.id) + if (!adapter) { + throw new Error(`No adapter found for chain: ${chainConfig.id}`) + } + + const balance = await adapter.asset.getNativeBalance(fromAddress) + + return { + assetType: balance.symbol, + name: chainConfig.name, + amount: balance.amount, + decimals: balance.amount.decimals, + } +} + +export type SubmitWeb3Result = + | { status: 'ok'; txHash: string } + | { status: 'password' } + | { status: 'error'; message: string } + +export interface SubmitWeb3Params { + chainConfig: ChainConfig + walletId: string + password: string + fromAddress: string + toAddress: string + amount: Amount +} + +export async function submitWeb3Transfer({ + chainConfig, + walletId, + password, + fromAddress, + toAddress, + amount, +}: SubmitWeb3Params): Promise { + ensureAdapters() + + // Get mnemonic from wallet storage + let mnemonic: string + try { + mnemonic = await walletStorageService.getMnemonic(walletId, password) + } catch (error) { + if (error instanceof WalletStorageError && error.code === WalletStorageErrorCode.DECRYPTION_FAILED) { + return { status: 'password' } + } + return { + status: 'error', + message: error instanceof Error ? error.message : '未知错误', + } + } + + if (!amount.isPositive()) { + return { status: 'error', message: '请输入有效金额' } + } + + try { + const registry = getAdapterRegistry() + registry.setChainConfigs([chainConfig]) + + const adapter = registry.getAdapter(chainConfig.id) + if (!adapter) { + return { status: 'error', message: `不支持的链: ${chainConfig.id}` } + } + + console.log('[submitWeb3Transfer] Starting transfer:', { + chain: chainConfig.id, + type: chainConfig.type, + fromAddress, + toAddress, + amount: amount.toRawString() + }) + + // Derive private key from mnemonic + const seed = mnemonicToSeedSync(mnemonic) + + // Build unsigned transaction + console.log('[submitWeb3Transfer] Building transaction...') + const unsignedTx = await adapter.transaction.buildTransaction({ + from: fromAddress, + to: toAddress, + amount, + }) + + // Sign transaction + console.log('[submitWeb3Transfer] Signing transaction...') + const signedTx = await adapter.transaction.signTransaction(unsignedTx, seed) + + // Broadcast transaction + console.log('[submitWeb3Transfer] Broadcasting transaction...') + const txHash = await adapter.transaction.broadcastTransaction(signedTx) + + console.log('[submitWeb3Transfer] SUCCESS! txHash:', txHash) + return { status: 'ok', txHash } + } catch (error) { + console.error('[submitWeb3Transfer] FAILED:', error) + + const errorMessage = error instanceof Error ? error.message : String(error) + + // Handle specific error cases + if (errorMessage.includes('insufficient') || errorMessage.includes('余额不足')) { + return { status: 'error', message: '余额不足' } + } + + if (errorMessage.includes('fee') || errorMessage.includes('手续费') || errorMessage.includes('gas')) { + return { status: 'error', message: '手续费不足' } + } + + if (errorMessage.includes('not yet implemented') || errorMessage.includes('not supported')) { + return { status: 'error', message: '该链转账功能尚未完全实现' } + } + + return { + status: 'error', + message: errorMessage || '交易失败,请稍后重试', + } + } +} + +/** + * Validate address for Web3 chains + */ +export function validateWeb3Address(chainConfig: ChainConfig, address: string): string | null { + ensureAdapters() + + const registry = getAdapterRegistry() + registry.setChainConfigs([chainConfig]) + + const adapter = registry.getAdapter(chainConfig.id) + if (!adapter) { + return '不支持的链类型' + } + + if (!address || address.trim() === '') { + return '请输入收款地址' + } + + if (!adapter.identity.isValidAddress(address)) { + return '无效的地址格式' + } + + return null +} From dba36f26dcdd124003c32531c2e559a72d184a4c Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 27 Dec 2025 22:14:30 +0800 Subject: [PATCH 8/8] docs/test: add Web3 adapter documentation and unit tests - Add white-book documentation explaining API choices (mempool.space vs PublicNode for Bitcoin) - Add unit tests for EVM, Bitcoin, and Tron adapters - Update testnet documentation with Bitcoin API comparison table --- .../02-web3-adapters.md" | 209 ++++++++++++++++++ .../index.md" | 44 ++-- .../__tests__/bitcoin-adapter.test.ts | 79 +++++++ .../__tests__/evm-adapter.test.ts | 47 ++++ .../__tests__/tron-adapter.test.ts | 70 ++++++ 5 files changed, 436 insertions(+), 13 deletions(-) create mode 100644 "docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/\345\256\236\347\216\260\350\247\204\350\214\203/02-web3-adapters.md" create mode 100644 src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts create mode 100644 src/services/chain-adapter/__tests__/evm-adapter.test.ts create mode 100644 src/services/chain-adapter/__tests__/tron-adapter.test.ts diff --git "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/\345\256\236\347\216\260\350\247\204\350\214\203/02-web3-adapters.md" "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/\345\256\236\347\216\260\350\247\204\350\214\203/02-web3-adapters.md" new file mode 100644 index 00000000..e9ce4951 --- /dev/null +++ "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/\345\256\236\347\216\260\350\247\204\350\214\203/02-web3-adapters.md" @@ -0,0 +1,209 @@ +# Web3 链适配器实现 + +> 本文档描述 EVM、Tron、Bitcoin 链适配器的实现细节。 + +## 概述 + +Web3 适配器为非 Bioforest 链提供统一接口,包括: + +| 链类型 | 适配器 | API 端点 | 签名状态 | +|-------|--------|---------|---------| +| EVM (ETH/BSC) | `EvmAdapter` | PublicNode JSON-RPC | ✅ 完整 | +| Tron | `TronAdapter` | PublicNode HTTP API | ✅ 完整 | +| Bitcoin | `BitcoinAdapter` | mempool.space REST | ⚠️ 部分 | + +## 目录结构 + +``` +src/services/chain-adapter/ +├── evm/ +│ ├── adapter.ts # 主适配器 +│ ├── identity-service.ts # 地址验证 +│ ├── asset-service.ts # 余额查询 +│ ├── chain-service.ts # 链信息、Gas 价格 +│ ├── transaction-service.ts # 交易构建、签名、广播 +│ └── types.ts +├── tron/ +│ ├── adapter.ts +│ ├── identity-service.ts # Base58Check 地址 +│ ├── asset-service.ts # TRX 余额 +│ ├── chain-service.ts # 带宽/能量 +│ ├── transaction-service.ts +│ └── types.ts +├── bitcoin/ +│ ├── adapter.ts +│ ├── identity-service.ts # Bech32/Base58 地址 +│ ├── asset-service.ts # UTXO 余额 +│ ├── chain-service.ts # 费率估算 +│ ├── transaction-service.ts # UTXO 选择 +│ └── types.ts +└── index.ts # 适配器注册 +``` + +## EVM 适配器 + +### 地址派生 + +使用 BIP44 路径 `m/44'/60'/0'/0/index`: + +```typescript +import { HDKey } from '@scure/bip32' +import { keccak_256 } from '@noble/hashes/sha3.js' + +async deriveAddress(seed: Uint8Array, index = 0): Promise
{ + const hdKey = HDKey.fromMasterSeed(seed) + const derived = hdKey.derive(`m/44'/60'/0'/0/${index}`) + const pubKey = secp256k1.getPublicKey(derived.privateKey!, false) + const hash = keccak_256(pubKey.slice(1)) + return '0x' + bytesToHex(hash.slice(-20)) +} +``` + +### 交易签名 + +使用 RLP 编码和 EIP-155 签名: + +```typescript +// 1. 构建交易数据 +const txData = { + nonce: await this.rpc('eth_getTransactionCount', [from, 'pending']), + gasPrice: await this.rpc('eth_gasPrice'), + gasLimit: '0x5208', // 21000 for simple transfer + to, value, data: '0x', + chainId: this.evmChainId, +} + +// 2. RLP 编码(EIP-155 预签名) +const rawTx = this.rlpEncode([nonce, gasPrice, gasLimit, to, value, data, chainId, '0x', '0x']) + +// 3. 签名 +const msgHash = keccak_256(hexToBytes(rawTx.slice(2))) +const sig = secp256k1.sign(msgHash, privateKey, { format: 'recovered' }) +const v = chainId * 2 + 35 + sig.recovery + +// 4. 编码签名交易 +const signedRaw = this.rlpEncode([nonce, gasPrice, gasLimit, to, value, data, v, r, s]) +``` + +### API 调用 + +使用标准 Ethereum JSON-RPC: + +```typescript +private async rpc(method: string, params: unknown[] = []): Promise { + const response = await fetch(this.rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }), + }) + const json = await response.json() + return json.result +} +``` + +## Tron 适配器 + +### 地址格式 + +Tron 使用 Base58Check 编码,前缀 `0x41`: + +```typescript +// 公钥 → 地址 +const pubKeyHash = keccak_256(pubKey.slice(1)).slice(-20) +const payload = new Uint8Array([0x41, ...pubKeyHash]) +const checksum = sha256(sha256(payload)).slice(0, 4) +return base58.encode([...payload, ...checksum]) // 以 'T' 开头 +``` + +### 交易流程 + +1. **创建交易**:调用 `/wallet/createtransaction` +2. **签名**:对 `txID`(已是 hash)进行 secp256k1 签名 +3. **广播**:调用 `/wallet/broadcasttransaction` + +```typescript +// 创建交易 +const rawTx = await this.api('/wallet/createtransaction', { + owner_address: hexAddress, + to_address: toHexAddress, + amount: Number(amount.raw), +}) + +// 签名(Tron 的 txID 已经是 hash) +const sig = secp256k1.sign(hexToBytes(rawTx.txID), privateKey, { format: 'recovered' }) + +// 广播 +await this.api('/wallet/broadcasttransaction', { ...rawTx, signature: [bytesToHex(sig)] }) +``` + +## Bitcoin 适配器 + +### 地址类型支持 + +| 类型 | 前缀 | BIP 路径 | 编码 | +|-----|------|---------|------| +| P2WPKH (SegWit) | bc1q | m/84'/0'/0'/0/x | Bech32 | +| P2TR (Taproot) | bc1p | m/86'/0'/0'/0/x | Bech32m | +| P2PKH (Legacy) | 1 | m/44'/0'/0'/0/x | Base58Check | + +默认使用 P2WPKH (Native SegWit): + +```typescript +import { bech32 } from '@scure/base' + +async deriveAddress(seed: Uint8Array, index = 0): Promise
{ + const hdKey = HDKey.fromMasterSeed(seed) + const derived = hdKey.derive(`m/84'/0'/0'/0/${index}`) + const pubKeyHash = ripemd160(sha256(derived.publicKey!)) + const words = bech32.toWords(pubKeyHash) + return bech32.encode('bc', [0, ...words]) +} +``` + +### UTXO 查询 + +使用 mempool.space API: + +```typescript +// 获取 UTXO 列表 +const utxos = await this.api(`/address/${address}/utxo`) + +// 计算余额 +const info = await this.api(`/address/${address}`) +const balance = info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum +``` + +### 交易签名限制 + +Bitcoin 交易签名比 EVM/Tron 复杂,需要: + +1. UTXO 选择算法 +2. 为每个输入构建 sighash +3. 签名每个输入 +4. 构建完整的 witness 数据 + +**当前状态**:余额查询、UTXO 列表、交易历史已实现。完整签名需要集成 `bitcoinjs-lib` 或类似库。 + +## 依赖库 + +| 库 | 用途 | +|---|------| +| `@noble/curves` | secp256k1 签名 | +| `@noble/hashes` | sha256, keccak256, ripemd160 | +| `@scure/bip32` | HD 密钥派生 | +| `@scure/bip39` | 助记词处理 | +| `@scure/base` | bech32, base58 编码 | +| `viem` | EVM 工具(可选,用于进一步简化)| + +## 测试 + +```bash +# 运行适配器测试 +pnpm test -- --testPathPattern="chain-adapter" +``` + +## 相关文档 + +- [链配置管理](../07-链配置管理/index.md) +- [测试网络](../../08-测试篇/06-测试网络/index.md) +- [ITransactionService](../ITransactionService.md) diff --git "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" index c4d834e9..b31484fe 100644 --- "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" +++ "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/06-\346\265\213\350\257\225\347\275\221\347\273\234/index.md" @@ -10,25 +10,43 @@ - **全链支持**:Ethereum、BSC、Tron、Bitcoin 等 - **主网+测试网**:同一提供商,API 一致 -## PublicNode 端点汇总 +## 公共 API 端点汇总 ### 主网 -| 链 | RPC 端点 | 协议 | -|---|---------|------| -| Ethereum | `https://ethereum-rpc.publicnode.com` | JSON-RPC | -| BSC | `https://bsc-rpc.publicnode.com` | JSON-RPC | -| Tron | `https://tron-rpc.publicnode.com` | Tron HTTP API | -| Bitcoin | `https://bitcoin-rpc.publicnode.com` | Bitcoin JSON-RPC | +| 链 | RPC 端点 | 协议 | 说明 | +|---|---------|------|------| +| Ethereum | `https://ethereum-rpc.publicnode.com` | JSON-RPC | PublicNode | +| BSC | `https://bsc-rpc.publicnode.com` | JSON-RPC | PublicNode | +| Tron | `https://tron-rpc.publicnode.com` | Tron HTTP API | PublicNode | +| Bitcoin | `https://mempool.space/api` | REST API | mempool.space | ### 测试网 -| 链 | RPC 端点 | 协议 | -|---|---------|------| -| Ethereum Sepolia | `https://ethereum-sepolia-rpc.publicnode.com` | JSON-RPC | -| BSC Testnet | `https://bsc-testnet-rpc.publicnode.com` | JSON-RPC | -| Tron Nile | `https://nile.trongrid.io` | Tron HTTP API | -| Bitcoin Signet | `https://mempool.space/signet/api` | REST API | +| 链 | RPC 端点 | 协议 | 说明 | +|---|---------|------|------| +| Ethereum Sepolia | `https://ethereum-sepolia-rpc.publicnode.com` | JSON-RPC | PublicNode | +| BSC Testnet | `https://bsc-testnet-rpc.publicnode.com` | JSON-RPC | PublicNode | +| Tron Nile | `https://nile.trongrid.io` | Tron HTTP API | TronGrid | +| Bitcoin Testnet | `https://mempool.space/testnet/api` | REST API | mempool.space | +| Bitcoin Signet | `https://mempool.space/signet/api` | REST API | mempool.space | + +### Bitcoin 为什么使用 mempool.space? + +PublicNode 提供标准 Bitcoin Core JSON-RPC,但存在以下限制: + +1. **地址查询慢**:`scantxoutset` 需要扫描整个 UTXO 集,约需 60 秒 +2. **无交易历史**:标准 RPC 不支持按地址查询交易记录 +3. **部分方法禁用**:`getaddressinfo` 等方法被限制 + +mempool.space 提供专为钱包优化的 REST API: + +| 功能 | mempool.space | PublicNode | +|------|--------------|------------| +| 余额查询 | < 1秒 | ~60秒 | +| UTXO 列表 | ✅ 支持 | ✅ 支持(慢)| +| 交易历史 | ✅ 支持 | ❌ 不支持 | +| 广播交易 | ✅ 支持 | ✅ 支持 | ## 测试网络详情 diff --git a/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts b/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts new file mode 100644 index 00000000..3f2b3ddf --- /dev/null +++ b/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { BitcoinAdapter } from '../bitcoin' +import type { ChainConfig } from '@/services/chain-config' +import { mnemonicToSeedSync } from '@scure/bip39' + +const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +const btcConfig: ChainConfig = { + id: 'bitcoin', + version: '1.0', + type: 'bip39', + name: 'Bitcoin', + symbol: 'BTC', + decimals: 8, + enabled: true, + source: 'default', +} + +describe('BitcoinAdapter', () => { + const adapter = new BitcoinAdapter(btcConfig) + + describe('BitcoinIdentityService', () => { + it('derives correct P2WPKH address from mnemonic', async () => { + const seed = mnemonicToSeedSync(TEST_MNEMONIC) + const address = await adapter.identity.deriveAddress(seed, 0) + + // P2WPKH (bc1q...) address for this mnemonic at BIP84 path m/84'/0'/0'/0/0 + expect(address).toMatch(/^bc1q[a-z0-9]{38,}$/) + expect(address.startsWith('bc1q')).toBe(true) + }) + + it('derives different addresses for different indices', async () => { + const seed = mnemonicToSeedSync(TEST_MNEMONIC) + const addr0 = await adapter.identity.deriveAddress(seed, 0) + const addr1 = await adapter.identity.deriveAddress(seed, 1) + + expect(addr0).not.toBe(addr1) + }) + + it('validates P2WPKH addresses correctly', () => { + // Valid bc1q addresses + expect(adapter.identity.isValidAddress('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(true) + expect(adapter.identity.isValidAddress('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4')).toBe(true) + + // Invalid addresses + expect(adapter.identity.isValidAddress('invalid')).toBe(false) + expect(adapter.identity.isValidAddress('bc1q123')).toBe(false) + expect(adapter.identity.isValidAddress('')).toBe(false) + }) + + it('validates P2TR (Taproot) addresses correctly', () => { + // Valid bc1p addresses + expect(adapter.identity.isValidAddress('bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0')).toBe(true) + }) + + it('validates legacy P2PKH addresses correctly', () => { + // Valid legacy addresses starting with 1 + expect(adapter.identity.isValidAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2')).toBe(true) + expect(adapter.identity.isValidAddress('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa')).toBe(true) + }) + + it('normalizes bech32 addresses to lowercase', () => { + const addr = 'BC1QAR0SRRR7XFKVY5L643LYDNW9RE59GTZZWF5MDQ' + expect(adapter.identity.normalizeAddress(addr)).toBe(addr.toLowerCase()) + }) + }) + + describe('BitcoinChainService', () => { + it('returns correct chain info', () => { + const info = adapter.chain.getChainInfo() + + expect(info.chainId).toBe('bitcoin') + expect(info.symbol).toBe('BTC') + expect(info.decimals).toBe(8) + expect(info.blockTime).toBe(600) // ~10 minutes + expect(info.confirmations).toBe(6) + }) + }) +}) diff --git a/src/services/chain-adapter/__tests__/evm-adapter.test.ts b/src/services/chain-adapter/__tests__/evm-adapter.test.ts new file mode 100644 index 00000000..5202e8c5 --- /dev/null +++ b/src/services/chain-adapter/__tests__/evm-adapter.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { EvmAdapter } from '../evm' +import type { ChainConfig } from '@/services/chain-config' + +const ethConfig: ChainConfig = { + id: 'ethereum', + version: '1.0', + type: 'evm', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + enabled: true, + source: 'default', +} + +describe('EvmAdapter', () => { + const adapter = new EvmAdapter(ethConfig) + + describe('EvmIdentityService', () => { + it('validates Ethereum addresses correctly', () => { + expect(adapter.identity.isValidAddress('0x9858effd232b4033e47d90003d41ec34ecaeda94')).toBe(true) + expect(adapter.identity.isValidAddress('0x9858EfFD232B4033E47d90003D41EC34EcaEda94')).toBe(true) // checksummed + expect(adapter.identity.isValidAddress('invalid')).toBe(false) + expect(adapter.identity.isValidAddress('0x123')).toBe(false) + expect(adapter.identity.isValidAddress('')).toBe(false) + }) + + it('normalizes addresses to EIP-55 checksum format', () => { + const addr = '0x9858effd232b4033e47d90003d41ec34ecaeda94' + const normalized = adapter.identity.normalizeAddress(addr) + // EIP-55 checksum format preserves mixed case + expect(normalized.startsWith('0x')).toBe(true) + expect(normalized.length).toBe(42) + }) + }) + + describe('EvmChainService', () => { + it('returns correct chain info', () => { + const info = adapter.chain.getChainInfo() + + expect(info.chainId).toBe('ethereum') + expect(info.symbol).toBe('ETH') + expect(info.decimals).toBe(18) + expect(info.confirmations).toBe(12) + }) + }) +}) diff --git a/src/services/chain-adapter/__tests__/tron-adapter.test.ts b/src/services/chain-adapter/__tests__/tron-adapter.test.ts new file mode 100644 index 00000000..38806298 --- /dev/null +++ b/src/services/chain-adapter/__tests__/tron-adapter.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { TronAdapter } from '../tron' +import type { ChainConfig } from '@/services/chain-config' +import { mnemonicToSeedSync } from '@scure/bip39' + +const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +const tronConfig: ChainConfig = { + id: 'tron', + version: '1.0', + type: 'tron', + name: 'Tron', + symbol: 'TRX', + decimals: 6, + enabled: true, + source: 'default', +} + +describe('TronAdapter', () => { + const adapter = new TronAdapter(tronConfig) + + describe('TronIdentityService', () => { + it('derives correct Tron address from mnemonic', async () => { + const seed = mnemonicToSeedSync(TEST_MNEMONIC) + const address = await adapter.identity.deriveAddress(seed, 0) + + // Tron addresses start with 'T' and are 34 characters + expect(address).toMatch(/^T[A-Za-z0-9]{33}$/) + expect(address.startsWith('T')).toBe(true) + expect(address.length).toBe(34) + }) + + it('derives different addresses for different indices', async () => { + const seed = mnemonicToSeedSync(TEST_MNEMONIC) + const addr0 = await adapter.identity.deriveAddress(seed, 0) + const addr1 = await adapter.identity.deriveAddress(seed, 1) + + expect(addr0).not.toBe(addr1) + }) + + it('validates Tron addresses correctly', () => { + // Valid Tron addresses + expect(adapter.identity.isValidAddress('TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g')).toBe(true) + expect(adapter.identity.isValidAddress('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t')).toBe(true) + + // Invalid addresses + expect(adapter.identity.isValidAddress('invalid')).toBe(false) + expect(adapter.identity.isValidAddress('T123')).toBe(false) + expect(adapter.identity.isValidAddress('')).toBe(false) + expect(adapter.identity.isValidAddress('0x9858effd232b4033e47d90003d41ec34ecaeda94')).toBe(false) // Ethereum addr + }) + + it('does not modify valid Tron addresses', () => { + const addr = 'TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g' + expect(adapter.identity.normalizeAddress(addr)).toBe(addr) + }) + }) + + describe('TronChainService', () => { + it('returns correct chain info', () => { + const info = adapter.chain.getChainInfo() + + expect(info.chainId).toBe('tron') + expect(info.symbol).toBe('TRX') + expect(info.decimals).toBe(6) + expect(info.blockTime).toBe(3) // ~3 seconds + expect(info.confirmations).toBe(19) + }) + }) +})