From 6972c8f3048590b1969bd899b91473e30530ed43 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 13:38:19 +0800 Subject: [PATCH 01/33] feat(key-fetch): add throttleError and apiKey plugins for Tron RPC - Add throttleError plugin with errorMatchers for 429 rate limit suppression - Add apiKey plugin for generic API key header injection - Add onError hook to FetchPlugin interface for error interception - Add getApiKey helper to encapsulate __API_KEYS__ global access - Apply throttling and API key to all Tron RPC endpoints - Prioritize tron-rpc-pro in default-chains.json config --- packages/key-fetch/src/core.ts | 33 ++++- packages/key-fetch/src/derive.ts | 6 +- packages/key-fetch/src/fallback.ts | 7 +- packages/key-fetch/src/index.ts | 2 + packages/key-fetch/src/plugins/api-key.ts | 51 ++++++++ packages/key-fetch/src/plugins/index.ts | 2 + .../key-fetch/src/plugins/throttle-error.ts | 123 ++++++++++++++++++ packages/key-fetch/src/types.ts | 15 ++- public/configs/default-chains.json | 2 +- .../chain-adapter/providers/api-key-picker.ts | 18 +++ .../providers/tron-rpc-provider.ts | 30 ++++- 11 files changed, 274 insertions(+), 15 deletions(-) create mode 100644 packages/key-fetch/src/plugins/api-key.ts create mode 100644 packages/key-fetch/src/plugins/throttle-error.ts diff --git a/packages/key-fetch/src/core.ts b/packages/key-fetch/src/core.ts index 2f3f7c25a..e73002b6b 100644 --- a/packages/key-fetch/src/core.ts +++ b/packages/key-fetch/src/core.ts @@ -172,10 +172,20 @@ class KeyFetchInstanceImpl< if (!response.ok) { const errorText = await response.text().catch(() => '') - throw new Error( + const error = new Error( `[${this.name}] HTTP ${response.status}: ${response.statusText}` + (errorText ? `\n响应内容: ${errorText.slice(0, 200)}` : '') ) + + // 让插件有机会处理错误(如 throttleError 节流) + for (const plugin of this.plugins) { + if (plugin.onError?.(error, response, middlewareContext)) { + ;(error as Error & { __errorHandled?: boolean }).__errorHandled = true + break + } + } + + throw error } // 使用统一的 body 函数解析中间件链返回的响应 @@ -235,8 +245,15 @@ class KeyFetchInstanceImpl< url, params: params, refetch: async () => { - const data = await this.fetch(params, { skipCache: true }) - this.notify(cacheKey, data) + try { + const data = await this.fetch(params, { skipCache: true }) + this.notify(cacheKey, data) + } catch (error) { + // 如果插件已处理错误(如 throttleError),静默 + if (!(error as Error & { __errorHandled?: boolean }).__errorHandled) { + console.error(`[key-fetch] Error in refetch for ${this.name}:`, error) + } + } }, } @@ -257,7 +274,10 @@ class KeyFetchInstanceImpl< const data = await this.fetch(params, { skipCache: true }) this.notify(cacheKey, data) } catch (error) { - console.error(`[key-fetch] Error refetching ${this.name}:`, error) + // 如果插件已处理错误(如 throttleError),跳过默认日志 + if (!(error as Error & { __errorHandled?: boolean }).__errorHandled) { + console.error(`[key-fetch] Error refetching ${this.name}:`, error) + } } }) cleanups.push(unsubRegistry) @@ -269,7 +289,10 @@ class KeyFetchInstanceImpl< callback(data, 'initial') }) .catch(error => { - console.error(`[key-fetch] Error fetching ${this.name}:`, error) + // 如果插件已处理错误(如 throttleError),跳过默认日志 + if (!(error as Error & { __errorHandled?: boolean }).__errorHandled) { + console.error(`[key-fetch] Error fetching ${this.name}:`, error) + } }) // 返回取消订阅函数 diff --git a/packages/key-fetch/src/derive.ts b/packages/key-fetch/src/derive.ts index 452816a71..f1859ff70 100644 --- a/packages/key-fetch/src/derive.ts +++ b/packages/key-fetch/src/derive.ts @@ -84,8 +84,10 @@ export function derive< // 订阅 source,source 更新时触发 refetch return source.subscribe(context.params, () => { context.refetch().catch((error) => { - // Error is already logged by core.ts, just need to prevent unhandled rejection - console.error(`[key-fetch] Error in derive refetch for ${name}:`, error) + // 如果插件已处理错误(如 throttleError),跳过日志 + if (!(error as Error & { __errorHandled?: boolean }).__errorHandled) { + console.error(`[key-fetch] Error in derive refetch for ${name}:`, error) + } }) }) }, diff --git a/packages/key-fetch/src/fallback.ts b/packages/key-fetch/src/fallback.ts index 2951a241d..fbe087211 100644 --- a/packages/key-fetch/src/fallback.ts +++ b/packages/key-fetch/src/fallback.ts @@ -73,7 +73,12 @@ export function fallback( // 全部失败错误处理 const handleAllFailed = onAllFailed ?? ((errors: Error[]) => { - throw new AggregateError(errors, `All ${errors.length} provider(s) failed for: ${name}`) + const aggError = new AggregateError(errors, `All ${errors.length} provider(s) failed for: ${name}`) + // 如果任一子错误已被处理(如 throttleError),传递标记 + if (errors.some(e => (e as Error & { __errorHandled?: boolean }).__errorHandled)) { + ;(aggError as Error & { __errorHandled?: boolean }).__errorHandled = true + } + throw aggError }) // 如果没有 source,创建一个总是失败的实例 diff --git a/packages/key-fetch/src/index.ts b/packages/key-fetch/src/index.ts index 47897d893..691c7b134 100644 --- a/packages/key-fetch/src/index.ts +++ b/packages/key-fetch/src/index.ts @@ -79,6 +79,8 @@ export { ttl } from './plugins/ttl' export { dedupe } from './plugins/dedupe' export { tag } from './plugins/tag' export { etag } from './plugins/etag' +export { throttleError, errorMatchers } from './plugins/throttle-error' +export { apiKey } from './plugins/api-key' export { transform } from './plugins/transform' export type { TransformOptions } from './plugins/transform' export { cache, MemoryCacheStorage, IndexedDBCacheStorage } from './plugins/cache' diff --git a/packages/key-fetch/src/plugins/api-key.ts b/packages/key-fetch/src/plugins/api-key.ts new file mode 100644 index 000000000..974b722c7 --- /dev/null +++ b/packages/key-fetch/src/plugins/api-key.ts @@ -0,0 +1,51 @@ +/** + * API Key Plugin + * + * 通用 API Key 插件,支持自定义请求头和前缀 + */ + +import type { FetchPlugin } from '../types' + +export interface ApiKeyOptions { + /** 请求头名称,如 'TRON-PRO-API-KEY', 'Authorization', 'X-API-Key' */ + header: string + /** API Key 值,如果为空则不添加头 */ + key: string | undefined + /** 可选前缀,如 'Bearer ' */ + prefix?: string +} + +/** + * API Key 插件 + * + * @example + * ```ts + * import { apiKey } from '@biochain/key-fetch' + * + * // TronGrid + * keyFetch.create({ + * use: [apiKey({ header: 'TRON-PRO-API-KEY', key: 'xxx' })], + * }) + * + * // Bearer Token + * keyFetch.create({ + * use: [apiKey({ header: 'Authorization', key: 'token', prefix: 'Bearer ' })], + * }) + * ``` + */ +export function apiKey(options: ApiKeyOptions): FetchPlugin { + const { header, key, prefix = '' } = options + + return { + name: 'api-key', + + onFetch: async (request, next) => { + if (key) { + const headers = new Headers(request.headers) + headers.set(header, `${prefix}${key}`) + return next(new Request(request, { headers })) + } + return next(request) + }, + } +} diff --git a/packages/key-fetch/src/plugins/index.ts b/packages/key-fetch/src/plugins/index.ts index ec8ec8dab..e51cc6eda 100644 --- a/packages/key-fetch/src/plugins/index.ts +++ b/packages/key-fetch/src/plugins/index.ts @@ -10,3 +10,5 @@ export { ttl } from './ttl' export { dedupe } from './dedupe' export { tag } from './tag' export { etag } from './etag' +export { throttleError, errorMatchers } from './throttle-error' +export { apiKey } from './api-key' diff --git a/packages/key-fetch/src/plugins/throttle-error.ts b/packages/key-fetch/src/plugins/throttle-error.ts new file mode 100644 index 000000000..4338e0282 --- /dev/null +++ b/packages/key-fetch/src/plugins/throttle-error.ts @@ -0,0 +1,123 @@ +/** + * Throttle Error Plugin + * + * 对匹配的错误进行日志节流,避免终端刷屏 + */ + +import type { FetchPlugin } from '../types' + +export interface ThrottleErrorOptions { + /** 错误匹配器 - 返回 true 表示需要节流 */ + match: (error: Error) => boolean + /** 时间窗口(毫秒),默认 10000ms */ + windowMs?: number + /** 窗口内首次匹配时的处理,默认 console.warn */ + onFirstMatch?: (error: Error, name: string) => void + /** 窗口结束时汇总回调 */ + onSummary?: (count: number, name: string) => void +} + +/** 预置错误匹配器 */ + +/** 高阶函数:为匹配器添加 AggregateError 支持 */ +const withAggregateError = (matcher: (msg: string) => boolean) => (e: Error): boolean => { + if (matcher(e.message)) return true + if (e instanceof AggregateError) { + return e.errors.some(inner => inner instanceof Error && matcher(inner.message)) + } + return false +} + +export const errorMatchers = { + /** 匹配 HTTP 状态码(支持 AggregateError) */ + httpStatus: (...codes: number[]) => + withAggregateError(msg => codes.some(code => msg.includes(`HTTP ${code}`))), + + /** 匹配关键词(支持 AggregateError) */ + contains: (...keywords: string[]) => + withAggregateError(msg => keywords.some(kw => msg.toLowerCase().includes(kw.toLowerCase()))), + + /** 组合多个匹配器 (OR) */ + any: (...matchers: Array<(e: Error) => boolean>) => (e: Error) => + matchers.some(m => m(e)), +} + +/** + * 错误日志节流插件 + * + * @example + * ```ts + * import { throttleError, errorMatchers } from '@biochain/key-fetch' + * + * keyFetch.create({ + * name: 'api.example', + * use: [ + * throttleError({ + * match: errorMatchers.httpStatus(429), + * windowMs: 10_000, + * }), + * ], + * }) + * ``` + */ +export function throttleError(options: ThrottleErrorOptions): FetchPlugin { + const { + match, + windowMs = 10_000, + onFirstMatch = (err, name) => { + console.warn(`[${name}] ${err.message.split('\n')[0]} (suppressing for ${windowMs / 1000}s)`) + }, + onSummary = (count, name) => { + if (count > 0) { + console.warn(`[${name}] Suppressed ${count} similar errors in last ${windowMs / 1000}s`) + } + }, + } = options + + // 每个 name 独立的节流状态 + const throttleState = new Map | null + }>() + + const getState = (name: string) => { + let state = throttleState.get(name) + if (!state) { + state = { inWindow: false, suppressedCount: 0, timer: null } + throttleState.set(name, state) + } + return state + } + + return { + name: 'throttle-error', + + onError: (error, _response, context) => { + if (!match(error)) { + return false // 不匹配,交给默认处理 + } + + const state = getState(context.name) + + if (!state.inWindow) { + // 首次匹配,打印警告并启动窗口 + state.inWindow = true + state.suppressedCount = 0 + onFirstMatch(error, context.name) + + state.timer = setTimeout(() => { + onSummary(state.suppressedCount, context.name) + state.inWindow = false + state.suppressedCount = 0 + state.timer = null + }, windowMs) + } else { + // 窗口内,静默计数 + state.suppressedCount++ + } + + return true // 已处理,跳过默认日志 + }, + } +} diff --git a/packages/key-fetch/src/types.ts b/packages/key-fetch/src/types.ts index c0874b290..3784c69f5 100644 --- a/packages/key-fetch/src/types.ts +++ b/packages/key-fetch/src/types.ts @@ -80,6 +80,9 @@ export interface MiddlewareContext

{ /** 解析 Request/Response body(根据 X-Superjson 头自动选择 superjson.parse 或 JSON.parse) */ body: (input: Request | Response) => Promise parseBody: (input: string, isSuperjson?: boolean) => Promise + + /** 标记错误已被插件处理(如 throttleError),core.ts 将跳过默认日志 */ + errorHandled?: boolean } /** @@ -99,7 +102,17 @@ export interface FetchPlugin

{ * - 可以修改 next() 返回的 response * - 可以不调用 next() 直接返回缓存的 response */ - onFetch: FetchMiddleware

+ onFetch?: FetchMiddleware

+ + /** + * 错误处理钩子(可选) + * 在 HTTP 错误抛出前调用,可用于节流、重试等 + * @param error 即将抛出的错误 + * @param response 原始 Response(如果有) + * @param context 中间件上下文 + * @returns 返回 true 表示错误已处理,跳过默认日志 + */ + onError?: (error: Error, response: Response | undefined, context: MiddlewareContext

) => boolean /** * 订阅时调用(可选) diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index 6047cfba6..61d0470c7 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -216,12 +216,12 @@ ], "decimals": 6, "apis": [ - { "type": "tron-rpc", "endpoint": "https://api.trongrid.io" }, { "type": "tron-rpc-pro", "endpoint": "https://api.trongrid.io", "config": { "apiKeyEnv": "TRONGRID_API_KEY" } }, + { "type": "tron-rpc", "endpoint": "https://api.trongrid.io" }, { "type": "tronwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/tron" } ], "explorer": { diff --git a/src/services/chain-adapter/providers/api-key-picker.ts b/src/services/chain-adapter/providers/api-key-picker.ts index ff0541e6e..bb8eec4ed 100644 --- a/src/services/chain-adapter/providers/api-key-picker.ts +++ b/src/services/chain-adapter/providers/api-key-picker.ts @@ -60,3 +60,21 @@ export function clearApiKeyCache(): void { export function getLockedApiKey(cacheKey: string): string | undefined { return lockedKeys.get(cacheKey) } + +/** + * 从环境变量获取 API Key + * + * 封装了 __API_KEYS__ 全局变量的读取逻辑,简化 Provider 中的 API Key 获取。 + * + * @param envKey - 环境变量名,如 'TRONGRID_API_KEY', 'ETHERSCAN_API_KEY' + * @param cacheKey - 缓存键,用于锁定随机选择的 key + * @returns API Key 或 undefined + * + * @example + * const apiKey = getApiKey('TRONGRID_API_KEY', `trongrid-${chainId}`) + */ +export function getApiKey(envKey: string | undefined, cacheKey: string): string | undefined { + if (!envKey) return undefined + const apiKeys = (globalThis as unknown as Record>).__API_KEYS__ + return pickApiKey(apiKeys?.[envKey], cacheKey) +} diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.ts b/src/services/chain-adapter/providers/tron-rpc-provider.ts index 61642ce89..e11721a60 100644 --- a/src/services/chain-adapter/providers/tron-rpc-provider.ts +++ b/src/services/chain-adapter/providers/tron-rpc-provider.ts @@ -5,7 +5,7 @@ */ import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, pathParams, postBody } from '@biochain/key-fetch' +import { keyFetch, interval, deps, derive, transform, pathParams, postBody, throttleError, errorMatchers, apiKey } from '@biochain/key-fetch' import type { KeyFetchInstance } from '@biochain/key-fetch' import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, BlockHeightOutput, TransactionOutput, TransactionsOutput, AddressParams, TxHistoryParams, TransactionParams } from './types' import { @@ -21,6 +21,7 @@ import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' import { TronIdentityMixin } from '../tron/identity-mixin' import { TronTransactionMixin } from '../tron/transaction-mixin' +import { pickApiKey, getApiKey } from './api-key-picker' // ==================== Schema 定义 ==================== @@ -179,6 +180,20 @@ export class TronRpcProvider extends TronIdentityMixin(TronTransactionMixin(Tron const symbol = this.symbol const decimals = this.decimals + // 读取 API Key + const tronApiKey = getApiKey(this.config?.apiKeyEnv as string, `trongrid-${chainId}`) + + // API Key 插件(TronGrid 使用 TRON-PRO-API-KEY 头) + const tronApiKeyPlugin = apiKey({ + header: 'TRON-PRO-API-KEY', + key: tronApiKey, + }) + + // 共享的 429 错误节流插件 + const tronThrottleError = throttleError({ + match: errorMatchers.httpStatus(429), + }) + // 基础 API fetcher // 区块 API - 使用 interval 轮询 this.#blockApi = keyFetch.create({ @@ -186,7 +201,7 @@ export class TronRpcProvider extends TronIdentityMixin(TronTransactionMixin(Tron outputSchema: TronNowBlockSchema, url: `${baseUrl}/wallet/getnowblock`, method: 'POST', - use: [interval(3_000)], // Tron 3s 出块 + use: [interval(3_000), tronApiKeyPlugin, tronThrottleError], }) // 账户信息 - 由 blockApi 驱动 @@ -203,6 +218,8 @@ export class TronRpcProvider extends TronIdentityMixin(TronTransactionMixin(Tron address: tronAddressToHex(params.address as string), }), }), + tronApiKeyPlugin, + tronThrottleError, ], }) @@ -212,7 +229,7 @@ export class TronRpcProvider extends TronIdentityMixin(TronTransactionMixin(Tron outputSchema: TronTxListSchema, inputSchema: TxHistoryParamsSchema, url: `${baseUrl}/v1/accounts/:address/transactions`, - use: [deps(this.#blockApi), pathParams()], + use: [deps(this.#blockApi), pathParams(), tronApiKeyPlugin, tronThrottleError], }) // 派生视图 @@ -287,10 +304,13 @@ export class TronRpcProvider extends TronIdentityMixin(TronTransactionMixin(Tron url: `${baseUrl}/wallet/gettransactionbyid`, method: 'POST', - }).use(deps(this.#blockApi), // 交易状态会随区块变化 + }).use(deps(this.#blockApi), postBody({ transform: (params) => ({ value: params.txHash }), - }),) + }), + tronApiKeyPlugin, + tronThrottleError, + ) this.transaction = derive({ name: `tron-rpc.${chainId}.transaction`, From df1dad08c393b36cee13ba8d1716c71e431fc9b3 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 13:46:26 +0800 Subject: [PATCH 02/33] feat(providers): add API key and 429 throttling to Etherscan and EVM RPC - Etherscan: add API key via query param + 429 error throttling - EVM RPC: add 429 error throttling for public nodes --- .../providers/etherscan-provider.ts | 32 +++++++++++++++++-- .../providers/evm-rpc-provider.ts | 11 ++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/services/chain-adapter/providers/etherscan-provider.ts b/src/services/chain-adapter/providers/etherscan-provider.ts index 8b2a425ad..59390f6d1 100644 --- a/src/services/chain-adapter/providers/etherscan-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-provider.ts @@ -5,8 +5,8 @@ */ import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, searchParams } from '@biochain/key-fetch' -import type { KeyFetchInstance } from '@biochain/key-fetch' +import { keyFetch, interval, deps, derive, transform, searchParams, throttleError, errorMatchers } from '@biochain/key-fetch' +import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' import { BalanceOutputSchema, @@ -19,6 +19,7 @@ import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' import { EvmIdentityMixin } from '../evm/identity-mixin' import { EvmTransactionMixin } from '../evm/transaction-mixin' +import { getApiKey } from './api-key-picker' // ==================== Schema 定义 ==================== @@ -100,6 +101,27 @@ export class EtherscanProvider extends EvmIdentityMixin(EvmTransactionMixin(Ethe const symbol = this.symbol const decimals = this.decimals + // 读取 API Key + const etherscanApiKey = getApiKey(this.config?.apiKeyEnv as string, `etherscan-${chainId}`) + + // API Key 插件(Etherscan 使用 apikey 查询参数) + const etherscanApiKeyPlugin: FetchPlugin = { + name: 'etherscan-api-key', + onFetch: async (request, next) => { + if (etherscanApiKey) { + const url = new URL(request.url) + url.searchParams.set('apikey', etherscanApiKey) + return next(new Request(url.toString(), request)) + } + return next(request) + }, + } + + // 共享 429 节流 + const etherscanThrottleError = throttleError({ + match: errorMatchers.httpStatus(429), + }) + // 区块高度 API - 使用 Etherscan 的 proxy 模块 const blockHeightApi = keyFetch.create({ name: `etherscan.${chainId}.blockHeight`, @@ -113,6 +135,8 @@ export class EtherscanProvider extends EvmIdentityMixin(EvmTransactionMixin(Ethe action: 'eth_blockNumber', }), }), + etherscanApiKeyPlugin, + etherscanThrottleError, ], }) @@ -132,6 +156,8 @@ export class EtherscanProvider extends EvmIdentityMixin(EvmTransactionMixin(Ethe tag: 'latest', })), }), + etherscanApiKeyPlugin, + etherscanThrottleError, ], }) @@ -153,6 +179,8 @@ export class EtherscanProvider extends EvmIdentityMixin(EvmTransactionMixin(Ethe sort: 'desc', })), }), + etherscanApiKeyPlugin, + etherscanThrottleError, ], }) diff --git a/src/services/chain-adapter/providers/evm-rpc-provider.ts b/src/services/chain-adapter/providers/evm-rpc-provider.ts index 07bb9eabe..6b7e28f70 100644 --- a/src/services/chain-adapter/providers/evm-rpc-provider.ts +++ b/src/services/chain-adapter/providers/evm-rpc-provider.ts @@ -5,7 +5,7 @@ */ import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, combine, postBody } from '@biochain/key-fetch' +import { keyFetch, interval, deps, derive, transform, combine, postBody, throttleError, errorMatchers } from '@biochain/key-fetch' import type { KeyFetchInstance } from '@biochain/key-fetch' import type { ApiProvider, Balance, BalanceOutput, BlockHeightOutput, TransactionOutput, AddressParams, TransactionParams } from './types' import { BalanceOutputSchema, BlockHeightOutputSchema, TransactionOutputSchema, AddressParamsSchema, TransactionParamsSchema } from './types' @@ -89,6 +89,11 @@ export class EvmRpcProvider extends EvmIdentityMixin(EvmTransactionMixin(EvmRpcB const { endpoint: rpc, symbol, decimals } = this + // 共享 429 节流 + const evmThrottleError = throttleError({ + match: errorMatchers.httpStatus(429), + }) + // 区块高度 RPC - 使用 interval 轮询 this.#blockRpc = keyFetch.create({ name: `evm-rpc.${chainId}.blockRpc`, @@ -105,6 +110,7 @@ export class EvmRpcProvider extends EvmIdentityMixin(EvmTransactionMixin(EvmRpcB params: [], }), }), + evmThrottleError, ], }) @@ -125,6 +131,7 @@ export class EvmRpcProvider extends EvmIdentityMixin(EvmTransactionMixin(EvmRpcB params: [params.address, 'latest'], }), }), + evmThrottleError, ], }) @@ -145,6 +152,7 @@ export class EvmRpcProvider extends EvmIdentityMixin(EvmTransactionMixin(EvmRpcB params: [params.txHash], }), }), + evmThrottleError, ], }) @@ -165,6 +173,7 @@ export class EvmRpcProvider extends EvmIdentityMixin(EvmTransactionMixin(EvmRpcB params: [params.txHash], }), }), + evmThrottleError, ], }) From f4b1170b5bcb3f605190dae0364b027d567b19c5 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 14:54:08 +0800 Subject: [PATCH 03/33] feat(providers): split Etherscan into V1/V2 with chainid support - Create etherscan-v2-provider.ts with chainid parameter for V2 API - Rename etherscan-provider.ts to etherscan-v1-provider.ts - Add JsonRpcResponseSchema for proxy module responses - Add hex balance conversion for V2 API - Reorder Ethereum APIs to prioritize etherscan-v2 - Update V1 factory to not match etherscan-v2 type - Update tests for blockscout-v1 matching --- public/configs/default-chains.json | 14 +- .../__tests__/etherscan-provider.test.ts | 40 +-- ...n-provider.ts => etherscan-v1-provider.ts} | 11 +- .../providers/etherscan-v2-provider.ts | 268 ++++++++++++++++++ src/services/chain-adapter/providers/index.ts | 9 +- 5 files changed, 314 insertions(+), 28 deletions(-) rename src/services/chain-adapter/providers/{etherscan-provider.ts => etherscan-v1-provider.ts} (94%) create mode 100644 src/services/chain-adapter/providers/etherscan-v2-provider.ts diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index 61d0470c7..008f6f0ed 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -159,11 +159,16 @@ ], "decimals": 18, "apis": [ - { "type": "blockscout-v1", "endpoint": "https://eth.blockscout.com/api" }, - { "type": "ethereum-rpc", "endpoint": "https://ethereum-rpc.publicnode.com" }, { "type": "etherscan-v2", "endpoint": "https://api.etherscan.io/v2/api", + "config": { "apiKeyEnv": "ETHERSCAN_API_KEY", "evmChainId": 1 } + }, + { "type": "blockscout-v1", "endpoint": "https://eth.blockscout.com/api" }, + { "type": "ethereum-rpc", "endpoint": "https://ethereum-rpc.publicnode.com" }, + { + "type": "etherscan-v1", + "endpoint": "https://api.etherscan.io/api", "config": { "apiKeyEnv": "ETHERSCAN_API_KEY" } }, { "type": "ethwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/eth" } @@ -192,6 +197,11 @@ { "type": "etherscan-v2", "endpoint": "https://api.etherscan.io/v2/api", + "config": { "apiKeyEnv": "ETHERSCAN_API_KEY", "evmChainId": 56 } + }, + { + "type": "etherscan-v1", + "endpoint": "https://api.bscscan.com/api", "config": { "apiKeyEnv": "ETHERSCAN_API_KEY" } }, { "type": "bscwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/bsc" } diff --git a/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts b/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts index cff6317b0..674cf7e31 100644 --- a/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts +++ b/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { EtherscanProvider, createEtherscanProvider } from '../etherscan-provider' +import { EtherscanV1Provider, createEtherscanV1Provider } from '../etherscan-v1-provider' import type { ParsedApiEntry } from '@/services/chain-config' import { keyFetch } from '@biochain/key-fetch' @@ -45,7 +45,7 @@ function createMockResponse(data: T, ok = true, status = 200): Response { }) } -describe('EtherscanProvider', () => { +describe('EtherscanV1Provider', () => { const mockEntry: ParsedApiEntry = { type: 'blockscout-eth', endpoint: 'https://eth.blockscout.com/api', @@ -56,19 +56,23 @@ describe('EtherscanProvider', () => { keyFetch.clear() }) - describe('createEtherscanProvider', () => { - it('creates provider for etherscan-* type', () => { - const provider = createEtherscanProvider(mockEntry, 'ethereum') - expect(provider).toBeInstanceOf(EtherscanProvider) + describe('createEtherscanV1Provider', () => { + it('creates provider for etherscan-v1 type', () => { + const entry: ParsedApiEntry = { + type: 'etherscan-v1', + endpoint: 'https://api.etherscan.io/api', + } + const provider = createEtherscanV1Provider(entry, 'ethereum') + expect(provider).toBeInstanceOf(EtherscanV1Provider) }) - it('creates provider for *scan-* type', () => { - const bscEntry: ParsedApiEntry = { - type: 'bscscan-v1', - endpoint: 'https://api.bscscan.com/api', + it('creates provider for blockscout-v1 type', () => { + const blockscoutEntry: ParsedApiEntry = { + type: 'blockscout-v1', + endpoint: 'https://eth.blockscout.com/api', } - const provider = createEtherscanProvider(bscEntry, 'binance') - expect(provider).toBeInstanceOf(EtherscanProvider) + const provider = createEtherscanV1Provider(blockscoutEntry, 'ethereum') + expect(provider).toBeInstanceOf(EtherscanV1Provider) }) it('returns null for non-scan type', () => { @@ -76,7 +80,7 @@ describe('EtherscanProvider', () => { type: 'ethereum-rpc', endpoint: 'https://rpc.example.com', } - const provider = createEtherscanProvider(rpcEntry, 'ethereum') + const provider = createEtherscanV1Provider(rpcEntry, 'ethereum') expect(provider).toBeNull() }) }) @@ -101,7 +105,7 @@ describe('EtherscanProvider', () => { return createMockResponse(balanceResponse) }) - const provider = new EtherscanProvider(mockEntry, 'ethereum') + const provider = new EtherscanV1Provider(mockEntry, 'ethereum') const balance = await provider.nativeBalance.fetch({ address: '0x1234' }) expect(mockFetch).toHaveBeenCalled() @@ -126,7 +130,7 @@ describe('EtherscanProvider', () => { return createMockResponse({ status: '1', message: 'OK', result: [] }) }) - const provider = new EtherscanProvider(mockEntry, 'ethereum') + const provider = new EtherscanV1Provider(mockEntry, 'ethereum') const txs = await provider.transactionHistory.fetch({ address: receiver }) expect(txs).toHaveLength(1) @@ -161,7 +165,7 @@ describe('EtherscanProvider', () => { return createMockResponse(txListResponse) }) - const provider = new EtherscanProvider(mockEntry, 'ethereum') + const provider = new EtherscanV1Provider(mockEntry, 'ethereum') await provider.transactionHistory.fetch({ address: '0xTestLimit', limit: 10 }) expect(mockFetch).toHaveBeenCalled() @@ -183,7 +187,7 @@ describe('EtherscanProvider', () => { return createMockResponse(txListResponse) }) - const provider = new EtherscanProvider(mockEntry, 'ethereum') + const provider = new EtherscanV1Provider(mockEntry, 'ethereum') await provider.transactionHistory.fetch({ address: '0xDefaultLimit' }) expect(mockFetch).toHaveBeenCalled() @@ -227,7 +231,7 @@ describe('EtherscanProvider', () => { mockFetch.mockResolvedValue(createMockResponse(txListResponse)) - const provider = new EtherscanProvider(mockEntry, 'ethereum') + const provider = new EtherscanV1Provider(mockEntry, 'ethereum') const txs = await provider.transactionHistory.fetch( { address: testAddress }, { skipCache: true } diff --git a/src/services/chain-adapter/providers/etherscan-provider.ts b/src/services/chain-adapter/providers/etherscan-v1-provider.ts similarity index 94% rename from src/services/chain-adapter/providers/etherscan-provider.ts rename to src/services/chain-adapter/providers/etherscan-v1-provider.ts index 59390f6d1..8ada6d990 100644 --- a/src/services/chain-adapter/providers/etherscan-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-v1-provider.ts @@ -1,7 +1,8 @@ /** - * Etherscan API Provider + * Etherscan V1 API Provider * * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 + * V1 API 作为 V2 的兜底方案 */ import { z } from 'zod' @@ -81,7 +82,7 @@ class EtherscanBase { // ==================== Provider 实现 (使用 Mixin 继承) ==================== -export class EtherscanProvider extends EvmIdentityMixin(EvmTransactionMixin(EtherscanBase)) implements ApiProvider { +export class EtherscanV1Provider extends EvmIdentityMixin(EvmTransactionMixin(EtherscanBase)) implements ApiProvider { private readonly symbol: string private readonly decimals: number @@ -241,9 +242,9 @@ export class EtherscanProvider extends EvmIdentityMixin(EvmTransactionMixin(Ethe } } -export function createEtherscanProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type.includes('etherscan') || entry.type.includes('blockscout') || entry.type.includes('scan')) { - return new EtherscanProvider(entry, chainId) +export function createEtherscanV1Provider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === 'etherscan-v1' || entry.type.includes('blockscout')) { + return new EtherscanV1Provider(entry, chainId) } return null } diff --git a/src/services/chain-adapter/providers/etherscan-v2-provider.ts b/src/services/chain-adapter/providers/etherscan-v2-provider.ts new file mode 100644 index 000000000..e7973f51a --- /dev/null +++ b/src/services/chain-adapter/providers/etherscan-v2-provider.ts @@ -0,0 +1,268 @@ +/** + * Etherscan V2 API Provider + * + * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 + * V2 API 需要 chainid 参数,支持多链统一端点 + */ + +import { z } from 'zod' +import { keyFetch, interval, deps, derive, transform, searchParams, throttleError, errorMatchers } from '@biochain/key-fetch' +import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' +import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' +import { + BalanceOutputSchema, + TransactionsOutputSchema, + AddressParamsSchema, + TxHistoryParamsSchema, +} from './types' +import type { ParsedApiEntry } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' +import { Amount } from '@/types/amount' +import { EvmIdentityMixin } from '../evm/identity-mixin' +import { EvmTransactionMixin } from '../evm/transaction-mixin' +import { getApiKey } from './api-key-picker' + +// ==================== Schema 定义 ==================== + +const ApiResponseSchema = z.object({ + status: z.string(), + message: z.string(), + result: z.unknown(), +}).passthrough() + +// JSON-RPC 格式响应 (proxy 模块使用) +const JsonRpcResponseSchema = z.object({ + jsonrpc: z.string(), + id: z.number(), + result: z.string(), +}).passthrough() + +const NativeTxSchema = z.object({ + hash: z.string(), + from: z.string(), + to: z.string(), + value: z.string(), + timeStamp: z.string(), + isError: z.string(), + blockNumber: z.string(), + input: z.string().optional(), + methodId: z.string().optional(), + functionName: z.string().optional(), +}).passthrough() + +type ApiResponse = z.infer +type NativeTx = z.infer + +// ==================== 工具函数 ==================== + +function getDirection(from: string, to: string, address: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + if (fromLower === address && toLower === address) return 'self' + if (fromLower === address) return 'out' + return 'in' +} + +// ==================== Base Class for Mixins ==================== + +class EtherscanV2Base { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 (使用 Mixin 继承) ==================== + +export class EtherscanV2Provider extends EvmIdentityMixin(EvmTransactionMixin(EtherscanV2Base)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + + readonly #balanceApi: KeyFetchInstance + readonly #txListApi: KeyFetchInstance + + readonly nativeBalance: KeyFetchInstance + readonly transactionHistory: KeyFetchInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + + const baseUrl = this.endpoint + const symbol = this.symbol + const decimals = this.decimals + + // 读取 EVM Chain ID (V2 必需) + const evmChainId = this.config?.evmChainId as number | undefined + if (!evmChainId) { + throw new Error(`[EtherscanV2Provider] evmChainId is required for chain ${chainId}`) + } + + // 读取 API Key + const etherscanApiKey = getApiKey(this.config?.apiKeyEnv as string, `etherscan-${chainId}`) + + // API Key 插件(Etherscan 使用 apikey 查询参数) + const etherscanApiKeyPlugin: FetchPlugin = { + name: 'etherscan-api-key', + onFetch: async (request, next) => { + if (etherscanApiKey) { + const url = new URL(request.url) + url.searchParams.set('apikey', etherscanApiKey) + return next(new Request(url.toString(), request)) + } + return next(request) + }, + } + + // 共享 400/429 节流 + const etherscanThrottleError = throttleError({ + match: errorMatchers.httpStatus(400, 429), + }) + + // 区块高度 API - 使用 Etherscan 的 proxy 模块 (返回 JSON-RPC 格式) + const blockHeightApi = keyFetch.create({ + name: `etherscan-v2.${chainId}.blockHeight`, + outputSchema: JsonRpcResponseSchema, + url: `${baseUrl}`, + use: [ + interval(12_000), // EVM 链约 12s 出块 + searchParams({ + transform: () => ({ + chainid: evmChainId.toString(), + module: 'proxy', + action: 'eth_blockNumber', + }), + }), + etherscanApiKeyPlugin, + etherscanThrottleError, + ], + }) + + // 余额查询 - 由 blockHeightApi 驱动 + this.#balanceApi = keyFetch.create({ + name: `etherscan-v2.${chainId}.balanceApi`, + outputSchema: ApiResponseSchema, + inputSchema: AddressParamsSchema, + url: `${baseUrl}`, + use: [ + deps(blockHeightApi), + searchParams({ + transform: ((params) => ({ + chainid: evmChainId.toString(), + module: 'account', + action: 'balance', + address: params.address, + tag: 'latest', + })), + }), + etherscanApiKeyPlugin, + etherscanThrottleError, + ], + }) + + // 交易历史 - 由 blockHeightApi 驱动 + this.#txListApi = keyFetch.create({ + name: `etherscan-v2.${chainId}.txListApi`, + outputSchema: ApiResponseSchema, + inputSchema: TxHistoryParamsSchema, + url: `${baseUrl}`, + use: [ + deps(blockHeightApi), + searchParams({ + transform: ((params) => ({ + chainid: evmChainId.toString(), + module: 'account', + action: 'txlist', + address: params.address, + page: '1', + offset: String(params.limit ?? 20), + sort: 'desc', + })), + }), + etherscanApiKeyPlugin, + etherscanThrottleError, + ], + }) + + this.nativeBalance = derive({ + name: `etherscan-v2.${chainId}.nativeBalance`, + source: this.#balanceApi, + outputSchema: BalanceOutputSchema, + use: [ + transform({ + transform: (raw) => { + const result = raw.result + // API 可能返回错误消息而非余额 + if (typeof result !== 'string') { + throw new Error(`Invalid balance result: ${JSON.stringify(result)}`) + } + // 检查是否为错误消息 (如 "Missing/Invalid API Key") + if (raw.status === '0') { + throw new Error(`API error: ${result}`) + } + // V2 API 可能返回十六进制或十进制字符串 + const balanceValue = result.startsWith('0x') + ? BigInt(result).toString() + : result + return { + amount: Amount.fromRaw(balanceValue, decimals, symbol), + symbol, + } + }, + }), + ], + }) + + this.transactionHistory = derive({ + name: `etherscan-v2.${chainId}.transactionHistory`, + source: this.#txListApi, + outputSchema: TransactionsOutputSchema, + }).use(transform({ + transform: (raw: ApiResponse, ctx): Transaction[] => { + const result = raw.result + if (!Array.isArray(result)) return [] + + const address = ((ctx.params.address as string) ?? '').toLowerCase() + + return result + .map(item => NativeTxSchema.safeParse(item)) + .filter((r): r is z.ZodSafeParseSuccess => r.success) + .map((r): Transaction => { + const tx = r.data + const direction = getDirection(tx.from, tx.to, address) + return { + hash: tx.hash, + from: tx.from, + to: tx.to, + timestamp: parseInt(tx.timeStamp, 10) * 1000, + status: tx.isError === '0' ? 'confirmed' : 'failed', + blockNumber: BigInt(tx.blockNumber), + action: 'transfer' as const, + direction, + assets: [{ + assetType: 'native' as const, + value: tx.value, + symbol, + decimals, + }], + } + }) + }, + }),) + } +} + +export function createEtherscanV2Provider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === 'etherscan-v2') { + return new EtherscanV2Provider(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts index 478902fe5..3efc6eaff 100644 --- a/src/services/chain-adapter/providers/index.ts +++ b/src/services/chain-adapter/providers/index.ts @@ -12,7 +12,8 @@ export { InvalidDataError } from './errors'; // 导出所有 Provider 实现 export { ChainProvider } from './chain-provider'; -export { EtherscanProvider, createEtherscanProvider } from './etherscan-provider'; +export { EtherscanV1Provider, createEtherscanV1Provider } from './etherscan-v1-provider'; +export { EtherscanV2Provider, createEtherscanV2Provider } from './etherscan-v2-provider'; export { EvmRpcProvider, createEvmRpcProvider } from './evm-rpc-provider'; export { BiowalletProvider, createBiowalletProvider } from './biowallet-provider'; export { BscWalletProvider, createBscWalletProvider } from './bscwallet-provider'; @@ -28,7 +29,8 @@ import type { ParsedApiEntry } from '@/services/chain-config'; import { chainConfigService } from '@/services/chain-config'; import { ChainProvider } from './chain-provider'; -import { createEtherscanProvider } from './etherscan-provider'; +import { createEtherscanV1Provider } from './etherscan-v1-provider'; +import { createEtherscanV2Provider } from './etherscan-v2-provider'; import { createEvmRpcProvider } from './evm-rpc-provider'; import { createBiowalletProvider } from './biowallet-provider'; import { createBscWalletProvider } from './bscwallet-provider'; @@ -42,7 +44,8 @@ import { createBtcwalletProvider } from './btcwallet-provider'; const PROVIDER_FACTORIES: ApiProviderFactory[] = [ createBiowalletProvider, createBscWalletProvider, - createEtherscanProvider, + createEtherscanV2Provider, + createEtherscanV1Provider, createEvmRpcProvider, createTronRpcProvider, createMempoolProvider, From 6e1e5f7b549d1bb0d3da3268f2cbe5755672b814 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 15:34:09 +0800 Subject: [PATCH 04/33] feat(providers): add ServiceLimitedError for user-friendly API error handling - Create ServiceLimitedError class in key-fetch/errors.ts - Export from @biochain/key-fetch - V1/V2 providers throw ServiceLimitedError on API failures - Replaces silent empty array returns with explicit error --- packages/key-fetch/src/errors.ts | 11 ++++++++++ packages/key-fetch/src/index.ts | 4 ++++ .../providers/etherscan-v1-provider.ts | 18 ++++++++++++++--- .../providers/etherscan-v2-provider.ts | 20 ++++++++++++------- 4 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 packages/key-fetch/src/errors.ts diff --git a/packages/key-fetch/src/errors.ts b/packages/key-fetch/src/errors.ts new file mode 100644 index 000000000..23c0a7e7d --- /dev/null +++ b/packages/key-fetch/src/errors.ts @@ -0,0 +1,11 @@ +/** + * Key-Fetch 错误类型 + */ + +/** 服务受限错误 - 用于显示给用户的友好提示 */ +export class ServiceLimitedError extends Error { + constructor(message = '服务受限') { + super(message) + this.name = 'ServiceLimitedError' + } +} diff --git a/packages/key-fetch/src/index.ts b/packages/key-fetch/src/index.ts index 691c7b134..5e4eac1b5 100644 --- a/packages/key-fetch/src/index.ts +++ b/packages/key-fetch/src/index.ts @@ -99,6 +99,10 @@ export type { KeyFetchDeriveOptions } from './derive' export { fallback, NoSupportError } from './fallback' export type { FallbackOptions as MergeOptions } from './fallback' +// ==================== 导出错误类型 ==================== + +export { ServiceLimitedError } from './errors' + // ==================== 导出 Combine 工具 ==================== export { combine } from './combine' diff --git a/src/services/chain-adapter/providers/etherscan-v1-provider.ts b/src/services/chain-adapter/providers/etherscan-v1-provider.ts index 8ada6d990..7745e2d6a 100644 --- a/src/services/chain-adapter/providers/etherscan-v1-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-v1-provider.ts @@ -6,7 +6,7 @@ */ import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, searchParams, throttleError, errorMatchers } from '@biochain/key-fetch' +import { keyFetch, interval, deps, derive, transform, searchParams, throttleError, errorMatchers, ServiceLimitedError } from '@biochain/key-fetch' import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' import { @@ -193,7 +193,13 @@ export class EtherscanV1Provider extends EvmIdentityMixin(EvmTransactionMixin(Et transform({ transform: (raw) => { const result = raw.result - if (typeof result !== 'string') throw new Error('Invalid balance result') + // 检查 API 错误状态 + if (raw.status === '0') { + throw new ServiceLimitedError() + } + if (typeof result !== 'string') { + throw new ServiceLimitedError() + } return { amount: Amount.fromRaw(result, decimals, symbol), symbol, @@ -210,7 +216,13 @@ export class EtherscanV1Provider extends EvmIdentityMixin(EvmTransactionMixin(Et }).use(transform({ transform: (raw: ApiResponse, ctx): Transaction[] => { const result = raw.result - if (!Array.isArray(result)) return [] + // 检查 API 错误状态 + if (raw.status === '0') { + throw new ServiceLimitedError() + } + if (!Array.isArray(result)) { + throw new ServiceLimitedError() + } const address = ((ctx.params.address as string) ?? '').toLowerCase() diff --git a/src/services/chain-adapter/providers/etherscan-v2-provider.ts b/src/services/chain-adapter/providers/etherscan-v2-provider.ts index e7973f51a..5c354af52 100644 --- a/src/services/chain-adapter/providers/etherscan-v2-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-v2-provider.ts @@ -6,7 +6,7 @@ */ import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, searchParams, throttleError, errorMatchers } from '@biochain/key-fetch' +import { keyFetch, interval, deps, derive, transform, searchParams, throttleError, errorMatchers, ServiceLimitedError } from '@biochain/key-fetch' import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' import { @@ -200,13 +200,13 @@ export class EtherscanV2Provider extends EvmIdentityMixin(EvmTransactionMixin(Et transform({ transform: (raw) => { const result = raw.result + // 检查 API 错误状态 + if (raw.status === '0') { + throw new ServiceLimitedError() + } // API 可能返回错误消息而非余额 if (typeof result !== 'string') { - throw new Error(`Invalid balance result: ${JSON.stringify(result)}`) - } - // 检查是否为错误消息 (如 "Missing/Invalid API Key") - if (raw.status === '0') { - throw new Error(`API error: ${result}`) + throw new ServiceLimitedError() } // V2 API 可能返回十六进制或十进制字符串 const balanceValue = result.startsWith('0x') @@ -228,7 +228,13 @@ export class EtherscanV2Provider extends EvmIdentityMixin(EvmTransactionMixin(Et }).use(transform({ transform: (raw: ApiResponse, ctx): Transaction[] => { const result = raw.result - if (!Array.isArray(result)) return [] + // 检查 API 错误状态 + if (raw.status === '0') { + throw new ServiceLimitedError() + } + if (!Array.isArray(result)) { + throw new ServiceLimitedError() + } const address = ((ctx.params.address as string) ?? '').toLowerCase() From 63727031fd8152fb193e9c31a718464bca7432b7 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 15:49:35 +0800 Subject: [PATCH 05/33] feat(ui): add unified ServiceStatusAlert component for error handling - Create useServiceStatus hook to distinguish NoSupportError/ServiceLimitedError - Create ServiceStatusAlert component with i18n support - Add service.* i18n keys for zh-CN, en, zh-TW, ar - Update WalletTab to pass error status to WalletAddressPortfolioView - Update history page to show ServiceStatusAlert on errors - Replace ProviderFallbackWarning with ServiceStatusAlert in portfolio view --- src/components/common/index.ts | 1 + .../common/service-status-alert.tsx | 86 ++++++++++++++++++ .../wallet/wallet-address-portfolio-view.tsx | 8 +- src/hooks/use-service-status.ts | 87 +++++++++++++++++++ src/i18n/locales/ar/common.json | 10 +++ src/i18n/locales/en/common.json | 10 +++ src/i18n/locales/zh-CN/common.json | 10 +++ src/i18n/locales/zh-TW/common.json | 10 +++ src/pages/history/index.tsx | 15 +++- src/stackflow/activities/tabs/WalletTab.tsx | 13 ++- 10 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 src/components/common/service-status-alert.tsx create mode 100644 src/hooks/use-service-status.ts diff --git a/src/components/common/index.ts b/src/components/common/index.ts index e928a0b02..d94722ec8 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -42,3 +42,4 @@ export { TimeDisplay, formatDate, formatDateTime, formatTime, toDate } from './t export { FormField } from './form-field' export { ErrorBoundary } from './error-boundary' export { CopyableText } from './copyable-text' +export { ServiceStatusAlert, type ServiceStatusAlertProps, type ServiceAlertType } from './service-status-alert' diff --git a/src/components/common/service-status-alert.tsx b/src/components/common/service-status-alert.tsx new file mode 100644 index 000000000..c5c4f29a0 --- /dev/null +++ b/src/components/common/service-status-alert.tsx @@ -0,0 +1,86 @@ +/** + * 统一服务状态提示组件 + * + * 根据不同错误类型显示对应的用户友好提示 + */ + +import { useTranslation } from 'react-i18next' +import { Alert } from '@biochain/key-ui' +import { IconAlertTriangle, IconCloudOff, IconLock } from '@tabler/icons-react' +import { cn } from '@/lib/utils' + +export type ServiceAlertType = 'notSupported' | 'limited' | 'error' + +export interface ServiceStatusAlertProps { + /** 错误类型 */ + type: ServiceAlertType + /** 功能名称(如"交易历史") */ + feature?: string + /** 技术原因(可选展开查看) */ + reason?: string + /** 自定义样式 */ + className?: string +} + +/** + * 服务状态提示组件 + * + * @example + * ```tsx + * // 服务受限 + * + * + * // 不支持 + * + * + * // 查询失败 + * + * ``` + */ +export function ServiceStatusAlert({ type, feature, reason, className }: ServiceStatusAlertProps) { + const { t } = useTranslation('common') + + const config = { + notSupported: { + variant: 'info' as const, + icon: , + title: t('service.notSupported'), + desc: feature + ? t('service.notSupportedDesc', { feature }) + : t('service.notSupportedDescGeneric'), + }, + limited: { + variant: 'warning' as const, + icon: , + title: t('service.limited'), + desc: t('service.limitedDesc'), + }, + error: { + variant: 'error' as const, + icon: , + title: t('service.queryFailed'), + desc: t('service.queryFailedDesc'), + }, + } + + const { variant, icon, title, desc } = config[type] + + return ( + +

{desc}

+ {reason && ( +
+ + {t('service.technicalDetails')} + +

{reason}

+
+ )} + + ) +} diff --git a/src/components/wallet/wallet-address-portfolio-view.tsx b/src/components/wallet/wallet-address-portfolio-view.tsx index e5a9c75cc..c427a9499 100644 --- a/src/components/wallet/wallet-address-portfolio-view.tsx +++ b/src/components/wallet/wallet-address-portfolio-view.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { TokenList } from '@/components/token/token-list' import { TransactionList } from '@/components/transaction/transaction-list' import { SwipeableTabs } from '@/components/layout/swipeable-tabs' -import { ProviderFallbackWarning } from '@/components/common/provider-fallback-warning' +import { ServiceStatusAlert } from '@/components/common/service-status-alert' import { ErrorBoundary } from '@/components/common/error-boundary' import type { TokenInfo, TokenItemContext, TokenMenuItem } from '@/components/token/token-item' import type { TransactionInfo } from '@/components/transaction/transaction-item' @@ -77,7 +77,8 @@ export function WalletAddressPortfolioView({ tab === 'assets' ? (
{!tokensSupported && !tokensLoading && ( - {!transactionsSupported && !transactionsLoading && ( - + * } + * ``` + */ +export function useServiceStatus(error: Error | undefined, t: TFunction): ServiceStatus { + return useMemo(() => { + if (!error) { + return { supported: true, limited: false, type: 'ok' as const } + } + if (error instanceof NoSupportError) { + return { + supported: false, + limited: false, + type: 'notSupported' as const, + reason: t('common:service.notSupported'), + } + } + if (error instanceof ServiceLimitedError) { + return { + supported: true, + limited: true, + type: 'limited' as const, + reason: t('common:service.limited'), + } + } + return { + supported: true, + limited: false, + type: 'error' as const, + error, + reason: error.message, + } + }, [error, t]) +} + +/** + * 纯函数版本(不使用 useMemo) + * 用于非 React 环境或需要手动控制的场景 + */ +export function getServiceStatus(error: Error | undefined): Omit { + if (!error) { + return { supported: true, limited: false, type: 'ok' } + } + if (error instanceof NoSupportError) { + return { supported: false, limited: false, type: 'notSupported' } + } + if (error instanceof ServiceLimitedError) { + return { supported: true, limited: true, type: 'limited' } + } + return { supported: true, limited: false, type: 'error', error } +} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 12d125a0e..7951f44c3 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -519,5 +519,15 @@ "pattern": "ارسم النمط للتأكيد", "error": "النمط غير صحيح، يرجى المحاولة مرة أخرى" } + }, + "service": { + "notSupported": "غير مدعوم", + "notSupportedDesc": "هذه السلسلة لا تدعم {{feature}}", + "notSupportedDescGeneric": "هذه السلسلة لا تدعم هذه الميزة", + "limited": "الخدمة محدودة", + "limitedDesc": "خدمة API غير متاحة مؤقتًا. يرجى المحاولة لاحقًا أو استخدام مستكشف الكتل.", + "queryFailed": "فشل الاستعلام", + "queryFailedDesc": "فشل تحميل البيانات. يرجى التحقق من الشبكة والمحاولة مرة أخرى.", + "technicalDetails": "التفاصيل التقنية" } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 92caa5ab5..726be2fcf 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -519,5 +519,15 @@ "pattern": "Draw pattern to confirm", "error": "Pattern incorrect, please try again" } + }, + "service": { + "notSupported": "Not Supported", + "notSupportedDesc": "This chain does not support {{feature}}", + "notSupportedDescGeneric": "This chain does not support this feature", + "limited": "Service Limited", + "limitedDesc": "API service is temporarily unavailable. Please try again later or use block explorer.", + "queryFailed": "Query Failed", + "queryFailedDesc": "Failed to load data. Please check your network and try again.", + "technicalDetails": "Technical details" } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 47726884a..c50e0a0c8 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -519,5 +519,15 @@ "pattern": "请输入手势密码确认", "error": "手势密码错误,请重试" } + }, + "service": { + "notSupported": "暂不支持", + "notSupportedDesc": "当前链不支持{{feature}}功能", + "notSupportedDescGeneric": "当前链不支持此功能", + "limited": "服务受限", + "limitedDesc": "当前 API 服务暂时不可用,请稍后重试或使用区块浏览器查看", + "queryFailed": "查询失败", + "queryFailedDesc": "数据加载失败,请检查网络后重试", + "technicalDetails": "技术详情" } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 798fbfe18..9d8f51fda 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -519,5 +519,15 @@ "pattern": "請輸入手勢密碼確認", "error": "手勢密碼錯誤,請重試" } + }, + "service": { + "notSupported": "暫不支援", + "notSupportedDesc": "當前鏈不支援{{feature}}功能", + "notSupportedDescGeneric": "當前鏈不支援此功能", + "limited": "服務受限", + "limitedDesc": "當前 API 服務暫時不可用,請稍後重試或使用區塊瀏覽器查看", + "queryFailed": "查詢失敗", + "queryFailedDesc": "資料載入失敗,請檢查網路後重試", + "technicalDetails": "技術詳情" } } diff --git a/src/pages/history/index.tsx b/src/pages/history/index.tsx index 26499c54a..7bc17ff35 100644 --- a/src/pages/history/index.tsx +++ b/src/pages/history/index.tsx @@ -10,6 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { ChainProviderGate, useChainProvider } from '@/contexts'; import { useCurrentWallet, useEnabledChains, useSelectedChain, useChainConfigState, chainConfigSelectors } from '@/stores'; import { usePendingTransactions } from '@/hooks/use-pending-transactions'; +import { useServiceStatus } from '@/hooks/use-service-status'; +import { ServiceStatusAlert } from '@/components/common/service-status-alert'; import { cn } from '@/lib/utils'; import { toTransactionInfoList, type TransactionInfo } from '@/components/transaction'; import type { ChainType } from '@/stores'; @@ -45,11 +47,14 @@ function HistoryContent({ targetChain, address, filter, setFilter, walletId, dec const chainProvider = useChainProvider(); // 直接调用,不需要条件判断 - const { data: rawTransactions, isLoading, isFetching, refetch } = chainProvider.transactionHistory.useState( + const { data: rawTransactions, isLoading, isFetching, error, refetch } = chainProvider.transactionHistory.useState( { address, limit: 50 }, { enabled: !!address } ); + // 获取服务状态 + const txStatus = useServiceStatus(error, t); + // 获取 pending transactions const { transactions: pendingTransactions, @@ -216,6 +221,14 @@ function HistoryContent({ targetChain, address, filter, setFilter, walletId, dec )} {/* Confirmed Transactions */} + {(txStatus.limited || !txStatus.supported) && !isLoading && ( + + )} { // Token click handler }} From cc03732defa047b3a39feea11c55816100f0259b Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 16:09:43 +0800 Subject: [PATCH 06/33] feat(key-fetch): route Schema validation errors through onError hook - Modified core.ts to let Schema errors pass through plugin onError hooks - Enables throttleError to reduce console noise from repeated Schema errors - Preserves full error info for debugging when not handled by plugins - Added Schema error matching to etherscan-v2-provider throttleError --- packages/key-fetch/src/core.ts | 31 +++++++++++++++---- .../providers/etherscan-v2-provider.ts | 7 +++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/key-fetch/src/core.ts b/packages/key-fetch/src/core.ts index e73002b6b..88a031353 100644 --- a/packages/key-fetch/src/core.ts +++ b/packages/key-fetch/src/core.ts @@ -203,21 +203,40 @@ class KeyFetchInstanceImpl< return result } catch (err) { - console.error(this.name, err) // 包装 ZodError 为更可读的错误 + let schemaError: Error if (err && typeof err === 'object' && 'issues' in err) { const zodErr = err as { issues: Array<{ path: (string | number)[]; message: string }> } - const buildErrorMessage = () => `[${this.name}] Schema 验证失败:\n${zodErr.issues + const errorMessage = `[${this.name}] Schema 验证失败:\n${zodErr.issues .slice(0, 3) .map(i => ` - ${i.path.join('.')}: ${i.message}`) .join('\n')}` + (zodErr.issues.length > 3 ? `\n ... 还有 ${zodErr.issues.length - 3} 个错误` : '') + - `\n\nResponseJson: ${rawJson.slice(0, 300)}...` - + `\nResponseHeaders: ${[...response.headers.entries()].map(item => item.join("=")).join("; ")}` + `\n\nResponseJson: ${rawJson.slice(0, 300)}...` + + `\nResponseHeaders: ${[...response.headers.entries()].map(item => item.join("=")).join("; ")}` + schemaError = new Error(errorMessage) + } else { + schemaError = err instanceof Error ? err : new Error(String(err)) + } + + // 让插件有机会处理错误日志(如 throttleError 节流) + let errorHandled = false + for (const plugin of this.plugins) { + if (plugin.onError?.(schemaError, response, middlewareContext)) { + errorHandled = true + ;(schemaError as Error & { __errorHandled?: boolean }).__errorHandled = true + break + } + } + + // 未被插件处理时,输出完整日志便于调试 + if (!errorHandled) { + console.error(this.name, err) console.error(json, this.outputSchema) - throw new Error(buildErrorMessage()) } - throw err + + // 始终抛出错误(不吞掉) + throw schemaError } } diff --git a/src/services/chain-adapter/providers/etherscan-v2-provider.ts b/src/services/chain-adapter/providers/etherscan-v2-provider.ts index 5c354af52..472ea19d6 100644 --- a/src/services/chain-adapter/providers/etherscan-v2-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-v2-provider.ts @@ -122,9 +122,12 @@ export class EtherscanV2Provider extends EvmIdentityMixin(EvmTransactionMixin(Et }, } - // 共享 400/429 节流 + // 共享 400/429/Schema错误 节流 const etherscanThrottleError = throttleError({ - match: errorMatchers.httpStatus(400, 429), + match: errorMatchers.any( + errorMatchers.httpStatus(400, 429), + errorMatchers.contains('Schema 验证失败'), + ), }) // 区块高度 API - 使用 Etherscan 的 proxy 模块 (返回 JSON-RPC 格式) From 447cc90781c530def202da7589a60f566676bd42 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 17:23:44 +0800 Subject: [PATCH 07/33] feat(providers): add Moralis Provider for EVM token balances - Add MORALIS_API_KEY to set-secret.ts, vite.config.ts, .env.example - Add TokenMetadata interface for extended token info (spam, security score) - Add fromEntity/toEntity/summary fields to TransactionSchema - Create moralis-provider.ts with nativeBalance, tokenBalances, transactionHistory - Register Moralis as primary provider for ETH/BSC in default-chains.json - Moralis provides token list + rich metadata that Etherscan doesn't support --- .env.example | 4 + public/configs/default-chains.json | 8 + scripts/set-secret.ts | 1 + src/services/chain-adapter/providers/index.ts | 3 + .../providers/moralis-provider.ts | 416 ++++++++++++++++++ .../providers/transaction-schema.ts | 5 + src/services/chain-adapter/providers/types.ts | 23 + vite.config.ts | 2 + 8 files changed, 462 insertions(+) create mode 100644 src/services/chain-adapter/providers/moralis-provider.ts diff --git a/.env.example b/.env.example index 3f2525b0f..e6aecd724 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ # 申请地址: https://etherscan.io/myapikey # ETHERSCAN_API_KEY="key1,key2,key3" +# Moralis API Key(用于 EVM 链 Token 余额和交易历史) +# 申请地址: https://admin.moralis.io/ +# MORALIS_API_KEY="eyJhbGci..." + # ==================== E2E 测试 ==================== # 资金账号助记词 - 用于提供测试资金(24个中文词,空格分隔) diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index 008f6f0ed..f28a894c1 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -159,6 +159,10 @@ ], "decimals": 18, "apis": [ + { + "type": "moralis", + "endpoint": "https://deep-index.moralis.io/api/v2.2" + }, { "type": "etherscan-v2", "endpoint": "https://api.etherscan.io/v2/api", @@ -193,6 +197,10 @@ ], "decimals": 18, "apis": [ + { + "type": "moralis", + "endpoint": "https://deep-index.moralis.io/api/v2.2" + }, { "type": "bsc-rpc", "endpoint": "https://bsc-rpc.publicnode.com" }, { "type": "etherscan-v2", diff --git a/scripts/set-secret.ts b/scripts/set-secret.ts index bd7eb7582..b1abbd572 100644 --- a/scripts/set-secret.ts +++ b/scripts/set-secret.ts @@ -44,6 +44,7 @@ const FIELDS: SecretField[] = [ { key: 'E2E_TEST_SECOND_SECRET', label: 'E2E 安全密码', isPassword: true, required: false }, { key: 'TRONGRID_API_KEY', label: 'TronGrid API Key', isPassword: true, required: false }, { key: 'ETHERSCAN_API_KEY', label: 'Etherscan API Key', isPassword: true, required: false }, + { key: 'MORALIS_API_KEY', label: 'Moralis API Key', isPassword: true, required: false }, ] function validateMnemonic(v: string): string | null { diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts index 3efc6eaff..4c277b977 100644 --- a/src/services/chain-adapter/providers/index.ts +++ b/src/services/chain-adapter/providers/index.ts @@ -22,6 +22,7 @@ export { MempoolProvider, createMempoolProvider } from './mempool-provider'; export { EthWalletProvider, createEthwalletProvider } from './ethwallet-provider'; export { TronWalletProvider, createTronwalletProvider } from './tronwallet-provider'; export { BtcWalletProvider, createBtcwalletProvider } from './btcwallet-provider'; +export { MoralisProvider, createMoralisProvider } from './moralis-provider'; // 工厂函数 import type { ApiProvider, ApiProviderFactory } from './types'; @@ -39,9 +40,11 @@ import { createMempoolProvider } from './mempool-provider'; import { createEthwalletProvider } from './ethwallet-provider'; import { createTronwalletProvider } from './tronwallet-provider'; import { createBtcwalletProvider } from './btcwallet-provider'; +import { createMoralisProvider } from './moralis-provider'; /** 所有 Provider 工厂函数 */ const PROVIDER_FACTORIES: ApiProviderFactory[] = [ + createMoralisProvider, // Moralis 优先(支持 tokenBalances) createBiowalletProvider, createBscWalletProvider, createEtherscanV2Provider, diff --git a/src/services/chain-adapter/providers/moralis-provider.ts b/src/services/chain-adapter/providers/moralis-provider.ts new file mode 100644 index 000000000..d76c78cd1 --- /dev/null +++ b/src/services/chain-adapter/providers/moralis-provider.ts @@ -0,0 +1,416 @@ +/** + * Moralis API Provider + * + * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 + * 支持 EVM 链的 Token 余额查询和交易历史 + */ + +import { z } from 'zod' +import { keyFetch, interval, deps, derive, transform, throttleError, errorMatchers } from '@biochain/key-fetch' +import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' +import type { + ApiProvider, + TokenBalance, + Transaction, + Direction, + Action, + BalanceOutput, + TokenBalancesOutput, + TransactionsOutput, + AddressParams, + TxHistoryParams, +} from './types' +import { + BalanceOutputSchema, + TokenBalancesOutputSchema, + TransactionsOutputSchema, + AddressParamsSchema, + TxHistoryParamsSchema, +} from './types' +import type { ParsedApiEntry } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' +import { Amount } from '@/types/amount' +import { EvmIdentityMixin } from '../evm/identity-mixin' +import { EvmTransactionMixin } from '../evm/transaction-mixin' +import { getApiKey } from './api-key-picker' + +// ==================== 链 ID 映射 ==================== + +const MORALIS_CHAIN_MAP: Record = { + 'eth-mainnet': 'eth', + 'bsc-mainnet': 'bsc', + 'polygon-mainnet': 'polygon', + 'avalanche-mainnet': 'avalanche', + 'fantom-mainnet': 'fantom', + 'arbitrum-mainnet': 'arbitrum', + 'optimism-mainnet': 'optimism', + 'base-mainnet': 'base', +} + +// ==================== Schema 定义 ==================== + +// 原生余额响应 +const NativeBalanceResponseSchema = z.object({ + balance: z.string(), +}) + +// Token 余额响应 +const TokenBalanceItemSchema = z.object({ + token_address: z.string(), + symbol: z.string(), + name: z.string(), + decimals: z.number(), + balance: z.string(), + logo: z.string().nullable().optional(), + thumbnail: z.string().nullable().optional(), + possible_spam: z.boolean().optional(), + verified_contract: z.boolean().optional(), + total_supply: z.string().nullable().optional(), + security_score: z.number().nullable().optional(), +}) + +const TokenBalancesResponseSchema = z.array(TokenBalanceItemSchema) + +// 钱包历史响应 +const NativeTransferSchema = z.object({ + from_address: z.string(), + to_address: z.string(), + value: z.string(), + value_formatted: z.string().optional(), + direction: z.enum(['send', 'receive']).optional(), + token_symbol: z.string().optional(), + token_logo: z.string().optional(), +}) + +const Erc20TransferSchema = z.object({ + from_address: z.string(), + to_address: z.string(), + value: z.string(), + value_formatted: z.string().optional(), + token_name: z.string().optional(), + token_symbol: z.string().optional(), + token_decimals: z.string().optional(), + token_logo: z.string().optional(), + address: z.string(), // token contract address +}) + +const WalletHistoryItemSchema = z.object({ + hash: z.string(), + from_address: z.string(), + to_address: z.string().nullable(), + value: z.string(), + block_timestamp: z.string(), + block_number: z.string(), + receipt_status: z.string().optional(), + transaction_fee: z.string().optional(), + category: z.string().optional(), + summary: z.string().optional(), + possible_spam: z.boolean().optional(), + from_address_entity: z.string().nullable().optional(), + to_address_entity: z.string().nullable().optional(), + native_transfers: z.array(NativeTransferSchema).optional(), + erc20_transfers: z.array(Erc20TransferSchema).optional(), +}) + +const WalletHistoryResponseSchema = z.object({ + result: z.array(WalletHistoryItemSchema), + cursor: z.string().nullable().optional(), + page: z.number().optional(), + page_size: z.number().optional(), +}) + +type NativeBalanceResponse = z.infer +type TokenBalanceItem = z.infer +type WalletHistoryResponse = z.infer +type WalletHistoryItem = z.infer + +// ==================== 工具函数 ==================== + +function getDirection(from: string, to: string, address: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + const addrLower = address.toLowerCase() + if (fromLower === addrLower && toLower === addrLower) return 'self' + if (fromLower === addrLower) return 'out' + return 'in' +} + +function mapCategory(category: string | undefined): Action { + switch (category) { + case 'send': + case 'receive': + return 'transfer' + case 'token send': + case 'token receive': + return 'transfer' + case 'nft send': + case 'nft receive': + return 'transfer' + case 'approve': + return 'approve' + case 'contract interaction': + return 'contract' + default: + return 'transfer' + } +} + +// ==================== Base Class for Mixins ==================== + +class MoralisBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(MoralisBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly moralisChain: string + + readonly #nativeBalanceApi: KeyFetchInstance + readonly #tokenBalancesApi: KeyFetchInstance + readonly #walletHistoryApi: KeyFetchInstance + + readonly nativeBalance: KeyFetchInstance + readonly tokenBalances: KeyFetchInstance + readonly transactionHistory: KeyFetchInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + + // 映射 Moralis 链名 + this.moralisChain = MORALIS_CHAIN_MAP[chainId] + if (!this.moralisChain) { + throw new Error(`[MoralisProvider] Unsupported chain: ${chainId}`) + } + + const baseUrl = this.endpoint + const symbol = this.symbol + const decimals = this.decimals + const moralisChain = this.moralisChain + + // 读取 API Key + const apiKey = getApiKey('MORALIS_API_KEY', `moralis-${chainId}`) + if (!apiKey) { + throw new Error(`[MoralisProvider] MORALIS_API_KEY is required`) + } + + // API Key Header 插件 + const moralisApiKeyPlugin: FetchPlugin = { + name: 'moralis-api-key', + onFetch: async (request, next) => { + const headers = new Headers(request.headers) + headers.set('X-API-Key', apiKey) + headers.set('accept', 'application/json') + return next(new Request(request.url, { ...request, headers })) + }, + } + + // 429 节流 + const moralisThrottleError = throttleError({ + match: errorMatchers.any( + errorMatchers.httpStatus(429), + errorMatchers.contains('Schema 验证失败'), + ), + }) + + // 原生余额 API + this.#nativeBalanceApi = keyFetch.create({ + name: `moralis.${chainId}.nativeBalanceApi`, + outputSchema: NativeBalanceResponseSchema, + inputSchema: AddressParamsSchema, + url: (params) => `${baseUrl}/${params.address}/balance?chain=${moralisChain}`, + use: [ + interval(15_000), + moralisApiKeyPlugin, + moralisThrottleError, + ], + }) + + // Token 余额 API + this.#tokenBalancesApi = keyFetch.create({ + name: `moralis.${chainId}.tokenBalancesApi`, + outputSchema: TokenBalancesResponseSchema, + inputSchema: AddressParamsSchema, + url: (params) => `${baseUrl}/${params.address}/erc20?chain=${moralisChain}`, + use: [ + interval(30_000), // Token 余额变化较慢 + moralisApiKeyPlugin, + moralisThrottleError, + ], + }) + + // 钱包历史 API + this.#walletHistoryApi = keyFetch.create({ + name: `moralis.${chainId}.walletHistoryApi`, + outputSchema: WalletHistoryResponseSchema, + inputSchema: TxHistoryParamsSchema, + url: (params) => `${baseUrl}/wallets/${params.address}/history?chain=${moralisChain}&limit=${params.limit ?? 20}`, + use: [ + interval(15_000), + moralisApiKeyPlugin, + moralisThrottleError, + ], + }) + + // 派生:原生余额 + this.nativeBalance = derive({ + name: `moralis.${chainId}.nativeBalance`, + source: this.#nativeBalanceApi, + outputSchema: BalanceOutputSchema, + use: [ + transform({ + transform: (raw) => ({ + amount: Amount.fromRaw(raw.balance, decimals, symbol), + symbol, + }), + }), + ], + }) + + // 派生:Token 余额列表(含原生代币) + this.tokenBalances = derive({ + name: `moralis.${chainId}.tokenBalances`, + source: this.#tokenBalancesApi, + outputSchema: TokenBalancesOutputSchema, + use: [ + deps(this.#nativeBalanceApi), // 依赖原生余额 + transform({ + transform: (tokens, ctx) => { + const result: TokenBalance[] = [] + + // 添加原生代币(从依赖获取) + const nativeBalanceData = ctx.deps?.get(this.#nativeBalanceApi) as NativeBalanceResponse | undefined + if (nativeBalanceData) { + result.push({ + symbol, + name: symbol, + amount: Amount.fromRaw(nativeBalanceData.balance, decimals, symbol), + isNative: true, + decimals, + }) + } + + // 添加 ERC20 代币 + for (const token of tokens) { + result.push({ + symbol: token.symbol, + name: token.name, + amount: Amount.fromRaw(token.balance, token.decimals, token.symbol), + isNative: false, + decimals: token.decimals, + icon: token.logo ?? token.thumbnail ?? undefined, + contractAddress: token.token_address, + metadata: { + possibleSpam: token.possible_spam, + securityScore: token.security_score, + verified: token.verified_contract, + totalSupply: token.total_supply ?? undefined, + }, + }) + } + + return result + }, + }), + ], + }) + + // 派生:交易历史 + this.transactionHistory = derive({ + name: `moralis.${chainId}.transactionHistory`, + source: this.#walletHistoryApi, + outputSchema: TransactionsOutputSchema, + }).use(transform({ + transform: (raw: WalletHistoryResponse, ctx): Transaction[] => { + const address = ((ctx.params.address as string) ?? '').toLowerCase() + + return raw.result + .filter(item => !item.possible_spam) + .map((item): Transaction => { + const direction = getDirection(item.from_address, item.to_address ?? '', address) + const action = mapCategory(item.category) + + // 确定资产类型 + const hasErc20 = item.erc20_transfers && item.erc20_transfers.length > 0 + const hasNative = item.native_transfers && item.native_transfers.length > 0 + + // 构建资产列表 + const assets: Transaction['assets'] = [] + + if (hasErc20) { + for (const transfer of item.erc20_transfers!) { + assets.push({ + assetType: 'token', + value: transfer.value, + symbol: transfer.token_symbol ?? 'Unknown', + decimals: parseInt(transfer.token_decimals ?? '18', 10), + contractAddress: transfer.address, + name: transfer.token_name, + logoUrl: transfer.token_logo ?? undefined, + }) + } + } + + if (hasNative || assets.length === 0) { + // 添加原生资产 + const nativeValue = hasNative + ? item.native_transfers![0].value + : item.value + assets.unshift({ + assetType: 'native', + value: nativeValue, + symbol, + decimals, + }) + } + + return { + hash: item.hash, + from: item.from_address, + to: item.to_address ?? '', + timestamp: new Date(item.block_timestamp).getTime(), + status: item.receipt_status === '1' ? 'confirmed' : 'failed', + blockNumber: BigInt(item.block_number), + action, + direction, + assets, + fee: item.transaction_fee ? { + value: item.transaction_fee, + symbol, + decimals, + } : undefined, + fromEntity: item.from_address_entity ?? undefined, + toEntity: item.to_address_entity ?? undefined, + summary: item.summary, + } + }) + }, + })) + } +} + +export function createMoralisProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === 'moralis') { + try { + return new MoralisProvider(entry, chainId) + } catch (err) { + console.warn(`[MoralisProvider] Failed to create provider for ${chainId}:`, err) + return null + } + } + return null +} diff --git a/src/services/chain-adapter/providers/transaction-schema.ts b/src/services/chain-adapter/providers/transaction-schema.ts index 3dbef0fde..4591c7886 100644 --- a/src/services/chain-adapter/providers/transaction-schema.ts +++ b/src/services/chain-adapter/providers/transaction-schema.ts @@ -151,6 +151,11 @@ export const TransactionSchema = z.object({ // 合约信息 (合约调用时,可选) contract: ContractInfoSchema.optional(), + + // 扩展信息 (来自 Moralis 等高级 API) + fromEntity: z.string().optional(), // 如 "Binance" + toEntity: z.string().optional(), + summary: z.string().optional(), // 如 "Received 0.1 ETH from Binance" }) // ==================== 类型导出 ==================== diff --git a/src/services/chain-adapter/providers/types.ts b/src/services/chain-adapter/providers/types.ts index 73d7bd028..188fec9e3 100644 --- a/src/services/chain-adapter/providers/types.ts +++ b/src/services/chain-adapter/providers/types.ts @@ -100,6 +100,18 @@ export interface Balance { symbol: string } +/** 代币扩展元数据(来自 Moralis 等高级 API) */ +export interface TokenMetadata { + /** 是否为垃圾代币 */ + possibleSpam?: boolean + /** 安全评分 (0-100) */ + securityScore?: number | null + /** 是否已验证合约 */ + verified?: boolean + /** 总供应量 */ + totalSupply?: string +} + /** 代币余额(含 native + 所有资产) * * 继承自老代码 Token 类型的核心字段: @@ -119,6 +131,8 @@ export interface TokenBalance { icon?: string /** 合约地址(ERC20/TRC20 等)*/ contractAddress?: string + /** 扩展元数据(来自 Moralis 等高级 API)*/ + metadata?: TokenMetadata } // ==================== 从 ../types 统一导入交易相关类型 ==================== @@ -155,6 +169,14 @@ export const BalanceOutputSchema = z.object({ }) export type BalanceOutput = z.infer +/** TokenMetadata 的 Zod Schema */ +const TokenMetadataSchema = z.object({ + possibleSpam: z.boolean().optional(), + securityScore: z.number().nullable().optional(), + verified: z.boolean().optional(), + totalSupply: z.string().optional(), +}) + /** 代币余额列表输出 Schema - ApiProvider 契约 */ export const TokenBalancesOutputSchema = z.array(z.object({ symbol: z.string(), @@ -164,6 +186,7 @@ export const TokenBalancesOutputSchema = z.array(z.object({ decimals: z.number(), icon: z.string().optional(), contractAddress: z.string().optional(), + metadata: TokenMetadataSchema.optional(), })) export type TokenBalancesOutput = z.infer diff --git a/vite.config.ts b/vite.config.ts index d38b6df5a..d0e69b26f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -73,6 +73,7 @@ export default defineConfig(({ mode }) => { const tronGridApiKey = env.TRONGRID_API_KEY ?? process.env.TRONGRID_API_KEY ?? ''; const etherscanApiKey = env.ETHERSCAN_API_KEY ?? process.env.ETHERSCAN_API_KEY ?? ''; + const moralisApiKey = env.MORALIS_API_KEY ?? process.env.MORALIS_API_KEY ?? ''; return { base: BASE_URL, @@ -162,6 +163,7 @@ export default defineConfig(({ mode }) => { __API_KEYS__: JSON.stringify({ TRONGRID_API_KEY: tronGridApiKey, ETHERSCAN_API_KEY: etherscanApiKey, + MORALIS_API_KEY: moralisApiKey, }), }, optimizeDeps: { From c9a98a7ef34d4a68f6775ed2b61630fb77da1bd7 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 17:34:39 +0800 Subject: [PATCH 08/33] fix(moralis-provider): use pathParams plugin for URL address substitution - Fix chain ID mapping (ethereum/binance instead of eth-mainnet/bsc-mainnet) - Add pathParams() plugin to replace :address in URLs - Add searchParams() for walletHistoryApi dynamic parameters - Fix Request headers construction in API key plugin Token balances now loading successfully from Moralis API. --- CHAT.md | 128 ++++++++++++++++++ .../providers/moralis-provider.ts | 39 ++++-- tsconfig.app.json | 13 +- 3 files changed, 164 insertions(+), 16 deletions(-) diff --git a/CHAT.md b/CHAT.md index d765a9cc9..b6b6e589f 100644 --- a/CHAT.md +++ b/CHAT.md @@ -1394,6 +1394,7 @@ review 的具体方向: 1. vitest进行单元测试 / storybook+vitest进行真实 DOM 测试 / e2e进行真实流程测试; 2. storybook-e2e / e2e 测试所生成截图是否覆盖了我们的变更; 3. 审查截图是否符合预期 + 4. 测试代码是否过于冗余? 5. 白皮书是否更新 --- @@ -1500,3 +1501,130 @@ All configured providers failed. Showing default value. ``` 等数据加载下来,这个错误提示就不见了。说明程序错误地处理了“isLoading” 的逻辑,需要修复。然后找到同类的问题,继续统一修复。 + +--- + +CODE-JOB: +首先,统一一下: +interface ITransactionService 和 interface ApiProvider 存在重复,所以需要合并,把 src/services/chain-adapter/types.ts 这里面属于是真确的设计,全部规整一下,比如可以改成: interface ApiProvider extends Partial。不要 再让我看到出现重复的代码。 + +接着把接口做对了,就是 estimateFee/ buildTransaction ,它们的参数需要重新设计。但具体的实现上,可以先保持 只实现 TransferParams,其他的交易类型可以抛出不支持,不过 bioChain 的几种交易(转账、销毁、支付密码),都应该支持起来。其他链的可以只支持基本转账。 + +再然后,清理一下代码,确定 ChainProvider 已经统一了所有的标准,不再存在额外的标准或者专用标准。如果还发现存在额外标准,就继续做整合。 + +REVIEW-JOB: +签名面板是否已经统一使用 ChainProvider ? +资产、交易列表、pending交易列表、交易详情列表 等等页面,是否已经统一使用 ChainProvider ? +src/queries 这个文件夹里面的老代码还有谁在用?能不能升级成用 chain-provider? + +--- + +fetchGenesisBlock 这个函数的实现有问题. +首先 chain-config 针对 biowallet-v1 已经提供了 config.genesisBlock, +而 chain-config 已经提供了 chain-adapter。 +所以本质上应该有 chain-adapter 直接提供 genesisBlock-url + +--- + +这些 query 依赖了 react-query。 统一升级成我们的 key-fetch。目的是可以做成 service,使得更容易测试。 +链配置:use-chain-config-query.ts +汇率:use-exchange-rate-query.ts +价格:use-price-query.ts +质押:use-staking-query.ts + +--- + +export interface IChainService 这个应该被重构,它本身的很多功能可以用 key-fetch 来实现 + +--- + +1. 预览页面,它不该用 BottomSheet 来进行展示,它应该是一个独立的 Activity,不能影响目前的交易签名面板 +2. 预览交易是一个组件,不是页面,它是渲染在 send 页面中的,send 页面中的顶部会有一个步骤进度条,一共四步:填写表单、预览交易、广播中、结果展示 +3. 广播中、结果展示:这里的组件,本质上和“交易详情”页面,是同一个组件 +4. 预览交易:和“交易详情”页面类似,但是是一个简化版本,因为这时候还没有签名 +5. 填写表单页面,点击发送,这时候 + +--- + +继续收尾工作 + +1. 默认使用 1 天的授权 +2. 提交代码后,检查我们 KeyApp 的 CI,直到它能通过。 +3. 对 [chain-services](/Users/kzf/Dev/GitHub/chain-services) 项目的代码做整理和提交,有一些没用的残留的文件可以删掉。 + +BUG: + +- 我第一次登录,执行 login.signin 之前需要进行授权,授权了b9gB9NzHKWsDKGYFCaNva6xRnxPwFfGcfx的账号。 +- 这时候 RWA-HUB 有了 Token +- 退出后,第二次登录,这次执行 login.signin 报错,发现它使用了 address: "bJ52cfZfhtVG6upHsDSpGi9L9QGqiimicM" +- 说明 cryptoBox 存在严重的安全问题。 + +--- + +i18n check 补充代码字符串中不可以有中文的检查 +统一处理 KNOWN_PERMISSIONS 的 i18n + +--- + +我们的 docs 网站上的 dweb 安装链接有问题,应该是`dweb://install?url=xxx.json`,现在却只想了 zip 文件。json 文件可以参考 https://iweb.xin/rwahub.bfmeta.com.dweb/metadata.json,确定符合标准。 + +你的目标是修复 dweb 的安装链接 + +--- + +目前 i18n 文件夹是被我们 exclude 的,因为如果开启,会遇到 tsc 的 bug。我怀疑是 json 太多导致动态生成类型存在一些问题。请你验证是不是 i18next.d.ts 引用了太多 json 导致的tsc-bug。 +如果是,我个人的方案是:编写一个插件,能替代 i18next.d.ts: + +1. gitignore i18next.d.ts +2. 执行 typecheck 之前,会使用这个插件更新生成 i18next.d.ts +3. 包装成 vite 插件,支持 vite-dev/build 都更新 i18next.d.ts,并且 dev 支持相关的 json 变更,就自动更新 i18next.d.ts + +以上是我个人的想法,如果社区中有类似的或者更好的方案,请与我讨论。 + +--- + +深入调查并修复:我们明明已经配置 remoteMiniapps,为什么最终 cd 构建出来的 webapp 没有 rwa-hub? + +--- + +接着我们需要改进 rwa-hub的样式适配。 +方案一:我记得我们的 bio-sdk 是有提供 Context,其中包含了 safe-area 相关的配置,因为我们向页面注入了一个“胶囊”。所以我们需要在 [raw-hub](/Users/kzf/Dev/GitHub/chain-services/bfm-rwa-hub-app) 的源码中,注入css: `html{--f7-safe-area-top: ${safeAreaInsetTop}px }`。 +方案二:让胶囊可以临时拖动,但是拖动的范围有限。并且拖动结束的 3s 后,会自动归位。自动归位的目的,是因为我们不能动态修改 胶囊对页面的影响,如果可以永久拖动,那么开发者适配胶囊的意义就没有了。拖动只是一个不得已的选择 + +目前来说 方案一 是可以立刻见效可以直接做的。你先执行方案一,然后我们再来讨论有什么更好的长期方案 + +--- + +深入调研我们的 Provider + +[key-fetch] Error refetching tron-rpc.tron.txListApi: Error: [tron-rpc.tron.txListApi] HTTP 429: +响应内容: {"Error":"request rate exceeded the allowed_rps(3), and the query server is suspended for 4 s. To obtain higher request quotas and a more stable service, it is recommended to authenticate with an API +at KeyFetchInstanceImpl.doFetch (core.ts:119:13) + +这种报错属于预期之中,不该在终端中疯狂显示,而是应该通过截流或者防抖来在终端打印警告。现在打印的是 error. +除了 tron,其它知名的 Provider 也应该有提供一定的错误处理的插件 + +--- + +基于 spec 文件,基于与 main 分支的差异,开始self-review。 + +相关 spec(基于时间从旧到新排序),每一个 spec 文件都是一次迭代后的计划产出: + +- /Users/kzf/.factory/specs/2026-01-22-etherscan-v1-v2-provider-separation-plan.md +- /Users/kzf/.factory/specs/2026-01-22-ui-servicelimitederror.md +- /Users/kzf/.factory/specs/2026-01-22-blockheight-schema.md +- /Users/kzf/.factory/specs/2026-01-22-moralis-provider.md + +review 的具体方向: + +1. 功能是否开发完全? +2. 代码架构是否合理? + 1. 代码是否在正确的文件文件夹内? + 2. 是否有和原项目重复代码? +3. 是否有遵守白皮书提供的最佳实践 +4. 测试是否完善: + 1. vitest进行单元测试 / storybook+vitest进行真实 DOM 测试 / e2e进行真实流程测试; + 2. storybook-e2e / e2e 测试所生成截图是否覆盖了我们的变更; + 3. 审查截图是否符合预期 + 4. 测试代码是否过于冗余? +5. 白皮书是否更新 diff --git a/src/services/chain-adapter/providers/moralis-provider.ts b/src/services/chain-adapter/providers/moralis-provider.ts index d76c78cd1..656bd5da8 100644 --- a/src/services/chain-adapter/providers/moralis-provider.ts +++ b/src/services/chain-adapter/providers/moralis-provider.ts @@ -6,7 +6,7 @@ */ import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, throttleError, errorMatchers } from '@biochain/key-fetch' +import { keyFetch, interval, deps, derive, transform, throttleError, errorMatchers, searchParams, pathParams } from '@biochain/key-fetch' import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' import type { ApiProvider, @@ -37,14 +37,14 @@ import { getApiKey } from './api-key-picker' // ==================== 链 ID 映射 ==================== const MORALIS_CHAIN_MAP: Record = { - 'eth-mainnet': 'eth', - 'bsc-mainnet': 'bsc', - 'polygon-mainnet': 'polygon', - 'avalanche-mainnet': 'avalanche', - 'fantom-mainnet': 'fantom', - 'arbitrum-mainnet': 'arbitrum', - 'optimism-mainnet': 'optimism', - 'base-mainnet': 'base', + 'ethereum': 'eth', + 'binance': 'bsc', + 'polygon': 'polygon', + 'avalanche': 'avalanche', + 'fantom': 'fantom', + 'arbitrum': 'arbitrum', + 'optimism': 'optimism', + 'base': 'base', } // ==================== Schema 定义 ==================== @@ -215,7 +215,11 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali const headers = new Headers(request.headers) headers.set('X-API-Key', apiKey) headers.set('accept', 'application/json') - return next(new Request(request.url, { ...request, headers })) + return next(new Request(request.url, { + method: request.method, + headers, + body: request.body, + })) }, } @@ -232,9 +236,10 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali name: `moralis.${chainId}.nativeBalanceApi`, outputSchema: NativeBalanceResponseSchema, inputSchema: AddressParamsSchema, - url: (params) => `${baseUrl}/${params.address}/balance?chain=${moralisChain}`, + url: `${baseUrl}/:address/balance?chain=${moralisChain}`, use: [ interval(15_000), + pathParams(), moralisApiKeyPlugin, moralisThrottleError, ], @@ -245,9 +250,10 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali name: `moralis.${chainId}.tokenBalancesApi`, outputSchema: TokenBalancesResponseSchema, inputSchema: AddressParamsSchema, - url: (params) => `${baseUrl}/${params.address}/erc20?chain=${moralisChain}`, + url: `${baseUrl}/:address/erc20?chain=${moralisChain}`, use: [ interval(30_000), // Token 余额变化较慢 + pathParams(), moralisApiKeyPlugin, moralisThrottleError, ], @@ -258,9 +264,16 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali name: `moralis.${chainId}.walletHistoryApi`, outputSchema: WalletHistoryResponseSchema, inputSchema: TxHistoryParamsSchema, - url: (params) => `${baseUrl}/wallets/${params.address}/history?chain=${moralisChain}&limit=${params.limit ?? 20}`, + url: `${baseUrl}/wallets/:address/history`, use: [ interval(15_000), + pathParams(), + searchParams({ + transform: (params: TxHistoryParams) => ({ + chain: moralisChain, + limit: String(params.limit ?? 20), + }), + }), moralisApiKeyPlugin, moralisThrottleError, ], diff --git a/tsconfig.app.json b/tsconfig.app.json index 7e27ce6e4..5186a49d3 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -35,9 +35,16 @@ "#authorize-impl": ["./src/services/authorize/web.ts"], "#currency-exchange-impl": ["./src/services/currency-exchange/web.ts"], "#staking-impl": ["./src/services/staking/web.ts"], - "#transaction-impl": ["./src/services/transaction/web.ts"] - } + "#transaction-impl": ["./src/services/transaction/web.ts"], + }, }, "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.stories.ts", "src/**/*.stories.tsx", "packages/**/*"] + "exclude": [ + "src/**/*.test.ts", + "src/i18n/**", + "src/**/*.test.tsx", + "src/**/*.stories.ts", + "src/**/*.stories.tsx", + "packages/**/*", + ], } From d217cf5a86e9a9ab15624c507577f362797f51ea Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 17:46:31 +0800 Subject: [PATCH 09/33] feat(moralis-provider): filter spam tokens, add icon fallback, fix React key conflict - Filter tokens with possible_spam=true from tokenBalances - Add TrustWallet Assets as icon fallback (by contract address) - Add contractAddress to TokenInfo interface for unique React keys - Fix token list key collision by using contractAddress instead of symbol Icon fallback chain: Moralis logo > thumbnail > TrustWallet Assets > letter --- src/components/token/adapters.ts | 1 + src/components/token/token-item.tsx | 2 ++ src/components/token/token-list.tsx | 2 +- .../providers/moralis-provider.ts | 36 +++++++++++++++++-- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/components/token/adapters.ts b/src/components/token/adapters.ts index 9317dbb89..e4bf5dc77 100644 --- a/src/components/token/adapters.ts +++ b/src/components/token/adapters.ts @@ -22,6 +22,7 @@ export function tokenBalanceToTokenInfo(token: TokenBalance, chain: string): Tok chain, icon: token.icon, change24h: 0, // Price change not available in TokenBalance + contractAddress: token.contractAddress, } } diff --git a/src/components/token/token-item.tsx b/src/components/token/token-item.tsx index 16223a748..a2ba4b725 100644 --- a/src/components/token/token-item.tsx +++ b/src/components/token/token-item.tsx @@ -33,6 +33,8 @@ export interface TokenInfo { chain: ChainType; icon?: string | undefined; change24h?: number | undefined; + /** Contract address for ERC20/BEP20 tokens, undefined for native tokens */ + contractAddress?: string | undefined; } /** Context passed to renderActions for conditional rendering */ diff --git a/src/components/token/token-list.tsx b/src/components/token/token-list.tsx index c0d3f3980..9f50dc2d6 100644 --- a/src/components/token/token-list.tsx +++ b/src/components/token/token-list.tsx @@ -70,7 +70,7 @@ export function TokenList({
{tokens.map((token) => ( = { 'base': 'base', } +// TrustWallet Assets 链名映射 +const TRUSTWALLET_CHAIN_MAP: Record = { + 'ethereum': 'ethereum', + 'binance': 'smartchain', + 'polygon': 'polygon', + 'avalanche': 'avalanchec', + 'fantom': 'fantom', + 'arbitrum': 'arbitrum', + 'optimism': 'optimism', + 'base': 'base', +} + +/** + * 获取 EVM 代币图标回退 URL (TrustWallet Assets) + * 基于合约地址从 TrustWallet Assets 获取图标 + */ +function getEvmTokenIconFallback(chainId: string, contractAddress: string): string | null { + const chain = TRUSTWALLET_CHAIN_MAP[chainId] + if (!chain) return null + // TrustWallet 使用 checksum 地址,contractAddress 已经是正确格式 + return `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/${chain}/assets/${contractAddress}/logo.png` +} + // ==================== Schema 定义 ==================== // 原生余额响应 @@ -317,15 +340,24 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali }) } + // 过滤垃圾代币,只保留非 spam 的代币 + const filteredTokens = tokens.filter(token => !token.possible_spam) + // 添加 ERC20 代币 - for (const token of tokens) { + for (const token of filteredTokens) { + // 图标回退:Moralis logo > thumbnail > TrustWallet Assets + const icon = token.logo + ?? token.thumbnail + ?? getEvmTokenIconFallback(chainId, token.token_address) + ?? undefined + result.push({ symbol: token.symbol, name: token.name, amount: Amount.fromRaw(token.balance, token.decimals, token.symbol), isNative: false, decimals: token.decimals, - icon: token.logo ?? token.thumbnail ?? undefined, + icon, contractAddress: token.token_address, metadata: { possibleSpam: token.possible_spam, From 01bdd9530f50be89ae8e019b17028e07e8aeaae0 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 17:56:24 +0800 Subject: [PATCH 10/33] fix(key-fetch): pass params to deps plugin subscriptions deps plugin was subscribing to dependencies with empty params {}, causing pathParams() to fail replacing :address placeholder. Now passes ctx.params so URL parameters are correctly substituted. --- packages/key-fetch/src/plugins/deps.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/key-fetch/src/plugins/deps.ts b/packages/key-fetch/src/plugins/deps.ts index 8ed82bccf..5ad6d9aa3 100644 --- a/packages/key-fetch/src/plugins/deps.ts +++ b/packages/key-fetch/src/plugins/deps.ts @@ -69,9 +69,9 @@ export function deps(...dependencies: const cleanups: (() => void)[] = [] for (const dep of dependencies) { - // 订阅依赖的 fetcher(使用空 params,因为依赖通常是全局的如 blockApi) - // 当依赖数据更新时,触发当前 fetcher 的 refetch - const unsubDep = dep.subscribe({}, (_data, event) => { + // 订阅依赖的 fetcher,传递当前 context 的 params + // 这样依赖的 pathParams 等插件可以正确替换 URL 参数 + const unsubDep = dep.subscribe(ctx.params, (_data, event) => { // 依赖数据更新时,触发当前 fetcher 重新获取 if (event === 'update') { ctx.refetch() From 3b379c195283ab4f6f8fe94ddf5c89e79e622973 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 17:57:28 +0800 Subject: [PATCH 11/33] Revert "fix(key-fetch): pass params to deps plugin subscriptions" This reverts commit 01bdd9530f50be89ae8e019b17028e07e8aeaae0. --- packages/key-fetch/src/plugins/deps.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/key-fetch/src/plugins/deps.ts b/packages/key-fetch/src/plugins/deps.ts index 5ad6d9aa3..8ed82bccf 100644 --- a/packages/key-fetch/src/plugins/deps.ts +++ b/packages/key-fetch/src/plugins/deps.ts @@ -69,9 +69,9 @@ export function deps(...dependencies: const cleanups: (() => void)[] = [] for (const dep of dependencies) { - // 订阅依赖的 fetcher,传递当前 context 的 params - // 这样依赖的 pathParams 等插件可以正确替换 URL 参数 - const unsubDep = dep.subscribe(ctx.params, (_data, event) => { + // 订阅依赖的 fetcher(使用空 params,因为依赖通常是全局的如 blockApi) + // 当依赖数据更新时,触发当前 fetcher 的 refetch + const unsubDep = dep.subscribe({}, (_data, event) => { // 依赖数据更新时,触发当前 fetcher 重新获取 if (event === 'update') { ctx.refetch() From 422107bbae6e51e8140a27642625aa69c5410815 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 17:58:26 +0800 Subject: [PATCH 12/33] feat(key-fetch): refactor deps plugin with per-dependency params config - Add DepConfig interface with source and params function - Support three calling patterns: - deps(a, b, c) - original, empty params - deps([a, b, c]) - array form - deps([{ source: a, params: ctx => ctx.params }]) - per-dep config - Each dependency can have independent params transformation - Update moralis-provider to use new deps syntax for nativeBalanceApi This fixes the :address placeholder not being replaced in Moralis API URLs. --- packages/key-fetch/src/plugins/deps.ts | 69 ++++++++++++++----- .../providers/moralis-provider.ts | 2 +- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/packages/key-fetch/src/plugins/deps.ts b/packages/key-fetch/src/plugins/deps.ts index 8ed82bccf..145cc2a76 100644 --- a/packages/key-fetch/src/plugins/deps.ts +++ b/packages/key-fetch/src/plugins/deps.ts @@ -17,6 +17,25 @@ const dependencyCleanups = new Map void)[]>() // 跟踪每个 fetcher 的订阅者数量 const subscriberCounts = new Map() +/** 单个依赖配置 */ +export interface DepConfig { + /** 依赖的 KeyFetch 实例 */ + source: KeyFetchInstance + /** 从当前 context 生成依赖的 params,默认返回空对象 {} */ + params?: (ctx: SubscribeContext) => FetchParams +} + +/** 依赖输入:可以是 KeyFetchInstance 或 DepConfig */ +export type DepInput = KeyFetchInstance | DepConfig + +/** 规范化依赖输入为 DepConfig */ +function normalizeDepInput(input: DepInput): DepConfig { + if ('source' in input && typeof input.source === 'object') { + return input as DepConfig + } + return { source: input as KeyFetchInstance } +} + /** * 依赖插件 * @@ -25,22 +44,36 @@ const subscriberCounts = new Map() * * @example * ```ts - * const blockApi = keyFetch.create({ - * name: 'biowallet.blockApi', - * schema: BlockSchema, - * use: [interval(15_000)], - * }) - * + * // 简单用法:依赖使用空 params * const balanceFetch = keyFetch.create({ * name: 'biowallet.balance', - * schema: BalanceSchema, - * // 订阅 balance 时会自动订阅 blockApi - * // blockApi 更新时 balance 会自动刷新 * use: [deps(blockApi)], * }) + * + * // 高级用法:每个依赖独立配置 params + * const tokenBalances = keyFetch.create({ + * name: 'tokenBalances', + * use: [ + * deps([ + * { source: nativeBalanceApi, params: ctx => ctx.params }, + * { source: priceApi, params: ctx => ({ symbol: ctx.params.symbol }) }, + * blockApi, // 混合使用,等价于 { source: blockApi } + * ]) + * ], + * }) * ``` */ -export function deps(...dependencies: KeyFetchInstance[]): FetchPlugin { +export function deps( + ...args: DepInput[] | [DepInput[]] +): FetchPlugin { + // 支持 deps(a, b, c) 和 deps([a, b, c]) 两种调用方式 + const inputs: DepInput[] = args.length === 1 && Array.isArray(args[0]) + ? args[0] + : args as DepInput[] + + // 规范化为 DepConfig[] + const depConfigs = inputs.map(normalizeDepInput) + // 用于生成唯一 key const getSubscriptionKey = (ctx: SubscribeContext): string => { return `${ctx.name}::${JSON.stringify(ctx.params)}` @@ -52,14 +85,14 @@ export function deps(...dependencies: // onFetch: 注册依赖关系(用于 registry 追踪) async onFetch(request, next, context) { // 注册依赖关系到 registry - for (const dep of dependencies) { - globalRegistry.addDependency(context.name, dep.name) + for (const dep of depConfigs) { + globalRegistry.addDependency(context.name, dep.source.name) } return next(request) }, // onSubscribe: 自动订阅依赖并监听更新 - onSubscribe(ctx: SubscribeContext) { + onSubscribe(ctx: SubscribeContext) { const key = getSubscriptionKey(ctx) const count = (subscriberCounts.get(key) ?? 0) + 1 subscriberCounts.set(key, count) @@ -68,10 +101,10 @@ export function deps(...dependencies: if (count === 1) { const cleanups: (() => void)[] = [] - for (const dep of dependencies) { - // 订阅依赖的 fetcher(使用空 params,因为依赖通常是全局的如 blockApi) - // 当依赖数据更新时,触发当前 fetcher 的 refetch - const unsubDep = dep.subscribe({}, (_data, event) => { + for (const dep of depConfigs) { + // 使用配置的 params 函数生成依赖参数,默认空对象 + const depParams = dep.params ? dep.params(ctx) : {} + const unsubDep = dep.source.subscribe(depParams, (_data, event) => { // 依赖数据更新时,触发当前 fetcher 重新获取 if (event === 'update') { ctx.refetch() @@ -80,7 +113,7 @@ export function deps(...dependencies: cleanups.push(unsubDep) // 同时监听 registry 的更新事件(确保广播机制正常) - const unsubRegistry = globalRegistry.onUpdate(dep.name, () => { + const unsubRegistry = globalRegistry.onUpdate(dep.source.name, () => { globalRegistry.emitUpdate(ctx.name) }) cleanups.push(unsubRegistry) diff --git a/src/services/chain-adapter/providers/moralis-provider.ts b/src/services/chain-adapter/providers/moralis-provider.ts index 230812c95..3fce379b5 100644 --- a/src/services/chain-adapter/providers/moralis-provider.ts +++ b/src/services/chain-adapter/providers/moralis-provider.ts @@ -323,7 +323,7 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali source: this.#tokenBalancesApi, outputSchema: TokenBalancesOutputSchema, use: [ - deps(this.#nativeBalanceApi), // 依赖原生余额 + deps([{ source: this.#nativeBalanceApi, params: ctx => ctx.params }]), // 依赖原生余额,传递 address 参数 transform({ transform: (tokens, ctx) => { const result: TokenBalance[] = [] From 6e1e17b7e66ff5dac16cb69f02ffd5ff04b5738e Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 18:20:59 +0800 Subject: [PATCH 13/33] feat(chain-config): add tokenIconContract for EVM token icons - Add tokenIconContract schema field for contract-address-based icon lookup - Add getTokenIconByContract() method with checksum address conversion - Update default-chains.json: ETH/BSC use TrustWallet Assets URLs - Remove incorrect tokenIconBase from EVM chains (was pointing to BioChain CDN) - Refactor TokenIcon component: letter-first + image-overlay mode - Letter always renders as base layer - Image overlays and shows on successful load - Eliminates loading flicker Icon priority: Moralis logo > thumbnail > tokenIconContract config > letter --- CHAT.md | 2 + public/configs/default-chains.json | 12 +- src/components/wallet/token-icon.tsx | 111 +++++++++--------- .../providers/moralis-provider.ts | 27 +---- src/services/chain-config/index.ts | 14 ++- src/services/chain-config/schema.ts | 4 +- src/services/chain-config/service.ts | 22 ++++ 7 files changed, 96 insertions(+), 96 deletions(-) diff --git a/CHAT.md b/CHAT.md index b6b6e589f..c44af6a8c 100644 --- a/CHAT.md +++ b/CHAT.md @@ -1614,6 +1614,8 @@ at KeyFetchInstanceImpl.doFetch (core.ts:119:13) - /Users/kzf/.factory/specs/2026-01-22-ui-servicelimitederror.md - /Users/kzf/.factory/specs/2026-01-22-blockheight-schema.md - /Users/kzf/.factory/specs/2026-01-22-moralis-provider.md +- /Users/kzf/.factory/specs/2026-01-22-moralis-provider-1.md +- /Users/kzf/.factory/specs/2026-01-22-tokeniconcontract-ui.md review 的具体方向: diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index f28a894c1..a45b1d913 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -152,10 +152,8 @@ "name": "Ethereum", "symbol": "ETH", "icon": "../icons/ethereum/chain.svg", - "tokenIconBase": [ - "../icons/ethereum/tokens/$symbol.svg", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/eth/icon-$SYMBOL.png", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/eth/icon-$SYMBOL.png" + "tokenIconContract": [ + "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/$address/logo.png" ], "decimals": 18, "apis": [ @@ -190,10 +188,8 @@ "name": "BNB Smart Chain", "symbol": "BNB", "icon": "../icons/binance/chain.svg", - "tokenIconBase": [ - "../icons/binance/tokens/$symbol.svg", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/bsc/icon-$SYMBOL.png", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/bsc/icon-$SYMBOL.png" + "tokenIconContract": [ + "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/smartchain/assets/$address/logo.png" ], "decimals": 18, "apis": [ diff --git a/src/components/wallet/token-icon.tsx b/src/components/wallet/token-icon.tsx index b269b26e4..bc1d81ec9 100644 --- a/src/components/wallet/token-icon.tsx +++ b/src/components/wallet/token-icon.tsx @@ -68,28 +68,30 @@ function buildIconUrl(template: string, symbol: string): string { /** * Token 图标组件 * - * 加载优先级: - * 1. imageUrl prop(手动指定) - * 2. TokenIconProvider + chainId(多层 fallback) - * 3. 首字母 fallback + * 加载策略:首字母常驻 + 图片叠加 + * - 首字母始终渲染作为底层 + * - 图片加载成功后叠加显示,隐藏首字母 + * - 避免加载过程中的闪烁 * * @example * // 手动指定图标 * * - * // 自动从 Provider 获取(需要 chainId) + * // 自动从 Provider 获取(需要 chainId,仅 BioForest 链) * * * // 仅显示首字母 * */ export function TokenIcon({ symbol, chainId, imageUrl, size = 'md', className }: TokenIconProps) { + const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); const [baseIndex, setBaseIndex] = useState(0); const context = useTokenIconContext(); // 当 props 变化时重置状态 useEffect(() => { + setImageLoaded(false); setImageError(false); setBaseIndex(0); }, [symbol, chainId, imageUrl]); @@ -97,70 +99,63 @@ export function TokenIcon({ symbol, chainId, imageUrl, size = 'md', className }: const label = symbol.toUpperCase(); const firstLetter = label.charAt(0); - // 优先级 1: imageUrl prop - if (imageUrl && !imageError) { - return ( -
- {label} setImageError(true)} - /> -
- ); - } - - // 优先级 2: Provider + chainId - const bases = chainId ? context?.getTokenIconBases(chainId) ?? [] : []; - const currentBase = bases[baseIndex]; - const providerUrl = currentBase ? buildIconUrl(currentBase, symbol) : undefined; + // 确定图片 URL:优先使用 imageUrl prop,否则尝试从 Provider 获取 + let finalImageUrl: string | undefined = imageUrl ?? undefined; - if (providerUrl && baseIndex >= 0 && baseIndex < bases.length) { - return ( -
- {label} { - if (baseIndex < bases.length - 1) { - setBaseIndex(baseIndex + 1); - } else { - setBaseIndex(-1); // 触发 fallback - } - }} - /> -
- ); + // 只有在没有 imageUrl 且有 chainId 时才使用 tokenIconBase + if (!finalImageUrl && chainId) { + const bases = context?.getTokenIconBases(chainId) ?? []; + const currentBase = bases[baseIndex]; + if (currentBase && baseIndex >= 0 && baseIndex < bases.length) { + finalImageUrl = buildIconUrl(currentBase, symbol); + } } - // 优先级 3: 首字母 fallback + const handleImageError = () => { + // 如果还有更多 base 可以尝试,继续尝试下一个 + if (chainId && !imageUrl) { + const bases = context?.getTokenIconBases(chainId) ?? []; + if (baseIndex < bases.length - 1) { + setBaseIndex(baseIndex + 1); + setImageLoaded(false); + return; + } + } + setImageError(true); + }; + return (
- {firstLetter} + {/* 首字母始终渲染(底层) */} + + {firstLetter} + + + {/* 图片叠加(顶层),成功加载后显示 */} + {finalImageUrl && !imageError && ( + {label} setImageLoaded(true)} + onError={handleImageError} + /> + )}
); } diff --git a/src/services/chain-adapter/providers/moralis-provider.ts b/src/services/chain-adapter/providers/moralis-provider.ts index 3fce379b5..96cb9b2db 100644 --- a/src/services/chain-adapter/providers/moralis-provider.ts +++ b/src/services/chain-adapter/providers/moralis-provider.ts @@ -47,29 +47,6 @@ const MORALIS_CHAIN_MAP: Record = { 'base': 'base', } -// TrustWallet Assets 链名映射 -const TRUSTWALLET_CHAIN_MAP: Record = { - 'ethereum': 'ethereum', - 'binance': 'smartchain', - 'polygon': 'polygon', - 'avalanche': 'avalanchec', - 'fantom': 'fantom', - 'arbitrum': 'arbitrum', - 'optimism': 'optimism', - 'base': 'base', -} - -/** - * 获取 EVM 代币图标回退 URL (TrustWallet Assets) - * 基于合约地址从 TrustWallet Assets 获取图标 - */ -function getEvmTokenIconFallback(chainId: string, contractAddress: string): string | null { - const chain = TRUSTWALLET_CHAIN_MAP[chainId] - if (!chain) return null - // TrustWallet 使用 checksum 地址,contractAddress 已经是正确格式 - return `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/${chain}/assets/${contractAddress}/logo.png` -} - // ==================== Schema 定义 ==================== // 原生余额响应 @@ -345,10 +322,10 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali // 添加 ERC20 代币 for (const token of filteredTokens) { - // 图标回退:Moralis logo > thumbnail > TrustWallet Assets + // 图标优先级:Moralis logo > thumbnail > tokenIconContract 配置 const icon = token.logo ?? token.thumbnail - ?? getEvmTokenIconFallback(chainId, token.token_address) + ?? chainConfigService.getTokenIconByContract(chainId, token.token_address) ?? undefined result.push({ diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index 9dda994e7..a5637328f 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -154,13 +154,13 @@ function resolveRelativePath(path: string, jsonFileUrl: string): string { } /** - * 解析配置中的 icon 和 tokenIconBase 相对路径 + * 解析配置中的 icon、tokenIconBase 和 tokenIconContract 相对路径 */ function resolveIconPaths( - config: { icon?: string | undefined; tokenIconBase?: string[] | undefined }, + config: { icon?: string | undefined; tokenIconBase?: string[] | undefined; tokenIconContract?: string[] | undefined }, jsonFileUrl: string -): { icon?: string; tokenIconBase?: string[] } { - const result: { icon?: string; tokenIconBase?: string[] } = {} +): { icon?: string; tokenIconBase?: string[]; tokenIconContract?: string[] } { + const result: { icon?: string; tokenIconBase?: string[]; tokenIconContract?: string[] } = {} if (config.icon !== undefined) { result.icon = resolveRelativePath(config.icon, jsonFileUrl) @@ -172,6 +172,12 @@ function resolveIconPaths( ) } + if (config.tokenIconContract !== undefined) { + result.tokenIconContract = config.tokenIconContract.map((base) => + resolveRelativePath(base, jsonFileUrl) + ) + } + return result } diff --git a/src/services/chain-config/schema.ts b/src/services/chain-config/schema.ts index b8e1e0920..7379e897d 100644 --- a/src/services/chain-config/schema.ts +++ b/src/services/chain-config/schema.ts @@ -64,8 +64,10 @@ export const ChainConfigSchema = z symbol: z.string().min(1).max(10), /** 链图标 URL */ icon: z.string().min(1).optional(), - /** Token 图标基础路径数组,支持多层 fallback [本地, CDN, GitHub] */ + /** Token 图标基础路径数组,基于 symbol 查找,支持 $symbol/$SYMBOL 占位符 */ tokenIconBase: z.array(z.string().min(1)).optional(), + /** Token 图标路径数组,基于合约地址查找,支持 $address 占位符(EVM 链使用) */ + tokenIconContract: z.array(z.string().min(1)).optional(), prefix: z.string().min(1).max(10).optional(), // BioForest 特有 decimals: z.number().int().min(0).max(18), diff --git a/src/services/chain-config/service.ts b/src/services/chain-config/service.ts index e2b63c887..6e3fae3b7 100644 --- a/src/services/chain-config/service.ts +++ b/src/services/chain-config/service.ts @@ -6,6 +6,7 @@ */ import { chainConfigStore, chainConfigSelectors } from '@/stores/chain-config' +import { toChecksumAddress } from '@/lib/crypto' import type { ChainConfig, ParsedApiEntry } from './types' class ChainConfigService { @@ -148,6 +149,27 @@ class ChainConfigService { const config = this.getConfig(chainId) return config?.icon ?? null } + + /** + * 根据合约地址获取代币图标 URL + * 使用 tokenIconContract 配置模板,替换 $address 占位符 + * + * @param chainId 链 ID + * @param contractAddress 合约地址(会自动转换为 checksum 格式) + * @returns 图标 URL 或 null + */ + getTokenIconByContract(chainId: string, contractAddress: string): string | null { + const config = this.getConfig(chainId) + const templates = config?.tokenIconContract + if (!templates || templates.length === 0) return null + + try { + const checksumAddress = toChecksumAddress(contractAddress) + return templates[0].replace('$address', checksumAddress) + } catch { + return null + } + } } export const chainConfigService = new ChainConfigService() From 386abe13a1bbdb903f1c6661f5a317b7547ede08 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 18:30:56 +0800 Subject: [PATCH 14/33] perf(providers): increase polling interval to 30s minimum for API cost savings Non-BioChain providers now use at least 30s polling interval: - moralis: 15s -> 30s (nativeBalance, walletHistory) - tron-rpc: 3s -> 30s - tronwallet: 3s -> 30s - bscwallet: 3s -> 30s - ethwallet: 12s -> 30s - evm-rpc: 12s -> 30s - etherscan-v1: 12s -> 30s - etherscan-v2: 12s -> 30s BioChain (biowallet) keeps dynamic forgeInterval from genesis block. Bitcoin providers already use 60s (unchanged). --- src/services/chain-adapter/providers/bscwallet-provider.ts | 2 +- src/services/chain-adapter/providers/etherscan-v1-provider.ts | 2 +- src/services/chain-adapter/providers/etherscan-v2-provider.ts | 2 +- src/services/chain-adapter/providers/ethwallet-provider.ts | 2 +- src/services/chain-adapter/providers/evm-rpc-provider.ts | 2 +- src/services/chain-adapter/providers/moralis-provider.ts | 4 ++-- src/services/chain-adapter/providers/tron-rpc-provider.ts | 2 +- src/services/chain-adapter/providers/tronwallet-provider.ts | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/services/chain-adapter/providers/bscwallet-provider.ts b/src/services/chain-adapter/providers/bscwallet-provider.ts index e49f70cc7..cb9deae9f 100644 --- a/src/services/chain-adapter/providers/bscwallet-provider.ts +++ b/src/services/chain-adapter/providers/bscwallet-provider.ts @@ -72,7 +72,7 @@ export class BscWalletProvider extends EvmIdentityMixin(EvmTransactionMixin(BscW outputSchema: z.object({ timestamp: z.number() }), url: 'internal://trigger', use: [ - interval(3_000), // BSC 约 3s 出块 + interval(30_000), // 节约 API 费用,至少 30s 轮询 { name: 'trigger', onFetch: async (_req, _next, ctx) => { diff --git a/src/services/chain-adapter/providers/etherscan-v1-provider.ts b/src/services/chain-adapter/providers/etherscan-v1-provider.ts index 7745e2d6a..e9be9859c 100644 --- a/src/services/chain-adapter/providers/etherscan-v1-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-v1-provider.ts @@ -129,7 +129,7 @@ export class EtherscanV1Provider extends EvmIdentityMixin(EvmTransactionMixin(Et outputSchema: ApiResponseSchema, url: `${baseUrl}`, use: [ - interval(12_000), // EVM 链约 12s 出块 + interval(30_000), // 节约 API 费用,至少 30s 轮询 searchParams({ transform: () => ({ module: 'proxy', diff --git a/src/services/chain-adapter/providers/etherscan-v2-provider.ts b/src/services/chain-adapter/providers/etherscan-v2-provider.ts index 472ea19d6..8666c3b09 100644 --- a/src/services/chain-adapter/providers/etherscan-v2-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-v2-provider.ts @@ -136,7 +136,7 @@ export class EtherscanV2Provider extends EvmIdentityMixin(EvmTransactionMixin(Et outputSchema: JsonRpcResponseSchema, url: `${baseUrl}`, use: [ - interval(12_000), // EVM 链约 12s 出块 + interval(30_000), // 节约 API 费用,至少 30s 轮询 searchParams({ transform: () => ({ chainid: evmChainId.toString(), diff --git a/src/services/chain-adapter/providers/ethwallet-provider.ts b/src/services/chain-adapter/providers/ethwallet-provider.ts index e66800529..b2487bd20 100644 --- a/src/services/chain-adapter/providers/ethwallet-provider.ts +++ b/src/services/chain-adapter/providers/ethwallet-provider.ts @@ -103,7 +103,7 @@ export class EthWalletProvider extends EvmIdentityMixin(EvmTransactionMixin(EthW outputSchema: z.object({ timestamp: z.number() }), url: 'internal://trigger', // 虚拟 URL use: [ - interval(12_000), // EVM 链约 12s 出块 + interval(30_000), // 节约 API 费用,至少 30s 轮询 { name: 'trigger', onFetch: async (_req, _next, ctx) => { diff --git a/src/services/chain-adapter/providers/evm-rpc-provider.ts b/src/services/chain-adapter/providers/evm-rpc-provider.ts index 6b7e28f70..aacd6cd1f 100644 --- a/src/services/chain-adapter/providers/evm-rpc-provider.ts +++ b/src/services/chain-adapter/providers/evm-rpc-provider.ts @@ -101,7 +101,7 @@ export class EvmRpcProvider extends EvmIdentityMixin(EvmTransactionMixin(EvmRpcB url: rpc, method: 'POST', use: [ - interval(12_000), // EVM 链约 12-15s 出块 + interval(30_000), // 节约 API 费用,至少 30s 轮询 postBody({ transform: () => ({ jsonrpc: '2.0', diff --git a/src/services/chain-adapter/providers/moralis-provider.ts b/src/services/chain-adapter/providers/moralis-provider.ts index 96cb9b2db..1d96f33e0 100644 --- a/src/services/chain-adapter/providers/moralis-provider.ts +++ b/src/services/chain-adapter/providers/moralis-provider.ts @@ -238,7 +238,7 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali inputSchema: AddressParamsSchema, url: `${baseUrl}/:address/balance?chain=${moralisChain}`, use: [ - interval(15_000), + interval(30_000), // 节约 API 费用,至少 30s 轮询 pathParams(), moralisApiKeyPlugin, moralisThrottleError, @@ -266,7 +266,7 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali inputSchema: TxHistoryParamsSchema, url: `${baseUrl}/wallets/:address/history`, use: [ - interval(15_000), + interval(30_000), // 节约 API 费用,至少 30s 轮询 pathParams(), searchParams({ transform: (params: TxHistoryParams) => ({ diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.ts b/src/services/chain-adapter/providers/tron-rpc-provider.ts index e11721a60..6d45bd5af 100644 --- a/src/services/chain-adapter/providers/tron-rpc-provider.ts +++ b/src/services/chain-adapter/providers/tron-rpc-provider.ts @@ -201,7 +201,7 @@ export class TronRpcProvider extends TronIdentityMixin(TronTransactionMixin(Tron outputSchema: TronNowBlockSchema, url: `${baseUrl}/wallet/getnowblock`, method: 'POST', - use: [interval(3_000), tronApiKeyPlugin, tronThrottleError], + use: [interval(30_000), tronApiKeyPlugin, tronThrottleError], // 节约 API 费用,至少 30s 轮询 }) // 账户信息 - 由 blockApi 驱动 diff --git a/src/services/chain-adapter/providers/tronwallet-provider.ts b/src/services/chain-adapter/providers/tronwallet-provider.ts index 9e3317d87..3d57d7273 100644 --- a/src/services/chain-adapter/providers/tronwallet-provider.ts +++ b/src/services/chain-adapter/providers/tronwallet-provider.ts @@ -90,7 +90,7 @@ export class TronWalletProvider extends TronIdentityMixin(TronTransactionMixin(T outputSchema: z.object({ timestamp: z.number() }), url: 'internal://trigger', use: [ - interval(3_000), // Tron 约 3s 出块 + interval(30_000), // 节约 API 费用,至少 30s 轮询 { name: 'trigger', onFetch: async (_req, _next, ctx) => { From 6d370830ae6da6515baf5595f20b8524794c5b51 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 18:51:07 +0800 Subject: [PATCH 15/33] feat(moralis-provider): optimize API costs and add transactionStatus Cost optimization: - transactionHistory: 30s -> 2min polling (75% reduction) - tokenBalances: remove independent interval, now deps-driven - Triggered only when transactionHistory or nativeBalance changes - Expected 90%+ reduction in erc20 API calls New capabilities: - Add transactionStatus API for waiting transaction confirmation - Uses eth_getTransactionReceipt via RPC (3s polling) - Triggers nativeBalance and transactionHistory refresh on confirmation Dependency chain: nativeBalance (30s) -> transactionHistory (2min) -> tokenBalances (on-demand) --- .../providers/moralis-provider.ts | 85 +++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/src/services/chain-adapter/providers/moralis-provider.ts b/src/services/chain-adapter/providers/moralis-provider.ts index 1d96f33e0..27334fbe0 100644 --- a/src/services/chain-adapter/providers/moralis-provider.ts +++ b/src/services/chain-adapter/providers/moralis-provider.ts @@ -6,8 +6,9 @@ */ import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, throttleError, errorMatchers, searchParams, pathParams } from '@biochain/key-fetch' +import { keyFetch, interval, deps, derive, transform, throttleError, errorMatchers, searchParams, pathParams, postBody } from '@biochain/key-fetch' import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' +import { globalRegistry } from '@biochain/key-fetch' import type { ApiProvider, TokenBalance, @@ -17,15 +18,19 @@ import type { BalanceOutput, TokenBalancesOutput, TransactionsOutput, + TransactionStatusOutput, AddressParams, TxHistoryParams, + TransactionStatusParams, } from './types' import { BalanceOutputSchema, TokenBalancesOutputSchema, TransactionsOutputSchema, + TransactionStatusOutputSchema, AddressParamsSchema, TxHistoryParamsSchema, + TransactionStatusParamsSchema, } from './types' import type { ParsedApiEntry } from '@/services/chain-config' import { chainConfigService } from '@/services/chain-config' @@ -54,6 +59,17 @@ const NativeBalanceResponseSchema = z.object({ balance: z.string(), }) +// RPC 交易回执响应(用于 transactionStatus) +const TxReceiptRpcResponseSchema = z.object({ + jsonrpc: z.string(), + id: z.number(), + result: z.object({ + transactionHash: z.string(), + blockNumber: z.string(), + status: z.string().optional(), // "0x1" = success, "0x0" = failed + }).nullable(), +}) + // Token 余额响应 const TokenBalanceItemSchema = z.object({ token_address: z.string(), @@ -120,6 +136,7 @@ const WalletHistoryResponseSchema = z.object({ }) type NativeBalanceResponse = z.infer +type TxReceiptRpcResponse = z.infer type TokenBalanceItem = z.infer type WalletHistoryResponse = z.infer type WalletHistoryItem = z.infer @@ -181,10 +198,12 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali readonly #nativeBalanceApi: KeyFetchInstance readonly #tokenBalancesApi: KeyFetchInstance readonly #walletHistoryApi: KeyFetchInstance + readonly #txStatusApi: KeyFetchInstance readonly nativeBalance: KeyFetchInstance readonly tokenBalances: KeyFetchInstance readonly transactionHistory: KeyFetchInstance + readonly transactionStatus: KeyFetchInstance constructor(entry: ParsedApiEntry, chainId: string) { super(entry, chainId) @@ -245,14 +264,14 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali ], }) - // Token 余额 API + // Token 余额 API(不再独立轮询,由 transactionHistory 驱动) this.#tokenBalancesApi = keyFetch.create({ name: `moralis.${chainId}.tokenBalancesApi`, outputSchema: TokenBalancesResponseSchema, inputSchema: AddressParamsSchema, url: `${baseUrl}/:address/erc20?chain=${moralisChain}`, use: [ - interval(30_000), // Token 余额变化较慢 + // 移除 interval,改为依赖驱动 pathParams(), moralisApiKeyPlugin, moralisThrottleError, @@ -266,7 +285,7 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali inputSchema: TxHistoryParamsSchema, url: `${baseUrl}/wallets/:address/history`, use: [ - interval(30_000), // 节约 API 费用,至少 30s 轮询 + interval(120_000), // 2分钟轮询,大幅降低 API 费用 pathParams(), searchParams({ transform: (params: TxHistoryParams) => ({ @@ -295,12 +314,16 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali }) // 派生:Token 余额列表(含原生代币) + // 依赖 nativeBalance 和 transactionHistory,只有交易变化时才触发代币余额更新 this.tokenBalances = derive({ name: `moralis.${chainId}.tokenBalances`, source: this.#tokenBalancesApi, outputSchema: TokenBalancesOutputSchema, use: [ - deps([{ source: this.#nativeBalanceApi, params: ctx => ctx.params }]), // 依赖原生余额,传递 address 参数 + deps([ + { source: this.#nativeBalanceApi, params: ctx => ctx.params }, + { source: this.#walletHistoryApi, params: ctx => ({ address: ctx.params.address, limit: 1 }) }, + ]), transform({ transform: (tokens, ctx) => { const result: TokenBalance[] = [] @@ -422,6 +445,58 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali }) }, })) + + // 获取 RPC URL 用于交易状态查询 + const rpcUrl = chainConfigService.getRpcUrl(chainId) + + // 交易状态 API(通过 RPC 查询交易回执) + this.#txStatusApi = keyFetch.create({ + name: `moralis.${chainId}.txStatusApi`, + outputSchema: TxReceiptRpcResponseSchema, + inputSchema: TransactionStatusParamsSchema, + url: rpcUrl, + method: 'POST', + use: [ + interval(3_000), // 等待上链时 3s 轮询 + postBody({ + transform: (params: TransactionStatusParams) => ({ + jsonrpc: '2.0', + id: 1, + method: 'eth_getTransactionReceipt', + params: [params.txHash], + }), + }), + moralisThrottleError, + ], + }) + + // 派生:交易状态(交易确认后触发余额和历史刷新) + this.transactionStatus = derive({ + name: `moralis.${chainId}.transactionStatus`, + source: this.#txStatusApi, + outputSchema: TransactionStatusOutputSchema, + use: [ + transform({ + transform: (raw): TransactionStatusOutput => { + const receipt = raw.result + if (!receipt || !receipt.blockNumber) { + return { status: 'pending', confirmations: 0, requiredConfirmations: 1 } + } + + // 交易已上链,触发余额和历史刷新 + globalRegistry.emitUpdate(`moralis.${chainId}.nativeBalanceApi`) + globalRegistry.emitUpdate(`moralis.${chainId}.walletHistoryApi`) + + const isSuccess = receipt.status === '0x1' || receipt.status === undefined // 旧版交易无 status + return { + status: isSuccess ? 'confirmed' : 'failed', + confirmations: 1, + requiredConfirmations: 1, + } + }, + }), + ], + }) } } From 597245322b00dbf322134a1250f3a28daa5017b1 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 18:56:54 +0800 Subject: [PATCH 16/33] fix(key-fetch): export globalRegistry for external usage --- packages/key-fetch/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/key-fetch/src/index.ts b/packages/key-fetch/src/index.ts index 5e4eac1b5..6f71cdbd3 100644 --- a/packages/key-fetch/src/index.ts +++ b/packages/key-fetch/src/index.ts @@ -103,6 +103,10 @@ export type { FallbackOptions as MergeOptions } from './fallback' export { ServiceLimitedError } from './errors' +// ==================== 导出 Registry ==================== + +export { globalRegistry } from './registry' + // ==================== 导出 Combine 工具 ==================== export { combine } from './combine' From 6faa4f7c38b143200b21080d922cb25cfdffa82f Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 19:44:59 +0800 Subject: [PATCH 17/33] feat(key-fetch): enhance dedupe plugin with time window support - Upgrade dedupe plugin: global deduplication + configurable minInterval - Add DedupeThrottledError for throttled requests - Add 401 quota exceeded to moralis throttleError matcher - Read polling intervals from chain config (txStatusInterval, balanceInterval, erc20Interval) - Configure ETH: 12s/36s/150s, BSC: 15s/30s/120s --- packages/key-fetch/src/core.ts | 4 +- packages/key-fetch/src/index.ts | 2 +- packages/key-fetch/src/plugins/dedupe.ts | 126 ++++++++++++++++-- packages/key-fetch/src/plugins/index.ts | 2 +- public/configs/default-chains.json | 14 +- .../providers/moralis-provider.ts | 22 ++- 6 files changed, 150 insertions(+), 20 deletions(-) diff --git a/packages/key-fetch/src/core.ts b/packages/key-fetch/src/core.ts index 88a031353..d6db3e7f6 100644 --- a/packages/key-fetch/src/core.ts +++ b/packages/key-fetch/src/core.ts @@ -78,14 +78,14 @@ class KeyFetchInstanceImpl< async fetch(params: TIN, options?: { skipCache?: boolean }): Promise { const cacheKey = buildCacheKey(this.name, params) - // 检查进行中的请求(去重) + // 检查进行中的请求(基础去重) const pending = this.inFlight.get(cacheKey) if (pending) { return pending } // 发起请求(通过中间件链) - const task = this.doFetch((params), options) + const task = this.doFetch(params, options) this.inFlight.set(cacheKey, task) try { diff --git a/packages/key-fetch/src/index.ts b/packages/key-fetch/src/index.ts index 6f71cdbd3..d7eba9b37 100644 --- a/packages/key-fetch/src/index.ts +++ b/packages/key-fetch/src/index.ts @@ -76,7 +76,7 @@ export type { export { interval } from './plugins/interval' export { deps } from './plugins/deps' export { ttl } from './plugins/ttl' -export { dedupe } from './plugins/dedupe' +export { dedupe, DedupeThrottledError } from './plugins/dedupe' export { tag } from './plugins/tag' export { etag } from './plugins/etag' export { throttleError, errorMatchers } from './plugins/throttle-error' diff --git a/packages/key-fetch/src/plugins/dedupe.ts b/packages/key-fetch/src/plugins/dedupe.ts index 1aee98ef9..aa10dc36e 100644 --- a/packages/key-fetch/src/plugins/dedupe.ts +++ b/packages/key-fetch/src/plugins/dedupe.ts @@ -1,22 +1,132 @@ /** * Dedupe Plugin * - * 请求去重插件(已内置到 core,这里仅作为显式声明) + * 请求去重插件(升级版) + * + * 功能: + * 1. 同一 key 的并发请求复用同一个 Response + * 2. 可配置最小请求间隔,避免短时间内重复请求 */ -import type { FetchPlugin } from '../types' +import type { FetchPlugin, MiddlewareContext } from '../types' + +// 全局进行中请求缓存(存储的是克隆后的 Response Promise) +const globalPending = new Map; cloneCount: number }>() + +// 最近完成时间(用于时间窗口去重) +const lastFetchTime = new Map() + +// 最近成功的 Response 缓存(用于时间窗口内返回缓存) +const lastResponseBody = new Map() +const lastResponseStatus = new Map() +const lastResponseHeaders = new Map() + +export interface DedupeOptions { + /** 最小请求间隔(毫秒),0 表示不限制 */ + minInterval?: number + /** 生成缓存 key 的函数,默认使用 name + params */ + getKey?: (ctx: MiddlewareContext) => string +} + +/** 去重节流错误 */ +export class DedupeThrottledError extends Error { + constructor(message: string) { + super(message) + this.name = 'DedupeThrottledError' + } +} /** - * 请求去重插件 + * 请求去重插件(升级版) + * + * 功能: + * 1. 同一 key 的并发请求复用同一个 Response + * 2. 可配置最小请求间隔,避免短时间内重复请求 * - * 注意:去重已内置到 core 实现中,此插件仅作为显式声明使用 + * @example + * ```ts + * // 基础用法:并发去重 + * use: [dedupe()] + * + * // 高级用法:30秒内不重复请求 + * use: [dedupe({ minInterval: 30_000 })] + * ``` */ -export function dedupe(): FetchPlugin { +export function dedupe(options: DedupeOptions = {}): FetchPlugin { + const { minInterval = 0, getKey } = options + + const buildKey = (ctx: MiddlewareContext): string => { + if (getKey) return getKey(ctx) + return `${ctx.name}::${JSON.stringify(ctx.params)}` + } + return { name: 'dedupe', - // 透传请求(去重逻辑已在 core 中实现) - async onFetch(request, next) { - return next(request) + + async onFetch(request, next, context) { + const key = buildKey(context) + + // 1. 检查进行中的请求 + const pending = globalPending.get(key) + if (pending) { + // 复用进行中的请求,等待完成后重建 Response + const response = await pending.promise + // 从缓存的 body 重建 Response + const cachedBody = lastResponseBody.get(key) + const cachedStatus = lastResponseStatus.get(key) + const cachedHeaders = lastResponseHeaders.get(key) + if (cachedBody !== undefined && cachedStatus !== undefined) { + return new Response(cachedBody, { + status: cachedStatus, + headers: cachedHeaders, + }) + } + // 回退:返回原始响应(可能已被消费) + return response + } + + // 2. 检查时间窗口(如果设置了 minInterval) + if (minInterval > 0 && !context.skipCache) { + const lastTime = lastFetchTime.get(key) ?? 0 + const elapsed = Date.now() - lastTime + if (elapsed < minInterval) { + // 时间窗口内,返回缓存的 Response + const cachedBody = lastResponseBody.get(key) + const cachedStatus = lastResponseStatus.get(key) + const cachedHeaders = lastResponseHeaders.get(key) + if (cachedBody !== undefined && cachedStatus !== undefined) { + return new Response(cachedBody, { + status: cachedStatus, + headers: cachedHeaders, + }) + } + // 无缓存,抛出节流错误 + throw new DedupeThrottledError( + `Request throttled: ${minInterval - elapsed}ms remaining` + ) + } + } + + // 3. 创建新请求 + const task = next(request).then(async response => { + // 缓存响应内容用于后续复用 + const body = await response.clone().text() + lastResponseBody.set(key, body) + lastResponseStatus.set(key, response.status) + lastResponseHeaders.set(key, new Headers(response.headers)) + lastFetchTime.set(key, Date.now()) + + // 清理 pending + globalPending.delete(key) + + return response + }).catch(error => { + globalPending.delete(key) + throw error + }) + + globalPending.set(key, { promise: task, cloneCount: 0 }) + return task }, } } diff --git a/packages/key-fetch/src/plugins/index.ts b/packages/key-fetch/src/plugins/index.ts index e51cc6eda..e912d11f5 100644 --- a/packages/key-fetch/src/plugins/index.ts +++ b/packages/key-fetch/src/plugins/index.ts @@ -7,7 +7,7 @@ export { interval } from './interval' export { deps } from './deps' export { ttl } from './ttl' -export { dedupe } from './dedupe' +export { dedupe, DedupeThrottledError } from './dedupe' export { tag } from './tag' export { etag } from './etag' export { throttleError, errorMatchers } from './throttle-error' diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index a45b1d913..c858ac6b4 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -159,7 +159,12 @@ "apis": [ { "type": "moralis", - "endpoint": "https://deep-index.moralis.io/api/v2.2" + "endpoint": "https://deep-index.moralis.io/api/v2.2", + "config": { + "txStatusInterval": 12000, + "balanceInterval": 36000, + "erc20Interval": 150000 + } }, { "type": "etherscan-v2", @@ -195,7 +200,12 @@ "apis": [ { "type": "moralis", - "endpoint": "https://deep-index.moralis.io/api/v2.2" + "endpoint": "https://deep-index.moralis.io/api/v2.2", + "config": { + "txStatusInterval": 15000, + "balanceInterval": 30000, + "erc20Interval": 120000 + } }, { "type": "bsc-rpc", "endpoint": "https://bsc-rpc.publicnode.com" }, { diff --git a/src/services/chain-adapter/providers/moralis-provider.ts b/src/services/chain-adapter/providers/moralis-provider.ts index 27334fbe0..cce5c4aba 100644 --- a/src/services/chain-adapter/providers/moralis-provider.ts +++ b/src/services/chain-adapter/providers/moralis-provider.ts @@ -6,7 +6,7 @@ */ import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, throttleError, errorMatchers, searchParams, pathParams, postBody } from '@biochain/key-fetch' +import { keyFetch, interval, deps, derive, transform, throttleError, errorMatchers, searchParams, pathParams, postBody, dedupe } from '@biochain/key-fetch' import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' import { globalRegistry } from '@biochain/key-fetch' import type { @@ -227,6 +227,11 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali throw new Error(`[MoralisProvider] MORALIS_API_KEY is required`) } + // 从配置读取轮询间隔 + const txStatusInterval = (this.config?.txStatusInterval as number) ?? 3000 + const balanceInterval = (this.config?.balanceInterval as number) ?? 30000 + const erc20Interval = (this.config?.erc20Interval as number) ?? 120000 + // API Key Header 插件 const moralisApiKeyPlugin: FetchPlugin = { name: 'moralis-api-key', @@ -242,10 +247,12 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali }, } - // 429 节流 + // 429/401 节流 const moralisThrottleError = throttleError({ match: errorMatchers.any( errorMatchers.httpStatus(429), + errorMatchers.httpStatus(401), + errorMatchers.contains('usage has been consumed'), errorMatchers.contains('Schema 验证失败'), ), }) @@ -257,7 +264,8 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali inputSchema: AddressParamsSchema, url: `${baseUrl}/:address/balance?chain=${moralisChain}`, use: [ - interval(30_000), // 节约 API 费用,至少 30s 轮询 + dedupe({ minInterval: balanceInterval }), + interval(balanceInterval), pathParams(), moralisApiKeyPlugin, moralisThrottleError, @@ -271,7 +279,7 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali inputSchema: AddressParamsSchema, url: `${baseUrl}/:address/erc20?chain=${moralisChain}`, use: [ - // 移除 interval,改为依赖驱动 + dedupe({ minInterval: erc20Interval }), pathParams(), moralisApiKeyPlugin, moralisThrottleError, @@ -285,7 +293,8 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali inputSchema: TxHistoryParamsSchema, url: `${baseUrl}/wallets/:address/history`, use: [ - interval(120_000), // 2分钟轮询,大幅降低 API 费用 + dedupe({ minInterval: erc20Interval }), + interval(erc20Interval), pathParams(), searchParams({ transform: (params: TxHistoryParams) => ({ @@ -457,7 +466,8 @@ export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(Morali url: rpcUrl, method: 'POST', use: [ - interval(3_000), // 等待上链时 3s 轮询 + dedupe({ minInterval: txStatusInterval }), + interval(txStatusInterval), postBody({ transform: (params: TransactionStatusParams) => ({ jsonrpc: '2.0', From 9319724f86e1467709b57bb69eb0888904bcefef Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 22 Jan 2026 19:57:27 +0800 Subject: [PATCH 18/33] feat(key-fetch): auto dedupe based on plugin introspection + fallback cooldown - core.ts: Add automatic time-based deduplication calculated from interval/deps plugins - interval.ts: Expose _intervalMs property for introspection - deps.ts: Expose _sources property for introspection - fallback.ts: Add 60s cooldown for failed sources to prevent request storms --- packages/key-fetch/src/core.ts | 57 +++++++++++++++++++++- packages/key-fetch/src/fallback.ts | 18 ++++++- packages/key-fetch/src/plugins/deps.ts | 11 ++++- packages/key-fetch/src/plugins/interval.ts | 11 ++++- 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/key-fetch/src/core.ts b/packages/key-fetch/src/core.ts index d6db3e7f6..d1fd970fb 100644 --- a/packages/key-fetch/src/core.ts +++ b/packages/key-fetch/src/core.ts @@ -62,6 +62,10 @@ class KeyFetchInstanceImpl< private subscribers = new Map>>() private subscriptionCleanups = new Map void)[]>() private inFlight = new Map>() + + // Auto dedupe: time-based deduplication + private lastFetchTime = new Map() + private lastResult = new Map() constructor(options: KeyFetchDefineOptions) { this.name = options.name @@ -84,17 +88,68 @@ class KeyFetchInstanceImpl< return pending } + // Auto dedupe: 基于插件计算去重间隔 + const dedupeInterval = this.calculateDedupeInterval() + if (dedupeInterval > 0) { + const lastTime = this.lastFetchTime.get(cacheKey) + const lastData = this.lastResult.get(cacheKey) + if (lastTime && lastData !== undefined) { + const elapsed = Date.now() - lastTime + if (elapsed < dedupeInterval) { + return lastData + } + } + } + // 发起请求(通过中间件链) const task = this.doFetch(params, options) this.inFlight.set(cacheKey, task) try { - return await task + const result = await task + // Auto dedupe: 记录成功请求的时间和结果 + if (dedupeInterval > 0) { + this.lastFetchTime.set(cacheKey, Date.now()) + this.lastResult.set(cacheKey, result) + } + return result } finally { this.inFlight.delete(cacheKey) } } + /** 基于插件计算自动去重间隔 */ + private calculateDedupeInterval(): number { + let intervalMs: number | undefined + + for (const plugin of this.plugins) { + // 检查 interval 插件 + if ('_intervalMs' in plugin) { + const ms = plugin._intervalMs as number | (() => number) + const value = typeof ms === 'function' ? ms() : ms + intervalMs = intervalMs !== undefined ? Math.min(intervalMs, value) : value + } + + // 检查 deps 插件 - 取依赖源的最小间隔 + if ('_sources' in plugin) { + const sources = plugin._sources as KeyFetchInstance[] + for (const source of sources) { + // 递归获取依赖的间隔(通过检查其插件) + const sourceImpl = source as unknown as KeyFetchInstanceImpl + if (sourceImpl.calculateDedupeInterval) { + const depInterval = sourceImpl.calculateDedupeInterval() + if (depInterval > 0) { + intervalMs = intervalMs !== undefined ? Math.min(intervalMs, depInterval) : depInterval + } + } + } + } + } + + // 返回间隔的一半作为去重窗口(确保在下次轮询前不重复请求) + return intervalMs !== undefined ? Math.floor(intervalMs / 2) : 0 + } + private async doFetch(params: TIN, options?: { skipCache?: boolean }): Promise { // 创建基础 Request(只有 URL 模板,不做任何修改) const baseRequest = new Request(this.urlTemplate, { diff --git a/packages/key-fetch/src/fallback.ts b/packages/key-fetch/src/fallback.ts index fbe087211..7cc9f189b 100644 --- a/packages/key-fetch/src/fallback.ts +++ b/packages/key-fetch/src/fallback.ts @@ -151,6 +151,10 @@ function createFallbackFetcher never ): KeyFetchInstance { const first = sources[0] + + // Cooldown: 记录失败源及其冷却结束时间 + const COOLDOWN_MS = 60_000 // 60秒冷却 + const failedSources = new Map, number>() const merged: KeyFetchInstance = { name, @@ -161,12 +165,24 @@ function createFallbackFetcher { const errors: Error[] = [] + const now = Date.now() for (const source of sources) { + // 检查是否在冷却期 + const cooldownEnd = failedSources.get(source) + if (cooldownEnd && now < cooldownEnd) { + continue // 跳过冷却中的源 + } + try { - return await source.fetch(params, options) + const result = await source.fetch(params, options) + // 成功后清除冷却 + failedSources.delete(source) + return result } catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))) + // 失败后进入冷却期 + failedSources.set(source, now + COOLDOWN_MS) } } diff --git a/packages/key-fetch/src/plugins/deps.ts b/packages/key-fetch/src/plugins/deps.ts index 145cc2a76..6e4df71ca 100644 --- a/packages/key-fetch/src/plugins/deps.ts +++ b/packages/key-fetch/src/plugins/deps.ts @@ -36,6 +36,12 @@ function normalizeDepInput(input: DepInput return { source: input as KeyFetchInstance } } +/** Deps 插件扩展接口 */ +export interface DepsPlugin extends FetchPlugin { + /** 暴露依赖源供 core 读取(用于自动 dedupe 计算) */ + _sources: KeyFetchInstance[] +} + /** * 依赖插件 * @@ -65,7 +71,7 @@ function normalizeDepInput(input: DepInput */ export function deps( ...args: DepInput[] | [DepInput[]] -): FetchPlugin { +): DepsPlugin { // 支持 deps(a, b, c) 和 deps([a, b, c]) 两种调用方式 const inputs: DepInput[] = args.length === 1 && Array.isArray(args[0]) ? args[0] @@ -81,6 +87,9 @@ export function deps( return { name: 'deps', + + // 暴露依赖源供 core 读取 + _sources: depConfigs.map(d => d.source), // onFetch: 注册依赖关系(用于 registry 追踪) async onFetch(request, next, context) { diff --git a/packages/key-fetch/src/plugins/interval.ts b/packages/key-fetch/src/plugins/interval.ts index 69dc263ab..840b946e6 100644 --- a/packages/key-fetch/src/plugins/interval.ts +++ b/packages/key-fetch/src/plugins/interval.ts @@ -11,6 +11,12 @@ export interface IntervalOptions { ms: number | (() => number) } +/** Interval 插件扩展接口 */ +export interface IntervalPlugin extends FetchPlugin { + /** 暴露间隔时间供 core 读取(用于自动 dedupe 计算) */ + _intervalMs: number | (() => number) +} + /** * 定时轮询插件 * @@ -27,7 +33,7 @@ export interface IntervalOptions { * use: [interval(() => getForgeInterval())] * ``` */ -export function interval(ms: number | (() => number)): FetchPlugin { +export function interval(ms: number | (() => number)): IntervalPlugin { // 每个参数组合独立的轮询状态 const timers = new Map>() const active = new Map() @@ -39,6 +45,9 @@ export function interval(ms: number | (() => number)): FetchPlugin { return { name: 'interval', + + // 暴露间隔时间供 core 读取 + _intervalMs: ms, // 透传请求(不修改) async onFetch(request, next) { From d021e403ad01ff0bb7bdee7c841622886c95eff5 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 12:16:03 +0800 Subject: [PATCH 19/33] feat(chain-effect): deep refactor all providers with Effect native Source API - Create @biochain/chain-effect package with: - source.ts: createPollingSource, createDependentSource, createHybridSource - event-bus.ts: WalletEvent type, EventBusService with per-wallet filtering - instance.ts: createStreamInstanceFromSource for React bridge - http.ts: httpFetch with Effect error handling - Deep refactor 11 providers using Effect native patterns: - biowallet, moralis, tron-rpc, ethwallet, bscwallet, tronwallet - evm-rpc, etherscan-v1, etherscan-v2, mempool, btcwallet - Core pattern: txHistory (polling+events) -> balance (dependent) - EventBus supports chainId + address for per-wallet event isolation - No TTL guessing - cache invalidates on dependency change --- packages/chain-effect/package.json | 46 ++ packages/chain-effect/src/event-bus.ts | 132 ++++ packages/chain-effect/src/http.ts | 198 +++++ packages/chain-effect/src/index.ts | 68 ++ packages/chain-effect/src/instance.ts | 359 +++++++++ packages/chain-effect/src/react.ts | 203 +++++ packages/chain-effect/src/schema.ts | 168 +++++ packages/chain-effect/src/source.ts | 345 +++++++++ packages/chain-effect/src/stream.ts | 137 ++++ packages/chain-effect/tsconfig.json | 14 + .../providers/biowallet-provider.effect.ts | 701 ++++++++++++++++++ .../providers/bscwallet-provider.effect.ts | 226 ++++++ .../providers/btcwallet-provider.effect.ts | 221 ++++++ .../providers/etherscan-v1-provider.effect.ts | 281 +++++++ .../providers/etherscan-v2-provider.effect.ts | 290 ++++++++ .../providers/ethwallet-provider.effect.ts | 260 +++++++ .../providers/evm-rpc-provider.effect.ts | 296 ++++++++ .../providers/mempool-provider.effect.ts | 268 +++++++ .../providers/moralis-provider.effect.ts | 595 +++++++++++++++ .../providers/tron-rpc-provider.effect.ts | 434 +++++++++++ .../providers/tronwallet-provider.effect.ts | 233 ++++++ 21 files changed, 5475 insertions(+) create mode 100644 packages/chain-effect/package.json create mode 100644 packages/chain-effect/src/event-bus.ts create mode 100644 packages/chain-effect/src/http.ts create mode 100644 packages/chain-effect/src/index.ts create mode 100644 packages/chain-effect/src/instance.ts create mode 100644 packages/chain-effect/src/react.ts create mode 100644 packages/chain-effect/src/schema.ts create mode 100644 packages/chain-effect/src/source.ts create mode 100644 packages/chain-effect/src/stream.ts create mode 100644 packages/chain-effect/tsconfig.json create mode 100644 src/services/chain-adapter/providers/biowallet-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/bscwallet-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/btcwallet-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/ethwallet-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/evm-rpc-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/mempool-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/moralis-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/tron-rpc-provider.effect.ts create mode 100644 src/services/chain-adapter/providers/tronwallet-provider.effect.ts diff --git a/packages/chain-effect/package.json b/packages/chain-effect/package.json new file mode 100644 index 000000000..e1b7b1a7c --- /dev/null +++ b/packages/chain-effect/package.json @@ -0,0 +1,46 @@ +{ + "name": "@biochain/chain-effect", + "version": "0.1.0", + "description": "Effect TS based reactive chain data fetching", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./react": "./src/react.ts", + "./schema": "./src/schema.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "typecheck:run": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run --passWithNoTests", + "lint:run": "oxlint ." + }, + "peerDependencies": { + "react": "^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "dependencies": { + "effect": "^3.19.15", + "@effect/platform": "^0.94.2" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "oxlint": "^1.32.0", + "react": "^19.0.0", + "typescript": "^5.9.3", + "vitest": "^4.0.0" + }, + "keywords": [ + "biochain", + "effect", + "reactive", + "stream" + ], + "license": "MIT" +} diff --git a/packages/chain-effect/src/event-bus.ts b/packages/chain-effect/src/event-bus.ts new file mode 100644 index 000000000..0116fa6c2 --- /dev/null +++ b/packages/chain-effect/src/event-bus.ts @@ -0,0 +1,132 @@ +/** + * EventBus Service - 钱包事件总线 + * + * 用于跨数据源通信,支持按钱包(chainId + address)过滤事件 + */ + +import { Effect, PubSub, Stream } from "effect" + +// ==================== Event Types ==================== + +export type WalletEventType = "tx:confirmed" | "tx:sent" | "tx:failed" | "balance:changed" + +export interface WalletEvent { + /** 事件类型 */ + type: WalletEventType + /** 链 ID */ + chainId: string + /** 钱包地址 */ + address: string + /** 交易哈希(可选) */ + txHash?: string + /** 时间戳 */ + timestamp: number +} + +// ==================== EventBus Service ==================== + +export interface EventBusService { + /** 发送事件 */ + readonly emit: (event: WalletEvent) => Effect.Effect + /** 全局事件流 */ + readonly stream: Stream.Stream + /** 过滤特定钱包的事件流 */ + readonly forWallet: (chainId: string, address: string) => Stream.Stream + /** 过滤特定钱包 + 事件类型的事件流 */ + readonly forWalletEvents: ( + chainId: string, + address: string, + types: WalletEventType[] + ) => Stream.Stream + /** 关闭 */ + readonly shutdown: Effect.Effect +} + +/** + * 创建 EventBus 服务实例 + * + * @example + * ```ts + * const eventBus = yield* createEventBusService + * + * // 发送事件 + * yield* eventBus.emit({ + * type: "tx:confirmed", + * chainId: "ethereum", + * address: "0x1234...", + * txHash: "0xabcd...", + * timestamp: Date.now(), + * }) + * + * // 监听特定钱包的事件 + * const walletEvents = eventBus.forWalletEvents("ethereum", "0x1234...", ["tx:confirmed"]) + * ``` + */ +export const createEventBusService: Effect.Effect = Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded() + + const baseStream = Stream.fromPubSub(pubsub) + + return { + emit: (event: WalletEvent) => PubSub.publish(pubsub, event), + + stream: baseStream, + + forWallet: (chainId: string, address: string) => + baseStream.pipe( + Stream.filter( + (e) => e.chainId === chainId && e.address.toLowerCase() === address.toLowerCase() + ) + ), + + forWalletEvents: (chainId: string, address: string, types: WalletEventType[]) => + baseStream.pipe( + Stream.filter( + (e) => + e.chainId === chainId && + e.address.toLowerCase() === address.toLowerCase() && + types.includes(e.type) + ) + ), + + shutdown: PubSub.shutdown(pubsub), + } +}) + +// ==================== Helper Functions ==================== + +/** + * 创建 tx:confirmed 事件 + */ +export const txConfirmedEvent = ( + chainId: string, + address: string, + txHash?: string +): WalletEvent => ({ + type: "tx:confirmed", + chainId, + address, + txHash, + timestamp: Date.now(), +}) + +/** + * 创建 tx:sent 事件 + */ +export const txSentEvent = (chainId: string, address: string, txHash?: string): WalletEvent => ({ + type: "tx:sent", + chainId, + address, + txHash, + timestamp: Date.now(), +}) + +/** + * 创建 balance:changed 事件 + */ +export const balanceChangedEvent = (chainId: string, address: string): WalletEvent => ({ + type: "balance:changed", + chainId, + address, + timestamp: Date.now(), +}) diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts new file mode 100644 index 000000000..f19a44e0a --- /dev/null +++ b/packages/chain-effect/src/http.ts @@ -0,0 +1,198 @@ +/** + * HTTP Client utilities for Effect + * + * 封装 fetch API,提供类型安全的 HTTP 请求 + */ + +import { Effect, Schedule, Duration } from 'effect'; +import { Schema } from 'effect'; + +// ==================== Error Types ==================== + +/** HTTP 错误基类 */ +export class HttpError { + readonly _tag = 'HttpError' as const; + constructor( + readonly message: string, + readonly status?: number, + readonly body?: string, + ) {} +} + +/** 429 限流错误 */ +export class RateLimitError { + readonly _tag = 'RateLimitError' as const; + constructor( + readonly message: string, + readonly retryAfter?: number, + ) {} +} + +/** Schema 验证错误 */ +export class SchemaError { + readonly _tag = 'SchemaError' as const; + constructor( + readonly message: string, + readonly errors?: unknown, + ) {} +} + +export type FetchError = HttpError | RateLimitError | SchemaError; + +// ==================== HTTP Client ==================== + +export interface FetchOptions { + /** URL 模板,支持 :param 占位符 */ + url: string; + /** HTTP 方法 */ + method?: 'GET' | 'POST'; + /** URL 路径参数(替换 :param)*/ + pathParams?: Record; + /** URL 查询参数 */ + searchParams?: Record; + /** 请求头 */ + headers?: Record; + /** 请求体(POST)*/ + body?: unknown; + /** 响应 Schema */ + schema?: Schema.Schema; + /** 超时时间(毫秒)*/ + timeout?: number; +} + +/** + * 替换 URL 中的 :param 占位符 + */ +function replacePathParams(url: string, params?: Record): string { + if (!params) return url; + let result = url; + for (const [key, value] of Object.entries(params)) { + result = result.replace(`:${key}`, encodeURIComponent(String(value))); + } + return result; +} + +/** + * 添加查询参数到 URL + */ +function appendSearchParams(url: string, params?: Record): string { + if (!params) return url; + const urlObj = new URL(url); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + urlObj.searchParams.set(key, String(value)); + } + } + return urlObj.toString(); +} + +/** + * 创建 HTTP 请求 Effect + * + * @example + * ```ts + * const balance = httpFetch({ + * url: 'https://api.example.com/address/:address/balance', + * pathParams: { address: '0x...' }, + * schema: BalanceSchema, + * }) + * ``` + */ +export function httpFetch(options: FetchOptions): Effect.Effect { + const { url, method = 'GET', pathParams, searchParams, headers = {}, body, schema, timeout = 30000 } = options; + + // 构建最终 URL + let finalUrl = replacePathParams(url, pathParams); + finalUrl = appendSearchParams(finalUrl, searchParams); + + return Effect.tryPromise({ + try: async (signal) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const requestInit: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + signal: controller.signal, + }; + + if (method === 'POST' && body !== undefined) { + requestInit.body = JSON.stringify(body); + } + + const response = await fetch(finalUrl, requestInit); + clearTimeout(timeoutId); + + // 检查状态码 + if (response.status === 429) { + const retryAfter = response.headers.get('retry-after'); + throw new RateLimitError('Rate limit exceeded', retryAfter ? parseInt(retryAfter, 10) : undefined); + } + + if (!response.ok) { + const errorBody = await response.text().catch(() => ''); + throw new HttpError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + errorBody.slice(0, 500), + ); + } + + // 解析 JSON + const json = await response.json(); + + // Schema 验证 + if (schema) { + const result = Schema.decodeUnknownSync(schema)(json); + return result as T; + } + + return json as T; + } finally { + clearTimeout(timeoutId); + } + }, + catch: (error) => { + if (error instanceof RateLimitError) return error; + if (error instanceof HttpError) return error; + if (error instanceof SchemaError) return error; + if (error instanceof Error && error.name === 'AbortError') { + return new HttpError(`Request timeout after ${timeout}ms`); + } + return new HttpError(error instanceof Error ? error.message : String(error)); + }, + }); +} + +// ==================== Retry Policies ==================== + +/** 默认重试策略:指数退避,最多 3 次 */ +export const defaultRetrySchedule = Schedule.exponential(Duration.millis(1000), 2).pipe( + Schedule.compose(Schedule.recurs(3)), +); + +/** 429 限流重试策略:等待 5 秒,最多 3 次 */ +export const rateLimitRetrySchedule = Schedule.spaced(Duration.seconds(5)).pipe(Schedule.compose(Schedule.recurs(3))); + +/** + * 带重试的 HTTP 请求 + */ +export function httpFetchWithRetry( + options: FetchOptions, + retrySchedule: Schedule.Schedule = defaultRetrySchedule, +): Effect.Effect { + return httpFetch(options).pipe( + Effect.retry( + Schedule.whileInput(retrySchedule, (error: FetchError) => { + // 只对网络错误和限流重试 + if (error._tag === 'RateLimitError') return true; + if (error._tag === 'HttpError' && error.status && error.status >= 500) return true; + return false; + }), + ), + ); +} diff --git a/packages/chain-effect/src/index.ts b/packages/chain-effect/src/index.ts new file mode 100644 index 000000000..a693eba6c --- /dev/null +++ b/packages/chain-effect/src/index.ts @@ -0,0 +1,68 @@ +/** + * @biochain/chain-effect + * + * Effect TS based reactive chain data fetching + */ + +// Re-export Effect core types for convenience +export { Effect, Stream, Schedule, Duration, Ref, SubscriptionRef, PubSub, Fiber } from "effect" +export { Schema } from "effect" + +// Schema definitions +export * from "./schema" + +// HTTP utilities +export { + httpFetch, + httpFetchWithRetry, + defaultRetrySchedule, + rateLimitRetrySchedule, + HttpError, + RateLimitError, + SchemaError, + type FetchOptions, + type FetchError, +} from "./http" + +// Stream utilities +export { + polling, + triggered, + transform, + map, + filter, + changes, + type PollingOptions, + type TriggeredOptions, +} from "./stream" + +// Data Source (Effect native pattern) +export { + createEventBus, + createPollingSource, + createDependentSource, + createHybridSource, + type EventBus, + type DataSource, + type PollingSourceOptions, + type DependentSourceOptions, + type HybridSourceOptions, +} from "./source" + +// EventBus Service (钱包事件总线) +export { + createEventBusService, + txConfirmedEvent, + txSentEvent, + balanceChangedEvent, + type EventBusService, + type WalletEvent, + type WalletEventType, +} from "./event-bus" + +// Stream Instance (React bridge) +export { + createStreamInstance, + createStreamInstanceFromSource, + type StreamInstance, +} from "./instance" diff --git a/packages/chain-effect/src/instance.ts b/packages/chain-effect/src/instance.ts new file mode 100644 index 000000000..c9ebf7a3b --- /dev/null +++ b/packages/chain-effect/src/instance.ts @@ -0,0 +1,359 @@ +/** + * React 桥接层 + * + * 将 Effect 的 SubscriptionRef 桥接到 React Hook + */ + +import { Effect, SubscriptionRef, Stream, Fiber } from "effect" +import type { FetchError } from "./http" +import type { DataSource } from "./source" + +/** 兼容旧 API 的 StreamInstance 接口 */ +export interface StreamInstance { + readonly name: string + fetch(input: TInput): Promise + subscribe(input: TInput, callback: (data: TOutput, event: "initial" | "update") => void): () => void + useState( + input: TInput, + options?: { enabled?: boolean } + ): { + data: TOutput | undefined + isLoading: boolean + isFetching: boolean + error: Error | undefined + refetch: () => Promise + } + invalidate(): void +} + +/** + * 从 DataSource 创建 StreamInstance(兼容层) + * + * 用于将 Effect 的 DataSource 桥接到现有的 React 组件 + */ +export function createStreamInstanceFromSource( + name: string, + createSource: (input: TInput) => Effect.Effect> +): StreamInstance { + // 缓存已创建的 sources(按 inputKey) + const sources = new Map + refCount: number + }>() + + const getInputKey = (input: TInput): string => { + if (input === undefined || input === null) return "__empty__" + return JSON.stringify(input) + } + + const getOrCreateSource = async (input: TInput): Promise> => { + const key = getInputKey(input) + const cached = sources.get(key) + if (cached) { + cached.refCount++ + return cached.source + } + + const source = await Effect.runPromise(createSource(input)) + sources.set(key, { source, refCount: 1 }) + return source + } + + const releaseSource = (input: TInput): void => { + const key = getInputKey(input) + const cached = sources.get(key) + if (!cached) return + + cached.refCount-- + if (cached.refCount <= 0) { + Effect.runFork(cached.source.stop) + sources.delete(key) + } + } + + return { + name, + + async fetch(input: TInput): Promise { + const source = await getOrCreateSource(input) + const value = await Effect.runPromise(source.get) + if (value === null) { + // 强制刷新获取 + return Effect.runPromise(source.refresh) + } + return value + }, + + subscribe( + input: TInput, + callback: (data: TOutput, event: "initial" | "update") => void + ): () => void { + let cancelled = false + let cleanup: (() => void) | null = null + + getOrCreateSource(input).then((source) => { + if (cancelled) { + releaseSource(input) + return + } + + let isFirst = true + const program = Stream.runForEach(source.changes, (value) => + Effect.sync(() => { + if (cancelled) return + callback(value, isFirst ? "initial" : "update") + isFirst = false + }) + ) + + const fiber = Effect.runFork(program) + + cleanup = () => { + Effect.runFork(Fiber.interrupt(fiber)) + releaseSource(input) + } + }) + + return () => { + cancelled = true + cleanup?.() + } + }, + + useState(input: TInput, options?: { enabled?: boolean }) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const React = require("react") as typeof import("react") + const { useState, useEffect, useCallback, useRef, useMemo } = React + + const [data, setData] = useState(undefined) + const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) + const [error, setError] = useState(undefined) + + const inputKey = useMemo(() => getInputKey(input), [input]) + const inputRef = useRef(input) + inputRef.current = input + + const enabled = options?.enabled !== false + const instanceRef = useRef(this) + + const refetch = useCallback(async () => { + if (!enabled) return + setIsFetching(true) + setError(undefined) + try { + const source = await getOrCreateSource(inputRef.current) + const result = await Effect.runPromise(source.refresh) + setData(result) + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setIsFetching(false) + setIsLoading(false) + } + }, [enabled]) + + useEffect(() => { + if (!enabled) { + setData(undefined) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + return + } + + setIsLoading(true) + setIsFetching(true) + setError(undefined) + + let isCancelled = false + const unsubscribe = instanceRef.current.subscribe( + inputRef.current, + (newData: TOutput, event: "initial" | "update") => { + if (isCancelled) return + setData(newData) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + } + ) + + return () => { + isCancelled = true + unsubscribe() + } + }, [enabled, inputKey]) + + return { data, isLoading, isFetching, error, refetch } + }, + + invalidate(): void { + // 停止所有 sources,下次订阅时会重新创建 + for (const [key, cached] of sources) { + Effect.runFork(cached.source.stop) + } + sources.clear() + }, + } +} + +/** + * 简单的 StreamInstance 创建函数(向后兼容) + * + * 对于简单场景,直接包装一个 fetch 函数 + */ +export function createStreamInstance( + name: string, + createStream: (input: TInput) => Stream.Stream, + options?: { + ttl?: number + minInterval?: number + } +): StreamInstance { + // 简单缓存 + const cache = new Map() + const ttl = options?.ttl ?? 30000 + + const getInputKey = (input: TInput): string => { + if (input === undefined || input === null) return "__empty__" + return JSON.stringify(input) + } + + return { + name, + + async fetch(input: TInput): Promise { + const key = getInputKey(input) + const cached = cache.get(key) + const now = Date.now() + + if (cached && now - cached.timestamp < ttl) { + return cached.value + } + + const stream = createStream(input) + const result = await Effect.runPromise( + Stream.runHead(stream).pipe( + Effect.flatMap((option) => + option._tag === "Some" + ? Effect.succeed(option.value) + : Effect.fail(new Error("No data")) + ) + ) + ) + + cache.set(key, { value: result, timestamp: now }) + return result + }, + + subscribe( + input: TInput, + callback: (data: TOutput, event: "initial" | "update") => void + ): () => void { + let isFirst = true + let cancelled = false + + const stream = createStream(input) + const program = Stream.runForEach(stream, (value) => + Effect.sync(() => { + if (cancelled) return + + // 更新缓存 + const key = getInputKey(input) + cache.set(key, { value, timestamp: Date.now() }) + + callback(value, isFirst ? "initial" : "update") + isFirst = false + }) + ) + + const fiber = Effect.runFork(program) + + return () => { + cancelled = true + import("effect").then(({ Fiber }) => { + Effect.runFork(Fiber.interrupt(fiber)) + }) + } + }, + + useState(input: TInput, options?: { enabled?: boolean }) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const React = require("react") as typeof import("react") + const { useState, useEffect, useCallback, useRef, useMemo } = React + + const [data, setData] = useState(undefined) + const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) + const [error, setError] = useState(undefined) + + const inputKey = useMemo(() => getInputKey(input), [input]) + const inputRef = useRef(input) + inputRef.current = input + + const enabled = options?.enabled !== false + const instanceRef = useRef(this) + + const refetch = useCallback(async () => { + if (!enabled) return + setIsFetching(true) + setError(undefined) + try { + cache.delete(getInputKey(inputRef.current)) + const result = await instanceRef.current.fetch(inputRef.current) + setData(result) + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setIsFetching(false) + setIsLoading(false) + } + }, [enabled]) + + useEffect(() => { + if (!enabled) { + setData(undefined) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + return + } + + // 检查缓存 + const key = getInputKey(inputRef.current) + const cached = cache.get(key) + if (cached && Date.now() - cached.timestamp < ttl) { + setData(cached.value) + setIsLoading(false) + } else { + setIsLoading(true) + } + setIsFetching(true) + setError(undefined) + + let isCancelled = false + const unsubscribe = instanceRef.current.subscribe( + inputRef.current, + (newData: TOutput) => { + if (isCancelled) return + setData(newData) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + } + ) + + return () => { + isCancelled = true + unsubscribe() + } + }, [enabled, inputKey]) + + return { data, isLoading, isFetching, error, refetch } + }, + + invalidate(): void { + cache.clear() + }, + } +} diff --git a/packages/chain-effect/src/react.ts b/packages/chain-effect/src/react.ts new file mode 100644 index 000000000..712f67470 --- /dev/null +++ b/packages/chain-effect/src/react.ts @@ -0,0 +1,203 @@ +/** + * React Hooks for Effect Streams + * + * 提供与现有 key-fetch useState 兼容的 API + */ + +import { useState, useEffect, useCallback, useRef, useMemo } from "react" +import { Effect, Stream, Fiber, Runtime, Exit } from "effect" + +// ==================== Types ==================== + +export interface UseStreamOptions { + /** 是否启用(默认 true)*/ + enabled?: boolean +} + +export interface UseStreamResult { + /** 当前数据 */ + data: T | undefined + /** 是否正在加载(首次)*/ + isLoading: boolean + /** 是否正在获取(包括后台刷新)*/ + isFetching: boolean + /** 错误信息 */ + error: E | undefined + /** 手动刷新 */ + refetch: () => Promise +} + +export interface UseEffectOptions { + enabled?: boolean +} + +export interface UseEffectResult { + data: T | undefined + isLoading: boolean + error: E | undefined + refetch: () => Promise +} + +// ==================== Runtime ==================== + +/** 全局 Runtime(可被覆盖)*/ +let globalRuntime = Runtime.defaultRuntime + +export function setGlobalRuntime(runtime: Runtime.Runtime): void { + globalRuntime = runtime +} + +// ==================== Hooks ==================== + +/** + * 订阅 Effect Stream,返回最新值 + * + * @example + * ```tsx + * const balance$ = createBalanceStream(address) + * const { data, isLoading, error } = useStream(balance$) + * ``` + */ +export function useStream( + stream: Stream.Stream, + options?: UseStreamOptions +): UseStreamResult { + const [data, setData] = useState(undefined) + const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) + const [error, setError] = useState(undefined) + + const fiberRef = useRef | null>(null) + const enabled = options?.enabled !== false + + const cleanup = useCallback(() => { + if (fiberRef.current) { + Runtime.runFork(globalRuntime)(Fiber.interrupt(fiberRef.current)) + fiberRef.current = null + } + }, []) + + const subscribe = useCallback(() => { + if (!enabled) { + setData(undefined) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + return + } + + setIsLoading(true) + setIsFetching(true) + setError(undefined) + + const program = Stream.runForEach(stream, (value) => + Effect.sync(() => { + setData(value) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + }) + ).pipe( + Effect.catchAll((e) => + Effect.sync(() => { + setError(e) + setIsLoading(false) + setIsFetching(false) + }) + ) + ) + + fiberRef.current = Runtime.runFork(globalRuntime)(program) + }, [stream, enabled]) + + useEffect(() => { + subscribe() + return cleanup + }, [subscribe, cleanup]) + + const refetch = useCallback(async () => { + cleanup() + subscribe() + }, [cleanup, subscribe]) + + return { data, isLoading, isFetching, error, refetch } +} + +/** + * 执行单次 Effect,返回结果 + * + * @example + * ```tsx + * const fetchBalance = Effect.promise(() => fetch('/balance')) + * const { data, isLoading, error } = useEffect(fetchBalance) + * ``` + */ +export function useEffectOnce( + effect: Effect.Effect, + deps: unknown[] = [], + options?: UseEffectOptions +): UseEffectResult { + const [data, setData] = useState(undefined) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(undefined) + + const enabled = options?.enabled !== false + const depsKey = useMemo(() => JSON.stringify(deps), [deps]) + + const run = useCallback(async () => { + if (!enabled) { + setData(undefined) + setIsLoading(false) + setError(undefined) + return + } + + setIsLoading(true) + setError(undefined) + + const exit = await Runtime.runPromiseExit(globalRuntime)(effect) + + if (Exit.isSuccess(exit)) { + setData(exit.value) + setError(undefined) + } else { + const cause = exit.cause + // 提取第一个错误 + const firstError = cause._tag === "Fail" ? cause.error : undefined + setError(firstError as E) + } + setIsLoading(false) + }, [effect, enabled]) + + useEffect(() => { + run() + }, [depsKey, enabled]) + + return { data, isLoading, error, refetch: run } +} + +/** + * 创建带参数的 Stream Hook 工厂 + * + * @example + * ```tsx + * const useBalance = createStreamHook((address: string) => + * createBalanceStream(address) + * ) + * + * // 在组件中使用 + * const { data } = useBalance('0x...') + * ``` + */ +export function createStreamHook( + factory: (params: TParams) => Stream.Stream +) { + return function useStreamHook( + params: TParams, + options?: UseStreamOptions + ): UseStreamResult { + const paramsKey = useMemo(() => JSON.stringify(params), [params]) + const stream = useMemo(() => factory(params), [paramsKey]) + return useStream(stream, options) + } +} diff --git a/packages/chain-effect/src/schema.ts b/packages/chain-effect/src/schema.ts new file mode 100644 index 000000000..fab59d1ae --- /dev/null +++ b/packages/chain-effect/src/schema.ts @@ -0,0 +1,168 @@ +/** + * Chain Effect Schema Definitions + * + * 使用 Effect Schema 替代 Zod,与 Effect 生态深度集成 + */ + +import { Schema } from "effect" + +// ==================== 基础类型 ==================== + +/** 金额 Schema(字符串形式,避免精度丢失)*/ +export class AmountValue extends Schema.Class("AmountValue")({ + raw: Schema.String, + decimals: Schema.Number, + symbol: Schema.String, + formatted: Schema.optional(Schema.String), +}) {} + +// ==================== 参数 Schema ==================== + +/** 地址查询参数 */ +export class AddressParams extends Schema.Class("AddressParams")({ + address: Schema.String, +}) {} + +/** 交易历史查询参数 */ +export class TxHistoryParams extends Schema.Class("TxHistoryParams")({ + address: Schema.String, + limit: Schema.optionalWith(Schema.Number, { default: () => 20 }), + page: Schema.optional(Schema.Number), +}) {} + +/** 单笔交易查询参数 */ +export class TransactionParams extends Schema.Class("TransactionParams")({ + txHash: Schema.String, +}) {} + +// ==================== 输出 Schema ==================== + +/** 余额输出 */ +export class BalanceOutput extends Schema.Class("BalanceOutput")({ + amount: AmountValue, + symbol: Schema.String, +}) {} + +/** 代币元数据 */ +export class TokenMetadata extends Schema.Class("TokenMetadata")({ + possibleSpam: Schema.optional(Schema.Boolean), + securityScore: Schema.optional(Schema.NullOr(Schema.Number)), + verified: Schema.optional(Schema.Boolean), + totalSupply: Schema.optional(Schema.String), +}) {} + +/** 代币余额 */ +export class TokenBalance extends Schema.Class("TokenBalance")({ + symbol: Schema.String, + name: Schema.String, + amount: AmountValue, + isNative: Schema.Boolean, + decimals: Schema.Number, + icon: Schema.optional(Schema.String), + contractAddress: Schema.optional(Schema.String), + metadata: Schema.optional(TokenMetadata), +}) {} + +/** 代币余额列表 */ +export const TokenBalancesOutput = Schema.Array(TokenBalance) +export type TokenBalancesOutput = Schema.Schema.Type + +/** 资产类型 */ +export const AssetType = Schema.Literal("native", "token", "nft") +export type AssetType = Schema.Schema.Type + +/** 交易方向 */ +export const Direction = Schema.Literal("in", "out", "self") +export type Direction = Schema.Schema.Type + +/** 交易动作 */ +export const Action = Schema.Literal( + "transfer", + "approve", + "swap", + "stake", + "unstake", + "claim", + "bridge", + "contract", + "mint", + "burn", + "gift", + "grab", + "trust", + "signFor", + "emigrate", + "immigrate", + "issueAsset", + "increaseAsset", + "destroyAsset", + "issueEntity", + "destroyEntity", + "locationName", + "dapp", + "certificate", + "mark", + "signature", + "unknown" +) +export type Action = Schema.Schema.Type + +/** 交易状态 */ +export const TxStatus = Schema.Literal("pending", "confirming", "confirmed", "failed") +export type TxStatus = Schema.Schema.Type + +/** 资产信息 */ +export class Asset extends Schema.Class("Asset")({ + assetType: AssetType, + value: Schema.String, + symbol: Schema.String, + decimals: Schema.Number, + contractAddress: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + logoUrl: Schema.optional(Schema.String), + tokenId: Schema.optional(Schema.String), +}) {} + +/** 手续费信息 */ +export class FeeInfo extends Schema.Class("FeeInfo")({ + value: Schema.String, + symbol: Schema.String, + decimals: Schema.Number, +}) {} + +/** 交易记录 */ +export class Transaction extends Schema.Class("Transaction")({ + hash: Schema.String, + from: Schema.String, + to: Schema.String, + timestamp: Schema.Number, + status: TxStatus, + blockNumber: Schema.optional(Schema.BigIntFromSelf), + action: Action, + direction: Direction, + assets: Schema.Array(Asset), + fee: Schema.optional(FeeInfo), + nonce: Schema.optional(Schema.Number), + fromEntity: Schema.optional(Schema.String), + toEntity: Schema.optional(Schema.String), + summary: Schema.optional(Schema.String), +}) {} + +/** 交易列表 */ +export const TransactionsOutput = Schema.Array(Transaction) +export type TransactionsOutput = Schema.Schema.Type + +/** 交易详情(可为 null)*/ +export const TransactionOutput = Schema.NullOr(Transaction) +export type TransactionOutput = Schema.Schema.Type + +/** 交易状态输出 */ +export class TransactionStatusOutput extends Schema.Class("TransactionStatusOutput")({ + status: TxStatus, + confirmations: Schema.Number, + requiredConfirmations: Schema.Number, +}) {} + +/** 区块高度 */ +export const BlockHeightOutput = Schema.BigIntFromSelf +export type BlockHeightOutput = Schema.Schema.Type diff --git a/packages/chain-effect/src/source.ts b/packages/chain-effect/src/source.ts new file mode 100644 index 000000000..41af3fafc --- /dev/null +++ b/packages/chain-effect/src/source.ts @@ -0,0 +1,345 @@ +/** + * Effect 原生数据源 + * + * 使用 SubscriptionRef + Stream 实现响应式数据流 + * - createPollingSource: 定时轮询 + 事件触发 + * - createDependentSource: 依赖变化触发 + * - createEventBus: 外部事件总线 + */ + +import { Effect, Stream, Schedule, SubscriptionRef, Duration, PubSub, Fiber } from "effect" +import type { FetchError } from "./http" +import type { EventBusService, WalletEventType } from "./event-bus" + +// ==================== Event Bus ==================== + +export interface EventBus { + /** 发送事件 */ + emit: (event: string) => Effect.Effect + /** 事件流 */ + stream: Stream.Stream + /** 关闭 */ + shutdown: Effect.Effect +} + +/** + * 创建事件总线 + * + * @example + * ```ts + * const eventBus = yield* createEventBus() + * yield* eventBus.emit('tx:confirmed') + * ``` + */ +export const createEventBus = (): Effect.Effect => + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded() + + return { + emit: (event: string) => PubSub.publish(pubsub, event), + stream: Stream.fromPubSub(pubsub), + shutdown: PubSub.shutdown(pubsub), + } + }) + +// ==================== Polling Source ==================== + +export interface PollingSourceOptions { + /** 数据源名称(用于调试) */ + name: string + /** 获取数据的 Effect */ + fetch: Effect.Effect + /** 轮询间隔(基于完成时间) */ + interval: Duration.DurationInput + /** 外部事件触发流(可选,简单字符串事件) */ + events?: Stream.Stream + /** 钱包事件配置(可选,用于跨 source 通信) */ + walletEvents?: { + /** EventBus 服务实例 */ + eventBus: EventBusService + /** 链 ID */ + chainId: string + /** 钱包地址 */ + address: string + /** 监听的事件类型 */ + types: WalletEventType[] + } + /** 立即执行第一次(默认 true) */ + immediate?: boolean +} + +export interface DataSource { + /** 当前值(可订阅变化) */ + ref: SubscriptionRef.SubscriptionRef + /** 驱动 Fiber */ + fiber: Fiber.RuntimeFiber + /** 获取当前值 */ + get: Effect.Effect + /** 订阅变化流 */ + changes: Stream.Stream + /** 强制刷新 */ + refresh: Effect.Effect + /** 停止 */ + stop: Effect.Effect +} + +/** + * 创建轮询数据源 + * + * - 使用 Schedule.spaced 实现基于完成时间的间隔轮询 + * - 支持外部事件触发 + * - 使用 Stream.changes 自动去重 + * + * @example + * ```ts + * const txHistory = yield* createPollingSource({ + * name: 'txHistory', + * fetch: fetchTransactions(), + * interval: Duration.minutes(4), + * events: eventBus.stream.pipe(Stream.filter(e => e === 'tx:confirmed')), + * }) + * ``` + */ +export const createPollingSource = ( + options: PollingSourceOptions +): Effect.Effect, never, never> => + Effect.gen(function* () { + const { name, fetch, interval, events, walletEvents, immediate = true } = options + + const ref = yield* SubscriptionRef.make(null) + + // 轮询流:Schedule.spaced 自动基于完成时间计算下次 + const pollingStream = Stream.repeatEffect(fetch).pipe( + Stream.schedule(Schedule.spaced(interval)) + ) + + // 立即执行第一次 + const immediateStream = immediate + ? Stream.fromEffect(fetch) + : Stream.empty + + // 简单字符串事件触发流 + const simpleEventStream = events + ? events.pipe(Stream.mapEffect(() => fetch)) + : Stream.empty + + // 钱包事件触发流(按 chainId + address 过滤) + const walletEventStream = walletEvents + ? walletEvents.eventBus + .forWalletEvents(walletEvents.chainId, walletEvents.address, walletEvents.types) + .pipe(Stream.mapEffect(() => fetch)) + : Stream.empty + + // 合并所有触发源 + const combinedStream = Stream.merge( + Stream.merge( + Stream.concat(immediateStream, pollingStream), + simpleEventStream + ), + walletEventStream + ) + + // 使用 Stream.changes 去重(只有值变化才继续) + const driver = combinedStream.pipe( + Stream.changes + ) + + // 驱动 ref 更新 + const fiber = yield* driver.pipe( + Stream.runForEach((value) => SubscriptionRef.set(ref, value)), + Effect.fork + ) + + return { + ref, + fiber, + get: SubscriptionRef.get(ref), + changes: ref.changes.pipe( + Stream.filter((v): v is T => v !== null) + ), + refresh: Effect.gen(function* () { + const value = yield* fetch + yield* SubscriptionRef.set(ref, value) + return value + }), + stop: Fiber.interrupt(fiber).pipe(Effect.asVoid), + } + }) + +// ==================== Dependent Source ==================== + +export interface DependentSourceOptions { + /** 数据源名称 */ + name: string + /** 依赖的数据源 */ + dependsOn: SubscriptionRef.SubscriptionRef + /** 判断依赖是否真的变化了 */ + hasChanged: (prev: TDep | null, next: TDep) => boolean + /** 根据依赖值获取数据 */ + fetch: (dep: TDep) => Effect.Effect +} + +/** + * 创建依赖数据源 + * + * - 监听依赖变化,只有 hasChanged 返回 true 时才触发请求 + * - 依赖不变则永远命中缓存 + * + * @example + * ```ts + * const balance = yield* createDependentSource({ + * name: 'balance', + * dependsOn: txHistory.ref, + * hasChanged: (prev, next) => + * prev?.length !== next.length || prev?.[0]?.hash !== next[0]?.hash, + * fetch: (txList) => fetchBalance(txList[0]?.from), + * }) + * ``` + */ +export const createDependentSource = ( + options: DependentSourceOptions +): Effect.Effect, never, never> => + Effect.gen(function* () { + const { name, dependsOn, hasChanged, fetch } = options + + const ref = yield* SubscriptionRef.make(null) + let prevDep: TDep | null = null + + // 监听依赖变化 + const driver = dependsOn.changes.pipe( + // 过滤掉 null + Stream.filter((value): value is TDep => value !== null), + // 检查是否真的变化了 + Stream.filter((next) => { + const changed = hasChanged(prevDep, next) + if (changed) { + prevDep = next + } + return changed + }), + // 获取数据 + Stream.mapEffect((dep) => fetch(dep)) + ) + + // 驱动 ref 更新 + const fiber = yield* driver.pipe( + Stream.runForEach((value) => SubscriptionRef.set(ref, value)), + Effect.fork + ) + + return { + ref, + fiber, + get: SubscriptionRef.get(ref), + changes: ref.changes.pipe( + Stream.filter((v): v is T => v !== null) + ), + refresh: Effect.gen(function* () { + const dep = yield* SubscriptionRef.get(dependsOn) + if (dep === null) { + return yield* Effect.fail(new Error("Dependency not available") as unknown as FetchError) + } + const value = yield* fetch(dep) + yield* SubscriptionRef.set(ref, value) + return value + }), + stop: Fiber.interrupt(fiber).pipe(Effect.asVoid), + } + }) + +// ==================== Hybrid Source ==================== + +export interface HybridSourceOptions { + /** 数据源名称 */ + name: string + /** 依赖的数据源(变化时触发) */ + dependsOn?: { + source: SubscriptionRef.SubscriptionRef + hasChanged: (prev: TDep | null, next: TDep) => boolean + } + /** 兜底轮询间隔(可选) */ + interval?: Duration.DurationInput + /** 外部事件触发(可选) */ + events?: Stream.Stream + /** 获取数据 */ + fetch: Effect.Effect +} + +/** + * 创建混合数据源 + * + * 支持多种触发策略的组合: + * - 依赖变化触发 + * - 定时轮询(兜底) + * - 外部事件触发 + * + * 任何触发都会重置定时器 + */ +export const createHybridSource = ( + options: HybridSourceOptions +): Effect.Effect, never, never> => + Effect.gen(function* () { + const { name, dependsOn, interval, events, fetch } = options + + const ref = yield* SubscriptionRef.make(null) + let prevDep: TDep | null = null + + // 依赖触发流 + const depStream = dependsOn + ? dependsOn.source.changes.pipe( + Stream.filter((v): v is TDep => v !== null), + Stream.filter((next) => { + const changed = dependsOn.hasChanged(prevDep, next) + if (changed) prevDep = next + return changed + }), + Stream.mapEffect(() => fetch) + ) + : Stream.empty + + // 轮询流 + const pollingStream = interval + ? Stream.repeatEffect(fetch).pipe( + Stream.schedule(Schedule.spaced(interval)) + ) + : Stream.empty + + // 事件触发流 + const eventStream = events + ? events.pipe(Stream.mapEffect(() => fetch)) + : Stream.empty + + // 立即执行 + const immediateStream = Stream.fromEffect(fetch) + + // 合并所有流 + 去重 + const driver = Stream.merge( + Stream.merge( + Stream.merge(immediateStream, depStream), + pollingStream + ), + eventStream + ).pipe( + Stream.changes + ) + + const fiber = yield* driver.pipe( + Stream.runForEach((value) => SubscriptionRef.set(ref, value)), + Effect.fork + ) + + return { + ref, + fiber, + get: SubscriptionRef.get(ref), + changes: ref.changes.pipe( + Stream.filter((v): v is T => v !== null) + ), + refresh: Effect.gen(function* () { + const value = yield* fetch + yield* SubscriptionRef.set(ref, value) + return value + }), + stop: Fiber.interrupt(fiber).pipe(Effect.asVoid), + } + }) diff --git a/packages/chain-effect/src/stream.ts b/packages/chain-effect/src/stream.ts new file mode 100644 index 000000000..cbbae9504 --- /dev/null +++ b/packages/chain-effect/src/stream.ts @@ -0,0 +1,137 @@ +/** + * Stream utilities for chain data + * + * 提供轮询、依赖触发等常用 Stream 模式 + * + * 注意:去重功能推荐使用原生 Stream.changes + */ + +import { Stream, Effect, Schedule, Duration } from "effect" +import { httpFetch, type FetchOptions, type FetchError } from "./http" + +// ==================== Polling Stream ==================== + +export interface PollingOptions { + /** HTTP 请求配置 */ + fetch: FetchOptions + /** 轮询间隔(毫秒)*/ + interval: number + /** 是否立即执行第一次(默认 true)*/ + immediate?: boolean +} + +/** + * 创建轮询 Stream + * + * @example + * ```ts + * const blockHeight$ = polling({ + * fetch: { url: '/blocks/tip/height', schema: BlockHeightSchema }, + * interval: 30000, + * }) + * ``` + */ +export function polling(options: PollingOptions): Stream.Stream { + const { fetch: fetchOptions, interval, immediate = true } = options + + const fetchEffect = httpFetch(fetchOptions) + + const scheduledStream = Stream.repeatEffect(fetchEffect).pipe( + Stream.schedule(Schedule.spaced(Duration.millis(interval))) + ) + + if (immediate) { + return Stream.concat(Stream.fromEffect(fetchEffect), scheduledStream) + } + + return scheduledStream +} + +// ==================== Triggered Stream ==================== + +export interface TriggeredOptions { + /** 触发源 Stream */ + trigger: Stream.Stream + /** 触发时执行的请求 */ + fetch: FetchOptions | ((triggerValue: TTrigger) => FetchOptions) +} + +/** + * 创建触发式 Stream(类似 switchMap) + * + * @example + * ```ts + * const balance$ = triggered({ + * trigger: blockHeight$, + * fetch: (blockHeight) => ({ + * url: '/balance/:address', + * pathParams: { address }, + * }), + * }) + * ``` + */ +export function triggered( + options: TriggeredOptions +): Stream.Stream { + const { trigger, fetch: fetchConfig } = options + + return trigger.pipe( + Stream.mapEffect((triggerValue) => { + const fetchOptions = typeof fetchConfig === "function" + ? fetchConfig(triggerValue) + : fetchConfig + return httpFetch(fetchOptions) + }) + ) +} + +// ==================== Transform Utilities ==================== + +/** + * 对 Stream 应用转换函数 + */ +export function transform( + stream: Stream.Stream, + fn: (input: TInput) => TOutput | Promise +): Stream.Stream { + return stream.pipe( + Stream.mapEffect((input) => + Effect.tryPromise({ + try: async () => fn(input), + catch: (e) => e as E, + }) + ) + ) +} + +/** + * 对 Stream 应用同步转换函数 + */ +export function map( + stream: Stream.Stream, + fn: (input: TInput) => TOutput +): Stream.Stream { + return Stream.map(stream, fn) +} + +/** + * 过滤 Stream + */ +export function filter( + stream: Stream.Stream, + predicate: (value: T) => boolean +): Stream.Stream { + return Stream.filter(stream, predicate) +} + +// ==================== Re-export Effect Stream utilities ==================== + +/** + * 去重:使用原生 Stream.changes + * + * @example + * ```ts + * const deduped$ = myStream.pipe(Stream.changes) + * ``` + */ +export const changes = Stream.changes diff --git a/packages/chain-effect/tsconfig.json b/packages/chain-effect/tsconfig.json new file mode 100644 index 000000000..410db1f7e --- /dev/null +++ b/packages/chain-effect/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "downlevelIteration": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/src/services/chain-adapter/providers/biowallet-provider.effect.ts b/src/services/chain-adapter/providers/biowallet-provider.effect.ts new file mode 100644 index 000000000..98ab93d67 --- /dev/null +++ b/src/services/chain-adapter/providers/biowallet-provider.effect.ts @@ -0,0 +1,701 @@ +/** + * BioWallet API Provider (Effect TS - 深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + * - transactionHistory: 定时轮询 + 事件触发 + * - balance/tokenBalances: 依赖 transactionHistory 变化 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { + ApiProvider, + TokenBalance, + Transaction, + Direction, + Action, + BalanceOutput, + BlockHeightOutput, + TokenBalancesOutput, + TransactionOutput, + TransactionsOutput, + AddressParams, + TxHistoryParams, + TransactionParams, +} from "./types" +import { setForgeInterval } from "../bioforest/fetch" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { BioforestIdentityMixin } from "../bioforest/identity-mixin" +import { BioforestTransactionMixin } from "../bioforest/transaction-mixin" +import { BioforestAccountMixin } from "../bioforest/account-mixin" +import { fetchGenesisBlock } from "@/services/bioforest-sdk" + +// ==================== Effect Schema 定义 ==================== + +const BiowalletAssetItemSchema = S.Struct({ + assetNumber: S.String, + assetType: S.String, +}) + +const AssetResultSchema = S.Struct({ + address: S.String, + assets: S.Record({ key: S.String, value: S.Record({ key: S.String, value: BiowalletAssetItemSchema }) }), +}) + +const AssetResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.NullOr(AssetResultSchema)), +}) +type AssetResponse = S.Schema.Type + +const TransferAssetSchema = S.Struct({ + assetType: S.String, + amount: S.String, +}) + +const GiftAssetSchema = S.Struct({ + totalAmount: S.String, + assetType: S.String, +}) + +const GrabAssetSchema = S.Struct({ + transactionSignature: S.String, +}) + +const TrustAssetSchema = S.Struct({ + trustees: S.Array(S.String), + numberOfSignFor: S.Number, + assetType: S.String, + amount: S.String, +}) + +const SignatureAssetSchema = S.Struct({ + publicKey: S.optional(S.String), +}) + +const DestroyAssetSchema = S.Struct({ + assetType: S.String, + amount: S.String, +}) + +const IssueEntitySchema = S.Struct({ + entityId: S.optional(S.String), +}) + +const IssueEntityFactorySchema = S.Struct({ + factoryId: S.optional(S.String), +}) + +const TxAssetSchema = S.Struct({ + transferAsset: S.optional(TransferAssetSchema), + giftAsset: S.optional(GiftAssetSchema), + grabAsset: S.optional(GrabAssetSchema), + trustAsset: S.optional(TrustAssetSchema), + signature: S.optional(SignatureAssetSchema), + destroyAsset: S.optional(DestroyAssetSchema), + issueEntity: S.optional(IssueEntitySchema), + issueEntityFactory: S.optional(IssueEntityFactorySchema), +}) + +const BiowalletTxTransactionSchema = S.Struct({ + type: S.String, + senderId: S.String, + recipientId: S.optionalWith(S.String, { default: () => "" }), + timestamp: S.Number, + signature: S.String, + asset: S.optional(TxAssetSchema), +}) +type BiowalletTxTransaction = S.Schema.Type + +const BiowalletTxItemSchema = S.Struct({ + height: S.Number, + signature: S.String, + transaction: BiowalletTxTransactionSchema, +}) + +const TxListResultSchema = S.Struct({ + trs: S.Array(BiowalletTxItemSchema), + count: S.optional(S.Number), +}) + +const TxListResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(TxListResultSchema), +}) +type TxListResponse = S.Schema.Type + +const BlockResultSchema = S.Struct({ + height: S.Number, +}) + +const BlockResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(BlockResultSchema), +}) +type BlockResponse = S.Schema.Type + +const PendingTrItemSchema = S.Struct({ + state: S.Number, + trJson: BiowalletTxTransactionSchema, + signature: S.optional(S.String), + createdTime: S.String, +}) + +const PendingTrResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.Array(PendingTrItemSchema)), +}) +type PendingTrResponse = S.Schema.Type + +// ==================== 工具函数 ==================== + +function getDirection(from: string, to: string, address: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + const addrLower = address.toLowerCase() + + if (!toLower) return fromLower === addrLower ? "out" : "in" + if (fromLower === addrLower && toLower === addrLower) return "self" + if (fromLower === addrLower) return "out" + return "in" +} + +const DEFAULT_EPOCH_MS = 0 + +function detectAction(txType: string): Action { + const typeMap: Record = { + "AST-01": "transfer", + "AST-02": "transfer", + "AST-03": "destroyAsset", + "BSE-01": "signature", + "ETY-01": "issueEntity", + "ETY-02": "issueEntity", + "GFT-01": "gift", + "GFT-02": "gift", + "GRB-01": "grab", + "GRB-02": "grab", + "TRS-01": "trust", + "TRS-02": "trust", + "SGN-01": "signFor", + "SGN-02": "signFor", + "EMI-01": "emigrate", + "EMI-02": "emigrate", + "IMI-01": "immigrate", + "IMI-02": "immigrate", + "ISA-01": "issueAsset", + "ICA-01": "increaseAsset", + "DSA-01": "destroyAsset", + "ISE-01": "issueEntity", + "DSE-01": "destroyEntity", + "LNS-01": "locationName", + "DAP-01": "dapp", + "CRT-01": "certificate", + "MRK-01": "mark", + } + + const parts = txType.split("-") + if (parts.length >= 4) { + const suffix = `${parts[parts.length - 2]}-${parts[parts.length - 1]}` + return typeMap[suffix] ?? "unknown" + } + + return "unknown" +} + +function extractAssetInfo( + asset: BiowalletTxTransaction["asset"], + defaultSymbol: string +): { value: string | null; assetType: string } { + if (!asset) return { value: null, assetType: defaultSymbol } + + if (asset.transferAsset) { + return { value: asset.transferAsset.amount, assetType: asset.transferAsset.assetType } + } + if (asset.giftAsset) { + return { value: asset.giftAsset.totalAmount, assetType: asset.giftAsset.assetType } + } + if (asset.trustAsset) { + return { value: asset.trustAsset.amount, assetType: asset.trustAsset.assetType } + } + if (asset.grabAsset) { + return { value: "0", assetType: defaultSymbol } + } + if (asset.destroyAsset) { + return { value: asset.destroyAsset.amount, assetType: asset.destroyAsset.assetType } + } + if (asset.issueEntity || asset.issueEntityFactory) { + return { value: "0", assetType: defaultSymbol } + } + if (asset.signature) { + return { value: "0", assetType: defaultSymbol } + } + + return { value: null, assetType: defaultSymbol } +} + +function convertBioTransactionToTransaction( + bioTx: BiowalletTxTransaction, + options: { + signature: string + height?: number + status: "pending" | "confirmed" | "failed" + createdTime?: string + address?: string + epochMs: number + } +): Transaction { + const { signature, height, status, createdTime, address = "", epochMs } = options + const { value, assetType } = extractAssetInfo(bioTx.asset, "BFM") + + const timestamp = createdTime + ? new Date(createdTime).getTime() + : epochMs + bioTx.timestamp * 1000 + + const direction = address + ? getDirection(bioTx.senderId, bioTx.recipientId ?? "", address) + : "out" + + return { + hash: signature, + from: bioTx.senderId, + to: bioTx.recipientId ?? "", + timestamp, + status, + blockNumber: height !== undefined ? BigInt(height) : undefined, + action: detectAction(bioTx.type), + direction, + assets: [ + { + assetType: "native" as const, + value: value ?? "0", + symbol: assetType, + decimals: 8, + }, + ], + } +} + +// ==================== 判断交易列表是否变化 ==================== + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + // 比较第一条交易的 hash + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Base Class for Mixins ==================== + +class BiowalletBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class BiowalletProviderEffect + extends BioforestAccountMixin(BioforestIdentityMixin(BioforestTransactionMixin(BiowalletBase))) + implements ApiProvider +{ + private readonly symbol: string + private readonly decimals: number + private readonly baseUrl: string + private forgeInterval: number = 15000 + private epochMs: number = DEFAULT_EPOCH_MS + + // Provider 级别共享的 EventBus(延迟初始化) + private _eventBus: EventBusService | null = null + + // StreamInstance 接口(React 兼容层) + readonly nativeBalance: StreamInstance + readonly tokenBalances: StreamInstance + readonly transactionHistory: StreamInstance + readonly blockHeight: StreamInstance + readonly transaction: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + this.baseUrl = this.endpoint + + const genesisPath = chainConfigService.getBiowalletGenesisBlock(chainId) + if (genesisPath) { + fetchGenesisBlock(chainId, genesisPath) + .then((genesis) => { + const interval = genesis.asset.genesisAsset.forgeInterval + if (typeof interval === "number") { + this.forgeInterval = interval * 1000 + setForgeInterval(chainId, this.forgeInterval) + } + const beginEpochTime = genesis.asset.genesisAsset.beginEpochTime + if (typeof beginEpochTime === "number") { + this.epochMs = beginEpochTime + } + }) + .catch((err) => { + console.warn("Failed to fetch genesis block:", err) + }) + } + + const symbol = this.symbol + const decimals = this.decimals + const provider = this + + // ==================== transactionHistory: 定时轮询 + 事件触发 ==================== + this.transactionHistory = createStreamInstanceFromSource( + `biowallet.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params, symbol, decimals) + ) + + // ==================== nativeBalance: 依赖 transactionHistory 变化 ==================== + this.nativeBalance = createStreamInstanceFromSource( + `biowallet.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params, symbol, decimals) + ) + + // ==================== tokenBalances: 依赖 transactionHistory 变化 ==================== + this.tokenBalances = createStreamInstanceFromSource( + `biowallet.${chainId}.tokenBalances`, + (params) => provider.createTokenBalancesSource(params, symbol, decimals) + ) + + // ==================== blockHeight: 简单轮询 ==================== + this.blockHeight = createStreamInstanceFromSource( + `biowallet.${chainId}.blockHeight`, + () => provider.createBlockHeightSource() + ) + + // ==================== transaction: 简单查询 ==================== + this.transaction = createStreamInstanceFromSource( + `biowallet.${chainId}.transaction`, + (params) => provider.createTransactionSource(params) + ) + } + + // ==================== Source 创建方法 ==================== + + private createTransactionHistorySource( + params: TxHistoryParams, + symbol: string, + decimals: number + ): Effect.Effect> { + const provider = this + const address = params.address.toLowerCase() + const chainId = this.chainId + + return Effect.gen(function* () { + // 获取或创建 Provider 级别共享的 EventBus + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionList(params).pipe( + Effect.map((raw): TransactionsOutput => { + if (!raw.result?.trs) return [] + + return raw.result.trs + .map((item): Transaction | null => { + const tx = item.transaction + const action = detectAction(tx.type) + const direction = getDirection(tx.senderId, tx.recipientId ?? "", address) + const { value, assetType } = extractAssetInfo(tx.asset, symbol) + if (value === null) return null + + return { + hash: tx.signature ?? item.signature, + from: tx.senderId, + to: tx.recipientId ?? "", + timestamp: provider.epochMs + tx.timestamp * 1000, + status: "confirmed", + blockNumber: BigInt(item.height), + action, + direction, + assets: [ + { + assetType: "native" as const, + value, + symbol: assetType, + decimals, + }, + ], + } + }) + .filter((tx): tx is Transaction => tx !== null) + .sort((a, b) => b.timestamp - a.timestamp) + }) + ) + + // 使用 createPollingSource 实现定时轮询 + 事件触发 + const source = yield* createPollingSource({ + name: `biowallet.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.forgeInterval), + // 使用 walletEvents 配置,按 chainId + address 过滤事件 + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams, + symbol: string, + decimals: number + ): Effect.Effect> { + const provider = this + + return Effect.gen(function* () { + // 先创建 transactionHistory source 作为依赖 + const txHistorySource = yield* provider.createTransactionHistorySource( + { address: params.address, limit: 1 }, + symbol, + decimals + ) + + const fetchEffect = provider.fetchAddressAsset(params.address).pipe( + Effect.map((raw): BalanceOutput => { + if (!raw.result?.assets) { + return { amount: Amount.zero(decimals, symbol), symbol } + } + for (const magic of Object.values(raw.result.assets)) { + for (const asset of Object.values(magic)) { + if (asset.assetType === symbol) { + return { + amount: Amount.fromRaw(asset.assetNumber, decimals, symbol), + symbol, + } + } + } + } + return { amount: Amount.zero(decimals, symbol), symbol } + }) + ) + + // 依赖 transactionHistory 变化 + const source = yield* createDependentSource({ + name: `biowallet.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + private createTokenBalancesSource( + params: AddressParams, + symbol: string, + decimals: number + ): Effect.Effect> { + const provider = this + + return Effect.gen(function* () { + // 先创建 transactionHistory source 作为依赖 + const txHistorySource = yield* provider.createTransactionHistorySource( + { address: params.address, limit: 1 }, + symbol, + decimals + ) + + const fetchEffect = provider.fetchAddressAsset(params.address).pipe( + Effect.map((raw): TokenBalancesOutput => { + if (!raw.result?.assets) return [] + const tokens: TokenBalance[] = [] + + for (const magic of Object.values(raw.result.assets)) { + for (const asset of Object.values(magic)) { + const isNative = asset.assetType === symbol + tokens.push({ + symbol: asset.assetType, + name: asset.assetType, + amount: Amount.fromRaw(asset.assetNumber, decimals, asset.assetType), + isNative, + decimals, + }) + } + } + + tokens.sort((a, b) => { + if (a.isNative && !b.isNative) return -1 + if (!a.isNative && b.isNative) return 1 + return b.amount.toNumber() - a.amount.toNumber() + }) + + return tokens + }) + ) + + // 依赖 transactionHistory 变化 + const source = yield* createDependentSource({ + name: `biowallet.${provider.chainId}.tokenBalances`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + private createBlockHeightSource(): Effect.Effect> { + const provider = this + + return Effect.gen(function* () { + const fetchEffect = provider.fetchBlockHeight().pipe( + Effect.map((raw): BlockHeightOutput => { + if (!raw.result?.height) return BigInt(0) + return BigInt(raw.result.height) + }) + ) + + const source = yield* createPollingSource({ + name: `biowallet.${provider.chainId}.blockHeight`, + fetch: fetchEffect, + interval: Duration.millis(provider.forgeInterval), + }) + + return source + }) + } + + private createTransactionSource( + params: TransactionParams + ): Effect.Effect> { + const provider = this + + return Effect.gen(function* () { + const fetchEffect = Effect.all({ + pending: provider.fetchPendingTransactions(params.senderId ?? ""), + confirmed: provider.fetchSingleTransaction(params.txHash), + }).pipe( + Effect.map(({ pending, confirmed }): TransactionOutput => { + if (pending.result && pending.result.length > 0) { + const pendingTx = pending.result.find((tx) => tx.signature === params.txHash) + if (pendingTx) { + return convertBioTransactionToTransaction(pendingTx.trJson, { + signature: pendingTx.trJson.signature ?? pendingTx.signature ?? "", + status: "pending", + createdTime: pendingTx.createdTime, + epochMs: provider.epochMs, + }) + } + } + + if (confirmed.result?.trs?.length) { + const item = confirmed.result.trs[0] + return convertBioTransactionToTransaction(item.transaction, { + signature: item.transaction.signature ?? item.signature, + height: item.height, + status: "confirmed", + epochMs: provider.epochMs, + }) + } + + return null + }) + ) + + // 交易查询使用轮询(等待确认) + const source = yield* createPollingSource({ + name: `biowallet.${provider.chainId}.transaction`, + fetch: fetchEffect, + interval: Duration.millis(provider.forgeInterval), + }) + + return source + }) + } + + // ==================== HTTP Fetch 方法 ==================== + + private fetchBlockHeight(): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/block/lastblock`, + schema: BlockResponseSchema, + }) + } + + private fetchAddressAsset(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/address/asset`, + method: "POST", + body: { address }, + schema: AssetResponseSchema, + }) + } + + private fetchTransactionList(params: TxHistoryParams): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/transactions/query`, + method: "POST", + body: { + address: params.address, + page: params.page ?? 1, + pageSize: params.limit ?? 50, + sort: -1, + }, + schema: TxListResponseSchema, + }) + } + + private fetchSingleTransaction(txHash: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/transactions/query`, + method: "POST", + body: { signature: txHash }, + schema: TxListResponseSchema, + }) + } + + private fetchPendingTransactions(senderId: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/pendingTr`, + method: "POST", + body: { senderId, sort: -1 }, + schema: PendingTrResponseSchema, + }) + } +} + +export function createBiowalletProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === "biowallet-v1") { + return new BiowalletProviderEffect(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/bscwallet-provider.effect.ts b/src/services/chain-adapter/providers/bscwallet-provider.effect.ts new file mode 100644 index 000000000..8de8089dd --- /dev/null +++ b/src/services/chain-adapter/providers/bscwallet-provider.effect.ts @@ -0,0 +1,226 @@ +/** + * BscWallet API Provider - Effect TS Version (深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { + ApiProvider, + Transaction, + Direction, + BalanceOutput, + TransactionsOutput, + AddressParams, + TxHistoryParams, +} from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { EvmIdentityMixin } from "../evm/identity-mixin" +import { EvmTransactionMixin } from "../evm/transaction-mixin" + +// ==================== Effect Schema 定义 ==================== + +const BalanceApiSchema = S.Struct({ + balance: S.String, +}) +type BalanceApi = S.Schema.Type + +const TxItemSchema = S.Struct({ + hash: S.String, + from: S.String, + to: S.String, + value: S.String, + timestamp: S.Number, + status: S.optional(S.String), +}) +type TxItem = S.Schema.Type + +const TxApiSchema = S.Struct({ + transactions: S.Array(TxItemSchema), +}) +type TxApi = S.Schema.Type + +// ==================== 工具函数 ==================== + +function getDirection(from: string, to: string, address: string): Direction { + const f = from.toLowerCase() + const t = to.toLowerCase() + if (f === address && t === address) return "self" + return f === address ? "out" : "in" +} + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Base Class ==================== + +class BscWalletBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class BscWalletProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(BscWalletBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly baseUrl: string + private readonly pollingInterval: number = 30000 + + private _eventBus: EventBusService | null = null + + readonly nativeBalance: StreamInstance + readonly transactionHistory: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + this.baseUrl = this.endpoint + + const provider = this + + this.transactionHistory = createStreamInstanceFromSource( + `bscwallet.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params) + ) + + this.nativeBalance = createStreamInstanceFromSource( + `bscwallet.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + } + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + const provider = this + const address = params.address.toLowerCase() + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactions(params).pipe( + Effect.map((raw): TransactionsOutput => + raw.transactions.map((tx): Transaction => ({ + hash: tx.hash, + from: tx.from, + to: tx.to, + timestamp: tx.timestamp, + status: tx.status === "success" ? "confirmed" : "failed", + action: "transfer" as const, + direction: getDirection(tx.from, tx.to, address), + assets: [{ + assetType: "native" as const, + value: tx.value, + symbol, + decimals, + }], + })) + ) + ) + + const source = yield* createPollingSource({ + name: `bscwallet.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = provider.fetchBalance(params.address).pipe( + Effect.map((raw): BalanceOutput => ({ + amount: Amount.fromRaw(raw.balance, decimals, symbol), + symbol, + })) + ) + + const source = yield* createDependentSource({ + name: `bscwallet.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + private fetchBalance(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/balance?address=${address}`, + schema: BalanceApiSchema, + }) + } + + private fetchTransactions(params: TxHistoryParams): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/transactions?address=${params.address}&limit=${params.limit ?? 20}`, + schema: TxApiSchema, + }) + } +} + +export function createBscWalletProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === "bscwallet-v1") { + return new BscWalletProviderEffect(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/btcwallet-provider.effect.ts b/src/services/chain-adapter/providers/btcwallet-provider.effect.ts new file mode 100644 index 000000000..474a86e47 --- /dev/null +++ b/src/services/chain-adapter/providers/btcwallet-provider.effect.ts @@ -0,0 +1,221 @@ +/** + * BtcWallet API Provider (Blockbook) - Effect TS Version (深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + * - transactionHistory: 定时轮询 + 事件触发 + * - nativeBalance: 依赖 transactionHistory 变化 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { ApiProvider, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { BitcoinIdentityMixin } from "../bitcoin/identity-mixin" +import { BitcoinTransactionMixin } from "../bitcoin/transaction-mixin" + +// ==================== Effect Schema 定义 ==================== + +const TxItemSchema = S.Struct({ + txid: S.String, + vin: S.optional(S.Array(S.Struct({ + addresses: S.optional(S.Array(S.String)), + }))), + vout: S.optional(S.Array(S.Struct({ + addresses: S.optional(S.Array(S.String)), + value: S.optional(S.String), + }))), + blockTime: S.optional(S.Number), + confirmations: S.optional(S.Number), +}) + +const AddressInfoSchema = S.Struct({ + balance: S.String, + txs: S.optional(S.Number), + transactions: S.optional(S.Array(TxItemSchema)), +}) + +type AddressInfo = S.Schema.Type +type TxItem = S.Schema.Type + +// ==================== 工具函数 ==================== + +function getDirection(vin: TxItem["vin"], vout: TxItem["vout"], address: string): Direction { + const isFrom = vin?.some((v) => v.addresses?.includes(address)) + const isTo = vout?.some((v) => v.addresses?.includes(address)) + if (isFrom && isTo) return "self" + return isFrom ? "out" : "in" +} + +// ==================== 判断交易列表是否变化 ==================== + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Base Class ==================== + +class BtcWalletBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class BtcWalletProviderEffect extends BitcoinIdentityMixin(BitcoinTransactionMixin(BtcWalletBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly baseUrl: string + private readonly pollingInterval: number = 60000 + + private _eventBus: EventBusService | null = null + + readonly nativeBalance: StreamInstance + readonly transactionHistory: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + this.baseUrl = this.endpoint + + const provider = this + + // transactionHistory: 定时轮询 + 事件触发 + this.transactionHistory = createStreamInstanceFromSource( + `btcwallet.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params) + ) + + // nativeBalance: 依赖 transactionHistory 变化 + this.nativeBalance = createStreamInstanceFromSource( + `btcwallet.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + } + + // ==================== Source 创建方法 ==================== + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + const provider = this + const address = params.address + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchAddressInfo(params.address).pipe( + Effect.map((info): TransactionsOutput => + (info.transactions ?? []).map((tx) => ({ + hash: tx.txid, + from: tx.vin?.[0]?.addresses?.[0] ?? "", + to: tx.vout?.[0]?.addresses?.[0] ?? "", + timestamp: (tx.blockTime ?? 0) * 1000, + status: (tx.confirmations ?? 0) > 0 ? ("confirmed" as const) : ("pending" as const), + action: "transfer" as const, + direction: getDirection(tx.vin, tx.vout, address), + assets: [{ + assetType: "native" as const, + value: tx.vout?.[0]?.value ?? "0", + symbol, + decimals, + }], + })) + ) + ) + + const source = yield* createPollingSource({ + name: `btcwallet.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = provider.fetchAddressInfo(params.address).pipe( + Effect.map((info): BalanceOutput => ({ + amount: Amount.fromRaw(info.balance, decimals, symbol), + symbol, + })) + ) + + const source = yield* createDependentSource({ + name: `btcwallet.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private fetchAddressInfo(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/api/v2/address/${address}`, + schema: AddressInfoSchema, + }) + } +} + +export function createBtcwalletProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === "btcwallet-v1") return new BtcWalletProviderEffect(entry, chainId) + return null +} diff --git a/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts b/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts new file mode 100644 index 000000000..4c789865a --- /dev/null +++ b/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts @@ -0,0 +1,281 @@ +/** + * Etherscan V1 API Provider - Effect TS Version (深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + * - transactionHistory: 定时轮询 + 事件触发 + * - nativeBalance: 依赖 transactionHistory 变化 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + HttpError, + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { ApiProvider, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams, Transaction } from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { EvmIdentityMixin } from "../evm/identity-mixin" +import { EvmTransactionMixin } from "../evm/transaction-mixin" +import { getApiKey } from "./api-key-picker" + +// ==================== Effect Schema 定义 ==================== + +const ApiResponseSchema = S.Struct({ + status: S.String, + message: S.String, + result: S.Unknown, +}) + +const NativeTxSchema = S.Struct({ + hash: S.String, + from: S.String, + to: S.String, + value: S.String, + timeStamp: S.String, + isError: S.String, + blockNumber: S.String, + input: S.optional(S.String), + methodId: S.optional(S.String), + functionName: S.optional(S.String), +}) + +type ApiResponse = S.Schema.Type +type NativeTx = S.Schema.Type + +// ==================== 工具函数 ==================== + +function getDirection(from: string, to: string, address: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + if (fromLower === address && toLower === address) return "self" + if (fromLower === address) return "out" + return "in" +} + +function parseNativeTx(item: unknown): NativeTx | null { + try { + const { Schema: S } = require("effect") + return S.decodeUnknownSync(NativeTxSchema)(item) + } catch { + return null + } +} + +// ==================== 判断交易列表是否变化 ==================== + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Base Class ==================== + +class EtherscanBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class EtherscanV1ProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(EtherscanBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly baseUrl: string + private readonly apiKey: string | undefined + private readonly pollingInterval: number = 30000 + + private _eventBus: EventBusService | null = null + + readonly nativeBalance: StreamInstance + readonly transactionHistory: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + this.baseUrl = this.endpoint + this.apiKey = getApiKey(this.config?.apiKeyEnv as string, `etherscan-${chainId}`) + + const provider = this + + // transactionHistory: 定时轮询 + 事件触发 + this.transactionHistory = createStreamInstanceFromSource( + `etherscan.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params) + ) + + // nativeBalance: 依赖 transactionHistory 变化 + this.nativeBalance = createStreamInstanceFromSource( + `etherscan.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + } + + // ==================== URL 构建 ==================== + + private buildUrl(params: Record): string { + const url = new URL(this.baseUrl) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + if (this.apiKey) { + url.searchParams.set("apikey", this.apiKey) + } + return url.toString() + } + + // ==================== Source 创建方法 ==================== + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + const provider = this + const address = params.address.toLowerCase() + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionHistory(params).pipe( + Effect.map((raw): TransactionsOutput => { + if (raw.status === "0" || !Array.isArray(raw.result)) { + throw new HttpError("API rate limited", 429) + } + return (raw.result as unknown[]) + .map(parseNativeTx) + .filter((tx): tx is NativeTx => tx !== null) + .map((tx): Transaction => ({ + hash: tx.hash, + from: tx.from, + to: tx.to, + timestamp: parseInt(tx.timeStamp, 10) * 1000, + status: tx.isError === "0" ? ("confirmed" as const) : ("failed" as const), + blockNumber: BigInt(tx.blockNumber), + action: "transfer" as const, + direction: getDirection(tx.from, tx.to, address), + assets: [{ + assetType: "native" as const, + value: tx.value, + symbol, + decimals, + }], + })) + }) + ) + + const source = yield* createPollingSource({ + name: `etherscan.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = provider.fetchBalance(params.address).pipe( + Effect.map((raw): BalanceOutput => { + if (raw.status === "0" || typeof raw.result !== "string") { + throw new HttpError("API rate limited", 429) + } + return { + amount: Amount.fromRaw(raw.result, decimals, symbol), + symbol, + } + }) + ) + + const source = yield* createDependentSource({ + name: `etherscan.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private fetchBalance(address: string): Effect.Effect { + return httpFetch({ + url: this.buildUrl({ + module: "account", + action: "balance", + address, + tag: "latest", + }), + schema: ApiResponseSchema, + }) + } + + private fetchTransactionHistory(params: TxHistoryParams): Effect.Effect { + return httpFetch({ + url: this.buildUrl({ + module: "account", + action: "txlist", + address: params.address, + page: "1", + offset: String(params.limit ?? 20), + sort: "desc", + }), + schema: ApiResponseSchema, + }) + } +} + +export function createEtherscanV1ProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === "etherscan-v1" || entry.type.includes("blockscout")) { + return new EtherscanV1ProviderEffect(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts b/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts new file mode 100644 index 000000000..a6ebfcff1 --- /dev/null +++ b/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts @@ -0,0 +1,290 @@ +/** + * Etherscan V2 API Provider - Effect TS Version (深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + * - transactionHistory: 定时轮询 + 事件触发 + * - nativeBalance: 依赖 transactionHistory 变化 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + HttpError, + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { ApiProvider, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams, Transaction } from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { EvmIdentityMixin } from "../evm/identity-mixin" +import { EvmTransactionMixin } from "../evm/transaction-mixin" +import { getApiKey } from "./api-key-picker" + +// ==================== Effect Schema 定义 ==================== + +const ApiResponseSchema = S.Struct({ + status: S.String, + message: S.String, + result: S.Unknown, +}) + +const NativeTxSchema = S.Struct({ + hash: S.String, + from: S.String, + to: S.String, + value: S.String, + timeStamp: S.String, + isError: S.String, + blockNumber: S.String, + input: S.optional(S.String), + methodId: S.optional(S.String), + functionName: S.optional(S.String), +}) + +type ApiResponse = S.Schema.Type +type NativeTx = S.Schema.Type + +// ==================== 工具函数 ==================== + +function getDirection(from: string, to: string, address: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + if (fromLower === address && toLower === address) return "self" + if (fromLower === address) return "out" + return "in" +} + +function parseNativeTx(item: unknown): NativeTx | null { + try { + const { Schema: S } = require("effect") + return S.decodeUnknownSync(NativeTxSchema)(item) + } catch { + return null + } +} + +// ==================== 判断交易列表是否变化 ==================== + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Base Class ==================== + +class EtherscanV2Base { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class EtherscanV2ProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(EtherscanV2Base)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly baseUrl: string + private readonly apiKey: string | undefined + private readonly evmChainId: number + private readonly pollingInterval: number = 30000 + + private _eventBus: EventBusService | null = null + + readonly nativeBalance: StreamInstance + readonly transactionHistory: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + this.baseUrl = this.endpoint + this.apiKey = getApiKey(this.config?.apiKeyEnv as string, `etherscan-${chainId}`) + + const evmChainId = this.config?.evmChainId as number | undefined + if (!evmChainId) { + throw new Error(`[EtherscanV2Provider] evmChainId is required for chain ${chainId}`) + } + this.evmChainId = evmChainId + + const provider = this + + // transactionHistory: 定时轮询 + 事件触发 + this.transactionHistory = createStreamInstanceFromSource( + `etherscan-v2.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params) + ) + + // nativeBalance: 依赖 transactionHistory 变化 + this.nativeBalance = createStreamInstanceFromSource( + `etherscan-v2.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + } + + // ==================== URL 构建 ==================== + + private buildUrl(params: Record): string { + const url = new URL(this.baseUrl) + url.searchParams.set("chainid", this.evmChainId.toString()) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + if (this.apiKey) { + url.searchParams.set("apikey", this.apiKey) + } + return url.toString() + } + + // ==================== Source 创建方法 ==================== + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + const provider = this + const address = params.address.toLowerCase() + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionHistory(params).pipe( + Effect.map((raw): TransactionsOutput => { + if (raw.status === "0" || !Array.isArray(raw.result)) { + throw new HttpError("API rate limited", 429) + } + return (raw.result as unknown[]) + .map(parseNativeTx) + .filter((tx): tx is NativeTx => tx !== null) + .map((tx): Transaction => ({ + hash: tx.hash, + from: tx.from, + to: tx.to, + timestamp: parseInt(tx.timeStamp, 10) * 1000, + status: tx.isError === "0" ? ("confirmed" as const) : ("failed" as const), + blockNumber: BigInt(tx.blockNumber), + action: "transfer" as const, + direction: getDirection(tx.from, tx.to, address), + assets: [{ + assetType: "native" as const, + value: tx.value, + symbol, + decimals, + }], + })) + }) + ) + + const source = yield* createPollingSource({ + name: `etherscan-v2.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = provider.fetchBalance(params.address).pipe( + Effect.map((raw): BalanceOutput => { + if (raw.status === "0" || typeof raw.result !== "string") { + throw new HttpError("API rate limited", 429) + } + const balanceValue = (raw.result as string).startsWith("0x") + ? BigInt(raw.result as string).toString() + : raw.result as string + return { + amount: Amount.fromRaw(balanceValue, decimals, symbol), + symbol, + } + }) + ) + + const source = yield* createDependentSource({ + name: `etherscan-v2.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private fetchBalance(address: string): Effect.Effect { + return httpFetch({ + url: this.buildUrl({ + module: "account", + action: "balance", + address, + tag: "latest", + }), + schema: ApiResponseSchema, + }) + } + + private fetchTransactionHistory(params: TxHistoryParams): Effect.Effect { + return httpFetch({ + url: this.buildUrl({ + module: "account", + action: "txlist", + address: params.address, + page: "1", + offset: String(params.limit ?? 20), + sort: "desc", + }), + schema: ApiResponseSchema, + }) + } +} + +export function createEtherscanV2ProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === "etherscan-v2") return new EtherscanV2ProviderEffect(entry, chainId) + return null +} diff --git a/src/services/chain-adapter/providers/ethwallet-provider.effect.ts b/src/services/chain-adapter/providers/ethwallet-provider.effect.ts new file mode 100644 index 000000000..feb645f8f --- /dev/null +++ b/src/services/chain-adapter/providers/ethwallet-provider.effect.ts @@ -0,0 +1,260 @@ +/** + * EthWallet API Provider - Effect TS Version (深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + * - transactionHistory: 定时轮询 + 事件触发 + * - balance: 依赖 transactionHistory 变化 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { + ApiProvider, + Transaction, + Direction, + Action, + BalanceOutput, + TransactionsOutput, + AddressParams, + TxHistoryParams, +} from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { EvmIdentityMixin } from "../evm/identity-mixin" +import { EvmTransactionMixin } from "../evm/transaction-mixin" + +// ==================== Effect Schema 定义 ==================== + +const BalanceResponseSchema = S.Struct({ + success: S.Boolean, + result: S.Union(S.String, S.Number), +}) +type BalanceResponse = S.Schema.Type + +const NativeTxSchema = S.Struct({ + blockNumber: S.String, + timeStamp: S.String, + hash: S.String, + from: S.String, + to: S.String, + value: S.String, + isError: S.optional(S.String), + input: S.optional(S.String), + methodId: S.optional(S.String), + functionName: S.optional(S.String), +}) +type NativeTx = S.Schema.Type + +const TxHistoryResponseSchema = S.Struct({ + success: S.Boolean, + result: S.Struct({ + status: S.optional(S.String), + result: S.Array(NativeTxSchema), + }), +}) +type TxHistoryResponse = S.Schema.Type + +// ==================== 工具函数 ==================== + +function getDirection(from: string, to: string, address: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + if (fromLower === address && toLower === address) return "self" + if (fromLower === address) return "out" + return "in" +} + +function detectAction(tx: NativeTx): Action { + if (tx.value && tx.value !== "0") return "transfer" + return "contract" +} + +// ==================== 判断交易列表是否变化 ==================== + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Base Class ==================== + +class EthWalletBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class EthWalletProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(EthWalletBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly baseUrl: string + + private readonly pollingInterval: number = 30000 + + // Provider 级别共享的 EventBus + private _eventBus: EventBusService | null = null + + readonly nativeBalance: StreamInstance + readonly transactionHistory: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + this.baseUrl = this.endpoint + + const provider = this + + // transactionHistory: 定时轮询 + 事件触发 + this.transactionHistory = createStreamInstanceFromSource( + `ethwallet.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params) + ) + + // nativeBalance: 依赖 transactionHistory 变化 + this.nativeBalance = createStreamInstanceFromSource( + `ethwallet.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + } + + // ==================== Source 创建方法 ==================== + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + const provider = this + const address = params.address.toLowerCase() + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionHistory(params).pipe( + Effect.map((raw): TransactionsOutput => { + if (!raw.result?.result) return [] + return raw.result.result.map((tx): Transaction => ({ + hash: tx.hash, + from: tx.from, + to: tx.to, + timestamp: parseInt(tx.timeStamp, 10) * 1000, + status: tx.isError === "1" ? "failed" : "confirmed", + blockNumber: BigInt(tx.blockNumber), + action: detectAction(tx), + direction: getDirection(tx.from, tx.to, address), + assets: [{ + assetType: "native" as const, + value: tx.value, + symbol, + decimals, + }], + })) + }) + ) + + const source = yield* createPollingSource({ + name: `ethwallet.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = provider.fetchBalance(params.address).pipe( + Effect.map((raw): BalanceOutput => ({ + amount: Amount.fromRaw(String(raw.result), decimals, symbol), + symbol, + })) + ) + + const source = yield* createDependentSource({ + name: `ethwallet.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private fetchBalance(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/balance`, + method: "POST", + body: { address }, + schema: BalanceResponseSchema, + }) + } + + private fetchTransactionHistory(params: TxHistoryParams): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/trans/normal/history`, + method: "POST", + body: { address: params.address, limit: params.limit ?? 20 }, + schema: TxHistoryResponseSchema, + }) + } +} + +export function createEthwalletProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === "ethwallet-v1") { + return new EthWalletProviderEffect(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/evm-rpc-provider.effect.ts b/src/services/chain-adapter/providers/evm-rpc-provider.effect.ts new file mode 100644 index 000000000..65a10e63b --- /dev/null +++ b/src/services/chain-adapter/providers/evm-rpc-provider.effect.ts @@ -0,0 +1,296 @@ +/** + * EVM RPC Provider - Effect TS Version (深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + * - blockHeight: 定时轮询 + * - nativeBalance: 依赖 blockHeight 变化 + * - transaction: 依赖 blockHeight 变化 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + type FetchError, + type DataSource, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { ApiProvider, BalanceOutput, BlockHeightOutput, TransactionOutput, AddressParams, TransactionParams, Action } from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { EvmIdentityMixin } from "../evm/identity-mixin" +import { EvmTransactionMixin } from "../evm/transaction-mixin" + +// ==================== Effect Schema 定义 ==================== + +const RpcResponseSchema = S.Struct({ + jsonrpc: S.String, + id: S.Number, + result: S.String, +}) + +const EvmTxResultSchema = S.Struct({ + hash: S.String, + from: S.String, + to: S.NullOr(S.String), + value: S.String, + blockNumber: S.NullOr(S.String), + input: S.String, +}) + +const EvmTxRpcSchema = S.Struct({ + jsonrpc: S.String, + id: S.Number, + result: S.NullOr(EvmTxResultSchema), +}) + +const EvmReceiptResultSchema = S.Struct({ + status: S.String, + blockNumber: S.String, + transactionHash: S.String, +}) + +const EvmReceiptRpcSchema = S.Struct({ + jsonrpc: S.String, + id: S.Number, + result: S.NullOr(EvmReceiptResultSchema), +}) + +type RpcResponse = S.Schema.Type +type EvmTxRpc = S.Schema.Type +type EvmReceiptRpc = S.Schema.Type + +// ==================== 判断区块高度是否变化 ==================== + +function hasBlockHeightChanged( + prev: BlockHeightOutput | null, + next: BlockHeightOutput +): boolean { + if (prev === null) return true + return prev !== next +} + +// ==================== Base Class ==================== + +class EvmRpcBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class EvmRpcProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(EvmRpcBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly pollingInterval: number = 30000 + + readonly nativeBalance: StreamInstance + readonly blockHeight: StreamInstance + readonly transaction: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + + const provider = this + + // 区块高度: 定时轮询 + this.blockHeight = createStreamInstanceFromSource( + `evm-rpc.${chainId}.blockHeight`, + () => provider.createBlockHeightSource() + ) + + // 原生余额: 依赖 blockHeight 变化 + this.nativeBalance = createStreamInstanceFromSource( + `evm-rpc.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + + // 单笔交易查询: 依赖 blockHeight 变化 + this.transaction = createStreamInstanceFromSource( + `evm-rpc.${chainId}.transaction`, + (params) => provider.createTransactionSource(params) + ) + } + + // ==================== Source 创建方法 ==================== + + private createBlockHeightSource(): Effect.Effect> { + const provider = this + + return Effect.gen(function* () { + const fetchEffect = provider.fetchBlockNumber().pipe( + Effect.map((res): BlockHeightOutput => BigInt(res.result)) + ) + + const source = yield* createPollingSource({ + name: `evm-rpc.${provider.chainId}.blockHeight`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const blockHeightSource = yield* provider.createBlockHeightSource() + + const fetchEffect = provider.fetchBalance(params.address).pipe( + Effect.map((res): BalanceOutput => { + const value = BigInt(res.result).toString() + return { amount: Amount.fromRaw(value, decimals, symbol), symbol } + }) + ) + + const source = yield* createDependentSource({ + name: `evm-rpc.${provider.chainId}.balance`, + dependsOn: blockHeightSource.ref, + hasChanged: hasBlockHeightChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + private createTransactionSource( + params: TransactionParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const blockHeightSource = yield* provider.createBlockHeightSource() + + const fetchEffect = Effect.gen(function* () { + const [txRes, receiptRes] = yield* Effect.all([ + provider.fetchTransaction(params.txHash), + provider.fetchTransactionReceipt(params.txHash), + ]) + + const tx = txRes.result + const receipt = receiptRes.result + + if (!tx) return null + + let status: "pending" | "confirmed" | "failed" + if (!receipt) { + status = "pending" + } else { + status = receipt.status === "0x1" ? "confirmed" : "failed" + } + + const value = BigInt(tx.value || "0x0").toString() + + return { + hash: tx.hash, + from: tx.from, + to: tx.to ?? "", + timestamp: Date.now(), + status, + blockNumber: receipt?.blockNumber ? BigInt(receipt.blockNumber) : undefined, + action: (tx.to ? "transfer" : "contract") as Action, + direction: "out" as const, + assets: [{ + assetType: "native" as const, + value, + symbol, + decimals, + }], + } + }) + + const source = yield* createDependentSource({ + name: `evm-rpc.${provider.chainId}.transaction`, + dependsOn: blockHeightSource.ref, + hasChanged: hasBlockHeightChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private fetchBlockNumber(): Effect.Effect { + return httpFetch({ + url: this.endpoint, + method: "POST", + body: { jsonrpc: "2.0", id: 1, method: "eth_blockNumber", params: [] }, + schema: RpcResponseSchema, + }) + } + + private fetchBalance(address: string): Effect.Effect { + return httpFetch({ + url: this.endpoint, + method: "POST", + body: { + jsonrpc: "2.0", + id: 1, + method: "eth_getBalance", + params: [address, "latest"], + }, + schema: RpcResponseSchema, + }) + } + + private fetchTransaction(txHash: string): Effect.Effect { + return httpFetch({ + url: this.endpoint, + method: "POST", + body: { + jsonrpc: "2.0", + id: 1, + method: "eth_getTransactionByHash", + params: [txHash], + }, + schema: EvmTxRpcSchema, + }) + } + + private fetchTransactionReceipt(txHash: string): Effect.Effect { + return httpFetch({ + url: this.endpoint, + method: "POST", + body: { + jsonrpc: "2.0", + id: 1, + method: "eth_getTransactionReceipt", + params: [txHash], + }, + schema: EvmReceiptRpcSchema, + }) + } +} + +export function createEvmRpcProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type.endsWith("-rpc") && (entry.type.includes("ethereum") || entry.type.includes("bsc"))) { + return new EvmRpcProviderEffect(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/mempool-provider.effect.ts b/src/services/chain-adapter/providers/mempool-provider.effect.ts new file mode 100644 index 000000000..28e68a4ab --- /dev/null +++ b/src/services/chain-adapter/providers/mempool-provider.effect.ts @@ -0,0 +1,268 @@ +/** + * Mempool.space API Provider - Effect TS Version (深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + * - blockHeight: 定时轮询 + * - transactionHistory: 定时轮询 + 事件触发 + * - nativeBalance: 依赖 transactionHistory 变化 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { ApiProvider, Direction, BalanceOutput, BlockHeightOutput, TransactionsOutput, AddressParams, TxHistoryParams } from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { BitcoinIdentityMixin } from "../bitcoin/identity-mixin" +import { BitcoinTransactionMixin } from "../bitcoin/transaction-mixin" + +// ==================== Effect Schema 定义 ==================== + +const AddressInfoSchema = S.Struct({ + chain_stats: S.Struct({ + funded_txo_sum: S.Number, + spent_txo_sum: S.Number, + }), +}) + +const TxSchema = S.Struct({ + txid: S.String, + vin: S.Array(S.Struct({ + prevout: S.optional(S.Struct({ + scriptpubkey_address: S.optional(S.String), + })), + })), + vout: S.Array(S.Struct({ + scriptpubkey_address: S.optional(S.String), + value: S.optional(S.Number), + })), + status: S.Struct({ + confirmed: S.Boolean, + block_time: S.optional(S.Number), + }), +}) + +const TxListSchema = S.Array(TxSchema) + +type AddressInfo = S.Schema.Type +type Tx = S.Schema.Type +type TxList = S.Schema.Type + +// ==================== 工具函数 ==================== + +function getDirection(vin: Tx["vin"], vout: Tx["vout"], address: string): Direction { + const isFrom = vin?.some((v) => v.prevout?.scriptpubkey_address === address) + const isTo = vout?.some((v) => v.scriptpubkey_address === address) + if (isFrom && isTo) return "self" + return isFrom ? "out" : "in" +} + +// ==================== 判断交易列表是否变化 ==================== + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Base Class ==================== + +class MempoolBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class MempoolProviderEffect extends BitcoinIdentityMixin(BitcoinTransactionMixin(MempoolBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly baseUrl: string + private readonly pollingInterval: number = 60000 + + private _eventBus: EventBusService | null = null + + readonly nativeBalance: StreamInstance + readonly transactionHistory: StreamInstance + readonly blockHeight: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + this.baseUrl = this.endpoint + + const provider = this + + // blockHeight: 定时轮询 + this.blockHeight = createStreamInstanceFromSource( + `mempool.${chainId}.blockHeight`, + () => provider.createBlockHeightSource() + ) + + // transactionHistory: 定时轮询 + 事件触发 + this.transactionHistory = createStreamInstanceFromSource( + `mempool.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params) + ) + + // nativeBalance: 依赖 transactionHistory 变化 + this.nativeBalance = createStreamInstanceFromSource( + `mempool.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + } + + // ==================== Source 创建方法 ==================== + + private createBlockHeightSource(): Effect.Effect> { + const provider = this + + return Effect.gen(function* () { + const fetchEffect = provider.fetchBlockHeight().pipe( + Effect.map((height): BlockHeightOutput => BigInt(height)) + ) + + const source = yield* createPollingSource({ + name: `mempool.${provider.chainId}.blockHeight`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + }) + + return source + }) + } + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + const provider = this + const address = params.address + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionHistory(params.address).pipe( + Effect.map((txList): TransactionsOutput => + txList.map((tx) => ({ + hash: tx.txid, + from: tx.vin[0]?.prevout?.scriptpubkey_address ?? "", + to: tx.vout[0]?.scriptpubkey_address ?? "", + timestamp: (tx.status.block_time ?? 0) * 1000, + status: tx.status.confirmed ? ("confirmed" as const) : ("pending" as const), + action: "transfer" as const, + direction: getDirection(tx.vin, tx.vout, address), + assets: [{ + assetType: "native" as const, + value: (tx.vout[0]?.value ?? 0).toString(), + symbol, + decimals, + }], + })) + ) + ) + + const source = yield* createPollingSource({ + name: `mempool.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = provider.fetchAddressInfo(params.address).pipe( + Effect.map((info): BalanceOutput => { + const balance = info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum + return { amount: Amount.fromRaw(balance.toString(), decimals, symbol), symbol } + }) + ) + + const source = yield* createDependentSource({ + name: `mempool.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private fetchBlockHeight(): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/blocks/tip/height`, + }) + } + + private fetchAddressInfo(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/address/${address}`, + schema: AddressInfoSchema, + }) + } + + private fetchTransactionHistory(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/address/${address}/txs`, + schema: TxListSchema, + }) + } +} + +export function createMempoolProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type.startsWith("mempool-")) return new MempoolProviderEffect(entry, chainId) + return null +} diff --git a/src/services/chain-adapter/providers/moralis-provider.effect.ts b/src/services/chain-adapter/providers/moralis-provider.effect.ts new file mode 100644 index 000000000..c0fc3b5a5 --- /dev/null +++ b/src/services/chain-adapter/providers/moralis-provider.effect.ts @@ -0,0 +1,595 @@ +/** + * Moralis API Provider (Effect TS - 深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + * - transactionHistory: 定时轮询 + 事件触发 + * - balance/tokenBalances: 依赖 transactionHistory 变化 + */ + +import { Effect, Stream, Schedule, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + txConfirmedEvent, + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { + ApiProvider, + TokenBalance, + Transaction, + Direction, + Action, + BalanceOutput, + TokenBalancesOutput, + TransactionsOutput, + TransactionStatusOutput, + AddressParams, + TxHistoryParams, + TransactionStatusParams, +} from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { EvmIdentityMixin } from "../evm/identity-mixin" +import { EvmTransactionMixin } from "../evm/transaction-mixin" +import { getApiKey } from "./api-key-picker" + +// ==================== 链 ID 映射 ==================== + +const MORALIS_CHAIN_MAP: Record = { + ethereum: "eth", + binance: "bsc", + polygon: "polygon", + avalanche: "avalanche", + fantom: "fantom", + arbitrum: "arbitrum", + optimism: "optimism", + base: "base", +} + +// ==================== Effect Schema 定义 ==================== + +const NativeBalanceResponseSchema = S.Struct({ + balance: S.String, +}) +type NativeBalanceResponse = S.Schema.Type + +const TxReceiptResultSchema = S.Struct({ + transactionHash: S.String, + blockNumber: S.String, + status: S.optional(S.String), +}) + +const TxReceiptRpcResponseSchema = S.Struct({ + jsonrpc: S.String, + id: S.Number, + result: S.NullOr(TxReceiptResultSchema), +}) +type TxReceiptRpcResponse = S.Schema.Type + +const TokenBalanceItemSchema = S.Struct({ + token_address: S.String, + symbol: S.String, + name: S.String, + decimals: S.Number, + balance: S.String, + logo: S.optional(S.NullOr(S.String)), + thumbnail: S.optional(S.NullOr(S.String)), + possible_spam: S.optional(S.Boolean), + verified_contract: S.optional(S.Boolean), + total_supply: S.optional(S.NullOr(S.String)), + security_score: S.optional(S.NullOr(S.Number)), +}) +type TokenBalanceItem = S.Schema.Type + +const TokenBalancesResponseSchema = S.Array(TokenBalanceItemSchema) + +const NativeTransferSchema = S.Struct({ + from_address: S.String, + to_address: S.String, + value: S.String, + value_formatted: S.optional(S.String), + direction: S.optional(S.Literal("send", "receive")), + token_symbol: S.optional(S.String), + token_logo: S.optional(S.String), +}) + +const Erc20TransferSchema = S.Struct({ + from_address: S.String, + to_address: S.String, + value: S.String, + value_formatted: S.optional(S.String), + token_name: S.optional(S.String), + token_symbol: S.optional(S.String), + token_decimals: S.optional(S.String), + token_logo: S.optional(S.String), + address: S.String, +}) + +const WalletHistoryItemSchema = S.Struct({ + hash: S.String, + from_address: S.String, + to_address: S.NullOr(S.String), + value: S.String, + block_timestamp: S.String, + block_number: S.String, + receipt_status: S.optional(S.String), + transaction_fee: S.optional(S.String), + category: S.optional(S.String), + summary: S.optional(S.String), + possible_spam: S.optional(S.Boolean), + from_address_entity: S.optional(S.NullOr(S.String)), + to_address_entity: S.optional(S.NullOr(S.String)), + native_transfers: S.optional(S.Array(NativeTransferSchema)), + erc20_transfers: S.optional(S.Array(Erc20TransferSchema)), +}) + +const WalletHistoryResponseSchema = S.Struct({ + result: S.Array(WalletHistoryItemSchema), + cursor: S.optional(S.NullOr(S.String)), + page: S.optional(S.Number), + page_size: S.optional(S.Number), +}) +type WalletHistoryResponse = S.Schema.Type + +// ==================== 工具函数 ==================== + +function getDirection(from: string, to: string, address: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + const addrLower = address.toLowerCase() + if (fromLower === addrLower && toLower === addrLower) return "self" + if (fromLower === addrLower) return "out" + return "in" +} + +function mapCategory(category: string | undefined): Action { + switch (category) { + case "send": + case "receive": + return "transfer" + case "token send": + case "token receive": + return "transfer" + case "nft send": + case "nft receive": + return "transfer" + case "approve": + return "approve" + case "contract interaction": + return "contract" + default: + return "transfer" + } +} + +// ==================== 判断交易列表是否变化 ==================== + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Retry Schedule for 429 ==================== + +const rateLimitRetrySchedule = Schedule.exponential(Duration.seconds(5), 2).pipe( + Schedule.compose(Schedule.recurs(3)), + Schedule.whileInput((error: FetchError) => + error._tag === "RateLimitError" || + (error._tag === "HttpError" && (error.status === 429 || error.status === 401)) + ) +) + +// ==================== Base Class for Mixins ==================== + +class MoralisBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(MoralisBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly moralisChain: string + private readonly apiKey: string + private readonly baseUrl: string + + private readonly txStatusInterval: number + private readonly balanceInterval: number + private readonly erc20Interval: number + + private readonly rpcUrl: string + + // Provider 级别共享的 EventBus(延迟初始化) + private _eventBus: EventBusService | null = null + + readonly nativeBalance: StreamInstance + readonly tokenBalances: StreamInstance + readonly transactionHistory: StreamInstance + readonly transactionStatus: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + + this.moralisChain = MORALIS_CHAIN_MAP[chainId] + if (!this.moralisChain) { + throw new Error(`[MoralisProviderEffect] Unsupported chain: ${chainId}`) + } + + this.baseUrl = this.endpoint + + const apiKey = getApiKey("MORALIS_API_KEY", `moralis-${chainId}`) + if (!apiKey) { + throw new Error(`[MoralisProviderEffect] MORALIS_API_KEY is required`) + } + this.apiKey = apiKey + + this.txStatusInterval = (this.config?.txStatusInterval as number) ?? 3000 + this.balanceInterval = (this.config?.balanceInterval as number) ?? 30000 + this.erc20Interval = (this.config?.erc20Interval as number) ?? 120000 + + this.rpcUrl = chainConfigService.getRpcUrl(chainId) + + const provider = this + + // ==================== transactionHistory: 定时轮询 + 事件触发 ==================== + this.transactionHistory = createStreamInstanceFromSource( + `moralis.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params) + ) + + // ==================== nativeBalance: 依赖 transactionHistory 变化 ==================== + this.nativeBalance = createStreamInstanceFromSource( + `moralis.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + + // ==================== tokenBalances: 依赖 transactionHistory 变化 ==================== + this.tokenBalances = createStreamInstanceFromSource( + `moralis.${chainId}.tokenBalances`, + (params) => provider.createTokenBalancesSource(params) + ) + + // ==================== transactionStatus: 简单轮询 ==================== + this.transactionStatus = createStreamInstanceFromSource( + `moralis.${chainId}.transactionStatus`, + (params) => provider.createTransactionStatusSource(params) + ) + } + + // ==================== Source 创建方法 ==================== + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + const provider = this + const address = params.address.toLowerCase() + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + // 获取或创建 Provider 级别共享的 EventBus + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchWalletHistory(params).pipe( + Effect.retry(rateLimitRetrySchedule), + Effect.map((raw): TransactionsOutput => { + return raw.result + .filter((item) => !item.possible_spam) + .map((item): Transaction => { + const direction = getDirection(item.from_address, item.to_address ?? "", address) + const action = mapCategory(item.category) + + const hasErc20 = item.erc20_transfers && item.erc20_transfers.length > 0 + const hasNative = item.native_transfers && item.native_transfers.length > 0 + + const assets: Transaction["assets"] = [] + + if (hasErc20) { + for (const transfer of item.erc20_transfers!) { + assets.push({ + assetType: "token", + value: transfer.value, + symbol: transfer.token_symbol ?? "Unknown", + decimals: parseInt(transfer.token_decimals ?? "18", 10), + contractAddress: transfer.address, + name: transfer.token_name, + logoUrl: transfer.token_logo ?? undefined, + }) + } + } + + if (hasNative || assets.length === 0) { + const nativeValue = hasNative ? item.native_transfers![0].value : item.value + assets.unshift({ + assetType: "native" as const, + value: nativeValue, + symbol, + decimals, + }) + } + + return { + hash: item.hash, + from: item.from_address, + to: item.to_address ?? "", + timestamp: new Date(item.block_timestamp).getTime(), + status: item.receipt_status === "1" ? "confirmed" : "failed", + blockNumber: BigInt(item.block_number), + action, + direction, + assets, + fee: item.transaction_fee + ? { value: item.transaction_fee, symbol, decimals } + : undefined, + fromEntity: item.from_address_entity ?? undefined, + toEntity: item.to_address_entity ?? undefined, + summary: item.summary, + } + }) + }) + ) + + const source = yield* createPollingSource({ + name: `moralis.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.erc20Interval), + // 使用 walletEvents 配置,按 chainId + address 过滤事件 + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = provider.fetchNativeBalance(params.address).pipe( + Effect.retry(rateLimitRetrySchedule), + Effect.map((raw): BalanceOutput => ({ + amount: Amount.fromRaw(raw.balance, decimals, symbol), + symbol, + })) + ) + + const source = yield* createDependentSource({ + name: `moralis.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + private createTokenBalancesSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = Effect.all({ + native: provider.fetchNativeBalance(params.address), + tokens: provider.fetchTokenBalances(params.address), + }).pipe( + Effect.retry(rateLimitRetrySchedule), + Effect.map(({ native, tokens }): TokenBalancesOutput => { + const result: TokenBalance[] = [] + + result.push({ + symbol, + name: symbol, + amount: Amount.fromRaw(native.balance, decimals, symbol), + isNative: true, + decimals, + }) + + const filteredTokens = tokens.filter((token) => !token.possible_spam) + + for (const token of filteredTokens) { + const icon = + token.logo ?? + token.thumbnail ?? + chainConfigService.getTokenIconByContract(chainId, token.token_address) ?? + undefined + + result.push({ + symbol: token.symbol, + name: token.name, + amount: Amount.fromRaw(token.balance, token.decimals, token.symbol), + isNative: false, + decimals: token.decimals, + icon, + contractAddress: token.token_address, + metadata: { + possibleSpam: token.possible_spam, + securityScore: token.security_score ?? undefined, + verified: token.verified_contract, + totalSupply: token.total_supply ?? undefined, + }, + }) + } + + return result + }) + ) + + const source = yield* createDependentSource({ + name: `moralis.${provider.chainId}.tokenBalances`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + private createTransactionStatusSource( + params: TransactionStatusParams + ): Effect.Effect> { + const provider = this + const chainId = this.chainId + + return Effect.gen(function* () { + // 获取或创建 Provider 级别共享的 EventBus + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionReceipt(params.txHash).pipe( + Effect.retry(rateLimitRetrySchedule), + Effect.flatMap((raw) => + Effect.gen(function* () { + const receipt = raw.result + if (!receipt || !receipt.blockNumber) { + return { status: "pending" as const, confirmations: 0, requiredConfirmations: 1 } + } + + // 交易已确认,发送事件通知(带钱包标识) + if (params.address) { + yield* eventBus.emit(txConfirmedEvent(chainId, params.address, params.txHash)) + } + + const isSuccess = receipt.status === "0x1" || receipt.status === undefined + return { + status: isSuccess ? ("confirmed" as const) : ("failed" as const), + confirmations: 1, + requiredConfirmations: 1, + } + }) + ) + ) + + const source = yield* createPollingSource({ + name: `moralis.${provider.chainId}.txStatus`, + fetch: fetchEffect, + interval: Duration.millis(provider.txStatusInterval), + }) + + return source + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private fetchNativeBalance(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/${address}/balance`, + searchParams: { chain: this.moralisChain }, + headers: { + "X-API-Key": this.apiKey, + accept: "application/json", + }, + schema: NativeBalanceResponseSchema, + }) + } + + private fetchTokenBalances(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/${address}/erc20`, + searchParams: { chain: this.moralisChain }, + headers: { + "X-API-Key": this.apiKey, + accept: "application/json", + }, + schema: TokenBalancesResponseSchema, + }) + } + + private fetchWalletHistory(params: TxHistoryParams): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/wallets/${params.address}/history`, + searchParams: { + chain: this.moralisChain, + limit: String(params.limit ?? 20), + }, + headers: { + "X-API-Key": this.apiKey, + accept: "application/json", + }, + schema: WalletHistoryResponseSchema, + }) + } + + private fetchTransactionReceipt(txHash: string): Effect.Effect { + return httpFetch({ + url: this.rpcUrl, + method: "POST", + body: { + jsonrpc: "2.0", + id: 1, + method: "eth_getTransactionReceipt", + params: [txHash], + }, + schema: TxReceiptRpcResponseSchema, + }) + } +} + +export function createMoralisProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === "moralis") { + try { + return new MoralisProviderEffect(entry, chainId) + } catch (err) { + console.warn(`[MoralisProviderEffect] Failed to create provider for ${chainId}:`, err) + return null + } + } + return null +} diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts b/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts new file mode 100644 index 000000000..2ffc5681d --- /dev/null +++ b/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts @@ -0,0 +1,434 @@ +/** + * Tron RPC Provider (Effect TS - 深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + * - transactionHistory: 定时轮询 + 事件触发 + * - balance: 依赖 transactionHistory 变化 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { + ApiProvider, + Direction, + BalanceOutput, + BlockHeightOutput, + TransactionOutput, + TransactionsOutput, + AddressParams, + TxHistoryParams, + TransactionParams, + Transaction, +} from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { TronIdentityMixin } from "../tron/identity-mixin" +import { TronTransactionMixin } from "../tron/transaction-mixin" +import { getApiKey } from "./api-key-picker" + +// ==================== Effect Schema 定义 ==================== + +const TronAccountSchema = S.Struct({ + balance: S.optional(S.Number), + address: S.optional(S.String), +}) +type TronAccount = S.Schema.Type + +const TronNowBlockSchema = S.Struct({ + block_header: S.optional(S.Struct({ + raw_data: S.optional(S.Struct({ + number: S.optional(S.Number), + })), + })), +}) +type TronNowBlock = S.Schema.Type + +const TronTxValueSchema = S.Struct({ + amount: S.optional(S.Number), + owner_address: S.optional(S.String), + to_address: S.optional(S.String), +}) + +const TronTxContractSchema = S.Struct({ + parameter: S.optional(S.Struct({ + value: S.optional(TronTxValueSchema), + })), + type: S.optional(S.String), +}) + +const TronTxSchema = S.Struct({ + txID: S.String, + block_timestamp: S.optional(S.Number), + raw_data: S.optional(S.Struct({ + contract: S.optional(S.Array(TronTxContractSchema)), + timestamp: S.optional(S.Number), + })), + ret: S.optional(S.Array(S.Struct({ + contractRet: S.optional(S.String), + }))), +}) +type TronTx = S.Schema.Type + +const TronTxListSchema = S.Struct({ + success: S.Boolean, + data: S.optional(S.Array(TronTxSchema)), +}) +type TronTxList = S.Schema.Type + +// ==================== 工具函数 ==================== + +const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +function base58Decode(input: string): Uint8Array { + const bytes = [0] + for (const char of input) { + const idx = BASE58_ALPHABET.indexOf(char) + if (idx === -1) throw new Error(`Invalid Base58 character: ${char}`) + let carry = idx + for (let i = 0; i < bytes.length; i++) { + carry += bytes[i] * 58 + bytes[i] = carry & 0xff + carry >>= 8 + } + while (carry > 0) { + bytes.push(carry & 0xff) + carry >>= 8 + } + } + for (const char of input) { + if (char !== "1") break + bytes.push(0) + } + return new Uint8Array(bytes.reverse()) +} + +function tronAddressToHex(address: string): string { + if (address.startsWith("41") && address.length === 42) return address + if (!address.startsWith("T")) throw new Error(`Invalid Tron address: ${address}`) + const decoded = base58Decode(address) + const addressBytes = decoded.slice(0, 21) + return Array.from(addressBytes).map((b) => b.toString(16).padStart(2, "0")).join("") +} + +function getDirection(from: string, to: string, address: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + const addrLower = address.toLowerCase() + if (fromLower === addrLower && toLower === addrLower) return "self" + if (fromLower === addrLower) return "out" + return "in" +} + +// ==================== 判断交易列表是否变化 ==================== + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Base Class ==================== + +class TronRpcBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class TronRpcProviderEffect extends TronIdentityMixin(TronTransactionMixin(TronRpcBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly baseUrl: string + private readonly headers: Record + + private readonly pollingInterval: number = 30000 + + // Provider 级别共享的 EventBus(延迟初始化) + private _eventBus: EventBusService | null = null + + readonly nativeBalance: StreamInstance + readonly transactionHistory: StreamInstance + readonly transaction: StreamInstance + readonly blockHeight: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + this.baseUrl = this.endpoint + + // API Key + const tronApiKey = getApiKey(this.config?.apiKeyEnv as string, `trongrid-${chainId}`) + this.headers = tronApiKey ? { "TRON-PRO-API-KEY": tronApiKey } : {} + + const provider = this + + // ==================== blockHeight: 定时轮询 ==================== + this.blockHeight = createStreamInstanceFromSource( + `tron-rpc.${chainId}.blockHeight`, + () => provider.createBlockHeightSource() + ) + + // ==================== transactionHistory: 定时轮询 + 事件触发 ==================== + this.transactionHistory = createStreamInstanceFromSource( + `tron-rpc.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params) + ) + + // ==================== nativeBalance: 依赖 transactionHistory 变化 ==================== + this.nativeBalance = createStreamInstanceFromSource( + `tron-rpc.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + + // ==================== transaction: 定时轮询(等待确认)==================== + this.transaction = createStreamInstanceFromSource( + `tron-rpc.${chainId}.transaction`, + (params) => provider.createTransactionSource(params) + ) + } + + // ==================== Source 创建方法 ==================== + + private createBlockHeightSource(): Effect.Effect> { + const provider = this + + return Effect.gen(function* () { + const fetchEffect = provider.fetchNowBlock().pipe( + Effect.map((raw): BlockHeightOutput => + BigInt(raw.block_header?.raw_data?.number ?? 0) + ) + ) + + const source = yield* createPollingSource({ + name: `tron-rpc.${provider.chainId}.blockHeight`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + }) + + return source + }) + } + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + const provider = this + const address = params.address.toLowerCase() + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + // 获取或创建 Provider 级别共享的 EventBus + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionList(params.address).pipe( + Effect.map((raw): TransactionsOutput => { + if (!raw.success || !raw.data) return [] + + return raw.data.map((tx): Transaction => { + const contract = tx.raw_data?.contract?.[0] + const value = contract?.parameter?.value + const from = value?.owner_address ?? "" + const to = value?.to_address ?? "" + const status = tx.ret?.[0]?.contractRet === "SUCCESS" ? "confirmed" : "failed" + + return { + hash: tx.txID, + from, + to, + timestamp: tx.block_timestamp ?? tx.raw_data?.timestamp ?? 0, + status: status as "confirmed" | "failed", + action: "transfer" as const, + direction: getDirection(from, to, address), + assets: [{ + assetType: "native" as const, + value: (value?.amount ?? 0).toString(), + symbol, + decimals, + }], + } + }) + }) + ) + + const source = yield* createPollingSource({ + name: `tron-rpc.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + // 使用 walletEvents 配置,按 chainId + address 过滤事件 + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + // 先创建 transactionHistory source 作为依赖 + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = provider.fetchAccount(params.address).pipe( + Effect.map((raw): BalanceOutput => ({ + amount: Amount.fromRaw((raw.balance ?? 0).toString(), decimals, symbol), + symbol, + })) + ) + + // 依赖 transactionHistory 变化 + const source = yield* createDependentSource({ + name: `tron-rpc.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + private createTransactionSource( + params: TransactionParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const fetchEffect = provider.fetchTransactionById(params.txHash).pipe( + Effect.map((tx): TransactionOutput => { + if (!tx.txID) return null + + const contract = tx.raw_data?.contract?.[0] + const value = contract?.parameter?.value + const from = value?.owner_address ?? "" + const to = value?.to_address ?? "" + + let status: "pending" | "confirmed" | "failed" + if (!tx.ret || tx.ret.length === 0) { + status = "pending" + } else { + status = tx.ret[0]?.contractRet === "SUCCESS" ? "confirmed" : "failed" + } + + return { + hash: tx.txID, + from, + to, + timestamp: tx.block_timestamp ?? tx.raw_data?.timestamp ?? 0, + status, + action: "transfer" as const, + direction: "out", + assets: [{ + assetType: "native" as const, + value: (value?.amount ?? 0).toString(), + symbol, + decimals, + }], + } + }) + ) + + // 交易查询使用轮询(等待确认) + const source = yield* createPollingSource({ + name: `tron-rpc.${provider.chainId}.transaction`, + fetch: fetchEffect, + interval: Duration.millis(3000), // 3秒检查一次 + }) + + return source + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private fetchNowBlock(): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/wallet/getnowblock`, + method: "POST", + headers: this.headers, + schema: TronNowBlockSchema, + }) + } + + private fetchAccount(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/wallet/getaccount`, + method: "POST", + headers: this.headers, + body: { address: tronAddressToHex(address) }, + schema: TronAccountSchema, + }) + } + + private fetchTransactionList(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/v1/accounts/${address}/transactions`, + headers: this.headers, + schema: TronTxListSchema, + }) + } + + private fetchTransactionById(txHash: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/wallet/gettransactionbyid`, + method: "POST", + headers: this.headers, + body: { value: txHash }, + schema: TronTxSchema, + }) + } +} + +export function createTronRpcProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === "tron-rpc" || entry.type === "tron-rpc-pro") { + return new TronRpcProviderEffect(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts new file mode 100644 index 000000000..6778d49d4 --- /dev/null +++ b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts @@ -0,0 +1,233 @@ +/** + * TronWallet API Provider - Effect TS Version (深度重构) + * + * 使用 Effect 原生 Source API 实现响应式数据获取 + */ + +import { Effect, Duration } from "effect" +import { Schema as S } from "effect" +import { + httpFetch, + createStreamInstanceFromSource, + createPollingSource, + createDependentSource, + createEventBusService, + type FetchError, + type DataSource, + type EventBusService, +} from "@biochain/chain-effect" +import type { StreamInstance } from "@biochain/chain-effect" +import type { + ApiProvider, + Transaction, + Direction, + BalanceOutput, + TransactionsOutput, + AddressParams, + TxHistoryParams, +} from "./types" +import type { ParsedApiEntry } from "@/services/chain-config" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" +import { TronIdentityMixin } from "../tron/identity-mixin" +import { TronTransactionMixin } from "../tron/transaction-mixin" + +// ==================== Effect Schema 定义 ==================== + +const BalanceResponseSchema = S.Struct({ + success: S.Boolean, + result: S.Union(S.String, S.Number), +}) +type BalanceResponse = S.Schema.Type + +const TronNativeTxSchema = S.Struct({ + txID: S.String, + from: S.String, + to: S.String, + amount: S.Number, + timestamp: S.Number, + contractRet: S.optional(S.String), +}) + +const TxHistoryResponseSchema = S.Struct({ + success: S.Boolean, + data: S.Array(TronNativeTxSchema), +}) +type TxHistoryResponse = S.Schema.Type + +// ==================== 工具函数 ==================== + +function getDirection(from: string, to: string, address: string): Direction { + const f = from.toLowerCase() + const t = to.toLowerCase() + if (f === address && t === address) return "self" + if (f === address) return "out" + return "in" +} + +function hasTransactionListChanged( + prev: TransactionsOutput | null, + next: TransactionsOutput +): boolean { + if (!prev) return true + if (prev.length !== next.length) return true + if (prev.length === 0 && next.length === 0) return false + return prev[0]?.hash !== next[0]?.hash +} + +// ==================== Base Class ==================== + +class TronWalletBase { + readonly chainId: string + readonly type: string + readonly endpoint: string + readonly config?: Record + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + } +} + +// ==================== Provider 实现 ==================== + +export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionMixin(TronWalletBase)) implements ApiProvider { + private readonly symbol: string + private readonly decimals: number + private readonly baseUrl: string + private readonly pollingInterval: number = 30000 + + private _eventBus: EventBusService | null = null + + readonly nativeBalance: StreamInstance + readonly transactionHistory: StreamInstance + + constructor(entry: ParsedApiEntry, chainId: string) { + super(entry, chainId) + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + this.baseUrl = this.endpoint + + const provider = this + + this.transactionHistory = createStreamInstanceFromSource( + `tronwallet.${chainId}.transactionHistory`, + (params) => provider.createTransactionHistorySource(params) + ) + + this.nativeBalance = createStreamInstanceFromSource( + `tronwallet.${chainId}.nativeBalance`, + (params) => provider.createBalanceSource(params) + ) + } + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + const provider = this + const address = params.address.toLowerCase() + const symbol = this.symbol + const decimals = this.decimals + const chainId = this.chainId + + return Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* createEventBusService + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactions(params).pipe( + Effect.map((raw): TransactionsOutput => { + if (!raw.success) return [] + return raw.data.map((tx): Transaction => ({ + hash: tx.txID, + from: tx.from, + to: tx.to, + timestamp: tx.timestamp, + status: tx.contractRet === "SUCCESS" ? "confirmed" : "failed", + action: "transfer" as const, + direction: getDirection(tx.from, tx.to, address), + assets: [{ + assetType: "native" as const, + value: String(tx.amount), + symbol, + decimals, + }], + })) + }) + ) + + const source = yield* createPollingSource({ + name: `tronwallet.${provider.chainId}.txHistory`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId, + address: params.address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + return source + }) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + const provider = this + const symbol = this.symbol + const decimals = this.decimals + + return Effect.gen(function* () { + const txHistorySource = yield* provider.createTransactionHistorySource({ + address: params.address, + limit: 1, + }) + + const fetchEffect = provider.fetchBalance(params.address).pipe( + Effect.map((raw): BalanceOutput => ({ + amount: Amount.fromRaw(String(raw.result), decimals, symbol), + symbol, + })) + ) + + const source = yield* createDependentSource({ + name: `tronwallet.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: () => fetchEffect, + }) + + return source + }) + } + + private fetchBalance(address: string): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/balance`, + method: "POST", + body: { address }, + schema: BalanceResponseSchema, + }) + } + + private fetchTransactions(params: TxHistoryParams): Effect.Effect { + return httpFetch({ + url: `${this.baseUrl}/transactions`, + method: "POST", + body: { address: params.address, limit: params.limit ?? 20 }, + schema: TxHistoryResponseSchema, + }) + } +} + +export function createTronwalletProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === "tronwallet-v1") { + return new TronWalletProviderEffect(entry, chainId) + } + return null +} From 7a15644255332e1e8474b663418022cb68a63934 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 12:28:51 +0800 Subject: [PATCH 20/33] feat(chain-effect): add superjson, NoSupportError, ServiceLimitedError - Add superjson to chain-effect for BigInt/Amount serialization - Add NoSupportError and ServiceLimitedError classes - Update pages to use chain-effect superjson instead of key-fetch - Fix unused imports in chain-effect source files - Delete old provider test files --- CHAT.md | 9 + package.json | 3 + packages/chain-effect/package.json | 3 +- packages/chain-effect/src/http.ts | 23 +- packages/chain-effect/src/index.ts | 7 + packages/chain-effect/src/instance.ts | 6 +- packages/chain-effect/src/source.ts | 6 +- .../__tests__/biowallet-integration.test.tsx | 285 -------- packages/key-fetch/src/__tests__/core.test.ts | 171 +++++ .../key-fetch/src/__tests__/derive.test.ts | 482 ------------- .../key-fetch/src/__tests__/merge.test.ts | 320 --------- .../src/__tests__/react-hooks.test.tsx | 340 --------- .../src/__tests__/react-integration.test.ts | 130 ---- packages/key-fetch/src/combine.ts | 380 ++++++---- packages/key-fetch/src/core.ts | 546 +++++---------- packages/key-fetch/src/derive.ts | 113 --- packages/key-fetch/src/fallback.ts | 397 +++++------ packages/key-fetch/src/index.ts | 209 +----- packages/key-fetch/src/plugins/api-key.ts | 35 +- packages/key-fetch/src/plugins/cache.ts | 59 +- packages/key-fetch/src/plugins/dedupe.ts | 149 ++-- packages/key-fetch/src/plugins/deps.ts | 151 ---- packages/key-fetch/src/plugins/etag.ts | 33 +- packages/key-fetch/src/plugins/http.ts | 143 ++++ packages/key-fetch/src/plugins/index.ts | 18 +- packages/key-fetch/src/plugins/interval.ts | 62 +- packages/key-fetch/src/plugins/params.ts | 144 +--- packages/key-fetch/src/plugins/tag.ts | 37 +- .../key-fetch/src/plugins/throttle-error.ts | 41 +- packages/key-fetch/src/plugins/transform.ts | 81 +-- packages/key-fetch/src/plugins/ttl.ts | 32 +- packages/key-fetch/src/plugins/unwrap.ts | 50 +- packages/key-fetch/src/react.ts | 229 ------ packages/key-fetch/src/registry.ts | 119 ---- packages/key-fetch/src/types.ts | 354 ++++------ pnpm-lock.yaml | 160 +++++ src/hooks/use-service-status.ts | 2 +- src/pages/address-transactions/index.tsx | 2 +- src/pages/history/detail.tsx | 4 +- src/pages/history/index.tsx | 4 +- .../__tests__/api-key-picker.test.ts | 77 --- .../biowallet-provider.bfmetav2.real.test.ts | 136 ---- .../biowallet-provider.biwmeta.real.test.ts | 202 ------ .../__tests__/biowallet-provider.real.test.ts | 79 --- .../__tests__/blockscout-balance.test.ts | 84 --- .../__tests__/btcwallet-provider.test.ts | 130 ---- .../__tests__/chain-provider.test.ts | 257 ------- .../__tests__/etherscan-provider.test.ts | 245 ------- .../__tests__/ethwallet-provider.test.ts | 202 ------ .../fixtures/real/bfmeta-lastblock.json | 42 -- .../real/bfmeta-transactions-query.json | 118 ---- .../fixtures/real/bfmetav2-lastblock.json | 13 - .../real/bfmetav2-transactions-query.json | 40 -- .../real/biwmeta-ast-02-transferAsset.json | 43 -- .../real/biwmeta-ast-03-destroyAsset.json | 45 -- .../real/biwmeta-bse-01-signature.json | 39 -- .../biwmeta-ety-01-issueEntityFactory.json | 43 -- .../real/biwmeta-ety-02-issueEntity.json | 50 -- .../fixtures/real/biwmeta-lastblock.json | 42 -- .../real/bsc-transactions-history.json | 8 - .../real/btc-mempool-address-txs.json | 50 -- .../eth-blockscout-native-approve-tx.json | 24 - .../real/eth-blockscout-native-swap-tx.json | 24 - .../eth-blockscout-native-transfer-tx.json | 24 - .../eth-blockscout-token-transfer-tx.json | 26 - .../real/tron-trongrid-account-txs.json | 136 ---- .../providers/__tests__/integration.test.ts | 8 - .../__tests__/mempool-provider.test.ts | 184 ----- .../__tests__/provider-capabilities.test.ts | 175 ----- .../__tests__/tron-rpc-provider.test.ts | 267 ------- .../__tests__/tronwallet-provider.test.ts | 215 ------ .../providers/biowallet-provider.ts | 651 ------------------ .../providers/bscwallet-provider.ts | 132 ---- .../providers/btcwallet-provider.ts | 145 ---- .../chain-adapter/providers/chain-provider.ts | 318 ++++----- .../providers/etherscan-v1-provider.ts | 262 ------- .../providers/etherscan-v2-provider.ts | 277 -------- .../providers/ethwallet-provider.ts | 189 ----- .../providers/evm-rpc-provider.ts | 263 ------- src/services/chain-adapter/providers/index.ts | 78 +-- .../providers/mempool-provider.ts | 153 ---- .../providers/moralis-provider.ts | 523 -------------- .../providers/tron-rpc-provider.ts | 364 ---------- .../providers/tronwallet-provider.ts | 166 ----- src/services/chain-adapter/providers/types.ts | 28 +- src/stackflow/activities/tabs/WalletTab.tsx | 4 +- 86 files changed, 1711 insertions(+), 10209 deletions(-) delete mode 100644 packages/key-fetch/src/__tests__/biowallet-integration.test.tsx create mode 100644 packages/key-fetch/src/__tests__/core.test.ts delete mode 100644 packages/key-fetch/src/__tests__/derive.test.ts delete mode 100644 packages/key-fetch/src/__tests__/merge.test.ts delete mode 100644 packages/key-fetch/src/__tests__/react-hooks.test.tsx delete mode 100644 packages/key-fetch/src/__tests__/react-integration.test.ts delete mode 100644 packages/key-fetch/src/derive.ts delete mode 100644 packages/key-fetch/src/plugins/deps.ts create mode 100644 packages/key-fetch/src/plugins/http.ts delete mode 100644 packages/key-fetch/src/react.ts delete mode 100644 packages/key-fetch/src/registry.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/api-key-picker.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/biowallet-provider.bfmetav2.real.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/blockscout-balance.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/btcwallet-provider.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/chain-provider.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-lastblock.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-transactions-query.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/bfmetav2-lastblock.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/bfmetav2-transactions-query.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-02-transferAsset.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-03-destroyAsset.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-bse-01-signature.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-01-issueEntityFactory.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-02-issueEntity.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-lastblock.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/bsc-transactions-history.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/btc-mempool-address-txs.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-approve-tx.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-swap-tx.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-transfer-tx.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-token-transfer-tx.json delete mode 100644 src/services/chain-adapter/providers/__tests__/fixtures/real/tron-trongrid-account-txs.json delete mode 100644 src/services/chain-adapter/providers/__tests__/integration.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/mempool-provider.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/provider-capabilities.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts delete mode 100644 src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts delete mode 100644 src/services/chain-adapter/providers/biowallet-provider.ts delete mode 100644 src/services/chain-adapter/providers/bscwallet-provider.ts delete mode 100644 src/services/chain-adapter/providers/btcwallet-provider.ts delete mode 100644 src/services/chain-adapter/providers/etherscan-v1-provider.ts delete mode 100644 src/services/chain-adapter/providers/etherscan-v2-provider.ts delete mode 100644 src/services/chain-adapter/providers/ethwallet-provider.ts delete mode 100644 src/services/chain-adapter/providers/evm-rpc-provider.ts delete mode 100644 src/services/chain-adapter/providers/mempool-provider.ts delete mode 100644 src/services/chain-adapter/providers/moralis-provider.ts delete mode 100644 src/services/chain-adapter/providers/tron-rpc-provider.ts delete mode 100644 src/services/chain-adapter/providers/tronwallet-provider.ts diff --git a/CHAT.md b/CHAT.md index c44af6a8c..52027aa0a 100644 --- a/CHAT.md +++ b/CHAT.md @@ -1616,6 +1616,15 @@ at KeyFetchInstanceImpl.doFetch (core.ts:119:13) - /Users/kzf/.factory/specs/2026-01-22-moralis-provider.md - /Users/kzf/.factory/specs/2026-01-22-moralis-provider-1.md - /Users/kzf/.factory/specs/2026-01-22-tokeniconcontract-ui.md +- /Users/kzf/.factory/specs/2026-01-22-moralis-provider-2.md +- /Users/kzf/.factory/specs/2026-01-22-enhanced-dedupe-plugin-with-time-window.md +- /Users/kzf/.factory/specs/2026-01-22-auto-dedupe-fallback-cooldown.md +- /Users/kzf/.factory/specs/2026-01-22-key-fetch-v2-core-refactoring-final.md +- /Users/kzf/.factory/specs/2026-01-22-key-fetch-v2.md +- /Users/kzf/.factory/specs/2026-01-23-key-fetch-rxjs-vs-effect-ts.md +- /Users/kzf/.factory/specs/2026-01-23-effect-ts-throttle-dedupe.md +- /Users/kzf/.factory/specs/2026-01-23-effect-subscriptionref-stream-changes.md +- /Users/kzf/.factory/specs/2026-01-23-eventbus-service.md review 的具体方向: diff --git a/package.json b/package.json index ffbffc1d8..88c6d77e6 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@bfchain/util": "^5.0.0", "@bfmeta/sign-util": "^1.3.10", "@biochain/bio-sdk": "workspace:*", + "@biochain/chain-effect": "workspace:*", "@biochain/key-fetch": "workspace:*", "@biochain/key-ui": "workspace:*", "@biochain/key-utils": "workspace:*", @@ -80,6 +81,7 @@ "@bnqkl/server-util": "^1.3.4", "@bnqkl/wallet-sdk": "^0.23.8", "@bnqkl/wallet-typings": "^0.23.8", + "@effect/platform": "^0.94.2", "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", "@fontsource/dm-mono": "^5.2.7", @@ -113,6 +115,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "ed2curve": "^0.3.0", + "effect": "^3.19.15", "i18next": "^25.7.1", "idb": "^8.0.3", "jsqr": "^1.4.0", diff --git a/packages/chain-effect/package.json b/packages/chain-effect/package.json index e1b7b1a7c..676ab8b69 100644 --- a/packages/chain-effect/package.json +++ b/packages/chain-effect/package.json @@ -27,7 +27,8 @@ }, "dependencies": { "effect": "^3.19.15", - "@effect/platform": "^0.94.2" + "@effect/platform": "^0.94.2", + "superjson": "^2.2.6" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts index f19a44e0a..65ea175b9 100644 --- a/packages/chain-effect/src/http.ts +++ b/packages/chain-effect/src/http.ts @@ -37,6 +37,27 @@ export class SchemaError { ) {} } +/** Provider 不支持此功能 */ +export class NoSupportError extends Error { + readonly _tag = 'NoSupportError' as const; + constructor(message = 'This feature is not supported by the provider') { + super(message); + this.name = 'NoSupportError'; + } +} + +/** 服务受限(如免费版不支持) */ +export class ServiceLimitedError extends Error { + readonly _tag = 'ServiceLimitedError' as const; + constructor( + message = 'This service is limited', + readonly reason?: string, + ) { + super(message); + this.name = 'ServiceLimitedError'; + } +} + export type FetchError = HttpError | RateLimitError | SchemaError; // ==================== HTTP Client ==================== @@ -106,7 +127,7 @@ export function httpFetch(options: FetchOptions): Effect.Effect { + try: async () => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); diff --git a/packages/chain-effect/src/index.ts b/packages/chain-effect/src/index.ts index a693eba6c..120451ca7 100644 --- a/packages/chain-effect/src/index.ts +++ b/packages/chain-effect/src/index.ts @@ -8,6 +8,11 @@ export { Effect, Stream, Schedule, Duration, Ref, SubscriptionRef, PubSub, Fiber } from "effect" export { Schema } from "effect" +// SuperJSON for serialization (handles BigInt, Amount, etc.) +import { SuperJSON } from "superjson" +export const superjson = new SuperJSON({ dedupe: true }) +export { SuperJSON } from "superjson" + // Schema definitions export * from "./schema" @@ -20,6 +25,8 @@ export { HttpError, RateLimitError, SchemaError, + NoSupportError, + ServiceLimitedError, type FetchOptions, type FetchError, } from "./http" diff --git a/packages/chain-effect/src/instance.ts b/packages/chain-effect/src/instance.ts index c9ebf7a3b..210e98702 100644 --- a/packages/chain-effect/src/instance.ts +++ b/packages/chain-effect/src/instance.ts @@ -4,7 +4,7 @@ * 将 Effect 的 SubscriptionRef 桥接到 React Hook */ -import { Effect, SubscriptionRef, Stream, Fiber } from "effect" +import { Effect, Stream, Fiber } from "effect" import type { FetchError } from "./http" import type { DataSource } from "./source" @@ -169,7 +169,7 @@ export function createStreamInstanceFromSource( let isCancelled = false const unsubscribe = instanceRef.current.subscribe( inputRef.current, - (newData: TOutput, event: "initial" | "update") => { + (newData: TOutput, _event: "initial" | "update") => { if (isCancelled) return setData(newData) setIsLoading(false) @@ -189,7 +189,7 @@ export function createStreamInstanceFromSource( invalidate(): void { // 停止所有 sources,下次订阅时会重新创建 - for (const [key, cached] of sources) { + for (const [_key, cached] of sources) { Effect.runFork(cached.source.stop) } sources.clear() diff --git a/packages/chain-effect/src/source.ts b/packages/chain-effect/src/source.ts index 41af3fafc..cf3afb54f 100644 --- a/packages/chain-effect/src/source.ts +++ b/packages/chain-effect/src/source.ts @@ -104,7 +104,7 @@ export const createPollingSource = ( options: PollingSourceOptions ): Effect.Effect, never, never> => Effect.gen(function* () { - const { name, fetch, interval, events, walletEvents, immediate = true } = options + const { fetch, interval, events, walletEvents, immediate = true } = options const ref = yield* SubscriptionRef.make(null) @@ -200,7 +200,7 @@ export const createDependentSource = ( options: DependentSourceOptions ): Effect.Effect, never, never> => Effect.gen(function* () { - const { name, dependsOn, hasChanged, fetch } = options + const { dependsOn, hasChanged, fetch } = options const ref = yield* SubscriptionRef.make(null) let prevDep: TDep | null = null @@ -279,7 +279,7 @@ export const createHybridSource = ( options: HybridSourceOptions ): Effect.Effect, never, never> => Effect.gen(function* () { - const { name, dependsOn, interval, events, fetch } = options + const { dependsOn, interval, events, fetch } = options const ref = yield* SubscriptionRef.make(null) let prevDep: TDep | null = null diff --git a/packages/key-fetch/src/__tests__/biowallet-integration.test.tsx b/packages/key-fetch/src/__tests__/biowallet-integration.test.tsx deleted file mode 100644 index 8be2323e6..000000000 --- a/packages/key-fetch/src/__tests__/biowallet-integration.test.tsx +++ /dev/null @@ -1,285 +0,0 @@ -/** - * BiowalletProvider Integration Tests - * - * Tests the exact structure of biowallet-provider to ensure - * nativeBalance derived from addressAsset works correctly - */ - -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' -import { renderHook, waitFor } from '@testing-library/react' -import { z } from 'zod' -import { keyFetch, fallback, derive, transform } from '../index' -import { postBody } from '../plugins/params' -import { ttl } from '../plugins/ttl' -import '@biochain/key-fetch/react' // Enable React support - -// Mock fetch globally -const mockFetch = vi.fn() -const originalFetch = global.fetch -beforeEach(() => { - global.fetch = mockFetch as unknown as typeof fetch - vi.clearAllMocks() -}) -afterEach(() => { - global.fetch = originalFetch -}) - -/** Helper to create mock Response */ -function createMockResponse(data: unknown, ok = true, status = 200): Response { - const jsonData = JSON.stringify(data) - return new Response(jsonData, { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('BiowalletProvider exact structure', () => { - // Exact schemas from biowallet-provider.ts - const BiowalletAssetItemSchema = z.object({ - assetNumber: z.string(), - assetType: z.string(), - }).passthrough() - - const AssetResponseSchema = z.object({ - success: z.boolean(), - result: z.object({ - address: z.string(), - assets: z.record(z.string(), z.record(z.string(), BiowalletAssetItemSchema)), - }).nullish(), // Changed from optional to nullish to handle null - }) - - const AddressParamsSchema = z.object({ - address: z.string(), - }) - - // From types.ts - const BalanceOutputSchema = z.object({ - amount: z.any(), // In real code this is Amount class - symbol: z.string(), - }) - - test('should correctly derive nativeBalance from addressAsset', async () => { - // Real API response format - const realApiResponse = { - success: true, - result: { - address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j', - assets: { - 'LLLQL': { - 'BFM': { - sourceChainMagic: 'LLLQL', - assetType: 'BFM', - sourceChainName: 'bfmeta', - assetNumber: '100005012', - iconUrl: 'https://example.com/icon.png' - }, - 'CPCC': { - sourceChainMagic: 'LLLQL', - assetType: 'CPCC', - assetNumber: '99999968' - } - } - }, - forgingRewards: '0' - } - } - mockFetch.mockImplementation(async (_url, init) => { - console.log('[Test] Fetch called with:', _url, init?.body) - return createMockResponse(realApiResponse) - }) - - const chainId = 'bfmeta' - const symbol = 'BFM' - const decimals = 8 - const baseUrl = 'https://walletapi.bfmeta.info/wallet/bfm' - - // Create addressAsset exactly like biowallet-provider - const addressAsset = keyFetch.create({ - name: `biowallet.${chainId}.addressAsset.test`, - outputSchema: AssetResponseSchema, - inputSchema: AddressParamsSchema, - url: `${baseUrl}/address/asset`, - method: 'POST', - use: [postBody(), ttl(60_000)], - }) - - // Create nativeBalance derived from addressAsset - const nativeBalance = derive({ - name: `biowallet.${chainId}.nativeBalance.test`, - source: addressAsset, - outputSchema: BalanceOutputSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - console.log('[Test] Transform called with raw data:', raw) - if (!raw.result?.assets) { - console.log('[Test] No assets found, returning zero') - return { amount: '0', symbol } - } - // 遍历嵌套结构 assets[magic][assetType] - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === symbol) { - console.log('[Test] Found asset:', asset) - return { - amount: asset.assetNumber, - symbol, - } - } - } - } - console.log('[Test] Asset not found, returning zero') - return { amount: '0', symbol } - }, - }), - ], - }) - - // First test: Direct fetch - const directResult = await addressAsset.fetch({ address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j' }) - console.log('[Test] Direct addressAsset.fetch result:', directResult) - expect(directResult.success).toBe(true) - expect(directResult.result?.assets).toBeDefined() - - // Second test: Derived fetch - const derivedResult = await nativeBalance.fetch({ address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j' }) - console.log('[Test] nativeBalance.fetch result:', derivedResult) - expect(derivedResult.amount).toBe('100005012') - expect(derivedResult.symbol).toBe('BFM') - }) - - test('ChainProvider.nativeBalance through merge', async () => { - const realApiResponse = { - success: true, - result: { - address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j', - assets: { - 'LLLQL': { - 'BFM': { assetType: 'BFM', assetNumber: '100005012' } - } - } - } - } - mockFetch.mockImplementation(async () => createMockResponse(realApiResponse)) - - const chainId = 'bfmeta' - const symbol = 'BFM' - const baseUrl = 'https://walletapi.bfmeta.info/wallet/bfm' - - // Simulate BiowalletProvider - const addressAsset = keyFetch.create({ - name: `biowallet.${chainId}.addressAsset.cp`, - outputSchema: AssetResponseSchema, - inputSchema: AddressParamsSchema, - url: `${baseUrl}/address/asset`, - method: 'POST', - use: [postBody(), ttl(60_000)], - }) - - const nativeBalance = derive({ - name: `biowallet.${chainId}.nativeBalance.cp`, - source: addressAsset, - outputSchema: BalanceOutputSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - if (!raw.result?.assets) { - return { amount: '0', symbol } - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === symbol) { - return { amount: asset.assetNumber, symbol } - } - } - } - return { amount: '0', symbol } - }, - }), - ], - }) - - // Simulate ChainProvider.nativeBalance (merge of provider balances) - const chainNativeBalance = fallback({ - name: `${chainId}.nativeBalance.cp`, - sources: [nativeBalance], - }) - - // Test useState like WalletTab does - const address = 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j' - - const { result } = renderHook(() => - chainNativeBalance.useState( - { address }, - { enabled: !!address } - ) - ) - - console.log('[Test] Initial state:', result.current) - expect(result.current.isLoading).toBe(true) - - await waitFor(() => { - console.log('[Test] Waiting for isLoading to be false, current:', result.current) - expect(result.current.isLoading).toBe(false) - }, { timeout: 5000 }) - - console.log('[Test] Final result:', result.current) - expect(result.current.data).toBeDefined() - expect(result.current.data?.amount).toBe('100005012') - }) - - test('should handle null result correctly', async () => { - const nullResponse = { - success: true, - result: null - } - mockFetch.mockImplementation(async () => createMockResponse(nullResponse)) - - const chainId = 'bfmetav2' - const symbol = 'BFM' - - const addressAsset = keyFetch.create({ - name: `biowallet.${chainId}.addressAsset.null`, - outputSchema: AssetResponseSchema, - url: 'https://walletapi.bf-meta.org/wallet/bfmetav2/address/asset', - method: 'POST', - use: [postBody(), ttl(60_000)], - }) - - const nativeBalance = derive({ - name: `biowallet.${chainId}.nativeBalance.null`, - source: addressAsset, - outputSchema: BalanceOutputSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - if (!raw.result?.assets) { - return { amount: '0', symbol } - } - return { amount: '0', symbol } - }, - }), - ], - }) - - const chainNativeBalance = fallback({ - name: `${chainId}.nativeBalance.null`, - sources: [nativeBalance], - }) - - const { result } = renderHook(() => - chainNativeBalance.useState( - { address: 'test' }, - { enabled: true } - ) - ) - - await waitFor(() => { - expect(result.current.isLoading).toBe(false) - }, { timeout: 5000 }) - - expect(result.current.data).toEqual({ amount: '0', symbol: 'BFM' }) - expect(result.current.error).toBeUndefined() - }) -}) diff --git a/packages/key-fetch/src/__tests__/core.test.ts b/packages/key-fetch/src/__tests__/core.test.ts new file mode 100644 index 000000000..fd87d41e3 --- /dev/null +++ b/packages/key-fetch/src/__tests__/core.test.ts @@ -0,0 +1,171 @@ +/** + * Key-Fetch v2 Core Tests + */ + +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' +import { z } from 'zod' +import { keyFetch, combine, useHttp, useInterval } from '../index' + +const mockFetch = vi.fn() +const originalFetch = global.fetch +beforeEach(() => { + global.fetch = mockFetch as unknown as typeof fetch + vi.clearAllMocks() +}) +afterEach(() => { + global.fetch = originalFetch +}) + +function createMockResponse(data: unknown, ok = true, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + statusText: ok ? 'OK' : 'Error', + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('keyFetch.create', () => { + const TestSchema = z.object({ + success: z.boolean(), + result: z.object({ value: z.string() }).nullable(), + }) + + test('should fetch and parse data', async () => { + const mockData = { success: true, result: { value: 'hello' } } + mockFetch.mockResolvedValueOnce(createMockResponse(mockData)) + + const instance = keyFetch.create({ + name: 'test.basic', + outputSchema: TestSchema, + use: [useHttp('https://api.test.com/data')], + }) + + const result = await instance.fetch({}) + expect(result).toEqual(mockData) + }) + + test('should handle null result', async () => { + const mockData = { success: true, result: null } + mockFetch.mockResolvedValueOnce(createMockResponse(mockData)) + + const instance = keyFetch.create({ + name: 'test.null', + outputSchema: TestSchema, + use: [useHttp('https://api.test.com/data')], + }) + + const result = await instance.fetch({}) + expect((result as { result: null }).result).toBeNull() + }) + + test('should subscribe and receive updates', async () => { + const mockData = { success: true, result: { value: 'test' } } + mockFetch.mockResolvedValue(createMockResponse(mockData)) + + const instance = keyFetch.create({ + name: 'test.subscribe', + outputSchema: TestSchema, + use: [useHttp('https://api.test.com/data')], + }) + + const callback = vi.fn() + const unsubscribe = instance.subscribe({}, callback) + + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + unsubscribe() + }) +}) + +describe('combine with useHttp + transform', () => { + const BalanceSchema = z.object({ + symbol: z.string(), + amount: z.string(), + }) + + const RawBalanceSchema = z.object({ + chain_stats: z.object({ + funded_txo_sum: z.number(), + spent_txo_sum: z.number(), + }), + }) + + test('should fetch with useHttp and apply transform', async () => { + const rawData = { chain_stats: { funded_txo_sum: 1000, spent_txo_sum: 300 } } + mockFetch.mockResolvedValueOnce(createMockResponse(rawData)) + + const balance = combine({ + name: 'test.balance', + outputSchema: BalanceSchema, + use: [useHttp('https://api.test.com/balance')], + transform: (data) => { + const raw = RawBalanceSchema.parse(data) + const amount = raw.chain_stats.funded_txo_sum - raw.chain_stats.spent_txo_sum + return { symbol: 'BTC', amount: amount.toString() } + }, + }) + + const result = await balance.fetch({}) + expect(result).toEqual({ symbol: 'BTC', amount: '700' }) + }) + + test('should subscribe and receive transformed data', async () => { + const rawData = { chain_stats: { funded_txo_sum: 500, spent_txo_sum: 100 } } + mockFetch.mockResolvedValue(createMockResponse(rawData)) + + const balance = combine({ + name: 'test.balance.sub', + outputSchema: BalanceSchema, + use: [useHttp('https://api.test.com/balance')], + transform: (data) => { + const raw = RawBalanceSchema.parse(data) + const amount = raw.chain_stats.funded_txo_sum - raw.chain_stats.spent_txo_sum + return { symbol: 'BTC', amount: amount.toString() } + }, + }) + + const callback = vi.fn() + const unsubscribe = balance.subscribe({}, callback) + + await new Promise(resolve => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + expect(callback.mock.calls[0][0]).toEqual({ symbol: 'BTC', amount: '400' }) + + unsubscribe() + }) +}) + +describe('combine with sources as trigger', () => { + const BlockSchema = z.number() + const BalanceSchema = z.object({ symbol: z.string(), amount: z.string() }) + + test('should refetch when source updates', async () => { + // 第一次调用返回区块高度 + mockFetch.mockResolvedValueOnce(createMockResponse(100)) + // 第二次调用返回余额 + mockFetch.mockResolvedValueOnce(createMockResponse({ funded: 1000, spent: 200 })) + + const blockHeight = keyFetch.create({ + name: 'block', + outputSchema: BlockSchema, + use: [useHttp('https://api.test.com/block')], + }) + + const balance = combine({ + name: 'balance', + outputSchema: BalanceSchema, + sources: [{ source: blockHeight, params: () => ({}) }], + use: [useHttp('https://api.test.com/balance/:address')], + transform: (data) => { + const raw = data as { funded: number; spent: number } + return { symbol: 'BTC', amount: (raw.funded - raw.spent).toString() } + }, + }) + + const result = await balance.fetch({ address: 'abc123' }) + expect(result).toEqual({ symbol: 'BTC', amount: '800' }) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/key-fetch/src/__tests__/derive.test.ts b/packages/key-fetch/src/__tests__/derive.test.ts deleted file mode 100644 index efb11f0b2..000000000 --- a/packages/key-fetch/src/__tests__/derive.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -/** - * Key-Fetch Derive Tests - * - * Tests for derive functionality including: - * - subscribe data flow - * - transform plugin processing - * - error handling - */ - -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' -import { z } from 'zod' -import { keyFetch, derive, transform } from '../index' -import '@biochain/key-fetch/react' // Enable React support - -// Mock fetch globally -const mockFetch = vi.fn() -const originalFetch = global.fetch -beforeEach(() => { - global.fetch = mockFetch as unknown as typeof fetch - vi.clearAllMocks() -}) -afterEach(() => { - global.fetch = originalFetch -}) - -/** Helper to create mock Response */ -function createMockResponse(data: unknown, ok = true, status = 200): Response { - const jsonData = JSON.stringify(data) - return new Response(jsonData, { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('keyFetch.create basic functionality', () => { - const TestSchema = z.object({ - success: z.boolean(), - result: z.object({ - value: z.string(), - }).nullable(), - }) - - test('should fetch and parse data correctly', async () => { - const mockData = { success: true, result: { value: 'hello' } } - mockFetch.mockResolvedValueOnce(createMockResponse(mockData)) - - const instance = keyFetch.create({ - name: 'test.basic', - outputSchema: TestSchema, - url: 'https://api.test.com/data', - }) - - const result = await instance.fetch({}) - - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(result).toEqual(mockData) - }) - - test('should handle null result correctly', async () => { - const mockData = { success: true, result: null } - mockFetch.mockResolvedValueOnce(createMockResponse(mockData)) - - const instance = keyFetch.create({ - name: 'test.null', - outputSchema: TestSchema, - url: 'https://api.test.com/data', - }) - - const result = await instance.fetch({}) - - expect(result).toEqual(mockData) - expect(result.result).toBeNull() - }) - - test('should throw on schema validation failure', async () => { - const invalidData = { success: 'not-boolean', result: null } - mockFetch.mockResolvedValueOnce(createMockResponse(invalidData)) - - const instance = keyFetch.create({ - name: 'test.invalid', - outputSchema: TestSchema, - url: 'https://api.test.com/data', - }) - - await expect(instance.fetch({})).rejects.toThrow() - }) -}) - -describe('keyFetch subscribe functionality', () => { - const TestSchema = z.object({ - value: z.number(), - }) - - test('should subscribe and receive data updates', async () => { - const mockData = { value: 42 } - mockFetch.mockResolvedValue(createMockResponse(mockData)) - - const instance = keyFetch.create({ - name: 'test.subscribe', - outputSchema: TestSchema, - url: 'https://api.test.com/data', - }) - - const callback = vi.fn() - const unsubscribe = instance.subscribe({}, callback) - - // Wait for async subscription to complete - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(callback).toHaveBeenCalled() - expect(callback).toHaveBeenCalledWith(mockData, expect.any(String)) - - unsubscribe() - }) -}) - -describe('derive functionality', () => { - // Source schema - simulates biowallet API response - const SourceSchema = z.object({ - success: z.boolean(), - result: z.object({ - address: z.string(), - assets: z.record(z.string(), z.record(z.string(), z.object({ - assetType: z.string(), - assetNumber: z.string(), - }))), - }).nullish(), - }) - - // Output schema - simulates balance - const BalanceSchema = z.object({ - symbol: z.string(), - amount: z.string(), - }) - - test('should derive and transform data correctly', async () => { - const sourceData = { - success: true, - result: { - address: 'testAddress', - assets: { - 'MAGIC': { - 'BFM': { assetType: 'BFM', assetNumber: '100000000' }, - 'CPCC': { assetType: 'CPCC', assetNumber: '50000000' }, - } - } - } - } - mockFetch.mockResolvedValue(createMockResponse(sourceData)) - - const source = keyFetch.create({ - name: 'test.source', - outputSchema: SourceSchema, - url: 'https://api.test.com/address/asset', - method: 'POST', - }) - - const derived = derive({ - name: 'test.derived.balance', - source, - outputSchema: BalanceSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - if (!raw.result?.assets) { - return { symbol: 'BFM', amount: '0' } - } - // Find BFM asset - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === 'BFM') { - return { - symbol: 'BFM', - amount: asset.assetNumber, - } - } - } - } - return { symbol: 'BFM', amount: '0' } - }, - }), - ], - }) - - const result = await derived.fetch({ address: 'testAddress' }) - - expect(result).toEqual({ symbol: 'BFM', amount: '100000000' }) - }) - - test('should handle null result in source data', async () => { - const sourceData = { - success: true, - result: null - } - mockFetch.mockResolvedValue(createMockResponse(sourceData)) - - const source = keyFetch.create({ - name: 'test.source.null', - outputSchema: SourceSchema, - url: 'https://api.test.com/address/asset', - method: 'POST', - }) - - const derived = derive({ - name: 'test.derived.null', - source, - outputSchema: BalanceSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - if (!raw.result?.assets) { - return { symbol: 'BFM', amount: '0' } - } - return { symbol: 'BFM', amount: '0' } - }, - }), - ], - }) - - const result = await derived.fetch({ address: 'testAddress' }) - - expect(result).toEqual({ symbol: 'BFM', amount: '0' }) - }) - - test('derive subscribe should receive transformed data', async () => { - const sourceData = { - success: true, - result: { - address: 'testAddress', - assets: { - 'MAGIC': { - 'BFM': { assetType: 'BFM', assetNumber: '100000000' }, - } - } - } - } - mockFetch.mockResolvedValue(createMockResponse(sourceData)) - - const source = keyFetch.create({ - name: 'test.source.sub', - outputSchema: SourceSchema, - url: 'https://api.test.com/address/asset', - method: 'POST', - }) - - const derived = derive({ - name: 'test.derived.sub', - source, - outputSchema: BalanceSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - if (!raw.result?.assets) { - return { symbol: 'BFM', amount: '0' } - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === 'BFM') { - return { symbol: 'BFM', amount: asset.assetNumber } - } - } - } - return { symbol: 'BFM', amount: '0' } - }, - }), - ], - }) - - const callback = vi.fn() - const unsubscribe = derived.subscribe({ address: 'testAddress' }, callback) - - // Wait for async subscription to complete - await new Promise(resolve => setTimeout(resolve, 200)) - - expect(callback).toHaveBeenCalled() - const calledArgs = callback.mock.calls[0] - expect(calledArgs[0]).toEqual({ symbol: 'BFM', amount: '100000000' }) - - unsubscribe() - }) - - test('derive subscribe should handle transform errors gracefully', async () => { - const sourceData = { - success: true, - result: { - address: 'testAddress', - assets: {} - } - } - mockFetch.mockResolvedValue(createMockResponse(sourceData)) - - const source = keyFetch.create({ - name: 'test.source.err', - outputSchema: SourceSchema, - url: 'https://api.test.com/address/asset', - method: 'POST', - }) - - const derived = derive({ - name: 'test.derived.err', - source, - outputSchema: BalanceSchema, - use: [ - transform, z.infer>({ - transform: () => { - throw new Error('Transform error') - }, - }), - ], - }) - - const callback = vi.fn() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) - - const unsubscribe = derived.subscribe({ address: 'testAddress' }, callback) - - // Wait for async subscription - await new Promise(resolve => setTimeout(resolve, 200)) - - // Callback should NOT be called due to error - expect(callback).not.toHaveBeenCalled() - // Error should be logged - expect(errorSpy).toHaveBeenCalled() - - unsubscribe() - - // Wait for any pending async operations to settle before restoring mock - await new Promise(resolve => setTimeout(resolve, 100)) - - errorSpy.mockRestore() - }) -}) - -describe('biowallet-provider simulation', () => { - // Exact schema from biowallet-provider - const BiowalletAssetItemSchema = z.object({ - assetNumber: z.string(), - assetType: z.string(), - }).passthrough() - - const AssetResponseSchema = z.object({ - success: z.boolean(), - result: z.object({ - address: z.string(), - assets: z.record(z.string(), z.record(z.string(), BiowalletAssetItemSchema)), - }).nullish(), - }) - - const BalanceOutputSchema = z.object({ - amount: z.string(), - symbol: z.string(), - }) - - test('should process real API response format', async () => { - // Real API response format from curl test - const realApiResponse = { - success: true, - result: { - address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j', - assets: { - 'LLLQL': { - 'BFM': { - sourceChainMagic: 'LLLQL', - assetType: 'BFM', - sourceChainName: 'bfmeta', - assetNumber: '100005012', - iconUrl: 'https://example.com/icon.png' - }, - 'CPCC': { - sourceChainMagic: 'LLLQL', - assetType: 'CPCC', - sourceChainName: 'bfmeta', - assetNumber: '99999968', - iconUrl: 'https://example.com/icon2.png' - } - } - }, - forgingRewards: '0' - } - } - mockFetch.mockResolvedValue(createMockResponse(realApiResponse)) - - const addressAsset = keyFetch.create({ - name: 'biowallet.bfmeta.addressAsset', - outputSchema: AssetResponseSchema, - url: 'https://walletapi.bfmeta.info/wallet/bfm/address/asset', - method: 'POST', - }) - - const nativeBalance = derive({ - name: 'biowallet.bfmeta.nativeBalance', - source: addressAsset, - outputSchema: BalanceOutputSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - const symbol = 'BFM' - if (!raw.result?.assets) { - return { amount: '0', symbol } - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === symbol) { - return { - amount: asset.assetNumber, - symbol, - } - } - } - } - return { amount: '0', symbol } - }, - }), - ], - }) - - const result = await nativeBalance.fetch({ address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j' }) - - expect(result).toEqual({ amount: '100005012', symbol: 'BFM' }) - }) - - test('subscribe should work with real API response format', async () => { - const realApiResponse = { - success: true, - result: { - address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j', - assets: { - 'LLLQL': { - 'BFM': { - sourceChainMagic: 'LLLQL', - assetType: 'BFM', - assetNumber: '100005012', - } - } - } - } - } - mockFetch.mockResolvedValue(createMockResponse(realApiResponse)) - - const addressAsset = keyFetch.create({ - name: 'biowallet.bfmeta.addressAsset.sub', - outputSchema: AssetResponseSchema, - url: 'https://walletapi.bfmeta.info/wallet/bfm/address/asset', - method: 'POST', - }) - - const nativeBalance = derive({ - name: 'biowallet.bfmeta.nativeBalance.sub', - source: addressAsset, - outputSchema: BalanceOutputSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - const symbol = 'BFM' - if (!raw.result?.assets) { - return { amount: '0', symbol } - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === symbol) { - return { amount: asset.assetNumber, symbol } - } - } - } - return { amount: '0', symbol } - }, - }), - ], - }) - - const callback = vi.fn() - const unsubscribe = nativeBalance.subscribe({ address: 'test' }, callback) - - await new Promise(resolve => setTimeout(resolve, 200)) - - expect(callback).toHaveBeenCalled() - expect(callback.mock.calls[0][0]).toEqual({ amount: '100005012', symbol: 'BFM' }) - - unsubscribe() - }) -}) diff --git a/packages/key-fetch/src/__tests__/merge.test.ts b/packages/key-fetch/src/__tests__/merge.test.ts deleted file mode 100644 index 97dd14e86..000000000 --- a/packages/key-fetch/src/__tests__/merge.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -/** - * Key-Fetch Merge Tests - * - * Tests for merge functionality which is used by ChainProvider - * to combine multiple sources with auto-fallback - */ - -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' -import { z } from 'zod' -import { keyFetch, fallback } from '../index' -import '@biochain/key-fetch/react' // Enable React support - -// Mock fetch globally -const mockFetch = vi.fn() -const originalFetch = global.fetch -beforeEach(() => { - global.fetch = mockFetch as unknown as typeof fetch - vi.clearAllMocks() -}) -afterEach(() => { - global.fetch = originalFetch -}) - -/** Helper to create mock Response */ -function createMockResponse(data: unknown, ok = true, status = 200): Response { - const jsonData = JSON.stringify(data) - return new Response(jsonData, { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('merge functionality', () => { - const BalanceSchema = z.object({ - amount: z.string(), - symbol: z.string(), - }) - - test('should fetch from first available source', async () => { - const mockData = { amount: '100', symbol: 'BFM' } - mockFetch.mockResolvedValueOnce(createMockResponse(mockData)) - - const source1 = keyFetch.create({ - name: 'merge.source1', - outputSchema: BalanceSchema, - url: 'https://api1.test.com/balance', - }) - - const source2 = keyFetch.create({ - name: 'merge.source2', - outputSchema: BalanceSchema, - url: 'https://api2.test.com/balance', - }) - - const merged = fallback({ - name: 'merge.test', - sources: [source1, source2], - }) - - const result = await merged.fetch({ address: 'test' }) - - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(result).toEqual(mockData) - }) - - test('should fallback to second source on first failure', async () => { - const mockData = { amount: '200', symbol: 'BFM' } - - // First source fails - mockFetch - .mockRejectedValueOnce(new Error('First source failed')) - .mockResolvedValueOnce(createMockResponse(mockData)) - - const source1 = keyFetch.create({ - name: 'merge.fail.source1', - outputSchema: BalanceSchema, - url: 'https://api1.test.com/balance', - }) - - const source2 = keyFetch.create({ - name: 'merge.fail.source2', - outputSchema: BalanceSchema, - url: 'https://api2.test.com/balance', - }) - - const merged = fallback({ - name: 'merge.fail.test', - sources: [source1, source2], - }) - - const result = await merged.fetch({ address: 'test' }) - - expect(mockFetch).toHaveBeenCalledTimes(2) - expect(result).toEqual(mockData) - }) - - test('merge subscribe should work with sources', async () => { - const mockData = { amount: '300', symbol: 'BFM' } - mockFetch.mockResolvedValue(createMockResponse(mockData)) - - const source1 = keyFetch.create({ - name: 'merge.sub.source1', - outputSchema: BalanceSchema, - url: 'https://api1.test.com/balance', - }) - - const merged = fallback({ - name: 'merge.sub.test', - sources: [source1], - }) - - const callback = vi.fn() - const unsubscribe = merged.subscribe({ address: 'test' }, callback) - - await new Promise(resolve => setTimeout(resolve, 200)) - - expect(callback).toHaveBeenCalled() - expect(callback.mock.calls[0][0]).toEqual(mockData) - - unsubscribe() - }) - - // Skip: useState requires React component context - cannot be tested outside components - test.skip('merge useState should return data', async () => { - const mockData = { amount: '400', symbol: 'BFM' } - mockFetch.mockResolvedValue(createMockResponse(mockData)) - - const source1 = keyFetch.create({ - name: 'merge.useState.source1', - outputSchema: BalanceSchema, - url: 'https://api1.test.com/balance', - }) - - const merged = fallback({ - name: 'merge.useState.test', - sources: [source1], - }) - - // Test that useState doesn't throw - expect(() => { - merged.useState({ address: 'test' }) - }).not.toThrow() - }) - - test('merge with empty sources should throw NoSupportError', async () => { - const merged = fallback({ - name: 'merge.empty.test', - sources: [], - }) - - await expect(merged.fetch({ address: 'test' })).rejects.toThrow() - }) -}) - -describe('merge with derived sources', () => { - // This simulates how ChainProvider uses merge with derived instances - - const SourceSchema = z.object({ - success: z.boolean(), - result: z.object({ - assets: z.record(z.string(), z.record(z.string(), z.object({ - assetType: z.string(), - assetNumber: z.string(), - }))), - }).nullish(), - }) - - const BalanceSchema = z.object({ - amount: z.string(), - symbol: z.string(), - }) - - // Skip: This test has issues with Response body being read multiple times in mock environment - // The core functionality is proven by 'merge subscribe should propagate data from derived source' - test.skip('should work with derived sources through merge', async () => { - const sourceData = { - success: true, - result: { - assets: { - 'MAGIC': { - 'BFM': { assetType: 'BFM', assetNumber: '500000000' }, - } - } - } - } - mockFetch.mockResolvedValue(createMockResponse(sourceData)) - - // Create a base fetcher (simulating addressAsset in biowallet-provider) - const addressAsset = keyFetch.create({ - name: 'merge.derived.addressAsset', - outputSchema: SourceSchema, - url: 'https://api.test.com/address/asset', - method: 'POST', - }) - - // Create a derived instance (simulating nativeBalance derive in biowallet-provider) - const { derive, transform } = await import('../index') - - const nativeBalance = derive({ - name: 'merge.derived.nativeBalance', - source: addressAsset, - outputSchema: BalanceSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - if (!raw.result?.assets) { - return { amount: '0', symbol: 'BFM' } - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === 'BFM') { - return { amount: asset.assetNumber, symbol: 'BFM' } - } - } - } - return { amount: '0', symbol: 'BFM' } - }, - }), - ], - }) - - // Merge the derived instance (simulating ChainProvider.nativeBalance) - const merged = fallback({ - name: 'chainProvider.merged.nativeBalance', - sources: [nativeBalance], - }) - - // Test fetch - const fetchResult = await merged.fetch({ address: 'test' }) - expect(fetchResult).toEqual({ amount: '500000000', symbol: 'BFM' }) - - // Test subscribe - const callback = vi.fn() - const unsubscribe = merged.subscribe({ address: 'test' }, callback) - - await new Promise(resolve => setTimeout(resolve, 200)) - - expect(callback).toHaveBeenCalled() - expect(callback.mock.calls[0][0]).toEqual({ amount: '500000000', symbol: 'BFM' }) - - unsubscribe() - }) - - test('merge subscribe should propagate data from derived source', async () => { - const sourceData = { - success: true, - result: { - assets: { - 'LLLQL': { - 'BFM': { assetType: 'BFM', assetNumber: '100005012' }, - 'CPCC': { assetType: 'CPCC', assetNumber: '99999968' }, - } - } - } - } - mockFetch.mockResolvedValue(createMockResponse(sourceData)) - - const { derive, transform } = await import('../index') - - const addressAsset = keyFetch.create({ - name: 'real.addressAsset', - outputSchema: SourceSchema, - url: 'https://walletapi.bfmeta.info/wallet/bfm/address/asset', - method: 'POST', - }) - - const nativeBalance = derive({ - name: 'real.nativeBalance', - source: addressAsset, - outputSchema: BalanceSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - console.log('[TEST] transform called with:', raw) - if (!raw.result?.assets) { - return { amount: '0', symbol: 'BFM' } - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === 'BFM') { - return { amount: asset.assetNumber, symbol: 'BFM' } - } - } - } - return { amount: '0', symbol: 'BFM' } - }, - }), - ], - }) - - const merged = fallback({ - name: 'real.merged', - sources: [nativeBalance], - }) - - // Track all callback invocations - const receivedData: unknown[] = [] - const callback = vi.fn((data) => { - console.log('[TEST] merge subscribe callback received:', data) - receivedData.push(data) - }) - - const unsubscribe = merged.subscribe({ address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j' }, callback) - - await new Promise(resolve => setTimeout(resolve, 300)) - - console.log('[TEST] Total callback invocations:', callback.mock.calls.length) - console.log('[TEST] Received data:', receivedData) - - expect(callback).toHaveBeenCalled() - expect(callback.mock.calls.length).toBeGreaterThanOrEqual(1) - - // Check that we received the correct transformed data - const lastCall = callback.mock.calls[callback.mock.calls.length - 1] - expect(lastCall[0]).toEqual({ amount: '100005012', symbol: 'BFM' }) - - unsubscribe() - }) -}) diff --git a/packages/key-fetch/src/__tests__/react-hooks.test.tsx b/packages/key-fetch/src/__tests__/react-hooks.test.tsx deleted file mode 100644 index c164d21bd..000000000 --- a/packages/key-fetch/src/__tests__/react-hooks.test.tsx +++ /dev/null @@ -1,340 +0,0 @@ -/** - * Key-Fetch React Hooks Tests - * - * Tests for useState functionality using @testing-library/react renderHook - * These tests run in a proper React component context - */ - -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' -import { renderHook, waitFor, act } from '@testing-library/react' -import { z } from 'zod' -import { keyFetch, fallback, derive, transform } from '../index' -import '@biochain/key-fetch/react' // Enable React support - -// Mock fetch globally -const mockFetch = vi.fn() -const originalFetch = global.fetch -beforeEach(() => { - global.fetch = mockFetch as unknown as typeof fetch - vi.clearAllMocks() -}) -afterEach(() => { - global.fetch = originalFetch -}) - -/** Helper to create mock Response */ -function createMockResponse(data: unknown, ok = true, status = 200): Response { - const jsonData = JSON.stringify(data) - return new Response(jsonData, { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('keyFetch useState in React component', () => { - const BalanceSchema = z.object({ - amount: z.string(), - symbol: z.string(), - }) - - test('should return loading state initially', async () => { - const mockData = { amount: '100', symbol: 'BFM' } - mockFetch.mockResolvedValue(createMockResponse(mockData)) - - const instance = keyFetch.create({ - name: 'react.test.balance', - outputSchema: BalanceSchema, - url: 'https://api.test.com/balance', - }) - - const { result } = renderHook(() => instance.useState({ address: 'test' })) - - // Initially loading - expect(result.current.isLoading).toBe(true) - expect(result.current.data).toBeUndefined() - - // Wait for data - await waitFor(() => { - expect(result.current.isLoading).toBe(false) - }) - - expect(result.current.data).toEqual(mockData) - expect(result.current.error).toBeUndefined() - }) - - // Skip: This test is flaky due to timing issues with error propagation - test.skip('should handle errors', async () => { - mockFetch.mockRejectedValue(new Error('Network error')) - - const instance = keyFetch.create({ - name: 'react.test.error', - outputSchema: BalanceSchema, - url: 'https://api.test.com/balance', - }) - - const { result } = renderHook(() => instance.useState({ address: 'test' })) - - await waitFor(() => { - expect(result.current.isLoading).toBe(false) - }) - - expect(result.current.error).toBeDefined() - expect(result.current.data).toBeUndefined() - }) - - test('should not fetch when disabled', async () => { - const mockData = { amount: '100', symbol: 'BFM' } - mockFetch.mockResolvedValue(createMockResponse(mockData)) - - const instance = keyFetch.create({ - name: 'react.test.disabled', - outputSchema: BalanceSchema, - url: 'https://api.test.com/balance', - }) - - const { result } = renderHook(() => - instance.useState({ address: 'test' }, { enabled: false }) - ) - - // Should immediately be not loading and have no data - expect(result.current.isLoading).toBe(false) - expect(result.current.data).toBeUndefined() - expect(mockFetch).not.toHaveBeenCalled() - }) -}) - -describe('merge useState in React component', () => { - const BalanceSchema = z.object({ - amount: z.string(), - symbol: z.string(), - }) - - test('should work with merge instance', async () => { - const mockData = { amount: '200', symbol: 'BFM' } - mockFetch.mockResolvedValue(createMockResponse(mockData)) - - const source = keyFetch.create({ - name: 'react.merge.source', - outputSchema: BalanceSchema, - url: 'https://api.test.com/balance', - }) - - const merged = fallback({ - name: 'react.merge.test', - sources: [source], - }) - - const { result } = renderHook(() => merged.useState({ address: 'test' })) - - expect(result.current.isLoading).toBe(true) - - await waitFor(() => { - expect(result.current.isLoading).toBe(false) - }) - - expect(result.current.data).toEqual(mockData) - }) -}) - -describe('derive useState in React component', () => { - const SourceSchema = z.object({ - success: z.boolean(), - result: z.object({ - assets: z.record(z.string(), z.record(z.string(), z.object({ - assetType: z.string(), - assetNumber: z.string(), - }))), - }).nullish(), - }) - - const BalanceSchema = z.object({ - amount: z.string(), - symbol: z.string(), - }) - - test('should work with derive instance and transform', async () => { - const sourceData = { - success: true, - result: { - assets: { - 'LLLQL': { - 'BFM': { assetType: 'BFM', assetNumber: '100005012' }, - } - } - } - } - mockFetch.mockResolvedValue(createMockResponse(sourceData)) - - const addressAsset = keyFetch.create({ - name: 'react.derive.source', - outputSchema: SourceSchema, - url: 'https://api.test.com/address/asset', - method: 'POST', - }) - - const nativeBalance = derive({ - name: 'react.derive.balance', - source: addressAsset, - outputSchema: BalanceSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - if (!raw.result?.assets) { - return { amount: '0', symbol: 'BFM' } - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === 'BFM') { - return { amount: asset.assetNumber, symbol: 'BFM' } - } - } - } - return { amount: '0', symbol: 'BFM' } - }, - }), - ], - }) - - const { result } = renderHook(() => nativeBalance.useState({ address: 'test' })) - - expect(result.current.isLoading).toBe(true) - - await waitFor(() => { - expect(result.current.isLoading).toBe(false) - }) - - expect(result.current.data).toEqual({ amount: '100005012', symbol: 'BFM' }) - }) - - test('should handle null result from API', async () => { - const sourceData = { - success: true, - result: null - } - mockFetch.mockResolvedValue(createMockResponse(sourceData)) - - const addressAsset = keyFetch.create({ - name: 'react.derive.null.source', - outputSchema: SourceSchema, - url: 'https://api.test.com/address/asset', - method: 'POST', - }) - - const nativeBalance = derive({ - name: 'react.derive.null.balance', - source: addressAsset, - outputSchema: BalanceSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - if (!raw.result?.assets) { - return { amount: '0', symbol: 'BFM' } - } - return { amount: '0', symbol: 'BFM' } - }, - }), - ], - }) - - const { result } = renderHook(() => nativeBalance.useState({ address: 'test' })) - - await waitFor(() => { - expect(result.current.isLoading).toBe(false) - }) - - expect(result.current.data).toEqual({ amount: '0', symbol: 'BFM' }) - }) -}) - -describe('ChainProvider simulation with merge and derive', () => { - const SourceSchema = z.object({ - success: z.boolean(), - result: z.object({ - address: z.string(), - assets: z.record(z.string(), z.record(z.string(), z.object({ - assetType: z.string(), - assetNumber: z.string(), - }))), - }).nullish(), - }) - - const BalanceSchema = z.object({ - amount: z.string(), - symbol: z.string(), - }) - - test('should work like ChainProvider.nativeBalance.useState()', async () => { - // Simulates real API response - const realApiResponse = { - success: true, - result: { - address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j', - assets: { - 'LLLQL': { - 'BFM': { assetType: 'BFM', assetNumber: '100005012' }, - 'CPCC': { assetType: 'CPCC', assetNumber: '99999968' }, - } - } - } - } - mockFetch.mockResolvedValue(createMockResponse(realApiResponse)) - - // Simulates BiowalletProvider.nativeBalance (derived from addressAsset) - const addressAsset = keyFetch.create({ - name: 'biowallet.bfmeta.addressAsset.react', - outputSchema: SourceSchema, - url: 'https://walletapi.bfmeta.info/wallet/bfm/address/asset', - method: 'POST', - }) - - const nativeBalance = derive({ - name: 'biowallet.bfmeta.nativeBalance.react', - source: addressAsset, - outputSchema: BalanceSchema, - use: [ - transform, z.infer>({ - transform: (raw) => { - const symbol = 'BFM' - if (!raw.result?.assets) { - return { amount: '0', symbol } - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === symbol) { - return { amount: asset.assetNumber, symbol } - } - } - } - return { amount: '0', symbol } - }, - }), - ], - }) - - // Simulates ChainProvider.nativeBalance (merge of provider balances) - const chainNativeBalance = fallback({ - name: 'bfmeta.nativeBalance.react', - sources: [nativeBalance], - }) - - // This is exactly how WalletTab uses it - const { result } = renderHook(() => - chainNativeBalance.useState( - { address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j' }, - { enabled: true } - ) - ) - - expect(result.current.isLoading).toBe(true) - expect(result.current.data).toBeUndefined() - - await waitFor(() => { - expect(result.current.isLoading).toBe(false) - }, { timeout: 3000 }) - - // Verify the data is correctly transformed - expect(result.current.data).toEqual({ amount: '100005012', symbol: 'BFM' }) - expect(result.current.error).toBeUndefined() - }) -}) diff --git a/packages/key-fetch/src/__tests__/react-integration.test.ts b/packages/key-fetch/src/__tests__/react-integration.test.ts deleted file mode 100644 index 57bc4f84e..000000000 --- a/packages/key-fetch/src/__tests__/react-integration.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Key-Fetch React Integration Tests - * - * Tests for useState injection mechanism that enables React support - * for KeyFetchInstance and derived instances. - */ - -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' -import { z } from 'zod' -import { keyFetch, derive, transform } from '../index' -import { injectUseState, getUseStateImpl } from '../core' - -// Mock React hook implementation -const mockUseStateImpl = vi.fn().mockReturnValue({ - data: undefined, - isLoading: true, - isFetching: false, - error: undefined, - refetch: vi.fn(), -}) - -describe('key-fetch React useState injection', () => { - beforeEach(() => { - // Inject mock useState implementation - injectUseState(mockUseStateImpl) - vi.clearAllMocks() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe('getUseStateImpl', () => { - test('should return injected implementation', () => { - const impl = getUseStateImpl() - expect(impl).toBe(mockUseStateImpl) - }) - }) - - describe('KeyFetchInstance.useState', () => { - const TestSchema = z.object({ - value: z.string(), - }) - - test('should call injected useState implementation', () => { - const instance = keyFetch.create({ - name: 'test.instance', - outputSchema: TestSchema, - url: 'https://api.test.com/data', - }) - - const params = { id: '123' } - const options = { enabled: true } - - instance.useState(params, options) - - expect(mockUseStateImpl).toHaveBeenCalledTimes(1) - expect(mockUseStateImpl).toHaveBeenCalledWith(instance, params, options) - }) - - test('should return useState result', () => { - const expectedResult = { - data: { value: 'test' }, - isLoading: false, - isFetching: false, - error: undefined, - refetch: vi.fn(), - } - mockUseStateImpl.mockReturnValueOnce(expectedResult) - - const instance = keyFetch.create({ - name: 'test.instance2', - outputSchema: TestSchema, - url: 'https://api.test.com/data', - }) - - const result = instance.useState({}) - - expect(result).toBe(expectedResult) - }) - }) - - describe('derive().useState', () => { - const SourceSchema = z.object({ - items: z.array(z.object({ - id: z.string(), - name: z.string(), - })), - }) - - const DerivedSchema = z.array(z.string()) - - test('should use injected useState implementation for derived instances', () => { - const sourceInstance = keyFetch.create({ - name: 'test.source', - outputSchema: SourceSchema, - url: 'https://api.test.com/items', - }) - - const derivedInstance = derive({ - name: 'test.derived', - source: sourceInstance, - outputSchema: DerivedSchema, - use: [ - transform, z.infer>({ - transform: (data) => data.items.map(item => item.name), - }), - ], - }) - - const params = { filter: 'active' } - const options = { enabled: true } - - derivedInstance.useState(params, options) - - expect(mockUseStateImpl).toHaveBeenCalledTimes(1) - // First argument should be the derived instance - expect(mockUseStateImpl.mock.calls[0][0]).toBe(derivedInstance) - }) - }) -}) - -describe('key-fetch getUseStateImpl before injection', () => { - test('getUseStateImpl returns the current implementation', () => { - // After injection in the beforeEach, it should exist - const impl = getUseStateImpl() - // This test is just to verify the getter works - expect(typeof impl).toBe('function') - }) -}) diff --git a/packages/key-fetch/src/combine.ts b/packages/key-fetch/src/combine.ts index bda4b378a..b13f5d5ea 100644 --- a/packages/key-fetch/src/combine.ts +++ b/packages/key-fetch/src/combine.ts @@ -1,162 +1,254 @@ /** - * Combine - 合并多个 KeyFetchInstance 的结果为一个 object + * Combine - 组合多个 KeyFetchInstance * - * 与 merge 不同,combine 会并行调用所有 sources 并将结果组合 - * - * @example - * ```ts - * import { combine } from '@biochain/key-fetch' - * - * const transaction = combine({ - * name: 'chain.transaction', - * schema: TransactionSchema, - * sources: { - * pending: pendingTxFetcher, - * confirmed: confirmedTxFetcher, - * }, - * use: [ - * transform({ - * transform: (results) => { - * // results = { pending: ..., confirmed: ... } - * return results.pending ?? results.confirmed - * }, - * }), - * ], - * }) - * ``` + * 核心功能: + * 1. 订阅 sources 作为触发器(任一 source 更新触发重新获取) + * 2. 通过 use 插件(如 useHttp)发起 HTTP 请求 + * 3. 在 transform 中做数据转换 */ -import { z } from 'zod' +import type { z } from 'zod' import type { - KeyFetchInstance, - FetchPlugin, + Context, + KeyFetchInstance, + Plugin, + SubscribeCallback, + UseStateOptions, + UseStateResult, } from './types' -import { keyFetch } from './index' +import { superjson } from './core' -/** Combine 选项 */ -export interface CombineOptions< - TOUT, - Sources extends Record, - TIN = InferCombinedParams -> { - /** 合并后的名称 */ - name: string - /** 输出 Schema */ - outputSchema: z.ZodType - /** 源 fetcher 对象 */ - sources: Sources - /** 自定义参数 Schema(可选,默认从 sources 推导) */ - inputSchema?: z.ZodType - /** 参数转换函数:将外部 params 转换为各个 source 需要的 params */ - transformParams?: (params: TIN) => InferCombinedParams - /** 插件列表 */ - use?: FetchPlugin[] +/** Combine 源配置 */ +export interface CombineSourceConfig { + source: KeyFetchInstance + params: (input: TInput) => unknown + key?: string } -/** 从 Sources 推导出组合的 params 类型 */ -type InferCombinedParams> = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- type inference requires any - [K in keyof Sources]: Sources[K] extends KeyFetchInstance ? P : never +/** Combine 选项 */ +export interface CombineOptions { + name: string + outputSchema: z.ZodType + inputSchema?: z.ZodType + /** 触发源(可选,为空时直接执行 use 插件) */ + sources?: CombineSourceConfig[] + /** 插件列表(如 useHttp) */ + use?: Plugin[] + /** 转换函数(处理 HTTP 响应或 sources 数据) */ + transform?: (data: unknown, input: TInput) => TOutput | Promise } -/** - * 合并多个 KeyFetchInstance 的结果 - * - * - 并行调用所有 sources - * - 将结果组合为 { [key]: result } 的 object - * - 支持 use 插件系统进行后续处理 - * - 自动订阅所有 sources - */ -export function combine< - TOUT, - Sources extends Record, - TIN = InferCombinedParams ->( - options: CombineOptions -): KeyFetchInstance { - const { name, outputSchema, sources, inputSchema, transformParams, use = [] } = options - - const sourceKeys = Object.keys(sources) - - // 创建一个虚拟的 URL(combine 不需要真实的 HTTP 请求) - const url = `combine://${name}` - - // 创建一个插件来拦截请求并调用所有 sources - const combinePlugin: FetchPlugin = { - name: 'combine', - onFetch: async (_request, _next, context) => { - // 转换 params:如果有 transformParams,使用它;否则直接使用 context.params - const sourceParams = transformParams - ? transformParams(context.params as unknown as TIN) - : (context.params as unknown as InferCombinedParams) - - // 并行调用所有 sources - const results = await Promise.all( - sourceKeys.map(async (key) => { - try { - const result = await sources[key].fetch(sourceParams[key]) - return [key, result] as const - } catch (error) { - // 某个 source 失败时返回 undefined - return [key, undefined] as const - } - }) - ) - - // 组合为 object - const combined = Object.fromEntries(results) - - // 直接返回 Response,让后续插件(如 transform)处理 - return context.createResponse(combined) - }, - onSubscribe: (context) => { - // 转换 params(与 onFetch 保持一致) - const sourceParams = transformParams - ? transformParams(context.params as unknown as TIN) - : (context.params as unknown as InferCombinedParams) - - // 订阅所有 sources,任何一个更新都触发 refetch - const unsubscribes = sourceKeys.map((key) => { - return sources[key].subscribe(sourceParams[key], () => { - // 任何 source 更新时,触发 combine 的 refetch - context.refetch() - }) - }) +export function combine( + options: CombineOptions +): KeyFetchInstance { + const { name, outputSchema, inputSchema, sources = [], use = [], transform } = options - // 返回清理函数 - return () => { - unsubscribes.forEach(unsub => unsub()) - } - }, + const sourceKeys = sources.map((s, i) => s.key ?? s.source.name ?? `source_${i}`) + const subscribers = new Map>>() + const subscriptionCleanups = new Map void)[]>() + + const buildCacheKey = (input: TInput): string => `${name}::${JSON.stringify(input)}` + + // 执行插件链获取数据 + async function executePlugins(input: TInput): Promise { + // 创建 Context + const ctx: Context = { + input, + req: new Request('about:blank'), + superjson, + self: instance, + state: new Map(), + name, + } + + // 构建洋葱模型中间件链 + const baseFetch = async (): Promise => { + if (ctx.req.url === 'about:blank') { + // 没有 HTTP 插件,返回空响应(用于纯 sources 模式) + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }) + } + return fetch(ctx.req) } - // 确定最终的 inputSchema - let finalParamsSchema: unknown | undefined - if (inputSchema) { - // 使用自定义的 inputSchema - finalParamsSchema = inputSchema - } else { - // 自动创建组合的 paramsSchema:{ sourceKey1: schema1, sourceKey2: schema2 } - const combinedParamsShape: Record = {} - for (const key of sourceKeys) { - const sourceParamsSchema = sources[key].inputSchema - if (sourceParamsSchema) { - combinedParamsShape[key] = sourceParamsSchema + let next = baseFetch + for (let i = use.length - 1; i >= 0; i--) { + const plugin = use[i] + if (plugin.onFetch) { + const currentNext = next + const pluginFn = plugin.onFetch + next = async () => pluginFn(ctx, currentNext) + } + } + + const response = await next() + + if (!response.ok && ctx.req.url !== 'about:blank') { + const errorText = await response.text().catch(() => '') + throw new Error(`[${name}] HTTP ${response.status}: ${response.statusText}${errorText ? `\n${errorText.slice(0, 200)}` : ''}`) + } + + // 解析响应 + const text = await response.text() + const isSuperjson = response.headers.get('X-Superjson') === 'true' + const json = text ? (isSuperjson ? superjson.parse(text) : JSON.parse(text)) : {} + + // 应用 transform + const result = transform ? await transform(json, input) : json as TOutput + + return outputSchema.parse(result) as TOutput + } + + const instance: KeyFetchInstance = { + name, + inputSchema, + outputSchema, + + async fetch(input: TInput): Promise { + if (inputSchema) inputSchema.parse(input) + + // 如果有 sources,先获取它们的数据(但不使用,只作为触发条件) + if (sources.length > 0) { + await Promise.all(sources.map(s => s.source.fetch(s.params(input)))) + } + + return executePlugins(input) + }, + + subscribe(input: TInput, callback: SubscribeCallback): () => void { + const cacheKey = buildCacheKey(input) + + let subs = subscribers.get(cacheKey) + if (!subs) { + subs = new Set() + subscribers.set(cacheKey, subs) + } + subs.add(callback) + + if (subs.size === 1) { + const cleanups: (() => void)[] = [] + + const refetch = async () => { + try { + const data = await executePlugins(input) + const currentSubs = subscribers.get(cacheKey) + if (currentSubs) { + currentSubs.forEach(cb => cb(data, 'update')) } + } catch (error) { + console.error(`[combine] Error fetching ${name}:`, error) + } } - finalParamsSchema = Object.keys(combinedParamsShape).length > 0 - ? z.object(combinedParamsShape) - : undefined - } - // 使用 keyFetch.create 创建实例 - return keyFetch.create({ - name, - outputSchema: outputSchema, - inputSchema: finalParamsSchema as z.ZodType, - url, - method: 'GET', - // 用户插件在前,combinePlugin 在后,这样 transform 可以处理 combined 结果 - use: [...use, combinePlugin], - }) as KeyFetchInstance + // 订阅所有 sources,任一更新时重新获取 + sources.forEach((sourceConfig) => { + const unsub = sourceConfig.source.subscribe(sourceConfig.params(input), () => { + refetch() + }) + cleanups.push(unsub) + }) + + // 执行插件的 onSubscribe + const ctx: Context = { + input, + req: new Request('about:blank'), + superjson, + self: instance, + state: new Map(), + name, + } + + for (const plugin of use) { + if (plugin.onSubscribe) { + const cleanup = plugin.onSubscribe(ctx, (data) => { + const currentSubs = subscribers.get(cacheKey) + if (currentSubs) { + currentSubs.forEach(cb => cb(data, 'update')) + } + }) + if (cleanup) cleanups.push(cleanup) + } + } + + subscriptionCleanups.set(cacheKey, cleanups) + } + + // 立即获取一次 + executePlugins(input) + .then(data => callback(data, 'initial')) + .catch(error => console.error(`[combine] Error fetching ${name}:`, error)) + + return () => { + subs?.delete(callback) + if (subs?.size === 0) { + subscribers.delete(cacheKey) + subscriptionCleanups.get(cacheKey)?.forEach(fn => fn()) + subscriptionCleanups.delete(cacheKey) + } + } + }, + + useState(input: TInput, options?: UseStateOptions): UseStateResult { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any + const React = require('react') as any + const { useState, useEffect, useCallback, useRef, useMemo } = React + + const [data, setData] = useState(undefined as TOutput | undefined) + const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) + const [error, setError] = useState(undefined as Error | undefined) + + const inputKey = useMemo(() => JSON.stringify(input ?? {}), [input]) + const inputRef = useRef(input) + inputRef.current = input + + const enabled = options?.enabled !== false + + const refetch = useCallback(async () => { + if (!enabled) return + setIsFetching(true) + setError(undefined) + try { + const result = await instance.fetch(inputRef.current) + setData(result) + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setIsFetching(false) + setIsLoading(false) + } + }, [enabled]) + + useEffect(() => { + if (!enabled) { + setData(undefined) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + return + } + + setIsLoading(true) + setIsFetching(true) + setError(undefined) + + let isCancelled = false + const unsubscribe = instance.subscribe(inputRef.current, (newData) => { + if (isCancelled) return + setData(newData) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + }) + + return () => { + isCancelled = true + unsubscribe() + } + }, [enabled, inputKey]) + + return { data, isLoading, isFetching, error, refetch } + }, + } + + return instance } diff --git a/packages/key-fetch/src/core.ts b/packages/key-fetch/src/core.ts index d1fd970fb..b0637593b 100644 --- a/packages/key-fetch/src/core.ts +++ b/packages/key-fetch/src/core.ts @@ -1,86 +1,75 @@ /** - * Key-Fetch Core + * Key-Fetch v2 Core * - * Schema-first 工厂模式实现 + * 极简主义核心实现 + * - 不包含 HTTP/轮询/缓存逻辑,全部插件化 + * - Schema First:inputSchema + outputSchema 是一等公民 + * - Lifecycle Driven:onInit → onSubscribe → onFetch */ import type { - ZodUnknowSchema, + Context, + Plugin, KeyFetchDefineOptions, KeyFetchInstance, - FetchParams, SubscribeCallback, - FetchPlugin, - MiddlewareContext, - SubscribeContext, - KeyFetchOutput, - KeyFetchInput, + UseStateOptions, + UseStateResult, } from './types' -import { globalCache, globalRegistry } from './registry' -import type z from 'zod' import { SuperJSON } from 'superjson' + export const superjson = new SuperJSON({ dedupe: true }) -/** 构建 URL,替换 :param 占位符 */ -function buildUrl(template: string, params: FetchParams = {}): string { - let url = template - if (typeof params === 'object' && params !== null) { - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - url = url.replace(`:${key}`, encodeURIComponent(String(value))) - } - } - } - return url -} +// ==================== 内部工具 ==================== /** 构建缓存 key */ -function buildCacheKey(name: string, params: FetchParams = {}): string { - // eslint-disable-next-line unicorn/no-array-sort -- toSorted not available in ES2021 target - const sortedParams = typeof params === 'object' && params !== null ? [...Object.entries(params)] - .filter(([, v]) => v !== undefined) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([k, v]: [string, string | number | boolean | undefined]) => `${k}=${encodeURIComponent(String(v))}`) - .join('&') : `#${JSON.stringify(params)}` - return sortedParams ? `${name}?${sortedParams}` : name +function buildCacheKey(name: string, input: unknown): string { + if (input === undefined || input === null) { + return name + } + if (typeof input === 'object') { + const sorted = Object.entries(input as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`) + .join('&') + return sorted ? `${name}?${sorted}` : name + } + return `${name}#${JSON.stringify(input)}` } -/** KeyFetch 实例实现 */ -class KeyFetchInstanceImpl< - TOUT, - TIN = unknown -> implements KeyFetchInstance { +// ==================== KeyFetch 实例实现 ==================== + +class KeyFetchInstanceImpl implements KeyFetchInstance { readonly name: string - readonly outputSchema: z.ZodType - readonly inputSchema: z.ZodType | undefined - readonly _output!: TOUT - readonly _params!: TIN - - private urlTemplate: string - private method: 'GET' | 'POST' - private plugins: FetchPlugin[] - private subscribers = new Map>>() + readonly inputSchema: import('zod').ZodType | undefined + readonly outputSchema: import('zod').ZodType + + private plugins: Plugin[] + private initCleanups: (() => void)[] = [] + private subscribers = new Map>>() private subscriptionCleanups = new Map void)[]>() - private inFlight = new Map>() - - // Auto dedupe: time-based deduplication - private lastFetchTime = new Map() - private lastResult = new Map() + private inFlight = new Map>() - constructor(options: KeyFetchDefineOptions) { + constructor(options: KeyFetchDefineOptions) { this.name = options.name - this.outputSchema = options.outputSchema this.inputSchema = options.inputSchema - this.urlTemplate = options.url ?? '' - this.method = options.method ?? 'GET' + this.outputSchema = options.outputSchema this.plugins = options.use ?? [] - // 注册到全局 - globalRegistry.register(this as unknown as KeyFetchInstance) + // 阶段 1: 执行所有插件的 onInit + for (const plugin of this.plugins) { + if (plugin.onInit) { + const cleanup = plugin.onInit(this) + if (cleanup) { + this.initCleanups.push(cleanup) + } + } + } } - async fetch(params: TIN, options?: { skipCache?: boolean }): Promise { - const cacheKey = buildCacheKey(this.name, params) + async fetch(input: TInput): Promise { + const cacheKey = buildCacheKey(this.name, input) // 检查进行中的请求(基础去重) const pending = this.inFlight.get(cacheKey) @@ -88,125 +77,46 @@ class KeyFetchInstanceImpl< return pending } - // Auto dedupe: 基于插件计算去重间隔 - const dedupeInterval = this.calculateDedupeInterval() - if (dedupeInterval > 0) { - const lastTime = this.lastFetchTime.get(cacheKey) - const lastData = this.lastResult.get(cacheKey) - if (lastTime && lastData !== undefined) { - const elapsed = Date.now() - lastTime - if (elapsed < dedupeInterval) { - return lastData - } - } - } - - // 发起请求(通过中间件链) - const task = this.doFetch(params, options) + const task = this.doFetch(input) this.inFlight.set(cacheKey, task) try { - const result = await task - // Auto dedupe: 记录成功请求的时间和结果 - if (dedupeInterval > 0) { - this.lastFetchTime.set(cacheKey, Date.now()) - this.lastResult.set(cacheKey, result) - } - return result + return await task } finally { this.inFlight.delete(cacheKey) } } - /** 基于插件计算自动去重间隔 */ - private calculateDedupeInterval(): number { - let intervalMs: number | undefined - - for (const plugin of this.plugins) { - // 检查 interval 插件 - if ('_intervalMs' in plugin) { - const ms = plugin._intervalMs as number | (() => number) - const value = typeof ms === 'function' ? ms() : ms - intervalMs = intervalMs !== undefined ? Math.min(intervalMs, value) : value - } - - // 检查 deps 插件 - 取依赖源的最小间隔 - if ('_sources' in plugin) { - const sources = plugin._sources as KeyFetchInstance[] - for (const source of sources) { - // 递归获取依赖的间隔(通过检查其插件) - const sourceImpl = source as unknown as KeyFetchInstanceImpl - if (sourceImpl.calculateDedupeInterval) { - const depInterval = sourceImpl.calculateDedupeInterval() - if (depInterval > 0) { - intervalMs = intervalMs !== undefined ? Math.min(intervalMs, depInterval) : depInterval - } - } - } - } + private async doFetch(input: TInput): Promise { + // 验证输入 + if (this.inputSchema) { + this.inputSchema.parse(input) } - // 返回间隔的一半作为去重窗口(确保在下次轮询前不重复请求) - return intervalMs !== undefined ? Math.floor(intervalMs / 2) : 0 - } - - private async doFetch(params: TIN, options?: { skipCache?: boolean }): Promise { - // 创建基础 Request(只有 URL 模板,不做任何修改) - const baseRequest = new Request(this.urlTemplate, { - method: this.method, + // 创建基础 Request(空 URL,由插件填充) + const baseRequest = new Request('about:blank', { + method: 'GET', headers: { 'Content-Type': 'application/json' }, }) - // 中间件上下文(包含 superjson 工具) - const middlewareContext: MiddlewareContext = { - name: this.name, - params, - skipCache: options?.skipCache ?? false, - // 直接暴露 superjson 库 + // 创建 Context + const ctx: Context = { + input, + req: baseRequest, superjson, - // 创建带 X-Superjson 头的 Response - createResponse: (data: T, init?: ResponseInit) => { - return data instanceof Response ? data : new Response(superjson.stringify(data), { - ...init, - headers: { - 'Content-Type': 'application/json', - 'X-Superjson': 'true', - ...init?.headers, - }, - }) - }, - // 创建带 X-Superjson 头的 Request - createRequest: (data: T, url?: string, init?: RequestInit) => { - return data instanceof Request ? data : new Request(url ?? baseRequest.url, { - ...init, - method: init?.method ?? 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Superjson': 'true', - ...init?.headers, - }, - body: superjson.stringify(data), - }) - }, - // 根据 X-Superjson 头自动选择解析方式 - body: async (input: Request | Response,): Promise => { - const text = await input.text() - // 防护性检查:某些 mock 的 Response 可能没有 headers - const isSuperjson = input.headers?.get?.('X-Superjson') === 'true' - return middlewareContext.parseBody(text, isSuperjson) - }, - parseBody: (input: string, isSuperjson?: boolean): T => { - if (isSuperjson) { - return superjson.parse(input) as T - } - return JSON.parse(input) as T - }, + self: this, + state: new Map(), + name: this.name, } - // 构建中间件链 - // 最内层是实际的 fetch - const baseFetch = async (request: Request): Promise => { - return fetch(request) + // 构建洋葱模型中间件链 + // 最内层是默认的 fetch(如果没有插件处理) + const baseFetch = async (): Promise => { + // 如果 URL 仍是 about:blank,说明没有 http 插件 + if (ctx.req.url === 'about:blank') { + throw new Error(`[${this.name}] No HTTP plugin configured. Use useHttp() plugin.`) + } + return fetch(ctx.req) } // 从后往前包装中间件 @@ -216,91 +126,33 @@ class KeyFetchInstanceImpl< if (plugin.onFetch) { const currentNext = next const pluginFn = plugin.onFetch - next = async (request: Request) => { - return pluginFn(request, currentNext, middlewareContext) - } + next = async () => pluginFn(ctx, currentNext) } } // 执行中间件链 - const response = await next(baseRequest) + const response = await next() if (!response.ok) { const errorText = await response.text().catch(() => '') - const error = new Error( + throw new Error( `[${this.name}] HTTP ${response.status}: ${response.statusText}` + - (errorText ? `\n响应内容: ${errorText.slice(0, 200)}` : '') + (errorText ? `\n${errorText.slice(0, 200)}` : '') ) - - // 让插件有机会处理错误(如 throttleError 节流) - for (const plugin of this.plugins) { - if (plugin.onError?.(error, response, middlewareContext)) { - ;(error as Error & { __errorHandled?: boolean }).__errorHandled = true - break - } - } - - throw error } - // 使用统一的 body 函数解析中间件链返回的响应 - // 这样 unwrap 等插件修改的响应内容能被正确处理 - const rawJson = await response.text() + // 解析响应 + const text = await response.text() const isSuperjson = response.headers.get('X-Superjson') === 'true' - const json = await middlewareContext.parseBody(rawJson, isSuperjson) - - // Schema 验证(核心!) - try { - const result = this.outputSchema.parse(json) as TOUT - - // 通知 registry 更新 - globalRegistry.emitUpdate(this.name) - - return result - } catch (err) { - // 包装 ZodError 为更可读的错误 - let schemaError: Error - if (err && typeof err === 'object' && 'issues' in err) { - const zodErr = err as { issues: Array<{ path: (string | number)[]; message: string }> } - const errorMessage = `[${this.name}] Schema 验证失败:\n${zodErr.issues - .slice(0, 3) - .map(i => ` - ${i.path.join('.')}: ${i.message}`) - .join('\n')}` + - (zodErr.issues.length > 3 ? `\n ... 还有 ${zodErr.issues.length - 3} 个错误` : '') + - `\n\nResponseJson: ${rawJson.slice(0, 300)}...` + - `\nResponseHeaders: ${[...response.headers.entries()].map(item => item.join("=")).join("; ")}` - schemaError = new Error(errorMessage) - } else { - schemaError = err instanceof Error ? err : new Error(String(err)) - } - - // 让插件有机会处理错误日志(如 throttleError 节流) - let errorHandled = false - for (const plugin of this.plugins) { - if (plugin.onError?.(schemaError, response, middlewareContext)) { - errorHandled = true - ;(schemaError as Error & { __errorHandled?: boolean }).__errorHandled = true - break - } - } - - // 未被插件处理时,输出完整日志便于调试 - if (!errorHandled) { - console.error(this.name, err) - console.error(json, this.outputSchema) - } + const json = isSuperjson ? superjson.parse(text) : JSON.parse(text) - // 始终抛出错误(不吞掉) - throw schemaError - } + // Schema 验证 + const result = this.outputSchema.parse(json) as TOutput + return result } - subscribe( - params: TIN, - callback: SubscribeCallback - ): () => void { - const cacheKey = buildCacheKey(this.name, params as FetchParams) - const url = buildUrl(this.urlTemplate, params as FetchParams) + subscribe(input: TInput, callback: SubscribeCallback): () => void { + const cacheKey = buildCacheKey(this.name, input) // 添加订阅者 let subs = this.subscribers.get(cacheKey) @@ -314,26 +166,25 @@ class KeyFetchInstanceImpl< if (subs.size === 1) { const cleanups: (() => void)[] = [] - const subscribeCtx: SubscribeContext = { + // 创建订阅 Context + const ctx: Context = { + input, + req: new Request('about:blank'), + superjson, + self: this, + state: new Map(), name: this.name, - url, - params: params, - refetch: async () => { - try { - const data = await this.fetch(params, { skipCache: true }) - this.notify(cacheKey, data) - } catch (error) { - // 如果插件已处理错误(如 throttleError),静默 - if (!(error as Error & { __errorHandled?: boolean }).__errorHandled) { - console.error(`[key-fetch] Error in refetch for ${this.name}:`, error) - } - } - }, } + // emit 函数:通知所有订阅者 + const emit = (data: TOutput) => { + this.notify(cacheKey, data, 'update') + } + + // 阶段 2: 执行所有插件的 onSubscribe for (const plugin of this.plugins) { if (plugin.onSubscribe) { - const cleanup = plugin.onSubscribe(subscribeCtx) + const cleanup = plugin.onSubscribe(ctx, emit) if (cleanup) { cleanups.push(cleanup) } @@ -341,32 +192,15 @@ class KeyFetchInstanceImpl< } this.subscriptionCleanups.set(cacheKey, cleanups) - - // 监听 registry 更新 - const unsubRegistry = globalRegistry.onUpdate(this.name, async () => { - try { - const data = await this.fetch(params, { skipCache: true }) - this.notify(cacheKey, data) - } catch (error) { - // 如果插件已处理错误(如 throttleError),跳过默认日志 - if (!(error as Error & { __errorHandled?: boolean }).__errorHandled) { - console.error(`[key-fetch] Error refetching ${this.name}:`, error) - } - } - }) - cleanups.push(unsubRegistry) } // 立即获取一次 - this.fetch(params) + this.fetch(input) .then(data => { callback(data, 'initial') }) .catch(error => { - // 如果插件已处理错误(如 throttleError),跳过默认日志 - if (!(error as Error & { __errorHandled?: boolean }).__errorHandled) { - console.error(`[key-fetch] Error fetching ${this.name}:`, error) - } + console.error(`[key-fetch] Error fetching ${this.name}:`, error) }) // 返回取消订阅函数 @@ -385,136 +219,94 @@ class KeyFetchInstanceImpl< } } - invalidate(): void { - // 清理所有相关缓存 - for (const key of globalCache.keys()) { - if (key.startsWith(this.name)) { - globalCache.delete(key) - } - } - } - - getCached(params?: TIN): TOUT | undefined { - const cacheKey = buildCacheKey(this.name, params as FetchParams) - const entry = globalCache.get(cacheKey) - return entry?.data - } - /** 通知特定 key 的订阅者 */ - private notify(cacheKey: string, data: TOUT): void { + private notify(cacheKey: string, data: TOutput, event: 'initial' | 'update'): void { const subs = this.subscribers.get(cacheKey) if (subs) { - subs.forEach(cb => cb(data, 'update')) + subs.forEach(cb => cb(data, event)) } } /** - * React Hook - 由 react.ts 模块注入实现 - * 如果直接调用而没有导入 react 模块,会抛出错误 + * React Hook - useState + * 内部判断 React 环境 */ - useState( - _params?: TIN, - _options?: { enabled?: boolean } - ): { data: TOUT | undefined; isLoading: boolean; isFetching: boolean; error: Error | undefined; refetch: () => Promise } { - throw new Error( - `[key-fetch] useState() requires React. Import from '@biochain/key-fetch' to enable React support.` - ) - } + useState(input: TInput, options?: UseStateOptions): UseStateResult { + // 尝试动态导入 React hooks + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any + const React = require('react') as any + + const { useState, useEffect, useCallback, useRef, useMemo } = React + + const [data, setData] = useState(undefined as TOutput | undefined) + const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) + const [error, setError] = useState(undefined as Error | undefined) + + const inputKey = useMemo(() => JSON.stringify(input ?? {}), [input]) + const inputRef = useRef(input) + inputRef.current = input + + const enabled = options?.enabled !== false + + const refetch = useCallback(async () => { + if (!enabled) return + + setIsFetching(true) + setError(undefined) + + try { + const result = await this.fetch(inputRef.current) + setData(result) + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setIsFetching(false) + setIsLoading(false) + } + }, [enabled]) + + useEffect(() => { + if (!enabled) { + setData(undefined) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + return + } - use(...plugins: FetchPlugin[]): KeyFetchInstance { - this.plugins.push(...plugins) - return this - } -} + setIsLoading(true) + setIsFetching(true) + setError(undefined) -// ==================== React 注入机制 ==================== + let isCancelled = false -/** 存储 useState 实现(由 react.ts 注入) */ -let useStateImpl: (( - kf: KF, - params: KeyFetchInput, - options?: { enabled?: boolean } -) => { data: KeyFetchOutput | undefined; isLoading: boolean; isFetching: boolean; error: Error | undefined; refetch: () => Promise }) | null = null + // 订阅更新 + const unsubscribe = this.subscribe(inputRef.current, (newData, _event) => { + if (isCancelled) return + setData(newData) + setIsLoading(false) + setIsFetching(false) + setError(undefined) + }) -/** - * 注入 React useState 实现 - * @internal - */ -export function injectUseState(impl: typeof useStateImpl): void { - useStateImpl = impl - // 使用 unknown 绕过类型检查,因为注入是内部实现细节 - ; Object.assign(KeyFetchInstanceImpl.prototype, { - useState( - this: KeyFetchInstance, - params?: FetchParams, - options?: { enabled?: boolean } - ) { - if (!useStateImpl) { - throw new Error('[key-fetch] useState implementation not injected') - } - return useStateImpl(this, params, options) + return () => { + isCancelled = true + unsubscribe() } - }) -} + }, [enabled, inputKey]) -/** - * 获取 useState 实现(供 derive.ts 使用) - * @internal - */ -export function getUseStateImpl() { - return useStateImpl + return { data, isLoading, isFetching, error, refetch } + } } +// ==================== 工厂函数 ==================== + /** * 创建 KeyFetch 实例 - * - * @example - * ```ts - * import { z } from 'zod' - * import { keyFetch, interval, deps } from '@biochain/key-fetch' - * - * // 定义 Schema - * const LastBlockSchema = z.object({ - * success: z.boolean(), - * result: z.object({ - * height: z.number(), - * timestamp: z.number(), - * }), - * }) - * - * // 创建 KeyFetch 实例 - * const lastBlockFetch = keyFetch.create({ - * name: 'bfmeta.lastblock', - * schema: LastBlockSchema, - * url: 'https://api.bfmeta.info/wallet/:chainId/lastblock', - * use: [interval(15_000)], - * }) - * - * // 使用 - * const data = await lastBlockFetch.fetch({ chainId: 'bfmeta' }) - * // data 类型自动推断,且已通过 Schema 验证 - * ``` */ -export function create( - options: KeyFetchDefineOptions -): KeyFetchInstance { +export function create( + options: KeyFetchDefineOptions +): KeyFetchInstance { return new KeyFetchInstanceImpl(options) } - -// Builder removed in favor of instance.use() pattern - -/** 获取已注册的实例 */ -export function get(name: string): KeyFetchInstance | undefined { - return globalRegistry.get(name) as unknown as KeyFetchInstance | undefined -} - -/** 按名称失效 */ -export function invalidate(name: string): void { - globalRegistry.invalidate(name) -} - -/** 清理所有(用于测试) */ -export function clear(): void { - globalRegistry.clear() - globalCache.clear() -} diff --git a/packages/key-fetch/src/derive.ts b/packages/key-fetch/src/derive.ts deleted file mode 100644 index f1859ff70..000000000 --- a/packages/key-fetch/src/derive.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Derive - 从现有 KeyFetchInstance 派生新实例 - * - * 派生实例共享同一个数据源,但可以应用不同的转换和验证 - * - * @example - * ```ts - * import { keyFetch, derive, transform } from '@biochain/key-fetch' - * - * // 原始 API fetcher - * const rawApi = keyFetch.create({ - * name: 'api.raw', - * schema: RawSchema, - * url: '/api/data', - * }) - * - * // 派生:应用转换 - * const processed = derive({ - * name: 'api.processed', - * source: rawApi, - * schema: ProcessedSchema, - * use: [ - * transform({ - * transform: (raw) => processData(raw), - * }), - * ], - * }) - * ``` - */ - -import type { - KeyFetchInstance, - FetchPlugin, -} from './types' -import { keyFetch } from './index' -import type { z } from 'zod' - -/** Derive 选项 */ -export interface KeyFetchDeriveOptions< - TSourceOut, - TOUT, - TIN = unknown -> { - /** 派生实例名称 */ - name: string - /** 源 KeyFetchInstance */ - source: KeyFetchInstance - /** 输出 Schema */ - outputSchema: z.ZodType - /** 插件列表(通常包含 transform) */ - use?: FetchPlugin[] -} - -/** - * 从现有 KeyFetchInstance 派生新实例 - * - * - 共享同一个数据源(source.fetch) - * - 通过插件链应用转换 - * - 自动继承订阅能力 - */ -export function derive< - TSourceOut, - TOUT, - TIN = unknown ->( - options: KeyFetchDeriveOptions -): KeyFetchInstance { - const { name, source, outputSchema: schema, use = [] } = options - - // 创建一个虚拟的 URL(derive 不需要真实的 HTTP 请求) - const url = `derive://${name}` - - // 创建一个插件来拦截请求并调用 source - const derivePlugin: FetchPlugin = { - name: 'derive', - onFetch: async (_request, _next, context) => { - // 调用 source 获取数据 - const sourceData = await source.fetch(context.params) - - // 返回 Response,让后续插件(如 transform)处理 - return context.createResponse(sourceData) - }, - onSubscribe: (context) => { - // 订阅 source,source 更新时触发 refetch - return source.subscribe(context.params, () => { - context.refetch().catch((error) => { - // 如果插件已处理错误(如 throttleError),跳过日志 - if (!(error as Error & { __errorHandled?: boolean }).__errorHandled) { - console.error(`[key-fetch] Error in derive refetch for ${name}:`, error) - } - }) - }) - }, - } - - // 使用 keyFetch.create 创建实例 - // 插件顺序:用户插件在前,derivePlugin 在后 - return Object.assign(keyFetch.create({ - name, - outputSchema: schema, - inputSchema: source.inputSchema, - url, - method: 'GET', - use: [...use, derivePlugin], - }), { - use(...plugins: FetchPlugin[]) { - return derive({ - ...options, - use: [...use, ...plugins], - }) - } - }) -} diff --git a/packages/key-fetch/src/fallback.ts b/packages/key-fetch/src/fallback.ts index 7cc9f189b..ee93494dc 100644 --- a/packages/key-fetch/src/fallback.ts +++ b/packages/key-fetch/src/fallback.ts @@ -1,238 +1,209 @@ /** - * Merge - 合并多个 KeyFetchInstance 实现 auto-fallback + * Fallback - 多源自动回退 * - * @example - * ```ts - * import { keyFetch, NoSupportError } from '@biochain/key-fetch' - * - * // 合并多个 fetcher,失败时自动 fallback - * const balanceFetcher = keyFetch.merge({ - * name: 'chain.balance', - * sources: [provider1.balance, provider2.balance].filter(Boolean), - * // 空数组时 - * onEmpty: () => { throw new NoSupportError('nativeBalance') }, - * // 全部失败时 - * onAllFailed: (errors) => { throw new AggregateError(errors, 'All providers failed') }, - * }) - * - * // 使用 - * const { data, error } = balanceFetcher.useState({ address }) - * if (error instanceof NoSupportError) { - * // 不支持 - * } + * 当第一个源失败时自动尝试下一个 */ +import type { z } from 'zod' import type { - KeyFetchInstance, - SubscribeCallback, - UseKeyFetchResult, - UseKeyFetchOptions, + KeyFetchInstance, + SubscribeCallback, + UseStateOptions, + UseStateResult, } from './types' -import { getUseStateImpl } from './core' -import z from 'zod' /** 自定义错误:不支持的能力 */ export class NoSupportError extends Error { - readonly capability: string + readonly capability: string - constructor(capability: string) { - super(`No provider supports: ${capability}`) - this.name = 'NoSupportError' - this.capability = capability - } + constructor(capability: string) { + super(`No provider supports: ${capability}`) + this.name = 'NoSupportError' + this.capability = capability + } } /** Fallback 选项 */ -export interface FallbackOptions { - /** 合并后的名称 */ - name: string - /** 源 fetcher 数组(可以是空数组) */ - sources: KeyFetchInstance[] - /** 当 sources 为空时调用,默认抛出 NoSupportError */ - onEmpty?: () => never - /** 当所有 sources 都失败时调用,默认抛出 AggregateError */ - onAllFailed?: (errors: Error[]) => never +export interface FallbackOptions { + /** 合并后的名称 */ + name: string + /** 源 fetcher 数组(可以是空数组) */ + sources: KeyFetchInstance[] + /** 当 sources 为空时调用,默认抛出 NoSupportError */ + onEmpty?: () => never + /** 当所有 sources 都失败时调用,默认抛出 AggregateError */ + onAllFailed?: (errors: Error[]) => never } /** - * 提供 KeyFetchInstance,一个出错自动使用下一个来回退 + * fallback - 多源自动回退 * - * - 如果 sources 为空,调用 onEmpty(默认抛出 NoSupportError) - * - 如果某个 source 失败,自动尝试下一个 - * - 如果全部失败,调用 onAllFailed(默认抛出 AggregateError) + * @example + * ```ts + * const balanceFetcher = fallback({ + * name: 'chain.balance', + * sources: [provider1.balance, provider2.balance], + * onEmpty: () => { throw new NoSupportError('nativeBalance') }, + * }) + * ``` */ -export function fallback( - options: FallbackOptions -): KeyFetchInstance { - const { name, sources, onEmpty, onAllFailed } = options - - // 空数组错误处理 - const handleEmpty = onEmpty ?? (() => { - throw new NoSupportError(name) - }) - - // 全部失败错误处理 - const handleAllFailed = onAllFailed ?? ((errors: Error[]) => { - const aggError = new AggregateError(errors, `All ${errors.length} provider(s) failed for: ${name}`) - // 如果任一子错误已被处理(如 throttleError),传递标记 - if (errors.some(e => (e as Error & { __errorHandled?: boolean }).__errorHandled)) { - ;(aggError as Error & { __errorHandled?: boolean }).__errorHandled = true +export function fallback( + options: FallbackOptions +): KeyFetchInstance { + const { name, sources, onEmpty, onAllFailed } = options + + const handleEmpty = onEmpty ?? (() => { + throw new NoSupportError(name) + }) + + const handleAllFailed = onAllFailed ?? ((errors: Error[]) => { + throw new AggregateError(errors, `All ${errors.length} provider(s) failed for: ${name}`) + }) + + // 空数组 + if (sources.length === 0) { + return createEmptyFetcher(name, handleEmpty) + } + + // 单个源 + if (sources.length === 1) { + return sources[0] + } + + // 多个源 + return createFallbackFetcher(name, sources, handleAllFailed) +} + +function createEmptyFetcher( + name: string, + handleEmpty: () => never +): KeyFetchInstance { + return { + name, + inputSchema: undefined, + outputSchema: { parse: () => undefined } as unknown as import('zod').ZodType, + + async fetch(): Promise { + handleEmpty() + }, + + subscribe(): () => void { + return () => {} + }, + + useState(): UseStateResult { + return { + data: undefined, + isLoading: false, + isFetching: false, + error: new NoSupportError(name), + refetch: async () => {}, + } + }, + } +} + +function createFallbackFetcher( + name: string, + sources: KeyFetchInstance[], + handleAllFailed: (errors: Error[]) => never +): KeyFetchInstance { + const first = sources[0] + + // Cooldown: 记录失败源 + const COOLDOWN_MS = 60_000 + const failedSources = new Map, number>() + + const instance: KeyFetchInstance = { + name, + inputSchema: first.inputSchema, + outputSchema: first.outputSchema, + + async fetch(input: TInput): Promise { + const errors: Error[] = [] + const now = Date.now() + + for (const source of sources) { + const cooldownEnd = failedSources.get(source) + if (cooldownEnd && now < cooldownEnd) { + continue + } + + try { + const result = await source.fetch(input) + failedSources.delete(source) + return result + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))) + failedSources.set(source, now + COOLDOWN_MS) } - throw aggError - }) + } + + handleAllFailed(errors) + }, + + subscribe(input: TInput, callback: SubscribeCallback): () => void { + return first.subscribe(input, callback) + }, + + useState(input: TInput, options?: UseStateOptions): UseStateResult { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any + const React = require('react') as any + const { useState, useEffect, useCallback, useRef, useMemo } = React + + const [data, setData] = useState(undefined as TOutput | undefined) + const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) + const [error, setError] = useState(undefined as Error | undefined) + + const inputKey = useMemo(() => JSON.stringify(input ?? {}), [input]) + const inputRef = useRef(input) + inputRef.current = input + + const enabled = options?.enabled !== false + + const refetch = useCallback(async () => { + if (!enabled) return + setIsFetching(true) + setError(undefined) + try { + const result = await instance.fetch(inputRef.current) + setData(result) + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setIsFetching(false) + setIsLoading(false) + } + }, [enabled]) - // 如果没有 source,创建一个总是失败的实例 - if (sources.length === 0) { - return createEmptyFetcher(name, handleEmpty) - } + useEffect(() => { + if (!enabled) { + setData(undefined) + setIsLoading(false) + return + } - // 只有一个 source,直接返回 - if (sources.length === 1) { - return sources[0] - } + setIsLoading(true) + setIsFetching(true) - // 多个 sources,创建 fallback 实例 - return createFallbackFetcher(name, sources, handleAllFailed) -} + let isCancelled = false + const unsubscribe = instance.subscribe(inputRef.current, (newData) => { + if (isCancelled) return + setData(newData) + setIsLoading(false) + setIsFetching(false) + }) -/** 创建一个总是抛出 NoSupportError 的 fetcher */ -function createEmptyFetcher( - name: string, - handleEmpty: () => never -): KeyFetchInstance { - return { - name, - outputSchema: z.any(), - inputSchema: undefined, - _output: undefined as TOUT, - _params: undefined as unknown as TPIN, - - async fetch(): Promise { - handleEmpty() - }, - - subscribe( - _params: TPIN, - _callback: SubscribeCallback - ): () => void { - // 不支持,直接返回空 unsubscribe - return () => { } - }, - - invalidate(): void { - // no-op - }, - - getCached(): TOUT | undefined { - return undefined - }, - - useState( - _params?: TPIN, - _options?: UseKeyFetchOptions - ): UseKeyFetchResult { - // 返回带 NoSupportError 的结果 - return { - data: undefined, - isLoading: false, - isFetching: false, - error: new NoSupportError(name), - refetch: async () => { }, - } - }, - use() { return this } - } -} + return () => { + isCancelled = true + unsubscribe() + } + }, [enabled, inputKey]) + + return { data, isLoading, isFetching, error, refetch } + }, + } -/** 创建带 fallback 逻辑的 fetcher */ -function createFallbackFetcher( - name: string, - sources: KeyFetchInstance[], - handleAllFailed: (errors: Error[]) => never -): KeyFetchInstance { - const first = sources[0] - - // Cooldown: 记录失败源及其冷却结束时间 - const COOLDOWN_MS = 60_000 // 60秒冷却 - const failedSources = new Map, number>() - - const merged: KeyFetchInstance = { - name, - outputSchema: first.outputSchema, - inputSchema: first.inputSchema, - _output: first._output, - _params: first._params, - - async fetch(params: TPIN, options?: { skipCache?: boolean }): Promise { - const errors: Error[] = [] - const now = Date.now() - - for (const source of sources) { - // 检查是否在冷却期 - const cooldownEnd = failedSources.get(source) - if (cooldownEnd && now < cooldownEnd) { - continue // 跳过冷却中的源 - } - - try { - const result = await source.fetch(params, options) - // 成功后清除冷却 - failedSources.delete(source) - return result - } catch (error) { - errors.push(error instanceof Error ? error : new Error(String(error))) - // 失败后进入冷却期 - failedSources.set(source, now + COOLDOWN_MS) - } - } - - handleAllFailed(errors) - }, - - subscribe( - params: TPIN, - callback: SubscribeCallback - ): () => void { - // 对于 subscribe,使用第一个可用的 source - // 如果第一个失败,不自动切换(订阅比较复杂) - return first.subscribe(params, callback) - }, - - invalidate(): void { - // 失效所有 sources - for (const source of sources) { - source.invalidate() - } - }, - - getCached(params?: TPIN): TOUT | undefined { - // 从第一个有缓存的 source 获取 - for (const source of sources) { - const cached = source.getCached(params) - if (cached !== undefined) { - return cached - } - } - return undefined - }, - - useState( - params: TPIN, - options?: UseKeyFetchOptions - ): UseKeyFetchResult { - // 使用注入的 useState 实现(与 derive 一致) - const impl = getUseStateImpl() - if (!impl) { - throw new Error( - `[key-fetch] useState() requires React. Import from '@biochain/key-fetch' to enable React support.` - ) - } - // 对于 merge 实例,直接调用注入的实现 - // 传入 merged 实例本身,这样 useKeyFetch 会正确使用 merged 的 subscribe - return impl(merged, params, options) - }, - use() { return this } - } - - return merged + return instance } diff --git a/packages/key-fetch/src/index.ts b/packages/key-fetch/src/index.ts index d7eba9b37..f5bbf8143 100644 --- a/packages/key-fetch/src/index.ts +++ b/packages/key-fetch/src/index.ts @@ -1,210 +1,57 @@ /** - * @biochain/key-fetch + * @biochain/key-fetch v2 * * Schema-first 插件化响应式 Fetch - * - * @example - * ```ts - * import { z } from 'zod' - * import { keyFetch, interval, deps } from '@biochain/key-fetch' - * - * // 定义 Schema - * const LastBlockSchema = z.object({ - * success: z.boolean(), - * result: z.object({ - * height: z.number(), - * timestamp: z.number(), - * }), - * }) - * - * // 创建 KeyFetch 实例(工厂模式) - * const lastBlockFetch = keyFetch.create({ - * name: 'bfmeta.lastblock', - * schema: LastBlockSchema, - * url: 'https://api.bfmeta.info/wallet/:chainId/lastblock', - * use: [interval(15_000)], - * }) - * - * // 请求(类型安全,已验证) - * const data = await lastBlockFetch.fetch({ chainId: 'bfmeta' }) - * - * // 订阅 - * const unsubscribe = lastBlockFetch.subscribe({ chainId: 'bfmeta' }, (data, event) => { - * console.log('区块更新:', data.result.height) - * }) - * - * // React 中使用 - * function BlockHeight() { - * const { data, isLoading } = lastBlockFetch.useState({ chainId: 'bfmeta' }) - * if (isLoading) return
Loading...
- * return
Height: {data?.result.height}
- * } - * ``` */ -import { create, get, invalidate, clear, superjson } from './core' -import { getInstancesByTag } from './plugins/tag' +import { create, superjson } from './core' +import { fallback } from './fallback' // ==================== 导出类型 ==================== export type { - // Schema types - ZodUnknowSchema, - // Cache types - CacheEntry, - CacheStore, - // Plugin types (middleware pattern) - FetchPlugin, - FetchMiddleware, - MiddlewareContext, - SubscribeContext, - CachePlugin, // deprecated alias - // Instance types - KeyFetchDefineOptions, - KeyFetchInstance, - FetchParams, - SubscribeCallback, - // Registry types - KeyFetchRegistry, - // React types - UseKeyFetchResult, - UseKeyFetchOptions, + Context, + Plugin, + KeyFetchDefineOptions, + KeyFetchInstance, + SubscribeCallback, + UseStateOptions, + UseStateResult, + InferInput, + InferOutput, } from './types' -// ==================== 导出插件 ==================== +// ==================== 核心插件 ==================== -export { interval } from './plugins/interval' -export { deps } from './plugins/deps' -export { ttl } from './plugins/ttl' -export { dedupe, DedupeThrottledError } from './plugins/dedupe' -export { tag } from './plugins/tag' -export { etag } from './plugins/etag' -export { throttleError, errorMatchers } from './plugins/throttle-error' -export { apiKey } from './plugins/api-key' -export { transform } from './plugins/transform' -export type { TransformOptions } from './plugins/transform' -export { cache, MemoryCacheStorage, IndexedDBCacheStorage } from './plugins/cache' -export type { CacheStorage, CachePluginOptions } from './plugins/cache' -export { searchParams, postBody, pathParams } from './plugins/params' -export { unwrap, walletApiUnwrap, etherscanApiUnwrap } from './plugins/unwrap' -export type { UnwrapOptions } from './plugins/unwrap' - -// ==================== 导出 Derive 工具 ==================== - -export { derive } from './derive' -export type { KeyFetchDeriveOptions } from './derive' +export { useHttp } from './plugins/http' +export type { UseHttpOptions } from './plugins/http' -// ==================== 导出 Merge 工具 ==================== +export { useInterval } from './plugins/interval' -export { fallback, NoSupportError } from './fallback' -export type { FallbackOptions as MergeOptions } from './fallback' - -// ==================== 导出错误类型 ==================== +export { useDedupe, DedupeThrottledError } from './plugins/dedupe' -export { ServiceLimitedError } from './errors' +// ==================== 工具插件 ==================== -// ==================== 导出 Registry ==================== - -export { globalRegistry } from './registry' +export { throttleError, errorMatchers } from './plugins/throttle-error' +export { apiKey } from './plugins/api-key' -// ==================== 导出 Combine 工具 ==================== +// ==================== Combine & Fallback ==================== export { combine } from './combine' -export type { CombineOptions } from './combine' +export type { CombineSourceConfig, CombineOptions } from './combine' -// ==================== React Hooks(内部注入)==================== -// 注意:不直接导出 useKeyFetch -// 用户应使用 fetcher.useState({ ... }) 方式调用 -// React hooks 在 ./react 模块加载时自动注入到 KeyFetchInstance.prototype - -import './react' // 副作用导入,注入 useState 实现 +export { fallback, NoSupportError } from './fallback' -// ==================== 统一的 body 解析函数 ==================== +// ==================== 错误类型 ==================== -/** - * 统一的响应 body 解析函数 - * 根据 X-Superjson 头自动选择解析方式 - */ -async function parseBody(input: Request | Response): Promise { - const text = await input.text() - const isSuperjson = input.headers.get('X-Superjson') === 'true' - if (isSuperjson) { - return superjson.parse(text) as T - } - return JSON.parse(text) as T -} +export { ServiceLimitedError } from './errors' // ==================== 主 API ==================== -import { fallback as mergeImpl } from './fallback' - -/** - * KeyFetch 命名空间 - */ export const keyFetch = { - /** - * 创建 KeyFetch 实例 - */ - create, - - - - /** - * 合并多个 KeyFetch 实例(auto-fallback) - */ - merge: mergeImpl, - - /** - * 获取已注册的实例 - */ - get, - - /** - * 按名称失效 - */ - invalidate, - - /** - * 按标签失效 - */ - invalidateByTag(tagName: string): void { - const names = getInstancesByTag(tagName) - for (const name of names) { - invalidate(name) - } - }, - - /** - * 清理所有(用于测试) - */ - clear, - - /** - * SuperJSON 实例(用于注册自定义类型序列化) - * - * @example - * ```ts - * import { keyFetch } from '@biochain/key-fetch' - * import { Amount } from './amount' - * - * keyFetch.superjson.registerClass(Amount, { - * identifier: 'Amount', - * ... - * }) - * ``` - */ - superjson, - - /** - * 统一的 body 解析函数(支持 superjson) - * - * @example - * ```ts - * const data = await keyFetch.body(response) - * ``` - */ - body: parseBody, + create, + merge: fallback, + superjson, } -// 默认导出 export default keyFetch diff --git a/packages/key-fetch/src/plugins/api-key.ts b/packages/key-fetch/src/plugins/api-key.ts index 974b722c7..16ac85c49 100644 --- a/packages/key-fetch/src/plugins/api-key.ts +++ b/packages/key-fetch/src/plugins/api-key.ts @@ -1,51 +1,36 @@ /** * API Key Plugin * - * 通用 API Key 插件,支持自定义请求头和前缀 + * 通用 API Key 插件 */ -import type { FetchPlugin } from '../types' +import type { Plugin } from '../types' export interface ApiKeyOptions { - /** 请求头名称,如 'TRON-PRO-API-KEY', 'Authorization', 'X-API-Key' */ + /** 请求头名称 */ header: string - /** API Key 值,如果为空则不添加头 */ + /** API Key 值 */ key: string | undefined - /** 可选前缀,如 'Bearer ' */ + /** 可选前缀 */ prefix?: string } /** * API Key 插件 - * - * @example - * ```ts - * import { apiKey } from '@biochain/key-fetch' - * - * // TronGrid - * keyFetch.create({ - * use: [apiKey({ header: 'TRON-PRO-API-KEY', key: 'xxx' })], - * }) - * - * // Bearer Token - * keyFetch.create({ - * use: [apiKey({ header: 'Authorization', key: 'token', prefix: 'Bearer ' })], - * }) - * ``` */ -export function apiKey(options: ApiKeyOptions): FetchPlugin { +export function apiKey(options: ApiKeyOptions): Plugin { const { header, key, prefix = '' } = options return { name: 'api-key', - onFetch: async (request, next) => { + async onFetch(ctx, next) { if (key) { - const headers = new Headers(request.headers) + const headers = new Headers(ctx.req.headers) headers.set(header, `${prefix}${key}`) - return next(new Request(request, { headers })) + ctx.req = new Request(ctx.req, { headers }) } - return next(request) + return next() }, } } diff --git a/packages/key-fetch/src/plugins/cache.ts b/packages/key-fetch/src/plugins/cache.ts index 3d9c4c6ae..9aa3dd460 100644 --- a/packages/key-fetch/src/plugins/cache.ts +++ b/packages/key-fetch/src/plugins/cache.ts @@ -1,15 +1,8 @@ /** * Cache Plugin - 可配置的缓存插件 - * - * 使用中间件模式:拦截请求,返回缓存或继续请求 - * - * 支持不同的存储后端: - * - memory: 内存缓存(默认) - * - indexedDB: IndexedDB 持久化存储 - * - custom: 自定义存储实现 */ -import type { FetchPlugin } from '../types' +import type { Plugin } from '../types' // ==================== 存储后端接口 ==================== @@ -76,10 +69,8 @@ export class IndexedDBCacheStorage implements CacheStorage { this.dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 1) - request.onerror = () => reject(request.error) request.onsuccess = () => resolve(request.result) - request.onupgradeneeded = () => { const db = request.result if (!db.objectStoreNames.contains(this.storeName)) { @@ -97,7 +88,6 @@ export class IndexedDBCacheStorage implements CacheStorage { const tx = db.transaction(this.storeName, 'readonly') const store = tx.objectStore(this.storeName) const request = store.get(key) - request.onerror = () => reject(request.error) request.onsuccess = () => { const entry = request.result as CacheStorageEntry | undefined @@ -116,7 +106,6 @@ export class IndexedDBCacheStorage implements CacheStorage { const tx = db.transaction(this.storeName, 'readwrite') const store = tx.objectStore(this.storeName) const request = store.put(entry, key) - request.onerror = () => reject(request.error) request.onsuccess = () => resolve() }) @@ -128,7 +117,6 @@ export class IndexedDBCacheStorage implements CacheStorage { const tx = db.transaction(this.storeName, 'readwrite') const store = tx.objectStore(this.storeName) const request = store.delete(key) - request.onerror = () => reject(request.error) request.onsuccess = () => resolve() }) @@ -140,7 +128,6 @@ export class IndexedDBCacheStorage implements CacheStorage { const tx = db.transaction(this.storeName, 'readwrite') const store = tx.objectStore(this.storeName) const request = store.clear() - request.onerror = () => reject(request.error) request.onsuccess = () => resolve() }) @@ -152,7 +139,6 @@ export class IndexedDBCacheStorage implements CacheStorage { const tx = db.transaction(this.storeName, 'readonly') const store = tx.objectStore(this.storeName) const request = store.getAllKeys() - request.onerror = () => reject(request.error) request.onsuccess = () => resolve(request.result as string[]) }) @@ -162,41 +148,17 @@ export class IndexedDBCacheStorage implements CacheStorage { // ==================== 缓存插件工厂 ==================== export interface CachePluginOptions { - /** 存储后端,默认使用内存 */ storage?: CacheStorage - /** 默认 TTL(毫秒) */ ttlMs?: number - /** 缓存标签 */ tags?: string[] } -// 默认内存存储实例 const defaultStorage = new MemoryCacheStorage() /** - * 创建缓存插件(中间件模式) - * - * @example - * ```ts - * // 使用内存缓存 - * const memoryCache = cache({ ttlMs: 60_000 }) - * - * // 使用 IndexedDB 持久化 - * const persistedCache = cache({ - * storage: new IndexedDBCacheStorage('my-app-cache'), - * ttlMs: 24 * 60 * 60 * 1000, // 1 day - * }) - * - * // 使用 - * const myFetch = keyFetch.create({ - * name: 'api.data', - * schema: MySchema, - * url: '/api/data', - * use: [persistedCache], - * }) - * ``` + * 创建缓存插件 */ -export function cache(options: CachePluginOptions = {}): FetchPlugin { +export function cache(options: CachePluginOptions = {}): Plugin { const storage = options.storage ?? defaultStorage const defaultTtlMs = options.ttlMs ?? 60_000 const tags = options.tags ?? [] @@ -204,26 +166,20 @@ export function cache(options: CachePluginOptions = {}): FetchPlugin { return { name: 'cache', - async onFetch(request, next, context) { - // 生成缓存 key - const cacheKey = `${context.name}:${request.url}` + async onFetch(ctx, next) { + const cacheKey = `${ctx.name}:${ctx.req.url}` - // 检查缓存 - const cached = await storage.get(cacheKey) + const cached = await storage.get(cacheKey) if (cached) { - // 缓存命中,构造缓存的 Response return new Response(JSON.stringify(cached.data), { status: 200, headers: { 'X-Cache': 'HIT' }, }) } - // 缓存未命中,继续请求 - const response = await next(request) + const response = await next() - // 如果请求成功,存储到缓存 if (response.ok) { - // 需要克隆 response 因为 body 只能读取一次 const clonedResponse = response.clone() const data = await clonedResponse.json() @@ -234,7 +190,6 @@ export function cache(options: CachePluginOptions = {}): FetchPlugin { tags, } - // 异步存储,不阻塞返回 void storage.set(cacheKey, entry) } diff --git a/packages/key-fetch/src/plugins/dedupe.ts b/packages/key-fetch/src/plugins/dedupe.ts index aa10dc36e..71be2e39e 100644 --- a/packages/key-fetch/src/plugins/dedupe.ts +++ b/packages/key-fetch/src/plugins/dedupe.ts @@ -1,132 +1,83 @@ /** - * Dedupe Plugin + * useDedupe Plugin * - * 请求去重插件(升级版) - * - * 功能: - * 1. 同一 key 的并发请求复用同一个 Response - * 2. 可配置最小请求间隔,避免短时间内重复请求 + * 去重/节流插件 - 在时间窗口内返回缓存结果 */ -import type { FetchPlugin, MiddlewareContext } from '../types' - -// 全局进行中请求缓存(存储的是克隆后的 Response Promise) -const globalPending = new Map; cloneCount: number }>() - -// 最近完成时间(用于时间窗口去重) -const lastFetchTime = new Map() - -// 最近成功的 Response 缓存(用于时间窗口内返回缓存) -const lastResponseBody = new Map() -const lastResponseStatus = new Map() -const lastResponseHeaders = new Map() - -export interface DedupeOptions { - /** 最小请求间隔(毫秒),0 表示不限制 */ - minInterval?: number - /** 生成缓存 key 的函数,默认使用 name + params */ - getKey?: (ctx: MiddlewareContext) => string -} +import type { Context, Plugin } from '../types' /** 去重节流错误 */ export class DedupeThrottledError extends Error { - constructor(message: string) { - super(message) + constructor(key: string, windowMs: number) { + super(`Request throttled: ${key} (window: ${windowMs}ms)`) this.name = 'DedupeThrottledError' } } +interface CacheEntry { + data: unknown + timestamp: number +} + /** - * 请求去重插件(升级版) + * useDedupe - 去重/节流插件 + * + * 在时间窗口内对相同 input 的请求返回缓存结果 * - * 功能: - * 1. 同一 key 的并发请求复用同一个 Response - * 2. 可配置最小请求间隔,避免短时间内重复请求 + * @param windowMs 去重时间窗口(毫秒),默认 5000ms * * @example * ```ts - * // 基础用法:并发去重 - * use: [dedupe()] - * - * // 高级用法:30秒内不重复请求 - * use: [dedupe({ minInterval: 30_000 })] + * keyFetch.create({ + * name: 'balance', + * outputSchema: BalanceSchema, + * use: [useHttp(url), useDedupe(10_000)], // 10秒内不重复请求 + * }) * ``` */ -export function dedupe(options: DedupeOptions = {}): FetchPlugin { - const { minInterval = 0, getKey } = options +export function useDedupe(windowMs: number = 5000): Plugin { + const cache = new Map() - const buildKey = (ctx: MiddlewareContext): string => { - if (getKey) return getKey(ctx) - return `${ctx.name}::${JSON.stringify(ctx.params)}` + const getKey = (ctx: Context): string => { + return `${ctx.name}::${JSON.stringify(ctx.input)}` } return { name: 'dedupe', - async onFetch(request, next, context) { - const key = buildKey(context) + async onFetch(ctx, next) { + const key = getKey(ctx) + const now = Date.now() - // 1. 检查进行中的请求 - const pending = globalPending.get(key) - if (pending) { - // 复用进行中的请求,等待完成后重建 Response - const response = await pending.promise - // 从缓存的 body 重建 Response - const cachedBody = lastResponseBody.get(key) - const cachedStatus = lastResponseStatus.get(key) - const cachedHeaders = lastResponseHeaders.get(key) - if (cachedBody !== undefined && cachedStatus !== undefined) { - return new Response(cachedBody, { - status: cachedStatus, - headers: cachedHeaders, - }) - } - // 回退:返回原始响应(可能已被消费) - return response + // 检查缓存 + const cached = cache.get(key) + if (cached && (now - cached.timestamp) < windowMs) { + // 返回缓存的 Mock Response + return new Response(JSON.stringify(cached.data), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Dedupe': 'hit', + }, + }) } - // 2. 检查时间窗口(如果设置了 minInterval) - if (minInterval > 0 && !context.skipCache) { - const lastTime = lastFetchTime.get(key) ?? 0 - const elapsed = Date.now() - lastTime - if (elapsed < minInterval) { - // 时间窗口内,返回缓存的 Response - const cachedBody = lastResponseBody.get(key) - const cachedStatus = lastResponseStatus.get(key) - const cachedHeaders = lastResponseHeaders.get(key) - if (cachedBody !== undefined && cachedStatus !== undefined) { - return new Response(cachedBody, { - status: cachedStatus, - headers: cachedHeaders, - }) - } - // 无缓存,抛出节流错误 - throw new DedupeThrottledError( - `Request throttled: ${minInterval - elapsed}ms remaining` - ) + // 执行实际请求 + const response = await next() + + // 如果成功,缓存结果 + if (response.ok) { + // 克隆 response 以便读取内容 + const cloned = response.clone() + try { + const data = await cloned.json() + cache.set(key, { data, timestamp: now }) + } catch { + // 解析失败,不缓存 } } - // 3. 创建新请求 - const task = next(request).then(async response => { - // 缓存响应内容用于后续复用 - const body = await response.clone().text() - lastResponseBody.set(key, body) - lastResponseStatus.set(key, response.status) - lastResponseHeaders.set(key, new Headers(response.headers)) - lastFetchTime.set(key, Date.now()) - - // 清理 pending - globalPending.delete(key) - - return response - }).catch(error => { - globalPending.delete(key) - throw error - }) - - globalPending.set(key, { promise: task, cloneCount: 0 }) - return task + return response }, } } diff --git a/packages/key-fetch/src/plugins/deps.ts b/packages/key-fetch/src/plugins/deps.ts deleted file mode 100644 index 6e4df71ca..000000000 --- a/packages/key-fetch/src/plugins/deps.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Deps Plugin - * - * 依赖插件 - 当依赖的 KeyFetch 实例数据变化时自动刷新 - * - * 核心逻辑: - * 1. 当订阅当前 fetcher 时,自动订阅所有依赖的 fetcher - * 2. 当依赖的 fetcher 数据更新时,触发当前 fetcher 的重新获取 - * 3. 取消订阅时,自动取消对依赖的订阅(如果没有其他订阅者) - */ - -import type { FetchPlugin, KeyFetchInstance, SubscribeContext, FetchParams } from '../types' -import { globalRegistry } from '../registry' - -// 存储依赖订阅的清理函数 -const dependencyCleanups = new Map void)[]>() -// 跟踪每个 fetcher 的订阅者数量 -const subscriberCounts = new Map() - -/** 单个依赖配置 */ -export interface DepConfig { - /** 依赖的 KeyFetch 实例 */ - source: KeyFetchInstance - /** 从当前 context 生成依赖的 params,默认返回空对象 {} */ - params?: (ctx: SubscribeContext) => FetchParams -} - -/** 依赖输入:可以是 KeyFetchInstance 或 DepConfig */ -export type DepInput = KeyFetchInstance | DepConfig - -/** 规范化依赖输入为 DepConfig */ -function normalizeDepInput(input: DepInput): DepConfig { - if ('source' in input && typeof input.source === 'object') { - return input as DepConfig - } - return { source: input as KeyFetchInstance } -} - -/** Deps 插件扩展接口 */ -export interface DepsPlugin extends FetchPlugin { - /** 暴露依赖源供 core 读取(用于自动 dedupe 计算) */ - _sources: KeyFetchInstance[] -} - -/** - * 依赖插件 - * - * 当订阅使用此插件的 fetcher 时,会自动订阅所有依赖的 fetcher, - * 确保依赖的 interval 轮询等功能正常工作。 - * - * @example - * ```ts - * // 简单用法:依赖使用空 params - * const balanceFetch = keyFetch.create({ - * name: 'biowallet.balance', - * use: [deps(blockApi)], - * }) - * - * // 高级用法:每个依赖独立配置 params - * const tokenBalances = keyFetch.create({ - * name: 'tokenBalances', - * use: [ - * deps([ - * { source: nativeBalanceApi, params: ctx => ctx.params }, - * { source: priceApi, params: ctx => ({ symbol: ctx.params.symbol }) }, - * blockApi, // 混合使用,等价于 { source: blockApi } - * ]) - * ], - * }) - * ``` - */ -export function deps( - ...args: DepInput[] | [DepInput[]] -): DepsPlugin { - // 支持 deps(a, b, c) 和 deps([a, b, c]) 两种调用方式 - const inputs: DepInput[] = args.length === 1 && Array.isArray(args[0]) - ? args[0] - : args as DepInput[] - - // 规范化为 DepConfig[] - const depConfigs = inputs.map(normalizeDepInput) - - // 用于生成唯一 key - const getSubscriptionKey = (ctx: SubscribeContext): string => { - return `${ctx.name}::${JSON.stringify(ctx.params)}` - } - - return { - name: 'deps', - - // 暴露依赖源供 core 读取 - _sources: depConfigs.map(d => d.source), - - // onFetch: 注册依赖关系(用于 registry 追踪) - async onFetch(request, next, context) { - // 注册依赖关系到 registry - for (const dep of depConfigs) { - globalRegistry.addDependency(context.name, dep.source.name) - } - return next(request) - }, - - // onSubscribe: 自动订阅依赖并监听更新 - onSubscribe(ctx: SubscribeContext) { - const key = getSubscriptionKey(ctx) - const count = (subscriberCounts.get(key) ?? 0) + 1 - subscriberCounts.set(key, count) - - // 只有第一个订阅者时才初始化依赖订阅 - if (count === 1) { - const cleanups: (() => void)[] = [] - - for (const dep of depConfigs) { - // 使用配置的 params 函数生成依赖参数,默认空对象 - const depParams = dep.params ? dep.params(ctx) : {} - const unsubDep = dep.source.subscribe(depParams, (_data, event) => { - // 依赖数据更新时,触发当前 fetcher 重新获取 - if (event === 'update') { - ctx.refetch() - } - }) - cleanups.push(unsubDep) - - // 同时监听 registry 的更新事件(确保广播机制正常) - const unsubRegistry = globalRegistry.onUpdate(dep.source.name, () => { - globalRegistry.emitUpdate(ctx.name) - }) - cleanups.push(unsubRegistry) - } - - dependencyCleanups.set(key, cleanups) - } - - // 返回清理函数 - return () => { - const newCount = (subscriberCounts.get(key) ?? 1) - 1 - subscriberCounts.set(key, newCount) - - // 最后一个订阅者取消时,清理依赖订阅 - if (newCount === 0) { - const cleanups = dependencyCleanups.get(key) - if (cleanups) { - cleanups.forEach(fn => fn()) - dependencyCleanups.delete(key) - } - subscriberCounts.delete(key) - } - } - }, - } -} diff --git a/packages/key-fetch/src/plugins/etag.ts b/packages/key-fetch/src/plugins/etag.ts index 63f2fc258..c3238a2f8 100644 --- a/packages/key-fetch/src/plugins/etag.ts +++ b/packages/key-fetch/src/plugins/etag.ts @@ -1,49 +1,36 @@ /** * ETag Plugin * - * HTTP ETag 缓存验证插件(中间件模式) + * HTTP ETag 缓存验证插件 */ -import type { FetchPlugin } from '../types' +import type { Plugin } from '../types' -// ETag 存储 const etagStore = new Map() /** * ETag 缓存验证插件 - * - * @example - * ```ts - * const configFetch = keyFetch.create({ - * name: 'chain.config', - * schema: ConfigSchema, - * use: [etag()], - * }) - * ``` */ -export function etag(): FetchPlugin { +export function etag(): Plugin { return { name: 'etag', - async onFetch(request, next, context) { - const cacheKey = `${context.name}:${request.url}` + async onFetch(ctx, next) { + const cacheKey = `${ctx.name}:${ctx.req.url}` const cachedEtag = etagStore.get(cacheKey) - // 如果有缓存的 ETag,添加 If-None-Match 头 - let modifiedRequest = request if (cachedEtag) { - const headers = new Headers(request.headers) + const headers = new Headers(ctx.req.headers) headers.set('If-None-Match', cachedEtag) - modifiedRequest = new Request(request.url, { - method: request.method, + ctx.req = new Request(ctx.req.url, { + method: ctx.req.method, headers, - body: request.body, + body: ctx.req.body, }) } - const response = await next(modifiedRequest) + const response = await next() - // 存储新的 ETag const newEtag = response.headers.get('etag') if (newEtag) { etagStore.set(cacheKey, newEtag) diff --git a/packages/key-fetch/src/plugins/http.ts b/packages/key-fetch/src/plugins/http.ts new file mode 100644 index 000000000..a9b8f4fb6 --- /dev/null +++ b/packages/key-fetch/src/plugins/http.ts @@ -0,0 +1,143 @@ +/** + * useHttp Plugin + * + * HTTP 请求插件 - 统一处理 URL、method、headers、body + * 替代旧版的 pathParams() + searchParams() + postBody() + */ + +import type { Context, Plugin } from '../types' + +type ContextAny = Context + +export interface UseHttpOptions { + /** HTTP 方法 */ + method?: 'GET' | 'POST' | ((ctx: Context) => 'GET' | 'POST') + /** 请求头 */ + headers?: HeadersInit | ((ctx: Context) => HeadersInit) + /** 请求体(POST 时使用) */ + body?: unknown | ((ctx: Context) => unknown) +} + +/** + * 替换 URL 中的 :param 占位符 + */ +function replacePathParams(url: string, input: unknown): string { + if (typeof input !== 'object' || input === null) { + return url + } + + let result = url + for (const [key, value] of Object.entries(input)) { + if (value !== undefined) { + result = result.replace(`:${key}`, encodeURIComponent(String(value))) + } + } + return result +} + +/** + * 将 input 添加为 query params(GET 请求) + */ +function appendSearchParams(url: string, input: unknown): string { + if (typeof input !== 'object' || input === null) { + return url + } + + const urlObj = new URL(url) + for (const [key, value] of Object.entries(input)) { + if (value !== undefined && !url.includes(`:${key}`)) { + urlObj.searchParams.set(key, String(value)) + } + } + return urlObj.toString() +} + +/** + * useHttp - HTTP 请求插件 + * + * @example + * ```ts + * // 简单 GET 请求 + * keyFetch.create({ + * name: 'user', + * outputSchema: UserSchema, + * use: [useHttp('https://api.example.com/users/:id')], + * }) + * + * // POST 请求带自定义 body + * keyFetch.create({ + * name: 'createUser', + * outputSchema: UserSchema, + * use: [useHttp('https://api.example.com/users', { + * method: 'POST', + * body: (ctx) => ({ name: ctx.input.name, email: ctx.input.email }), + * })], + * }) + * + * // 动态 URL + * keyFetch.create({ + * name: 'chainData', + * outputSchema: DataSchema, + * use: [useHttp((ctx) => `https://${ctx.input.chain}.api.com/data`)], + * }) + * ``` + */ +export function useHttp( + url: string | ((ctx: Context) => string), + options?: UseHttpOptions +): Plugin { + return { + name: 'http', + + async onFetch(ctx: ContextAny, next) { + const typedCtx = ctx as Context + + // 解析 URL + let finalUrl = typeof url === 'function' ? url(typedCtx) : url + + // 替换 :param 占位符 + finalUrl = replacePathParams(finalUrl, ctx.input) + + // 解析 method + const method = options?.method + ? (typeof options.method === 'function' ? options.method(typedCtx) : options.method) + : 'GET' + + // 解析 headers + const headers = options?.headers + ? (typeof options.headers === 'function' ? options.headers(typedCtx) : options.headers) + : {} + + // 构建请求配置 + const requestInit: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + } + + // 处理 body + if (method === 'POST') { + if (options?.body !== undefined) { + const bodyData = typeof options.body === 'function' + ? (options.body as (ctx: Context) => unknown)(typedCtx) + : options.body + requestInit.body = JSON.stringify(bodyData) + } else if (ctx.input !== undefined) { + // 默认使用 input 作为 body + requestInit.body = JSON.stringify(ctx.input) + } + } else { + // GET 请求:将未使用的 input 字段添加为 query params + finalUrl = appendSearchParams(finalUrl, ctx.input) + } + + // 更新 ctx.req + ctx.req = new Request(finalUrl, requestInit) + + // 执行请求 + return fetch(ctx.req) + }, + } +} diff --git a/packages/key-fetch/src/plugins/index.ts b/packages/key-fetch/src/plugins/index.ts index e912d11f5..a7fe743bd 100644 --- a/packages/key-fetch/src/plugins/index.ts +++ b/packages/key-fetch/src/plugins/index.ts @@ -4,11 +4,23 @@ * 导出所有内置插件 */ -export { interval } from './interval' -export { deps } from './deps' +// 新版插件 +export { useHttp } from './http' +export type { UseHttpOptions } from './http' +export { useInterval } from './interval' +export { useDedupe, DedupeThrottledError } from './dedupe' + +// 兼容旧版名称 +export { useInterval as interval } from './interval' +export { useDedupe as dedupe } from './dedupe' + +// 保留的插件 export { ttl } from './ttl' -export { dedupe, DedupeThrottledError } from './dedupe' export { tag } from './tag' export { etag } from './etag' export { throttleError, errorMatchers } from './throttle-error' export { apiKey } from './api-key' +export { transform } from './transform' +export { cache, MemoryCacheStorage, IndexedDBCacheStorage } from './cache' +export { unwrap, walletApiUnwrap, etherscanApiUnwrap } from './unwrap' +export { searchParams, postBody, pathParams } from './params' diff --git a/packages/key-fetch/src/plugins/interval.ts b/packages/key-fetch/src/plugins/interval.ts index 840b946e6..5bff6b237 100644 --- a/packages/key-fetch/src/plugins/interval.ts +++ b/packages/key-fetch/src/plugins/interval.ts @@ -1,60 +1,47 @@ /** - * Interval Plugin + * useInterval Plugin * - * 定时轮询插件 - 中间件模式 + * 定时轮询插件 - 在 onSubscribe 阶段启动定时器 */ -import type { FetchPlugin, SubscribeContext } from '../types' - -export interface IntervalOptions { - /** 轮询间隔(毫秒)或动态获取函数 */ - ms: number | (() => number) -} - -/** Interval 插件扩展接口 */ -export interface IntervalPlugin extends FetchPlugin { - /** 暴露间隔时间供 core 读取(用于自动 dedupe 计算) */ - _intervalMs: number | (() => number) -} +import type { Context, Plugin } from '../types' /** - * 定时轮询插件 + * useInterval - 定时轮询插件 + * + * @param ms 轮询间隔(毫秒)或动态获取函数 * * @example * ```ts - * const lastBlockFetch = keyFetch.create({ - * name: 'bfmeta.lastblock', - * schema: LastBlockSchema, - * url: 'https://api.bfmeta.info/wallet/:chainId/lastblock', - * use: [interval(15_000)], + * // 固定间隔 + * keyFetch.create({ + * name: 'blockHeight', + * outputSchema: BlockSchema, + * use: [useHttp(url), useInterval(30_000)], * }) * - * // 或动态间隔 - * use: [interval(() => getForgeInterval())] + * // 动态间隔 + * keyFetch.create({ + * name: 'blockHeight', + * outputSchema: BlockSchema, + * use: [useHttp(url), useInterval(() => getPollingInterval())], + * }) * ``` */ -export function interval(ms: number | (() => number)): IntervalPlugin { - // 每个参数组合独立的轮询状态 +export function useInterval(ms: number | (() => number)): Plugin { + // 每个 input 独立的轮询状态 const timers = new Map>() const active = new Map() const subscriberCounts = new Map() - const getKey = (ctx: SubscribeContext): string => { - return JSON.stringify(ctx.params) + const getKey = (ctx: Context): string => { + return `${ctx.name}::${JSON.stringify(ctx.input)}` } return { name: 'interval', - - // 暴露间隔时间供 core 读取 - _intervalMs: ms, - - // 透传请求(不修改) - async onFetch(request, next) { - return next(request) - }, - onSubscribe(ctx) { + onSubscribe(ctx, emit) { const key = getKey(ctx) const count = (subscriberCounts.get(key) ?? 0) + 1 subscriberCounts.set(key, count) @@ -67,8 +54,9 @@ export function interval(ms: number | (() => number)): IntervalPlugin { if (!active.get(key)) return try { - await ctx.refetch() - } catch (error) { + const data = await ctx.self.fetch(ctx.input) + emit(data) + } catch { // 静默处理轮询错误 } finally { if (active.get(key)) { diff --git a/packages/key-fetch/src/plugins/params.ts b/packages/key-fetch/src/plugins/params.ts index b39f51192..766476e13 100644 --- a/packages/key-fetch/src/plugins/params.ts +++ b/packages/key-fetch/src/plugins/params.ts @@ -1,183 +1,103 @@ /** * Params Plugin * - * 将请求参数组装到不同位置: - * - searchParams: URL Query String (?address=xxx&limit=10) - * - postBody: POST JSON Body ({ address: "xxx", limit: 10 }) - * - pathParams: URL Path (/users/:id -> /users/123)(默认在 core.ts 中处理) + * 将请求参数组装到不同位置(兼容旧版) */ -import type { FetchPlugin } from '../types' +import type { Plugin } from '../types' /** - * SearchParams 插件 - * - * 将 params 添加到 URL 的 query string 中 - * - * @example - * ```ts - * const fetcher = keyFetch.create({ - * name: 'balance', - * schema: BalanceSchema, - * url: 'https://api.example.com/address/asset', - * use: [searchParams()], - * }) - * - * // fetch({ address: 'xxx' }) 会请求: - * // GET https://api.example.com/address/asset?address=xxx - * - * // 带 transform 的用法(适用于需要转换参数名的 API): - * use: [searchParams({ - * transform: (params) => ({ - * module: 'account', - * action: 'balance', - * address: params.address, - * }), - * })] - * ``` + * SearchParams 插件 - 将 params 添加到 URL query string */ -export function searchParams

= {}>(options?: { - /** 额外固定参数(合并到 params) */ +export function searchParams

= Record>(options?: { defaults?: P - /** 转换函数(自定义 query params 格式) */ transform?: (params: P) => Record -}): FetchPlugin

{ +}): Plugin { return { name: 'params:searchParams', - onFetch: async (request, next, context) => { - const url = new URL(request.url) + async onFetch(ctx, next) { + const url = new URL(ctx.req.url) - // 合并默认参数并转换 const mergedParams = { ...options?.defaults, - ...context.params, - } - if (options?.defaults) { - for (const key in mergedParams) { - if ((mergedParams[key] === undefined || mergedParams[key] === null) && - options?.defaults?.[key] !== undefined && options?.defaults?.[key] !== null) { - (mergedParams as Record)[key] = options?.defaults?.[key] - } - } + ...(ctx.input as P), } + const finalParams = options?.transform ? options.transform(mergedParams) : mergedParams - // 添加 params 到 URL search params for (const [key, value] of Object.entries(finalParams)) { if (value !== undefined) { url.searchParams.set(key, String(value)) } } - // 创建新请求(更新 URL) - const newRequest = new Request(url.toString(), { - method: request.method, - headers: request.headers, - body: request.body, + ctx.req = new Request(url.toString(), { + method: ctx.req.method, + headers: ctx.req.headers, + body: ctx.req.body, }) - return next(newRequest) + return next() }, } } /** - * PostBody 插件 - * - * 将 params 设置为 POST 请求的 JSON body - * - * @example - * ```ts - * const fetcher = keyFetch.create({ - * name: 'transactions', - * schema: TransactionsSchema, - * url: 'https://api.example.com/transactions/query', - * method: 'POST', - * use: [postBody()], - * }) - * - * // fetch({ address: 'xxx', page: 1 }) 会请求: - * // POST https://api.example.com/transactions/query - * // Body: { "address": "xxx", "page": 1 } - * ``` + * PostBody 插件 - 将 params 设置为 POST body */ export function postBody>(options?: { - /** 额外固定参数(合并到 params) */ defaults?: Partial - /** 转换函数(自定义 body 格式) */ transform?: (params: TIN) => unknown -}): FetchPlugin { +}): Plugin { return { name: 'params:postBody', - onFetch: async (request, next, context) => { - // 合并默认参数 + async onFetch(ctx, next) { const mergedParams = { ...options?.defaults, - ...context.params, + ...(ctx.input as TIN), } - // 转换或直接使用 const body = options?.transform ? options.transform(mergedParams) : mergedParams - // 创建新请求(POST with JSON body) - const newRequest = new Request(request.url, { + ctx.req = new Request(ctx.req.url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) - return next(newRequest) + return next() }, } } /** - * Path Params 插件 - * - * 将 params 替换到 URL 路径中的 :param 占位符 - * - * @example - * ```ts - * const fetcher = keyFetch.create({ - * name: 'user', - * schema: UserSchema, - * url: 'https://api.example.com/users/:userId/profile', - * use: [pathParams()], - * }) - * - * // fetch({ userId: '123' }) 会请求: - * // GET https://api.example.com/users/123/profile - * ``` + * PathParams 插件 - 替换 URL 中的 :param 占位符 */ -export function pathParams(): FetchPlugin { +export function pathParams(): Plugin { return { name: 'params:pathParams', - onFetch: async (request, next, context) => { - let url = request.url + async onFetch(ctx, next) { + let url = ctx.req.url - // 替换 :param 占位符 - if (typeof context.params === 'object' && context.params !== null) { - for (const [key, value] of Object.entries(context.params)) { + if (typeof ctx.input === 'object' && ctx.input !== null) { + for (const [key, value] of Object.entries(ctx.input)) { if (value !== undefined) { url = url.replace(`:${key}`, encodeURIComponent(String(value))) } } } - // 创建新请求(更新 URL) - const newRequest = new Request(url, { - method: request.method, - headers: request.headers, - body: request.body, + ctx.req = new Request(url, { + method: ctx.req.method, + headers: ctx.req.headers, + body: ctx.req.body, }) - return next(newRequest) + return next() }, } } diff --git a/packages/key-fetch/src/plugins/tag.ts b/packages/key-fetch/src/plugins/tag.ts index a103dfde6..75204ddf4 100644 --- a/packages/key-fetch/src/plugins/tag.ts +++ b/packages/key-fetch/src/plugins/tag.ts @@ -1,37 +1,23 @@ /** * Tag Plugin * - * 标签插件 - 用于批量失效(中间件模式) + * 标签插件 - 用于批量失效 */ -import type { FetchPlugin } from '../types' +import type { Plugin } from '../types' -// 全局标签映射 const tagToInstances = new Map>() /** * 标签插件 - * - * @example - * ```ts - * const balanceFetch = keyFetch.create({ - * name: 'bfmeta.balance', - * schema: BalanceSchema, - * use: [tag('wallet-data')], - * }) - * - * // 批量失效 - * keyFetch.invalidateByTag('wallet-data') - * ``` */ -export function tag(...tags: string[]): FetchPlugin { +export function tag(...tags: string[]): Plugin { let initialized = false return { name: 'tag', - async onFetch(request, next, context) { - // 首次请求时注册标签 + async onFetch(ctx, next) { if (!initialized) { initialized = true for (const t of tags) { @@ -40,26 +26,15 @@ export function tag(...tags: string[]): FetchPlugin { instances = new Set() tagToInstances.set(t, instances) } - instances.add(context.name) + instances.add(ctx.name) } } - return next(request) + return next() }, } } -/** - * 按标签失效所有相关实例 - */ -export function invalidateByTag(tagName: string): void { - const instances = tagToInstances.get(tagName) - if (instances) { - // 需要通过 registry 失效 - // 这里仅提供辅助函数,实际失效需要在外部调用 - } -} - /** 获取标签下的实例名称 */ export function getInstancesByTag(tagName: string): string[] { const instances = tagToInstances.get(tagName) diff --git a/packages/key-fetch/src/plugins/throttle-error.ts b/packages/key-fetch/src/plugins/throttle-error.ts index 4338e0282..9e7a9b7ab 100644 --- a/packages/key-fetch/src/plugins/throttle-error.ts +++ b/packages/key-fetch/src/plugins/throttle-error.ts @@ -4,21 +4,19 @@ * 对匹配的错误进行日志节流,避免终端刷屏 */ -import type { FetchPlugin } from '../types' +import type { Plugin } from '../types' export interface ThrottleErrorOptions { /** 错误匹配器 - 返回 true 表示需要节流 */ match: (error: Error) => boolean /** 时间窗口(毫秒),默认 10000ms */ windowMs?: number - /** 窗口内首次匹配时的处理,默认 console.warn */ + /** 窗口内首次匹配时的处理 */ onFirstMatch?: (error: Error, name: string) => void /** 窗口结束时汇总回调 */ onSummary?: (count: number, name: string) => void } -/** 预置错误匹配器 */ - /** 高阶函数:为匹配器添加 AggregateError 支持 */ const withAggregateError = (matcher: (msg: string) => boolean) => (e: Error): boolean => { if (matcher(e.message)) return true @@ -29,38 +27,20 @@ const withAggregateError = (matcher: (msg: string) => boolean) => (e: Error): bo } export const errorMatchers = { - /** 匹配 HTTP 状态码(支持 AggregateError) */ httpStatus: (...codes: number[]) => withAggregateError(msg => codes.some(code => msg.includes(`HTTP ${code}`))), - /** 匹配关键词(支持 AggregateError) */ contains: (...keywords: string[]) => withAggregateError(msg => keywords.some(kw => msg.toLowerCase().includes(kw.toLowerCase()))), - /** 组合多个匹配器 (OR) */ any: (...matchers: Array<(e: Error) => boolean>) => (e: Error) => matchers.some(m => m(e)), } /** * 错误日志节流插件 - * - * @example - * ```ts - * import { throttleError, errorMatchers } from '@biochain/key-fetch' - * - * keyFetch.create({ - * name: 'api.example', - * use: [ - * throttleError({ - * match: errorMatchers.httpStatus(429), - * windowMs: 10_000, - * }), - * ], - * }) - * ``` */ -export function throttleError(options: ThrottleErrorOptions): FetchPlugin { +export function throttleError(options: ThrottleErrorOptions): Plugin { const { match, windowMs = 10_000, @@ -74,7 +54,6 @@ export function throttleError(options: ThrottleErrorOptions): FetchPlugin { }, } = options - // 每个 name 独立的节流状态 const throttleState = new Map { + onError(error, _response, ctx) { if (!match(error)) { - return false // 不匹配,交给默认处理 + return false } - const state = getState(context.name) + const state = getState(ctx.name) if (!state.inWindow) { - // 首次匹配,打印警告并启动窗口 state.inWindow = true state.suppressedCount = 0 - onFirstMatch(error, context.name) + onFirstMatch(error, ctx.name) state.timer = setTimeout(() => { - onSummary(state.suppressedCount, context.name) + onSummary(state.suppressedCount, ctx.name) state.inWindow = false state.suppressedCount = 0 state.timer = null }, windowMs) } else { - // 窗口内,静默计数 state.suppressedCount++ } - return true // 已处理,跳过默认日志 + return true }, } } diff --git a/packages/key-fetch/src/plugins/transform.ts b/packages/key-fetch/src/plugins/transform.ts index 4bbe02989..1c8eb1e9c 100644 --- a/packages/key-fetch/src/plugins/transform.ts +++ b/packages/key-fetch/src/plugins/transform.ts @@ -1,91 +1,38 @@ /** * Transform Plugin - 响应转换插件 - * - * 中间件模式:将 API 原始响应转换为标准输出类型 - * - * 每个 Provider 使用自己的 API Schema 验证响应 - * 然后通过 transform 插件转换为 ApiProvider 标准输出类型 */ -import type { FetchParams, FetchPlugin, MiddlewareContext } from '../types' +import type { Context, Plugin } from '../types' +import { superjson } from '../core' -export interface TransformOptions { - /** - * 转换函数 - * @param input 原始验证后的数据 - * @param context 中间件上下文(包含 params) - * @returns 转换后的标准输出 - */ - transform: (input: TInput, context: MiddlewareContext) => TOutput | Promise | Response | Promise +export interface TransformOptions { + transform: (input: TInput, ctx: Context) => TOutput | Promise } -/** - * 创建转换插件 - * - * @example - * ```ts - * // BioWallet API 响应转换为标准 Balance - * const biowalletBalanceTransform = transform({ - * transform: (raw, ctx) => { - * const { symbol, decimals } = ctx.params - * const nativeAsset = raw.result.assets.find(a => a.magic === symbol) - * return { - * amount: Amount.fromRaw(nativeAsset?.balance ?? '0', decimals, symbol), - * symbol, - * } - * }, - * }) - * - * // 使用 - * const balanceFetch = keyFetch.create({ - * name: 'biowallet.balance', - * schema: AssetResponseSchema, // 原始 API Schema - * url: '/address/asset', - * use: [biowalletBalanceTransform], // 转换为 Balance - * }) - * ``` - */ -export function transform( +export function transform( options: TransformOptions -): FetchPlugin { +): Plugin { return { name: 'transform', - async onFetch(request, next, context) { - // 调用下一个中间件获取响应 - const response = await next(request) + async onFetch(ctx, next) { + const response = await next() - // 如果响应不成功,直接返回 if (!response.ok) { return response } - // 解析原始响应 (使用 ctx.body 根据 X-Superjson 头自动选择解析方式) - const rawData = await context.body(response) + const text = await response.text() + const isSuperjson = response.headers.get('X-Superjson') === 'true' + const rawData = isSuperjson ? superjson.parse(text) as TInput : JSON.parse(text) as TInput - // 应用转换 - const transformed = await options.transform(rawData, context) + const transformed = await options.transform(rawData, ctx) - // 使用 ctx.createResponse 构建包含转换后数据的响应 - return context.createResponse(transformed, { + return new Response(JSON.stringify(transformed), { status: response.status, statusText: response.statusText, + headers: { 'Content-Type': 'application/json' }, }) }, } } - -// /** -// * 链式转换 - 组合多个转换步骤 -// */ -// export function pipeTransform( -// first: TransformOptions, -// second: TransformOptions -// ): TransformOptions { -// return { -// transform: async (input, context) => { -// const intermediate = await first.transform(input, context) -// return second.transform(intermediate instanceof Response ? context.body(intermediate) as B : intermediate, context) -// }, -// } -// } diff --git a/packages/key-fetch/src/plugins/ttl.ts b/packages/key-fetch/src/plugins/ttl.ts index 1bdb73313..3262598f8 100644 --- a/packages/key-fetch/src/plugins/ttl.ts +++ b/packages/key-fetch/src/plugins/ttl.ts @@ -1,51 +1,31 @@ /** * TTL Plugin * - * 缓存生存时间插件(中间件模式) + * 缓存生存时间插件 */ -import type { FetchPlugin } from '../types' +import type { Plugin } from '../types' -// 简单内存缓存 const cache = new Map() /** * TTL 缓存插件 - * - * @example - * ```ts - * const configFetch = keyFetch.create({ - * name: 'chain.config', - * schema: ConfigSchema, - * use: [ttl(5 * 60 * 1000)], // 5 分钟缓存 - * }) - * ``` */ -export function ttl(ms: number | (() => number)): FetchPlugin { +export function ttl(ms: number | (() => number)): Plugin { return { name: 'ttl', - async onFetch(request, next, context) { - // 如果跳过缓存,直接请求 - if (context.skipCache) { - return next(request) - } - - // 生成缓存 key - const cacheKey = `${context.name}:${JSON.stringify(context.params)}` + async onFetch(ctx, next) { + const cacheKey = `${ctx.name}:${JSON.stringify(ctx.input)}` const cached = cache.get(cacheKey) - // 检查缓存是否有效 const ttlMs = typeof ms === 'function' ? ms() : ms if (cached && Date.now() - cached.timestamp < ttlMs) { - // 返回缓存的响应副本 return cached.data.clone() } - // 发起请求 - const response = await next(request) + const response = await next() - // 缓存成功的响应 if (response.ok) { cache.set(cacheKey, { data: response.clone(), diff --git a/packages/key-fetch/src/plugins/unwrap.ts b/packages/key-fetch/src/plugins/unwrap.ts index 32d1f883b..bd4dc1bfc 100644 --- a/packages/key-fetch/src/plugins/unwrap.ts +++ b/packages/key-fetch/src/plugins/unwrap.ts @@ -1,62 +1,51 @@ /** * Unwrap Plugin - 响应解包插件 * - * 用于处理服务器返回的包装格式,如: - * - { success: true, result: {...} } - * - { status: '1', message: 'OK', result: [...] } + * 用于处理服务器返回的包装格式 */ -import type { FetchPlugin, MiddlewareContext } from '../types' +import type { Context, Plugin } from '../types' +import { superjson } from '../core' export interface UnwrapOptions { /** * 解包函数 * @param wrapped 包装的响应数据 - * @param context 中间件上下文 + * @param ctx 上下文 * @returns 解包后的内部数据 */ - unwrap: (wrapped: TWrapper, context: MiddlewareContext) => TInner | Promise + unwrap: (wrapped: TWrapper, ctx: Context) => TInner | Promise } /** * 创建解包插件 - * - * 服务器可能返回包装格式,使用此插件解包后再进行 schema 验证 - * - * @example - * ```ts - * // 处理 { success: true, result: {...} } 格式 - * const fetcher = keyFetch.create({ - * name: 'btcwallet.balance', - * schema: AddressInfoSchema, - * url: '/address/:address', - * use: [walletApiUnwrap(), ttl(60_000)], - * }) - * ``` */ export function unwrap( options: UnwrapOptions -): FetchPlugin { +): Plugin { return { name: 'unwrap', - async onFetch(request, next, context) { - const response = await next(request) + async onFetch(ctx, next) { + const response = await next() if (!response.ok) { return response } // 解析包装响应 - const wrapped = await context.body(response) + const text = await response.text() + const isSuperjson = response.headers.get('X-Superjson') === 'true' + const wrapped = isSuperjson ? superjson.parse(text) as TWrapper : JSON.parse(text) as TWrapper // 解包 - const inner = await options.unwrap(wrapped, context) + const inner = await options.unwrap(wrapped, ctx) - // 重新构建响应(带 X-Superjson 头以便 core.ts 正确解析) - return context.createResponse(inner, { + // 重新构建响应 + return new Response(JSON.stringify(inner), { status: response.status, statusText: response.statusText, + headers: { 'Content-Type': 'application/json' }, }) }, } @@ -64,9 +53,8 @@ export function unwrap( /** * Wallet API 包装格式解包器 - * { success: boolean, result: T } -> T */ -export function walletApiUnwrap(): FetchPlugin { +export function walletApiUnwrap(): Plugin { return unwrap<{ success: boolean; result: T }, T>({ unwrap: (wrapped, ctx) => { if (wrapped.success === false) { @@ -74,17 +62,15 @@ export function walletApiUnwrap(): FetchPlugin { } else if (wrapped.success === true) { return wrapped.result } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- legacy support - return wrapped as any as T + return wrapped as unknown as T }, }) } /** * Etherscan API 包装格式解包器 - * { status: '1', message: 'OK', result: T } -> T */ -export function etherscanApiUnwrap(): FetchPlugin { +export function etherscanApiUnwrap(): Plugin { return unwrap<{ status: string; message: string; result: T }, T>({ unwrap: (wrapped) => { if (wrapped.status !== '1') { diff --git a/packages/key-fetch/src/react.ts b/packages/key-fetch/src/react.ts deleted file mode 100644 index 77ea943bc..000000000 --- a/packages/key-fetch/src/react.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Key-Fetch React Hooks - * - * 基于工厂模式的 React 集成 - */ - -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { injectUseState } from './core' -import type { - KeyFetchInstance, - FetchParams, - UseKeyFetchResult, - UseKeyFetchOptions, - KeyFetchOutput, - KeyFetchInput, -} from './types' - -/** - * 稳定化 params 对象,避免每次渲染产生新引用 - * 使用 JSON.stringify 作为比较依据 - */ -function useStableParams(params: FetchParams | undefined): FetchParams | undefined { - const paramsStringRef = useRef('') - const stableParamsRef = useRef(params) - - const paramsString = JSON.stringify(params ?? {}) - - if (paramsString !== paramsStringRef.current) { - paramsStringRef.current = paramsString - stableParamsRef.current = params - } - - return stableParamsRef.current -} - -/** - * 响应式数据获取 Hook - * - * 订阅 KeyFetch 实例的数据变化,当数据更新时自动重新渲染 - * - * @example - * ```tsx - * // 在 chain-provider 中定义 - * const lastBlockFetch = keyFetch.create({ - * name: 'bfmeta.lastblock', - * schema: LastBlockSchema, - * url: 'https://api.bfmeta.info/wallet/:chainId/lastblock', - * use: [interval(15_000)], - * }) - * - * // 在组件中使用 - * function BlockHeight() { - * const { data, isLoading } = useKeyFetch(lastBlockFetch, { chainId: 'bfmeta' }) - * - */ -export function useKeyFetch( - kf: KF, - params: KeyFetchInput, - options?: UseKeyFetchOptions -): UseKeyFetchResult> { - type TOUT = KeyFetchOutput - - const [data, setData] = useState(undefined) - const [isLoading, setIsLoading] = useState(true) - const [isFetching, setIsFetching] = useState(false) - const [error, setError] = useState(undefined) - - // 稳定化 params,避免每次渲染产生新引用导致无限循环 - const stableParams = useStableParams(params) - const paramsRef = useRef(stableParams) - paramsRef.current = stableParams - - const enabled = options?.enabled !== false - - // 错误退避:连续错误时延迟重试 - const errorCountRef = useRef(0) - const lastFetchTimeRef = useRef(0) - - const refetch = useCallback(async () => { - if (!enabled) return - - setIsFetching(true) - setError(undefined) - - try { - const result = await kf.fetch(paramsRef.current, { skipCache: true }) as TOUT - setData(result) - errorCountRef.current = 0 // 成功时重置错误计数 - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))) - errorCountRef.current++ - } finally { - setIsFetching(false) - setIsLoading(false) - } - }, [kf, enabled]) - - // 使用 stableParams 的字符串表示作为依赖 - const paramsKey = useMemo(() => JSON.stringify(stableParams ?? {}), [stableParams]) - - useEffect(() => { - if (!enabled) { - setData(undefined) - setIsLoading(false) - setIsFetching(false) - setError(undefined) - return - } - - // 防抖:如果距离上次请求太近,跳过 - const now = Date.now() - const timeSinceLastFetch = now - lastFetchTimeRef.current - if (timeSinceLastFetch < 100 && errorCountRef.current > 0) { - // 在错误状态下,短时间内不重复请求 - return - } - lastFetchTimeRef.current = now - - // 捕获当前 params(避免闭包问题) - const currentParams = paramsRef.current - - setIsLoading(true) - setIsFetching(true) - setError(undefined) - - let isCancelled = false - - // 初始获取数据(带错误处理) - kf.fetch(currentParams) - .then((result) => { - if (isCancelled) return - setData(result as TOUT) - setIsLoading(false) - setIsFetching(false) - errorCountRef.current = 0 - }) - .catch((err) => { - if (isCancelled) return - setError(err instanceof Error ? err : new Error(String(err))) - setIsLoading(false) - setIsFetching(false) - errorCountRef.current++ - }) - - // 订阅后续更新(带错误处理) - const unsubscribe = kf.subscribe(currentParams, (newData, _event) => { - if (isCancelled) return - try { - setData(newData as TOUT) - setIsLoading(false) - setIsFetching(false) - setError(undefined) - errorCountRef.current = 0 - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))) - } - }) - - return () => { - isCancelled = true - unsubscribe() - } - // 只依赖 paramsKey(string),避免对象引用变化导致重复执行 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kf, enabled, paramsKey]) - - return { data, isLoading, isFetching, error, refetch } -} - -/** - * 订阅 Hook(不返回数据,只订阅) - * - * 用于需要监听数据变化但不需要渲染数据的场景 - * - * @example - * ```tsx - * function PendingTxWatcher() { - * useKeyFetchSubscribe(lastBlockFetch, { chainId: 'bfmeta' }, (data) => { - * // 区块更新时检查 pending 交易 - * checkPendingTransactions(data.result.height) - * }) - * - * return null - * } - * ``` - */ -export function useKeyFetchSubscribe( - kf: KF, - params: KeyFetchInput, - callback: (data: KeyFetchOutput, event: 'initial' | 'update') => void -): void { - const callbackRef = useRef(callback) - callbackRef.current = callback - - // 稳定化 params - const stableParams = useStableParams(params) - const paramsRef = useRef(stableParams) - paramsRef.current = stableParams - const paramsKey = useMemo(() => JSON.stringify(stableParams ?? {}), [stableParams]) - - useEffect(() => { - const currentParams = paramsRef.current ?? {} - const unsubscribe = kf.subscribe(currentParams, (data, event) => { - callbackRef.current(data as KeyFetchOutput, event) - }) - - return () => { - unsubscribe() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kf, paramsKey]) -} - -// ==================== 注入 useState 实现 ==================== - -/** - * 内部 useState 实现 - * 复用 useKeyFetch 逻辑,供 KeyFetchInstance.useState() 调用 - */ -function useStateImpl( - kf: KF, - params: KeyFetchInput, - options?: { enabled?: boolean } -): UseKeyFetchResult> { - return useKeyFetch(kf, params, options) -} - -// 注入到 KeyFetchInstance.prototype -injectUseState(useStateImpl) diff --git a/packages/key-fetch/src/registry.ts b/packages/key-fetch/src/registry.ts deleted file mode 100644 index 151e1091d..000000000 --- a/packages/key-fetch/src/registry.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Key-Fetch Registry - * - * 全局注册表,管理所有 KeyFetch 实例和依赖关系 - */ - -import type { KeyFetchRegistry, KeyFetchInstance, CacheStore, CacheEntry } from './types' - -/** 内存缓存实现 */ -class MemoryCacheStore implements CacheStore { - private store = new Map() - - get(key: string): CacheEntry | undefined { - return this.store.get(key) as CacheEntry | undefined - } - - set(key: string, entry: CacheEntry): void { - this.store.set(key, entry as CacheEntry) - } - - delete(key: string): boolean { - return this.store.delete(key) - } - - has(key: string): boolean { - return this.store.has(key) - } - - clear(): void { - this.store.clear() - } - - keys(): IterableIterator { - return this.store.keys() - } -} - -/** 全局缓存实例 */ -export const globalCache = new MemoryCacheStore() - -/** Registry 实现 */ -class KeyFetchRegistryImpl implements KeyFetchRegistry { - private instances = new Map() - private updateListeners = new Map void>>() - private dependencies = new Map>() // dependent -> dependencies - private dependents = new Map>() // dependency -> dependents - - register(kf: KF): void { - this.instances.set(kf.name, kf) - } - - get(name: string): KF | undefined { - return this.instances.get(name) as KF | undefined - } - - invalidate(name: string): void { - const kf = this.instances.get(name) - if (kf) { - kf.invalidate() - } - } - - onUpdate(name: string, callback: () => void): () => void { - let listeners = this.updateListeners.get(name) - if (!listeners) { - listeners = new Set() - this.updateListeners.set(name, listeners) - } - listeners.add(callback) - - return () => { - listeners?.delete(callback) - } - } - - emitUpdate(name: string): void { - // 通知自身的监听者 - const listeners = this.updateListeners.get(name) - if (listeners) { - listeners.forEach(cb => cb()) - } - - // 通知依赖此实例的其他实例 - const dependentNames = this.dependents.get(name) - if (dependentNames) { - dependentNames.forEach(depName => { - this.emitUpdate(depName) - }) - } - } - - addDependency(dependent: string, dependency: string): void { - // dependent 依赖 dependency - let deps = this.dependencies.get(dependent) - if (!deps) { - deps = new Set() - this.dependencies.set(dependent, deps) - } - deps.add(dependency) - - // dependency 被 dependent 依赖 - let dependentSet = this.dependents.get(dependency) - if (!dependentSet) { - dependentSet = new Set() - this.dependents.set(dependency, dependentSet) - } - dependentSet.add(dependent) - } - - clear(): void { - this.instances.clear() - this.updateListeners.clear() - this.dependencies.clear() - this.dependents.clear() - } -} - -/** 全局 Registry 单例 */ -export const globalRegistry = new KeyFetchRegistryImpl() diff --git a/packages/key-fetch/src/types.ts b/packages/key-fetch/src/types.ts index 3784c69f5..91b8eee67 100644 --- a/packages/key-fetch/src/types.ts +++ b/packages/key-fetch/src/types.ts @@ -1,5 +1,5 @@ /** - * Key-Fetch Types + * Key-Fetch v2 Types * * Schema-first 插件化响应式 Fetch 类型定义 */ @@ -7,254 +7,144 @@ import type { z } from 'zod' import type { SuperJSON } from 'superjson' -// ==================== Schema Types ==================== - -/** 任意 Zod Schema */ -export type ZodUnknowSchema = z.ZodType -export type ZodVoidSchema = z.ZodVoid - -/** 从 Schema 推断输出类型 */ -export type KeyFetchOutput = S extends KeyFetchInstance ? T : never -export type KeyFetchInput = S extends KeyFetchInstance ? T : never - -// ==================== Cache Types ==================== - -/** 缓存条目 */ -export interface CacheEntry { - data: T - timestamp: number - etag?: string -} - -/** 缓存存储接口 */ -export interface CacheStore { - get(key: string): CacheEntry | undefined - set(key: string, entry: CacheEntry): void - delete(key: string): boolean - has(key: string): boolean - clear(): void - keys(): IterableIterator -} - -// ==================== Plugin Types (Middleware Pattern) ==================== +// ==================== Core Types ==================== /** - * 中间件函数类型 + * Context - 贯穿整个生命周期的核心对象 * - * 插件核心:接收 Request,调用 next() 获取 Response,可以修改两者 - * - * @example - * ```ts - * const myMiddleware: FetchMiddleware<{ address: string }> = async (request, next, context) => { - * // context.params.address 是强类型 - * const url = new URL(request.url) - * url.searchParams.set('address', context.params.address) - * const modifiedRequest = new Request(url.toString(), request) - * return next(modifiedRequest) - * } - * ``` + * 严格类型化,无 options: any 模糊字段 */ -export type FetchMiddleware

= ( - request: Request, - next: (request: Request) => Promise, - context: MiddlewareContext

-) => Promise - -/** 中间件上下文 - 提供额外信息和工具 */ -export interface MiddlewareContext

{ +export interface Context { + /** 当前输入参数(类型安全) */ + readonly input: TInput + /** 标准 Request 对象(由插件构建/修改) */ + req: Request + /** SuperJSON 库实例(核心标准) */ + readonly superjson: SuperJSON + /** 允许插件反向操作实例 */ + readonly self: KeyFetchInstance + /** 插件间共享状态 */ + readonly state: Map /** KeyFetch 实例名称 */ - name: string - /** 原始请求参数(强类型) */ - params: P - /** 是否跳过缓存 */ - skipCache: boolean - - // ==================== SuperJSON 工具 (核心标准) ==================== - - /** SuperJSON 库实例(支持 BigInt、Date 等特殊类型的序列化) */ - superjson: SuperJSON - /** 创建包含序列化数据的 Response 对象(自动添加 X-Superjson: true 头) */ - createResponse: (data: T, init?: ResponseInit) => Response - /** 创建包含序列化数据的 Request 对象(自动添加 X-Superjson: true 头) */ - createRequest: (data: T, url?: string, init?: RequestInit) => Request - /** 解析 Request/Response body(根据 X-Superjson 头自动选择 superjson.parse 或 JSON.parse) */ - body: (input: Request | Response) => Promise - parseBody: (input: string, isSuperjson?: boolean) => Promise - - /** 标记错误已被插件处理(如 throttleError),core.ts 将跳过默认日志 */ - errorHandled?: boolean + readonly name: string } /** - * 插件接口 - * - * 使用 onFetch 中间件处理请求/响应 + * Plugin - 完整生命周期控制器 */ -export interface FetchPlugin

{ - /** 插件名称(用于调试和错误追踪) */ +export interface Plugin { + /** 插件名称(用于调试) */ name: string /** - * 中间件函数 - * - * 接收 Request 和 next 函数,返回 Response - * - 可以修改 request 后传给 next() - * - 可以修改 next() 返回的 response - * - 可以不调用 next() 直接返回缓存的 response + * 阶段 1: 实例创建时触发 + * 用于设置全局定时器、全局事件监听等 + * 极少使用,通常用于"没人订阅也要跑"的特殊热流 + * @returns cleanup 函数 */ - onFetch?: FetchMiddleware

+ onInit?: (self: KeyFetchInstance) => void | (() => void) /** - * 错误处理钩子(可选) - * 在 HTTP 错误抛出前调用,可用于节流、重试等 - * @param error 即将抛出的错误 - * @param response 原始 Response(如果有) - * @param context 中间件上下文 - * @returns 返回 true 表示错误已处理,跳过默认日志 + * 阶段 2: 有人订阅时触发 + * 用于实现"热流"、轮询、依赖监听 + * @param ctx 上下文 + * @param emit 发射数据到订阅者 + * @returns cleanup 函数 */ - onError?: (error: Error, response: Response | undefined, context: MiddlewareContext

) => boolean + onSubscribe?: ( + ctx: Context, + emit: (data: TOutput) => void + ) => void | (() => void) /** - * 订阅时调用(可选) - * 用于启动轮询等后台任务 - * @returns 清理函数 + * 阶段 3: 执行 Fetch 时触发(洋葱模型中间件) + * 负责构建 Request -> 执行(或Mock) -> 处理 Response + * @param ctx 上下文 + * @param next 调用下一个中间件 + * @returns Response */ - onSubscribe?: (context: SubscribeContext

) => (() => void) | void -} + onFetch?: ( + ctx: Context, + next: () => Promise + ) => Promise -/** 订阅上下文 */ -export interface SubscribeContext

{ - /** KeyFetch 实例名称 */ - name: string - /** 请求参数(强类型) */ - params: P - /** 完整 URL */ - url: string - /** 触发数据更新 */ - refetch: () => Promise + /** + * 错误处理钩子(可选) + * 在 HTTP 错误抛出前调用 + * @returns 返回 true 表示错误已处理 + */ + onError?: (error: Error, response: Response | undefined, ctx: Context) => boolean } -// 向后兼容别名 -/** @deprecated 使用 FetchPlugin 代替 */ -export type CachePlugin<_S extends ZodUnknowSchema = ZodUnknowSchema> = FetchPlugin - -// ==================== KeyFetch Instance Types ==================== +// ==================== KeyFetch Instance ==================== -/** 请求参数基础类型 */ -export type FetchParams = unknown -// export interface FetchParams { -// [key: string]: string | number | boolean | undefined -// } - -/** KeyFetch 定义选项 */ -export interface KeyFetchDefineOptions< - TOUT extends unknown, - TIN extends unknown = unknown, -> { +/** + * KeyFetch 定义选项 + */ +export interface KeyFetchDefineOptions { /** 唯一名称 */ name: string - /** 输出 Zod Schema(必选) */ - outputSchema: z.ZodType - /** 参数 Zod Schema(可选,用于类型推断和运行时验证) */ - inputSchema?: z.ZodType - /** 基础 URL 模板,支持 :param 占位符 */ - url?: string - /** HTTP 方法 */ - method?: 'GET' | 'POST' + /** 输入参数 Zod Schema */ + inputSchema?: z.ZodType + /** 输出结果 Zod Schema(必选) */ + outputSchema: z.ZodType /** 插件列表 */ - use?: FetchPlugin[] + use?: Plugin[] } -/** 订阅回调 */ -export type SubscribeCallback = (data: T, event: 'initial' | 'update') => void - -/** KeyFetch 实例 - 工厂函数返回的对象 */ -export interface KeyFetchInstance< - TOUT extends unknown = unknown, - TIN extends unknown = unknown, -> { +/** + * KeyFetch 实例 - 工厂函数返回的对象 + */ +export interface KeyFetchInstance { /** 实例名称 */ readonly name: string + /** 输入 Schema */ + readonly inputSchema: z.ZodType | undefined /** 输出 Schema */ - readonly outputSchema: z.ZodType - /** 参数 Schema */ - readonly inputSchema: z.ZodType | undefined - /** 输出类型(用于类型推断) */ - readonly _output: TOUT - /** 参数类型(用于类型推断) */ - readonly _params: TIN + readonly outputSchema: z.ZodType /** - * 执行请求 - * @param params 请求参数(强类型) - * @param options 额外选项 + * 冷流:单次请求 + * @param input 输入参数(类型安全) */ - fetch(params: TIN, options?: { skipCache?: boolean }): Promise + fetch(input: TInput): Promise /** - * 订阅数据变化 - * @param params 请求参数(强类型) + * 热流:订阅数据变化 + * @param input 输入参数 * @param callback 回调函数 * @returns 取消订阅函数 */ subscribe( - params: TIN, - callback: SubscribeCallback + input: TInput, + callback: SubscribeCallback ): () => void - /** - * 手动失效缓存 - */ - invalidate(): void - - /** - * 获取当前缓存的数据(如果有) - */ - getCached(params?: TIN): TOUT | undefined - /** * React Hook - 响应式数据绑定 - * - * @example - * ```tsx - * const { data, isLoading, error } = balanceFetcher.useState({ address }) - * if (isLoading) return - * if (error) return - * return - * ``` + * 内部判断 React 环境,无需额外注入 */ useState( - params: TIN, - options?: UseKeyFetchOptions - ): UseKeyFetchResult - - use(...plugins: FetchPlugin[]): KeyFetchInstance + input: TInput, + options?: UseStateOptions + ): UseStateResult } -// ==================== Registry Types ==================== +// ==================== Subscribe Types ==================== -/** 全局注册表 */ -export interface KeyFetchRegistry { - /** 注册 KeyFetch 实例 */ - register(kf: KF): void - /** 获取实例 */ - get(name: string): KF | undefined - /** 按名称失效 */ - invalidate(name: string): void - /** 监听实例更新 */ - onUpdate(name: string, callback: () => void): () => void - /** 触发更新通知 */ - emitUpdate(name: string): void - /** 添加依赖关系 */ - addDependency(dependent: string, dependency: string): void - /** 清理所有 */ - clear(): void -} +/** 订阅回调 */ +export type SubscribeCallback = (data: T, event: 'initial' | 'update') => void // ==================== React Types ==================== -/** useKeyFetch 返回值 */ -export interface UseKeyFetchResult { +/** useState 选项 */ +export interface UseStateOptions { + /** 是否启用(默认 true) */ + enabled?: boolean +} + +/** useState 返回值 */ +export interface UseStateResult { /** 数据 */ data: T | undefined /** 是否正在加载(首次) */ @@ -267,8 +157,70 @@ export interface UseKeyFetchResult { refetch: () => Promise } -/** useKeyFetch 选项 */ -export interface UseKeyFetchOptions { - /** 是否启用(默认 true) */ - enabled?: boolean +// ==================== Utility Types ==================== + +/** 从 KeyFetchInstance 推断输出类型 */ +export type InferOutput = T extends KeyFetchInstance ? O : never + +/** 从 KeyFetchInstance 推断输入类型 */ +export type InferInput = T extends KeyFetchInstance ? I : never + +// ==================== 兼容类型(供旧插件使用)==================== + +/** @deprecated 使用 Plugin 代替 */ +export type FetchPlugin = Plugin + +/** 请求参数基础类型 */ +export type FetchParams = Record + +/** 中间件上下文(兼容旧插件) */ +export interface MiddlewareContext { + name: string + params: TInput + skipCache: boolean + superjson: SuperJSON + createResponse: (data: T, init?: ResponseInit) => Response + createRequest: (data: T, url?: string, init?: RequestInit) => Request + body: (input: Request | Response) => Promise + parseBody: (input: string, isSuperjson?: boolean) => T +} + +// ==================== Combine Types ==================== + +/** Combine 源配置 */ +export interface CombineSource { + /** 源 KeyFetch 实例 */ + source: KeyFetchInstance + /** 从外部 input 生成源的 params */ + params: (input: TInput) => TSourceInput + /** 源的 key(用于 results 对象),默认使用 source.name */ + key?: string +} + +/** Combine 选项(简化版) */ +export interface CombineOptions { + /** 合并后的名称 */ + name: string + /** 输出 Schema */ + outputSchema: import('zod').ZodType + /** 源配置数组 */ + sources: CombineSource[] + /** 转换函数:将所有源的结果转换为最终输出 */ + transform: (results: Record, input: TInput) => TOutput + /** 额外插件 */ + use?: Plugin[] +} + +// ==================== Fallback Types ==================== + +/** Fallback 选项 */ +export interface FallbackOptions { + /** 合并后的名称 */ + name: string + /** 源 fetcher 数组(可以是空数组) */ + sources: KeyFetchInstance[] + /** 当 sources 为空时调用 */ + onEmpty?: () => never + /** 当所有 sources 都失败时调用 */ + onAllFailed?: (errors: Error[]) => never } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd40e289b..d3b84fb3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@biochain/bio-sdk': specifier: workspace:* version: link:packages/bio-sdk + '@biochain/chain-effect': + specifier: workspace:* + version: link:packages/chain-effect '@biochain/key-fetch': specifier: workspace:* version: link:packages/key-fetch @@ -44,6 +47,9 @@ importers: '@bnqkl/wallet-typings': specifier: ^0.23.8 version: 0.23.8 + '@effect/platform': + specifier: ^0.94.2 + version: 0.94.2(effect@3.19.15) '@fontsource-variable/dm-sans': specifier: ^5.2.8 version: 5.2.8 @@ -143,6 +149,9 @@ importers: ed2curve: specifier: ^0.3.0 version: 0.3.0 + effect: + specifier: ^3.19.15 + version: 3.19.15 i18next: specifier: ^25.7.1 version: 25.7.3(typescript@5.9.3) @@ -702,6 +711,34 @@ importers: specifier: ^4.0.0 version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + packages/chain-effect: + dependencies: + '@effect/platform': + specifier: ^0.94.2 + version: 0.94.2(effect@3.19.15) + effect: + specifier: ^3.19.15 + version: 3.19.15 + superjson: + specifier: ^2.2.6 + version: 2.2.6 + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.7 + oxlint: + specifier: ^1.32.0 + version: 1.39.0 + react: + specifier: ^19.0.0 + version: 19.2.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.0 + version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + packages/create-miniapp: dependencies: '@inquirer/prompts': @@ -1608,6 +1645,11 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 + '@effect/platform@0.94.2': + resolution: {integrity: sha512-85vdwpnK4oH/rJ3EuX/Gi2Hkt+K4HvXWr9bxCuqvty9hxyEcRxkJcqTesYrcVoQB6aULb1Za2B0MKoTbvffB3Q==} + peerDependencies: + effect: ^3.19.15 + '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} @@ -2599,6 +2641,36 @@ packages: '@cfworker/json-schema': optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@mswjs/interceptors@0.40.0': resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} engines: {node: '>=18'} @@ -5015,6 +5087,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.19.15: + resolution: {integrity: sha512-vzMmgfZKLcojmUjBdlQx+uaKryO7yULlRxjpDnHdnvcp1NPHxJyoM6IOXBLlzz2I/uPtZpGKavt5hBv7IvGZkA==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -5264,6 +5339,10 @@ packages: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -5319,6 +5398,9 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} @@ -6338,6 +6420,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.8: + resolution: {integrity: sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==} + msw@2.12.4: resolution: {integrity: sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg==} engines: {node: '>=18'} @@ -6351,6 +6440,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multipasta@0.2.7: + resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -6405,6 +6497,10 @@ packages: resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} hasBin: true + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -6833,6 +6929,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode.react@4.2.0: resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} peerDependencies: @@ -9404,6 +9503,13 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 + '@effect/platform@0.94.2(effect@3.19.15)': + dependencies: + effect: 3.19.15 + find-my-way-ts: 0.1.6 + msgpackr: 1.11.8 + multipasta: 0.2.7 + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 @@ -10436,6 +10542,24 @@ snapshots: - hono - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@mswjs/interceptors@0.40.0': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -12906,6 +13030,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.19.15: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.267: {} elliptic@6.6.1: @@ -13335,6 +13464,10 @@ snapshots: fake-indexeddb@6.2.5: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -13393,6 +13526,8 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way-ts@0.1.6: {} + find-up@3.0.0: dependencies: locate-path: 3.0.0 @@ -14375,6 +14510,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.8: + optionalDependencies: + msgpackr-extract: 3.0.3 + msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@24.10.4) @@ -14402,6 +14553,8 @@ snapshots: muggle-string@0.4.1: {} + multipasta@0.2.7: {} + mute-stream@2.0.0: {} mute-stream@3.0.0: {} @@ -14442,6 +14595,11 @@ snapshots: detect-libc: 2.1.2 optional: true + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp-build@4.8.4: {} node-releases@2.0.27: {} @@ -14810,6 +14968,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + qrcode.react@4.2.0(react@19.2.3): dependencies: react: 19.2.3 diff --git a/src/hooks/use-service-status.ts b/src/hooks/use-service-status.ts index df7c08400..261f62f17 100644 --- a/src/hooks/use-service-status.ts +++ b/src/hooks/use-service-status.ts @@ -5,7 +5,7 @@ */ import { useMemo } from 'react' -import { NoSupportError, ServiceLimitedError } from '@biochain/key-fetch' +import { NoSupportError, ServiceLimitedError } from '@biochain/chain-effect' import type { TFunction } from 'i18next' export interface ServiceStatus { diff --git a/src/pages/address-transactions/index.tsx b/src/pages/address-transactions/index.tsx index 4c30a9688..60ce2ef7c 100644 --- a/src/pages/address-transactions/index.tsx +++ b/src/pages/address-transactions/index.tsx @@ -10,7 +10,7 @@ import { Card, CardContent } from '@/components/ui/card' import { useEnabledChains } from '@/stores' import { ChainProviderGate, useChainProvider } from '@/contexts' import type { Transaction } from '@/services/chain-adapter/providers' -import { NoSupportError } from '@biochain/key-fetch' +import { NoSupportError } from '@biochain/chain-effect' import { IconSearch, IconExternalLink, IconArrowUpRight, IconArrowDownLeft, IconLoader2 } from '@tabler/icons-react' function formatAmount(amount: string, decimals: number): string { diff --git a/src/pages/history/detail.tsx b/src/pages/history/detail.tsx index e2e8fb5d8..1229d32ed 100644 --- a/src/pages/history/detail.tsx +++ b/src/pages/history/detail.tsx @@ -26,7 +26,7 @@ import { clipboardService } from '@/services/clipboard'; import type { TransactionType } from '@/components/transaction/transaction-item'; import { getTransactionStatusMeta, getTransactionVisualMeta } from '@/components/transaction/transaction-meta'; import { Amount } from '@/types/amount'; -import keyFetch from '@biochain/key-fetch'; +import { superjson } from '@biochain/chain-effect'; // Action 到 TransactionType 的映射 const ACTION_TO_TYPE: Record = { @@ -127,7 +127,7 @@ function TransactionDetailContent({ hash, chainId, txData, onBack }: Transaction // 解析传入的初始数据(用于即时显示,避免加载闪烁) const initialTransaction = useMemo( - () => txData ? keyFetch.superjson.parse(txData) : null, + () => txData ? superjson.parse(txData) : null, [txData] ); diff --git a/src/pages/history/index.tsx b/src/pages/history/index.tsx index 7bc17ff35..87ae6cacf 100644 --- a/src/pages/history/index.tsx +++ b/src/pages/history/index.tsx @@ -15,7 +15,7 @@ import { ServiceStatusAlert } from '@/components/common/service-status-alert'; import { cn } from '@/lib/utils'; import { toTransactionInfoList, type TransactionInfo } from '@/components/transaction'; import type { ChainType } from '@/stores'; -import keyFetch from '@biochain/key-fetch'; +import { superjson } from '@biochain/chain-effect'; /** 交易历史过滤器 */ interface TransactionFilter { @@ -94,7 +94,7 @@ function HistoryContent({ targetChain, address, filter, setFilter, walletId, dec if (!tx.id) return; // 从原始数据中找到对应的交易(通过 hash 匹配) const originalTx = rawTransactions?.find(t => t.hash === tx.hash); - const txData = originalTx ? keyFetch.superjson.stringify(originalTx) : undefined; + const txData = originalTx ? superjson.stringify(originalTx) : undefined; navigate({ to: `/transaction/${tx.id}`, search: { txData } }); }, [navigate, rawTransactions], diff --git a/src/services/chain-adapter/providers/__tests__/api-key-picker.test.ts b/src/services/chain-adapter/providers/__tests__/api-key-picker.test.ts deleted file mode 100644 index f9a4da647..000000000 --- a/src/services/chain-adapter/providers/__tests__/api-key-picker.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { pickApiKey, clearApiKeyCache, getLockedApiKey } from '../api-key-picker' - -describe('pickApiKey', () => { - beforeEach(() => { - clearApiKeyCache() - }) - - it('returns undefined for empty string', () => { - expect(pickApiKey('', 'test')).toBeUndefined() - expect(pickApiKey(' ', 'test')).toBeUndefined() - }) - - it('returns undefined for undefined input', () => { - expect(pickApiKey(undefined, 'test')).toBeUndefined() - }) - - it('returns the single key when only one provided', () => { - const result = pickApiKey('single-key', 'test') - expect(result).toBe('single-key') - }) - - it('returns one of the keys when multiple provided', () => { - const keys = 'key1,key2,key3' - const result = pickApiKey(keys, 'test') - expect(['key1', 'key2', 'key3']).toContain(result) - }) - - it('trims whitespace from keys', () => { - const keys = ' key1 , key2 , key3 ' - const result = pickApiKey(keys, 'test') - expect(['key1', 'key2', 'key3']).toContain(result) - }) - - it('filters out empty keys', () => { - const keys = 'key1,,key2, ,key3' - const result = pickApiKey(keys, 'test') - expect(['key1', 'key2', 'key3']).toContain(result) - }) - - it('locks the selected key for subsequent calls', () => { - const keys = 'key1,key2,key3' - const first = pickApiKey(keys, 'cache-test') - const second = pickApiKey(keys, 'cache-test') - const third = pickApiKey(keys, 'cache-test') - - expect(first).toBe(second) - expect(second).toBe(third) - }) - - it('uses different keys for different cache keys', () => { - const result1 = pickApiKey('only-one', 'service-a') - const result2 = pickApiKey('another-one', 'service-b') - - expect(result1).toBe('only-one') - expect(result2).toBe('another-one') - }) - - it('getLockedApiKey returns the cached key', () => { - pickApiKey('my-key', 'get-test') - expect(getLockedApiKey('get-test')).toBe('my-key') - }) - - it('getLockedApiKey returns undefined for uncached key', () => { - expect(getLockedApiKey('nonexistent')).toBeUndefined() - }) - - it('clearApiKeyCache clears all cached keys', () => { - pickApiKey('key1', 'cache1') - pickApiKey('key2', 'cache2') - - clearApiKeyCache() - - expect(getLockedApiKey('cache1')).toBeUndefined() - expect(getLockedApiKey('cache2')).toBeUndefined() - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/biowallet-provider.bfmetav2.real.test.ts b/src/services/chain-adapter/providers/__tests__/biowallet-provider.bfmetav2.real.test.ts deleted file mode 100644 index 7d6440458..000000000 --- a/src/services/chain-adapter/providers/__tests__/biowallet-provider.bfmetav2.real.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { BiowalletProvider } from '../biowallet-provider'; -import type { ParsedApiEntry } from '@/services/chain-config'; - -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: () => 'BFM', - getDecimals: () => 8, - getBiowalletGenesisBlock: () => null, - }, -})); - -const mockFetch = vi.fn(); -const originalFetch = global.fetch; -Object.assign(global, { fetch: mockFetch }); - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }); -}); - -function readFixture(name: string): T { - const dir = path.dirname(fileURLToPath(import.meta.url)); - const filePath = path.join(dir, 'fixtures/real', name); - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; -} - -/** 创建模拟 Response 对象 */ -function createMockResponse(data: unknown, ok = true, status = 200): Response { - const jsonData = JSON.stringify(data); - return new Response(jsonData, { - headers: { - 'Content-Type': 'application/json', - }, - status, - statusText: ok ? 'OK' : 'Not Found', - }); -} - -describe('BiowalletProvider (BFMetaV2 real fixtures)', () => { - const entry: ParsedApiEntry = { - type: 'biowallet-v1', - endpoint: 'https://walletapi.bf-meta.org/wallet/bfmetav2', - }; - - const lastblock = readFixture('bfmetav2-lastblock.json'); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('uses endpoint directly without path concatenation', () => { - const provider = new BiowalletProvider(entry, 'bfmetav2'); - expect(provider.endpoint).toBe('https://walletapi.bf-meta.org/wallet/bfmetav2'); - }); - - it('fetches block height from the correct endpoint', async () => { - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url; - if (url === 'https://walletapi.bf-meta.org/wallet/bfmetav2/block/lastblock') { - return createMockResponse(lastblock); - } - return createMockResponse({ error: 'Not found' }, false, 404); - }); - - const provider = new BiowalletProvider(entry, 'bfmetav2'); - const height = await provider.blockHeight.fetch({}); - - expect(mockFetch).toHaveBeenCalled(); - expect(height).toBe(BigInt(45052)); - }); - - it('converts transferAsset (AST-02) to transfer + native asset', async () => { - const query = readFixture('bfmetav2-transactions-query.json'); - const tx = query.result.trs[0].transaction; - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url; - const method = typeof input === 'string' ? undefined : input.method; - if (url.endsWith('/block/lastblock')) { - return createMockResponse(lastblock); - } - if (url.endsWith('/transactions/query')) { - expect(method).toBe('POST'); - return createMockResponse(query); - } - return createMockResponse({ error: 'Not found' }, false, 404); - }); - - const provider = new BiowalletProvider(entry, 'bfmetav2'); - const txs = await provider.transactionHistory.fetch({ address: tx.recipientId, limit: 10 }); - - expect(txs.length).toBeGreaterThan(0); - expect(txs[0].action).toBe('transfer'); - expect(txs[0].direction).toBe('in'); - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - symbol: 'BFM', - decimals: 8, - value: '444', - }); - }); - - it('queries address assets from the correct endpoint', async () => { - const assetResponse = { - success: true, - result: { - address: 'bPbubZwJGSJBB3feZpsvttMFj8spu1jCm2', - assets: { - GAGGQ: { - BFM: { assetNumber: '1000000000', assetType: 'BFM' }, - }, - }, - }, - }; - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url; - const method = typeof input === 'string' ? undefined : input.method; - if (url === 'https://walletapi.bf-meta.org/wallet/bfmetav2/address/asset') { - expect(method).toBe('POST'); - return createMockResponse(assetResponse); - } - return createMockResponse({ error: 'Not found' }, false, 404); - }); - - const provider = new BiowalletProvider(entry, 'bfmetav2'); - const balance = await provider.nativeBalance.fetch({ address: 'bPbubZwJGSJBB3feZpsvttMFj8spu1jCm2' }); - - expect(mockFetch).toHaveBeenCalled(); - expect(balance.symbol).toBe('BFM'); - expect(balance.amount.toRawString()).toBe('1000000000'); - }); -}); diff --git a/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts b/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts deleted file mode 100644 index 0b642f6b7..000000000 --- a/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { BiowalletProvider } from '../biowallet-provider'; -import type { ParsedApiEntry } from '@/services/chain-config'; -import { keyFetch } from '@biochain/key-fetch'; - -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: () => 'BIW', - getDecimals: () => 8, - getBiowalletGenesisBlock: () => null, - }, -})); - -const mockFetch = vi.fn(); -const originalFetch = global.fetch; - -Object.assign(global, { fetch: mockFetch }); - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }); -}); - -function readFixture(name: string): T { - const dir = path.dirname(fileURLToPath(import.meta.url)); - const filePath = path.join(dir, 'fixtures/real', name); - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; -} - -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('BiowalletProvider (BIWMeta real fixtures)', () => { - const entry: ParsedApiEntry = { - type: 'biowallet-v1', - endpoint: 'https://walletapi.bfmeta.info/wallet/biwmeta', - }; - - const lastblock = readFixture('biwmeta-lastblock.json'); - - beforeEach(() => { - vi.clearAllMocks(); - keyFetch.clear(); - }); - - it('converts transferAsset (AST-02) to transfer + native asset', async () => { - const query = readFixture('biwmeta-ast-02-transferAsset.json'); - const tx = query.result.trs[0].transaction; - - mockFetch.mockImplementation(async (input: Request | string, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.url - if (url.endsWith('/block/lastblock')) { - return createMockResponse(lastblock); - } - if (url.endsWith('/transactions/query')) { - expect(typeof input === 'string' ? init?.method : input.method).toBe('POST'); - return createMockResponse(query); - } - return createMockResponse({ error: 'Not found' }, false, 404); - }); - - const provider = new BiowalletProvider(entry, 'biwmeta'); - const txs = await provider.transactionHistory.fetch({ address: tx.recipientId, limit: 10 }); - - expect(txs.length).toBeGreaterThan(0); - expect(txs[0].action).toBe('transfer'); - expect(txs[0].direction).toBe('in'); - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - symbol: 'BIW', - decimals: 8, - value: '5000', - }); - }); - - it('converts destroyAsset (AST-03) to destroyAsset + native asset', async () => { - const query = readFixture('biwmeta-ast-03-destroyAsset.json'); - const tx = query.result.trs[0].transaction; - - mockFetch.mockImplementation(async (input: Request | string, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.url - if (url.endsWith('/block/lastblock')) { - return createMockResponse(lastblock); - } - if (url.endsWith('/transactions/query')) { - expect(typeof input === 'string' ? init?.method : input.method).toBe('POST'); - return createMockResponse(query); - } - return createMockResponse({ error: 'Not found' }, false, 404); - }); - - const provider = new BiowalletProvider(entry, 'biwmeta'); - const txs = await provider.transactionHistory.fetch({ address: tx.senderId, limit: 10 }); - - expect(txs.length).toBeGreaterThan(0); - expect(txs[0].action).toBe('destroyAsset'); - expect(txs[0].direction).toBe('out'); - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - symbol: 'AMGT', - decimals: 8, - value: '58636952548', - }); - }); - - it('converts issueEntity (ETY-02) to issueEntity + placeholder native asset', async () => { - const query = readFixture('biwmeta-ety-02-issueEntity.json'); - const tx = query.result.trs[0].transaction; - - mockFetch.mockImplementation(async (input: Request | string, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.url - if (url.endsWith('/block/lastblock')) { - return createMockResponse(lastblock); - } - if (url.endsWith('/transactions/query')) { - expect(typeof input === 'string' ? init?.method : input.method).toBe('POST'); - return createMockResponse(query); - } - return createMockResponse({ error: 'Not found' }, false, 404); - }); - - const provider = new BiowalletProvider(entry, 'biwmeta'); - const txs = await provider.transactionHistory.fetch({ address: tx.senderId, limit: 10 }); - - expect(txs.length).toBeGreaterThan(0); - expect(txs[0].action).toBe('issueEntity'); - expect(txs[0].direction).toBe('self'); - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - symbol: 'BIW', - decimals: 8, - value: '0', - }); - }); - - it('converts issueEntityFactory (ETY-01) to issueEntity + placeholder native asset', async () => { - const query = readFixture('biwmeta-ety-01-issueEntityFactory.json'); - const tx = query.result.trs[0].transaction; - - mockFetch.mockImplementation(async (input: Request | string, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.url - if (url.endsWith('/block/lastblock')) { - return createMockResponse(lastblock); - } - if (url.endsWith('/transactions/query')) { - expect(typeof input === 'string' ? init?.method : input.method).toBe('POST'); - return createMockResponse(query); - } - return createMockResponse({ error: 'Not found' }, false, 404); - }); - - const provider = new BiowalletProvider(entry, 'biwmeta'); - const txs = await provider.transactionHistory.fetch({ address: tx.senderId, limit: 10 }); - - expect(txs.length).toBeGreaterThan(0); - expect(txs[0].action).toBe('issueEntity'); - expect(txs[0].direction).toBe('self'); - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - symbol: 'BIW', - decimals: 8, - value: '0', - }); - }); - - it('converts signature (BSE-01) to signature + placeholder native asset', async () => { - const query = readFixture('biwmeta-bse-01-signature.json'); - const tx = query.result.trs[0].transaction; - - mockFetch.mockImplementation(async (input: Request | string, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.url - if (url.endsWith('/block/lastblock')) { - return createMockResponse(lastblock); - } - if (url.endsWith('/transactions/query')) { - expect(typeof input === 'string' ? init?.method : input.method).toBe('POST'); - return createMockResponse(query); - } - return createMockResponse({ error: 'Not found' }, false, 404); - }); - - const provider = new BiowalletProvider(entry, 'biwmeta'); - const txs = await provider.transactionHistory.fetch({ address: tx.senderId, limit: 10 }); - - expect(txs.length).toBeGreaterThan(0); - expect(txs[0].action).toBe('signature'); - expect(txs[0].direction).toBe('out'); - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - symbol: 'BIW', - decimals: 8, - value: '0', - }); - }); -}); diff --git a/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts b/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts deleted file mode 100644 index fd4cda95d..000000000 --- a/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { BiowalletProvider } from '../biowallet-provider' -import type { ParsedApiEntry } from '@/services/chain-config' -import { keyFetch } from '@biochain/key-fetch' - -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: () => 'BFM', - getDecimals: () => 8, - getBiowalletGenesisBlock: () => null, - }, -})) - -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }); - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }); -}); - -function readFixture(name: string): T { - const dir = path.dirname(fileURLToPath(import.meta.url)) - const filePath = path.join(dir, 'fixtures/real', name) - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T -} - -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('BiowalletProvider (real fixtures)', () => { - const entry: ParsedApiEntry = { - type: 'biowallet-v1', - endpoint: 'https://walletapi.bfmeta.info/wallet/bfm', - } - - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - }) - - it('converts transferAsset transactions from BFMeta API', async () => { - const address = 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j' - const lastblock = readFixture('bfmeta-lastblock.json') - const query = readFixture('bfmeta-transactions-query.json') - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - if (url.endsWith('/block/lastblock')) { - return createMockResponse(lastblock) - } - if (url.endsWith('/transactions/query')) { - expect(typeof input === 'string' ? 'POST' : input.method).toBe('POST') - return createMockResponse(query) - } - return createMockResponse({ error: 'Not found' }, false, 404) - }) - - const provider = new BiowalletProvider(entry, 'bfmeta') - const txs = await provider.transactionHistory.fetch({ address, limit: 10 }) - - expect(txs.length).toBeGreaterThan(0) - expect(txs[0].action).toBe('transfer') - expect(txs[0].direction).toBe('in') - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - symbol: 'BFM', - decimals: 8, - }) - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/blockscout-balance.test.ts b/src/services/chain-adapter/providers/__tests__/blockscout-balance.test.ts deleted file mode 100644 index f95d72413..000000000 --- a/src/services/chain-adapter/providers/__tests__/blockscout-balance.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Blockscout/Etherscan Provider Balance Support Test - * - * 验证 blockscout-v1 等 scan 类 provider 是否支持余额查询 - * 使用新 KeyFetch API - */ - -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import { EtherscanProvider } from '../etherscan-provider' -import { ChainProvider } from '../chain-provider' -import type { ParsedApiEntry } from '@/services/chain-config' -import { keyFetch } from '@biochain/key-fetch' - -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: () => 'ETH', - getDecimals: () => 18, - }, -})) - -// Mock fetch -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }) - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }) -}) - -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('Blockscout/Etherscan Provider Balance Support', () => { - const entry: ParsedApiEntry = { - type: 'etherscan-v2', - endpoint: 'https://eth.blockscout.com/api', - } - - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - }) - - it('etherscan-v2 should support nativeBalance', () => { - const provider = new EtherscanProvider(entry, 'ethereum') - const chainProvider = new ChainProvider('ethereum', [provider]) - - expect(chainProvider.supportsNativeBalance).toBe(true) - }) - - it('etherscan-v2 should return balance data via nativeBalance.fetch()', async () => { - // Mock Etherscan API response - mockFetch.mockResolvedValue(createMockResponse({ - status: '1', - message: 'OK', - result: '1000000000000000000', // 1 ETH - })) - - const provider = new EtherscanProvider(entry, 'ethereum') - const chainProvider = new ChainProvider('ethereum', [provider]) - const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' - - // 使用新 API: nativeBalance.fetch() - const balance = await chainProvider.nativeBalance.fetch({ address }) - - expect(balance).toBeDefined() - expect(balance.symbol).toBe('ETH') - expect(balance.amount).toBeDefined() - expect(balance.amount.decimals).toBe(18) - expect(balance.amount.toRawString()).toBe('1000000000000000000') - }) - - it('etherscan-v2 should support transactionHistory', () => { - const provider = new EtherscanProvider(entry, 'ethereum') - const chainProvider = new ChainProvider('ethereum', [provider]) - - expect(chainProvider.supportsTransactionHistory).toBe(true) - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/btcwallet-provider.test.ts b/src/services/chain-adapter/providers/__tests__/btcwallet-provider.test.ts deleted file mode 100644 index a9f19c738..000000000 --- a/src/services/chain-adapter/providers/__tests__/btcwallet-provider.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * BtcWallet Provider 测试 - * - * 使用 KeyFetch 架构 - */ - -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import { BtcWalletProvider, createBtcwalletProvider } from '../btcwallet-provider' -import type { ParsedApiEntry } from '@/services/chain-config' -import { keyFetch } from '@biochain/key-fetch' - -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: () => 'BTC', - getDecimals: () => 8, - }, -})) - -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }) - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }) -}) - -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('BtcWalletProvider', () => { - const entry: ParsedApiEntry = { - type: 'btcwallet-v1', - endpoint: 'https://walletapi.example.com/wallet/btc/blockbook', - } - - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - }) - - it('createBtcwalletProvider creates provider for btcwallet-v1', () => { - const provider = createBtcwalletProvider(entry, 'bitcoin') - expect(provider).toBeInstanceOf(BtcWalletProvider) - }) - - it('maps balance (confirmed + unconfirmed) to Amount', async () => { - const address = 'bc1qexample' - - // BtcWallet API returns {success: true, result: AddressInfo} - mockFetch.mockImplementation(async (_input: Request | string) => { - return createMockResponse({ - balance: '8', - }) - }) - - const provider = new BtcWalletProvider(entry, 'bitcoin') - const balance = await provider.nativeBalance.fetch({ address }) - - expect(balance.amount.toRawString()).toBe('8') - expect(balance.symbol).toBe('BTC') - }) - - it('computes direction and value from vin/vout', async () => { - const address = 'bc1qme' - - mockFetch.mockImplementation(async (_input: Request | string) => { - return createMockResponse({ - balance: '0', - transactions: [ - { - txid: 'tx1', - blockHeight: 100, - confirmations: 1, - blockTime: 1700000000, - vin: [{ addresses: [address], value: '5000' }], - vout: [ - { addresses: ['bc1qother'], value: '3000' }, - { addresses: ['bc1qchange'], value: '1900' }, - ], - }, - ], - }) - }) - - const provider = new BtcWalletProvider(entry, 'bitcoin') - const txs = await provider.transactionHistory.fetch({ address }) - - expect(txs).toHaveLength(1) - expect(txs[0].direction).toBe('out') - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - symbol: 'BTC', - decimals: 8, - }) - }) - - it('handles incoming transaction direction', async () => { - const address = 'bc1qreceiver' - - mockFetch.mockImplementation(async () => { - return createMockResponse({ - balance: '0', - transactions: [ - { - txid: 'tx2', - blockHeight: 200, - confirmations: 6, - blockTime: 1700001000, - vin: [{ addresses: ['bc1qsender'], value: '10000' }], - vout: [{ addresses: [address], value: '9000' }], - }, - ], - }) - }) - - const provider = new BtcWalletProvider(entry, 'bitcoin') - const txs = await provider.transactionHistory.fetch( - { address }, - { skipCache: true } - ) - - expect(txs).toHaveLength(1) - expect(txs[0].direction).toBe('in') - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts b/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts deleted file mode 100644 index 109bbedb0..000000000 --- a/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * ChainProvider 测试 - * - * 使用 KeyFetch 架构 - */ - -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import { ChainProvider } from '../chain-provider' -import type { ApiProvider, Balance, Transaction } from '../types' -import { BalanceOutputSchema, TransactionsOutputSchema } from '../types' -import { Amount } from '@/types/amount' -import { keyFetch, NoSupportError } from '@biochain/key-fetch' -import type { KeyFetchInstance } from '@biochain/key-fetch' - -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: () => 'TEST', - getDecimals: () => 8, - }, -})) - -// Mock fetch -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }) - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }) -}) - -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(keyFetch.superjson.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json', 'X-SuperJson': 'true' }, - }) -} - -// 创建 mock KeyFetchInstance -function createMockKeyFetchInstance(_mockData: T): KeyFetchInstance { - return keyFetch.create({ - name: `mock.${Date.now()}`, - outputSchema: BalanceOutputSchema, - url: 'https://mock.api/test', - use: [], - }) -} - -// Mock ApiProvider with KeyFetch instances -function createMockProvider(overrides: Partial = {}): ApiProvider { - return { - type: 'mock-provider', - endpoint: 'https://mock.api', - ...overrides, - } -} - -describe('ChainProvider', () => { - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - }) - - describe('supports', () => { - it('returns true for nativeBalance when a provider has the property', () => { - const mockBalance: Balance = { - amount: Amount.fromRaw('1000000', 8, 'TEST'), - symbol: 'TEST', - } - mockFetch.mockResolvedValue(createMockResponse(mockBalance)) - - // 创建一个带 nativeBalance 的 mock provider - const nativeBalanceFetcher = keyFetch.create({ - name: 'test.nativeBalance', - outputSchema: BalanceOutputSchema, - url: 'https://mock.api/balance', - }) - - const provider = createMockProvider({ - nativeBalance: nativeBalanceFetcher, - }) - const chainProvider = new ChainProvider('test', [provider]) - - expect(chainProvider.supports('nativeBalance')).toBe(true) - }) - - it('returns false when no provider has the capability', () => { - const provider = createMockProvider() - const chainProvider = new ChainProvider('test', [provider]) - - expect(chainProvider.supports('nativeBalance')).toBe(false) - }) - - it('returns true when any provider has the capability', () => { - const provider1 = createMockProvider({ type: 'p1' }) - - const txHistoryFetcher = keyFetch.create({ - name: 'test.transactionHistory', - outputSchema: TransactionsOutputSchema, - url: 'https://mock.api/txs', - }) - - const provider2 = createMockProvider({ - type: 'p2', - transactionHistory: txHistoryFetcher, - }) - const chainProvider = new ChainProvider('test', [provider1, provider2]) - - expect(chainProvider.supportsTransactionHistory).toBe(true) - }) - }) - - describe('KeyFetch property delegation', () => { - it('nativeBalance.fetch() returns balance from provider', async () => { - const mockBalance: Balance = { - amount: Amount.fromRaw('1000000', 8, 'TEST'), - symbol: 'TEST', - } - mockFetch.mockResolvedValue(createMockResponse(mockBalance)) - - const nativeBalanceFetcher = keyFetch.create({ - name: 'test.nativeBalance', - outputSchema: BalanceOutputSchema, - url: 'https://mock.api/balance', - }) - - const provider = createMockProvider({ - nativeBalance: nativeBalanceFetcher, - }) - const chainProvider = new ChainProvider('test', [provider]) - - const result = await chainProvider.nativeBalance.fetch({ address: '0x123' }) - - expect(result.symbol).toBe('TEST') - }) - - it('transactionHistory.fetch() returns transactions from provider', async () => { - const mockTxs: Transaction[] = [ - { - hash: '0xabc', - from: '0x1', - to: '0x2', - timestamp: Date.now(), - status: 'confirmed', - action: 'transfer', - direction: 'out', - assets: [ - { - assetType: 'native', - value: '1000', - symbol: 'TEST', - decimals: 8, - }, - ], - }, - ] - mockFetch.mockResolvedValue(createMockResponse(mockTxs)) - - const txHistoryFetcher = keyFetch.create({ - name: 'test.transactionHistory', - outputSchema: TransactionsOutputSchema, - url: 'https://mock.api/txs', - }) - - const provider = createMockProvider({ - transactionHistory: txHistoryFetcher, - }) - const chainProvider = new ChainProvider('test', [provider]) - - const result = await chainProvider.transactionHistory.fetch({ address: '0x123' }) - - expect(result).toHaveLength(1) - expect(result[0].hash).toBe('0xabc') - }) - - it('throws NoSupportError when no provider has the capability', async () => { - const provider = createMockProvider() - const chainProvider = new ChainProvider('test', [provider]) - - await expect( - chainProvider.nativeBalance.fetch({ address: '0x123' }) - ).rejects.toThrow(NoSupportError) - }) - }) - - describe('convenience properties', () => { - it('supportsNativeBalance reflects provider capabilities', () => { - const nativeBalanceFetcher = keyFetch.create({ - name: 'test.nativeBalance.2', - outputSchema: BalanceOutputSchema, - url: 'https://mock.api/balance', - }) - - const provider = createMockProvider({ - nativeBalance: nativeBalanceFetcher, - }) - const chainProvider = new ChainProvider('test', [provider]) - - expect(chainProvider.supportsNativeBalance).toBe(true) - expect(chainProvider.supportsTransactionHistory).toBe(false) - }) - - it('supportsTransactionHistory reflects provider capabilities', () => { - const txHistoryFetcher = keyFetch.create({ - name: 'test.transactionHistory.2', - outputSchema: TransactionsOutputSchema, - url: 'https://mock.api/txs', - }) - - const provider = createMockProvider({ - transactionHistory: txHistoryFetcher, - }) - const chainProvider = new ChainProvider('test', [provider]) - - expect(chainProvider.supportsNativeBalance).toBe(false) - expect(chainProvider.supportsTransactionHistory).toBe(true) - }) - }) - - describe('getProviderByType', () => { - it('returns the provider matching the type', () => { - const provider1 = createMockProvider({ type: 'ethereum-rpc' }) - const provider2 = createMockProvider({ type: 'etherscan-v2' }) - const chainProvider = new ChainProvider('test', [provider1, provider2]) - - expect(chainProvider.getProviderByType('etherscan-v2')).toBe(provider2) - }) - - it('returns undefined for unknown type', () => { - const provider = createMockProvider({ type: 'ethereum-rpc' }) - const chainProvider = new ChainProvider('test', [provider]) - - expect(chainProvider.getProviderByType('unknown')).toBeUndefined() - }) - }) - - describe('legacy method proxies', () => { - it('estimateFee is proxied from provider', async () => { - const mockFeeEstimate = { - standard: { amount: Amount.fromRaw('1000', 8, 'TEST'), symbol: 'TEST' }, - } - - const provider = createMockProvider({ - estimateFee: vi.fn().mockResolvedValue(mockFeeEstimate), - }) - const chainProvider = new ChainProvider('test', [provider]) - - const result = await chainProvider.estimateFee!({ - from: 'a', - to: 'b', - amount: Amount.fromRaw('1', 8, 'TEST'), - }) - - expect(result).toEqual(mockFeeEstimate) - }) - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts b/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts deleted file mode 100644 index 674cf7e31..000000000 --- a/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Etherscan Provider 测试 - * - * 使用 KeyFetch 架构与真实 fixture 数据 - */ - -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { EtherscanV1Provider, createEtherscanV1Provider } from '../etherscan-v1-provider' -import type { ParsedApiEntry } from '@/services/chain-config' -import { keyFetch } from '@biochain/key-fetch' - -// Mock chainConfigService -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: (chainId: string) => chainId === 'ethereum' ? 'ETH' : 'UNKNOWN', - getDecimals: (chainId: string) => chainId === 'ethereum' ? 18 : 8, - }, -})) - -// Mock fetch -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }) - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }) -}) - -// 读取真实 fixture 数据 -function readFixture(name: string): T { - const dir = path.dirname(fileURLToPath(import.meta.url)) - const filePath = path.join(dir, 'fixtures/real', name) - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T -} - -// 创建 mock Response 辅助函数 -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('EtherscanV1Provider', () => { - const mockEntry: ParsedApiEntry = { - type: 'blockscout-eth', - endpoint: 'https://eth.blockscout.com/api', - } - - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - }) - - describe('createEtherscanV1Provider', () => { - it('creates provider for etherscan-v1 type', () => { - const entry: ParsedApiEntry = { - type: 'etherscan-v1', - endpoint: 'https://api.etherscan.io/api', - } - const provider = createEtherscanV1Provider(entry, 'ethereum') - expect(provider).toBeInstanceOf(EtherscanV1Provider) - }) - - it('creates provider for blockscout-v1 type', () => { - const blockscoutEntry: ParsedApiEntry = { - type: 'blockscout-v1', - endpoint: 'https://eth.blockscout.com/api', - } - const provider = createEtherscanV1Provider(blockscoutEntry, 'ethereum') - expect(provider).toBeInstanceOf(EtherscanV1Provider) - }) - - it('returns null for non-scan type', () => { - const rpcEntry: ParsedApiEntry = { - type: 'ethereum-rpc', - endpoint: 'https://rpc.example.com', - } - const provider = createEtherscanV1Provider(rpcEntry, 'ethereum') - expect(provider).toBeNull() - }) - }) - - describe('nativeBalance', () => { - it('fetches balance with correct API parameters', async () => { - const balanceResponse = { - status: '1', - message: 'OK', - result: '1000000000000000000', // 1 ETH - } - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - const parsed = new URL(url) - - expect(parsed.searchParams.get('module')).toBe('account') - expect(parsed.searchParams.get('action')).toBe('balance') - expect(parsed.searchParams.get('address')).toBe('0x1234') - expect(parsed.searchParams.get('tag')).toBe('latest') - - return createMockResponse(balanceResponse) - }) - - const provider = new EtherscanV1Provider(mockEntry, 'ethereum') - const balance = await provider.nativeBalance.fetch({ address: '0x1234' }) - - expect(mockFetch).toHaveBeenCalled() - expect(balance.symbol).toBe('ETH') - expect(balance.amount.toRawString()).toBe('1000000000000000000') - }) - }) - - describe('transactionHistory', () => { - it('converts real Blockscout native transfer sample to native asset', async () => { - const receiver = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' - const nativeTx = readFixture<{ tx: any }>('eth-blockscout-native-transfer-tx.json').tx - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - const parsed = new URL(url) - const action = parsed.searchParams.get('action') - - if (action === 'txlist') { - return createMockResponse({ status: '1', message: 'OK', result: [nativeTx] }) - } - return createMockResponse({ status: '1', message: 'OK', result: [] }) - }) - - const provider = new EtherscanV1Provider(mockEntry, 'ethereum') - const txs = await provider.transactionHistory.fetch({ address: receiver }) - - expect(txs).toHaveLength(1) - expect(txs[0].hash).toBe(nativeTx.hash) - expect(txs[0].action).toBe('transfer') - expect(txs[0].direction).toBe('in') - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - value: nativeTx.value, - symbol: 'ETH', - decimals: 18, - }) - }) - - it('fetches transactions with limit parameter', async () => { - const txListResponse = { - status: '1', - message: 'OK', - result: [], - } - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - const parsed = new URL(url) - - expect(parsed.searchParams.get('module')).toBe('account') - expect(parsed.searchParams.get('action')).toBe('txlist') - expect(parsed.searchParams.get('address')).toBe('0xTestLimit') - expect(parsed.searchParams.get('offset')).toBe('10') - expect(parsed.searchParams.get('sort')).toBe('desc') - - return createMockResponse(txListResponse) - }) - - const provider = new EtherscanV1Provider(mockEntry, 'ethereum') - await provider.transactionHistory.fetch({ address: '0xTestLimit', limit: 10 }) - - expect(mockFetch).toHaveBeenCalled() - }) - - it('uses default limit of 20 when not specified', async () => { - const txListResponse = { - status: '1', - message: 'OK', - result: [], - } - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - const parsed = new URL(url) - - expect(parsed.searchParams.get('offset')).toBe('20') - - return createMockResponse(txListResponse) - }) - - const provider = new EtherscanV1Provider(mockEntry, 'ethereum') - await provider.transactionHistory.fetch({ address: '0xDefaultLimit' }) - - expect(mockFetch).toHaveBeenCalled() - }) - - it('correctly determines direction based on from/to addresses', async () => { - const testAddress = '0xDirectionTest' - const txListResponse = { - status: '1', - message: 'OK', - result: [ - { - hash: '0xin', - from: '0xother', - to: testAddress, - value: '100', - timeStamp: '1700000000', - isError: '0', - blockNumber: '1', - }, - { - hash: '0xout', - from: testAddress, - to: '0xother', - value: '100', - timeStamp: '1700000001', - isError: '0', - blockNumber: '2', - }, - { - hash: '0xself', - from: testAddress, - to: testAddress, - value: '100', - timeStamp: '1700000002', - isError: '0', - blockNumber: '3', - }, - ], - } - - mockFetch.mockResolvedValue(createMockResponse(txListResponse)) - - const provider = new EtherscanV1Provider(mockEntry, 'ethereum') - const txs = await provider.transactionHistory.fetch( - { address: testAddress }, - { skipCache: true } - ) - - expect(txs[0].direction).toBe('in') - expect(txs[1].direction).toBe('out') - expect(txs[2].direction).toBe('self') - }) - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts b/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts deleted file mode 100644 index d12f98643..000000000 --- a/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * EthWallet Provider 测试 - * - * 使用 KeyFetch 架构 - * Mock 格式匹配真实服务器响应: { success: boolean, result: ... } - */ - -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { EthWalletProvider, createEthwalletProvider } from '../ethwallet-provider' -import type { ParsedApiEntry } from '@/services/chain-config' -import { keyFetch } from '@biochain/key-fetch' - -// Mock chainConfigService -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: (chainId: string) => chainId === 'ethereum' ? 'ETH' : 'UNKNOWN', - getDecimals: (chainId: string) => chainId === 'ethereum' ? 18 : 18, - }, -})) - -// Mock fetch -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }) - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }) -}) - -// 读取真实 fixture 数据 -function readFixture(name: string): T { - const dir = path.dirname(fileURLToPath(import.meta.url)) - const filePath = path.join(dir, 'fixtures/real', name) - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T -} - -// 创建 mock Response 辅助函数 -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('EthWalletProvider', () => { - const entry: ParsedApiEntry = { - type: 'ethwallet-v1', - endpoint: 'https://walletapi.example.com/wallet/eth', - } - - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - }) - - describe('createEthwalletProvider', () => { - it('creates provider for ethwallet-v1 type', () => { - const provider = createEthwalletProvider(entry, 'ethereum') - expect(provider).toBeInstanceOf(EthWalletProvider) - }) - - it('returns null for non-ethwallet type', () => { - const rpcEntry: ParsedApiEntry = { - type: 'ethereum-rpc', - endpoint: 'https://rpc.example.com', - } - const provider = createEthwalletProvider(rpcEntry, 'ethereum') - expect(provider).toBeNull() - }) - }) - - describe('nativeBalance', () => { - it('fetches balance with walletApi wrapper format', async () => { - // 真实服务器响应: { success: true, result: "balance_string" } - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - expect(url).toContain('/balance') - return createMockResponse({ - success: true, - result: '1000000000000000000', // 1 ETH - }) - }) - - const provider = new EthWalletProvider(entry, 'ethereum') - const balance = await provider.nativeBalance.fetch({ address: '0x1234' }) - - expect(mockFetch).toHaveBeenCalled() - expect(balance.symbol).toBe('ETH') - expect(balance.amount.toRawString()).toBe('1000000000000000000') - }) - }) - - describe('transactionHistory', () => { - it('converts real Blockscout native transfer to standard format', async () => { - const receiver = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' - const nativeTx = readFixture<{ tx: any }>('eth-blockscout-native-transfer-tx.json').tx - - // 真实服务器响应: { success: true, result: { status: "1", result: [...] } } - mockFetch.mockImplementation(async () => { - return createMockResponse({ - success: true, - result: { - status: '1', - result: [nativeTx], - }, - }) - }) - - const provider = new EthWalletProvider(entry, 'ethereum') - const txs = await provider.transactionHistory.fetch({ address: receiver }) - - expect(txs).toHaveLength(1) - expect(txs[0].hash).toBe(nativeTx.hash) - expect(txs[0].action).toBe('transfer') - expect(txs[0].direction).toBe('in') - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - value: nativeTx.value, - symbol: 'ETH', - decimals: 18, - }) - }) - - it('correctly determines direction for outgoing transaction', async () => { - const sender = '0xSenderAddress' - - mockFetch.mockResolvedValue(createMockResponse({ - success: true, - result: { - status: '1', - result: [{ - hash: '0xout', - from: sender, - to: '0xReceiverAddress', - value: '1000000000000000000', - timeStamp: '1700000000', - blockNumber: '123', - isError: '0', - }], - }, - })) - - const provider = new EthWalletProvider(entry, 'ethereum') - const txs = await provider.transactionHistory.fetch( - { address: sender }, - { skipCache: true } - ) - - expect(txs[0].direction).toBe('out') - }) - - it('handles failed transaction', async () => { - const address = '0xFailedTxAddress' - - mockFetch.mockResolvedValue(createMockResponse({ - success: true, - result: { - status: '1', - result: [{ - hash: '0xfailed', - from: address, - to: '0xContract', - value: '0', - timeStamp: '1700000000', - blockNumber: '456', - isError: '1', // 标记为失败 - }], - }, - })) - - const provider = new EthWalletProvider(entry, 'ethereum') - const txs = await provider.transactionHistory.fetch( - { address }, - { skipCache: true } - ) - - expect(txs[0].status).toBe('failed') - }) - - it('handles empty transaction list', async () => { - mockFetch.mockResolvedValue(createMockResponse({ - success: true, - result: { - status: '1', - result: [], - }, - })) - - const provider = new EthWalletProvider(entry, 'ethereum') - const txs = await provider.transactionHistory.fetch( - { address: '0xEmptyAddress' }, - { skipCache: true } - ) - - expect(txs).toHaveLength(0) - }) - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-lastblock.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-lastblock.json deleted file mode 100644 index 55d0ea07a..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-lastblock.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "success": true, - "result": { - "height": 1349449, - "timestamp": 23069565, - "blockSize": 494, - "signature": "7bc74353f6f9103b30567f3b02203c449e115a0f2c30ace1940ad3296f00805b0ad931b4b43d70c0a4dda4f06e86d2d9ac00871a4def152fa15f5f85fdeb840c", - "generatorPublicKey": "14e4dcb4aea3785ac66f613e14b6985b55865be03a2fc3765b19fa255d75a471", - "previousBlockSignature": "9d3c133301082c3c4781d65c56175a005901077050466d0098da424956b2fc501831e1fbb5645c2417da82f1bff63b634c837bb872345f248d03d49be9b0020e", - "reward": "1000000000", - "magic": "LLLQL", - "remark": { - "info": "", - "debug": "BFM_linux_v3.8.2_P11_DP1_T0_C0_A0.00 UNTRS_B0_E0_TIME14 LOST 0" - }, - "asset": { - "commonAsset": { - "assetChangeHash": "a73e1d4ab9cd4dd1b8fcfecf9813fa1b7529d5ad1c6c32948b686d8909abf696" - } - }, - "version": 1, - "transactionInfo": { - "startTindex": 119684, - "offset": 0, - "numberOfTransactions": 0, - "payloadHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "payloadLength": 0, - "blobSize": 0, - "totalAmount": "0", - "totalFee": "0", - "transactionInBlocks": [], - "statisticInfo": { - "totalFee": "0", - "totalAsset": "0", - "totalChainAsset": "0", - "totalAccount": 0, - "magicAssetTypeTypeStatisticHashMap": {}, - "numberOfTransactionsHashMap": {} - } - } - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-transactions-query.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-transactions-query.json deleted file mode 100644 index 35500c53d..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-transactions-query.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "success": true, - "result": { - "trs": [ - { - "tIndex": 115990, - "height": 1274316, - "signature": "7c1959883967cd629d12ced0240d8406667bf91ad16dc6d5d709db72ae9ee4ea8eb9974bc01151de805d48eb7fd77e4385467c8dc4f9ac386a9ffce2e1b43603", - "transaction": { - "version": 1, - "type": "BFM-BFMETA-AST-02", - "senderId": "b9gB9NzHKWsDKGYFCaNva6xRnxPwFfGcfx", - "senderPublicKey": "fd49214ce56d5d14d70c740765827d0ac9a5c8913eeeb50bf8e8766e6dce8c2a", - "fee": "260", - "timestamp": 21942540, - "applyBlockHeight": 1274314, - "effectiveBlockHeight": 1274414, - "signature": "628a0e17ae4093a0f43fd25b0ca9105bbfd387d110fb25ce4df3c972e0b8c83c780e445beef539e4efc40ce048838b5eaa1095aa92bc3bde196232a339955306", - "asset": { - "transferAsset": { - "sourceChainName": "bfmeta", - "sourceChainMagic": "LLLQL", - "assetType": "BFM", - "amount": "3" - } - }, - "rangeType": 0, - "range": [], - "fromMagic": "LLLQL", - "toMagic": "LLLQL", - "remark": { - "n": "BFM Pay", - "m": "true", - "a": "dweb", - "postscript": "test" - }, - "recipientId": "bCfAynSAKhzgKLi3BXyuh5k22GctLR72j", - "storageKey": "assetType", - "storageValue": "BFM" - } - }, - { - "tIndex": 116001, - "height": 1274442, - "signature": "21fc153d4e9773c6d9986bf7379a711078564ad9e8580ab916e1a25c94f7a4e68b3aaeeb6c27dc781c96c6f7ef299c164775c2d20544110d8cff29543ea3f504", - "transaction": { - "version": 1, - "type": "BFM-BFMETA-AST-02", - "senderId": "b9gB9NzHKWsDKGYFCaNva6xRnxPwFfGcfx", - "senderPublicKey": "fd49214ce56d5d14d70c740765827d0ac9a5c8913eeeb50bf8e8766e6dce8c2a", - "fee": "300", - "timestamp": 21944445, - "applyBlockHeight": 1274441, - "effectiveBlockHeight": 1274541, - "signature": "48ee7fba0b35a0bfcda5a593cc3280d060218024620f80206eb7dae37503fa91d7997353dfc36e0d165edb1013f826dcccac8accbc2ede9082e681939bc9040a", - "asset": { - "transferAsset": { - "sourceChainName": "bfmeta", - "sourceChainMagic": "LLLQL", - "assetType": "BFM", - "amount": "1" - } - }, - "rangeType": 0, - "range": [], - "fromMagic": "LLLQL", - "toMagic": "LLLQL", - "remark": { - "n": "KeyApp", - "m": "true", - "a": "web" - }, - "recipientId": "bCfAynSAKhzgKLi3BXyuh5k22GctLR72j", - "storageKey": "assetType", - "storageValue": "BFM" - } - }, - { - "tIndex": 116293, - "height": 1279655, - "signature": "7b43d900e6e4e675006c5bbfbf61e817c7df245c52adaeaeb45b1691672f650a897a0f0bb525bfa6da19aafbc4d6ff3ed123c0f39ce9826713755bcde7d48308", - "transaction": { - "version": 1, - "type": "BFM-BFMETA-AST-02", - "senderId": "bFgBYCqJE1BuDZRi76dRKt9QV8QpsdzAQn", - "senderPublicKey": "da3298a82e4e362c5cb036f2bfca94387209b0f87f201ff7e5366e5a6b8913c5", - "fee": "250", - "timestamp": 22022640, - "applyBlockHeight": 1279654, - "effectiveBlockHeight": 1279754, - "signature": "3a1aa404a1ad112dae591d99c16e92ec83d5e0d609ecf32aa49bd039b912563e81c06a8160574707c6a488cde09a78993610f371bf28010dc7a1c370d1001e03", - "asset": { - "transferAsset": { - "sourceChainName": "bfmeta", - "sourceChainMagic": "LLLQL", - "assetType": "BFM", - "amount": "1000" - } - }, - "rangeType": 0, - "range": [], - "fromMagic": "LLLQL", - "toMagic": "LLLQL", - "remark": { - "n": "KeyApp", - "m": "true", - "a": "web" - }, - "recipientId": "bCfAynSAKhzgKLi3BXyuh5k22GctLR72j", - "storageKey": "assetType", - "storageValue": "BFM" - } - } - ], - "count": 3, - "cmdLimitPerQuery": 20 - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmetav2-lastblock.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmetav2-lastblock.json deleted file mode 100644 index 5552ecdc1..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmetav2-lastblock.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "success": true, - "result": { - "height": 45052, - "timestamp": 1472055, - "blockSize": 492, - "signature": "b9630bbdd9c5745fa89ec7c2b59cc5385e5e618377334d356a31c68c19a19ac69038e02d23f224f72c524cbda0d5990cf335d42f0ffbec72ac310d1e4354eb04", - "generatorPublicKey": "ab7b49784a710c1afb2e522553934b02392b4a89778f882b1d911de10d2c4c63", - "previousBlockSignature": "b010a5355bd83650c4e8b38afc579c2dc136b473e6d26ed5be697fcd40ecc7b01aef2ea99395dde3de8e52d97d64dc41ffa9da4190a80bd4713f5db66889ca0e", - "reward": "1000000000", - "magic": "GAGGQ" - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmetav2-transactions-query.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmetav2-transactions-query.json deleted file mode 100644 index 1ecd5eb2a..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmetav2-transactions-query.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "success": true, - "result": { - "trs": [ - { - "tIndex": 3, - "height": 1, - "signature": "f4b1b23bd4e0618d98f859fa3a9235a7babfb011298955f0c5dbbf9919cf33517f284b19bb0045dc6a2dfc99465bafc300fde6f31198375cf4bf9fb894f64306", - "transaction": { - "version": 1, - "type": "BFM-BFMETAV2-AST-02", - "senderId": "b54ACZhWTyHDTxiZumQcksKMwa1zhuejKe", - "senderPublicKey": "013233143c0074592c733ff4c2d4fcde3c697d7b30e1638e90b0fb9e9c2fc90d", - "rangeType": 0, - "range": [], - "fee": "82", - "timestamp": 0, - "fromMagic": "GAGGQ", - "toMagic": "GAGGQ", - "applyBlockHeight": 1, - "effectiveBlockHeight": 1, - "signature": "47cc4d38322ae3af7f9126086f3b1ea7c49ba1bbf3dab0f246ed8a428c2babc98dda2034ebe80ac92276e3561fdbfdc0f067da38cc0fa6861d3bb017be1ece05", - "remark": {}, - "asset": { - "transferAsset": { - "sourceChainName": "bfmetav2", - "sourceChainMagic": "GAGGQ", - "assetType": "BFM", - "amount": "444" - } - }, - "recipientId": "bPbubZwJGSJBB3feZpsvttMFj8spu1jCm2", - "storageKey": "assetType", - "storageValue": "BFM" - } - } - ], - "count": 1 - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-02-transferAsset.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-02-transferAsset.json deleted file mode 100644 index 285e57994..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-02-transferAsset.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "success": true, - "result": { - "trs": [ - { - "tIndex": 2202, - "height": 71, - "signature": "04740bd3b46c2f775b67846ad7d6dfbe6ede4db568ae6470f87f9ddb9671881007f24d1131d204e9e8f8e9d99cacb6940855ba07490f2155f97d496c17d7a609", - "transaction": { - "version": 1, - "type": "BIW-BIWMETA-AST-02", - "senderId": "bNKbZW7PJFJk5ygb5MDoQUQcqEiKRLpM1G", - "senderPublicKey": "625c8841be07d0c52bd2320927e30a190cfb70fd17ce7f565c9cdfd8f8f1381a", - "fee": "5000", - "timestamp": 20064919, - "applyBlockHeight": 1, - "effectiveBlockHeight": 3001, - "signature": "ae1f5de56b79822954e3da0090cecc20e5af603cf6737e4f57cdd667e66c170f2fcb566f43053c163b755dd62847c22ee49996664c188f1361ab170924ecf202", - "asset": { - "transferAsset": { - "sourceChainName": "biwmeta", - "sourceChainMagic": "X44FA", - "assetType": "BIW", - "amount": "5000" - } - }, - "rangeType": 0, - "range": [], - "fromMagic": "X44FA", - "toMagic": "X44FA", - "remark": { - "from": "official transfer 5uv4" - }, - "recipientId": "bPp3sNMAXXpvD17Hotujuz1kR2CBd7eidK", - "storageKey": "assetType", - "storageValue": "BIW" - } - } - ], - "count": 1, - "cmdLimitPerQuery": 20 - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-03-destroyAsset.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-03-destroyAsset.json deleted file mode 100644 index 2372005d3..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-03-destroyAsset.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "success": true, - "result": { - "trs": [ - { - "tIndex": 106838, - "height": 851325, - "signature": "7b44db363f5a322ef116d32732607644791ecd2b72fbf90c479da96ddb6aacc2f2b21a173088db86607ec33d7c3a5e43ad1314557bcc1accb021c820d89a9407", - "transaction": { - "version": 1, - "type": "BIW-BIWMETA-AST-03", - "senderId": "bPyMpaHrVV2qVfRtZXkaUF6zT7JkyE6XHQ", - "senderPublicKey": "225eb336594197eab65cee7ca8a1c2a45129f707aca006d97df004f84f70b426", - "fee": "2346", - "timestamp": 33127935, - "applyBlockHeight": 851324, - "effectiveBlockHeight": 851424, - "signature": "3c96dfa4f6579ed753549df27653e762780c40c0a1f959cfa18655095ca975666ddc2aa151f179d78fd1fe27b0d7de82ba13edcef4bd53d7768399d848c94c07", - "asset": { - "destroyAsset": { - "sourceChainName": "biwmeta", - "sourceChainMagic": "X44FA", - "assetType": "AMGT", - "amount": "58636952548" - } - }, - "rangeType": 0, - "range": [], - "fromMagic": "X44FA", - "toMagic": "X44FA", - "remark": {}, - "recipientId": "b4pwE8rhnHsYcKru4dWmfVvEt9ubKebDVD", - "storageKey": "assetType", - "storageValue": "AMGT" - }, - "assetPrealnum": { - "remainAssetPrealnum": "9995569446935164", - "frozenMainAssetPrealnum": "100055648268" - } - } - ], - "count": 1, - "cmdLimitPerQuery": 20 - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-bse-01-signature.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-bse-01-signature.json deleted file mode 100644 index 4ce595d12..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-bse-01-signature.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "success": true, - "result": { - "trs": [ - { - "tIndex": 106826, - "height": 850140, - "signature": "a7e5e2e39710d75fcbf20be33f772fd3ab48c12fb76be63cc1aadce233de5758fd6b881dcfed6a6a805a7009c87974db9a5a4a653c2a3490e94c586c7628c20b", - "transaction": { - "version": 1, - "type": "BIW-BIWMETA-BSE-01", - "senderId": "b2Hp9DbuBaXA3fqTAwu2dRBcDELokKmjna", - "senderPublicKey": "6f57ff5b709869b4dc3988923f818705eb00da5f77a2b9bcda97899a9a73f014", - "fee": "2244", - "timestamp": 33110160, - "applyBlockHeight": 850139, - "effectiveBlockHeight": 850239, - "signature": "eb85d4ef1303f900815fd23098c4837b2576f27c776afb599574a6d5b0c10c1fd230cbf8613a76ac48988deb0e70c3ea6b2fcf58745771d0ca16832f24e83e0c", - "asset": { - "signature": { - "publicKey": "589d35807a2e2b6849719cf6081e71ad1cf7f41e34675002252da3b96f51c300" - } - }, - "rangeType": 0, - "range": [], - "fromMagic": "X44FA", - "toMagic": "X44FA", - "remark": { - "n": "BIW Meta", - "m": "true", - "a": "dweb" - } - } - } - ], - "count": 1, - "cmdLimitPerQuery": 20 - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-01-issueEntityFactory.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-01-issueEntityFactory.json deleted file mode 100644 index 024da8dc7..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-01-issueEntityFactory.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "success": true, - "result": { - "trs": [ - { - "tIndex": 2, - "height": 1, - "signature": "a8f7fdc3363783168988530b291410d9cfa5afd9e2c85b55f7ebdb4997036eb88947504ad2bc865d149a668fab95d8910fa29cf23e65e0a94a838be8c5cc3b0c", - "transaction": { - "version": 1, - "type": "BIW-BIWMETA-ETY-01", - "senderId": "bNKbZW7PJFJk5ygb5MDoQUQcqEiKRLpM1G", - "senderPublicKey": "625c8841be07d0c52bd2320927e30a190cfb70fd17ce7f565c9cdfd8f8f1381a", - "fee": "846", - "timestamp": 0, - "applyBlockHeight": 1, - "effectiveBlockHeight": 1, - "signature": "6160e4827b0fb7758e54f74cf906fbc6de5b6c9f803e0bf5dd39260a187fb0623600bc9a44de788cfc476f36eea0513244f550ed123aff0fbe8c84becf458206", - "asset": { - "issueEntityFactory": { - "sourceChainName": "biwmeta", - "sourceChainMagic": "X44FA", - "factoryId": "share", - "entityPrealnum": "10000", - "entityFrozenAssetPrealnum": "0", - "purchaseAssetPrealnum": "0" - } - }, - "rangeType": 0, - "range": [], - "fromMagic": "X44FA", - "toMagic": "X44FA", - "remark": {}, - "recipientId": "bNKbZW7PJFJk5ygb5MDoQUQcqEiKRLpM1G", - "storageKey": "factoryId", - "storageValue": "share" - } - } - ], - "count": 1, - "cmdLimitPerQuery": 20 - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-02-issueEntity.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-02-issueEntity.json deleted file mode 100644 index 8eafe6283..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-02-issueEntity.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "success": true, - "result": { - "trs": [ - { - "tIndex": 6, - "height": 1, - "signature": "767eec9911cccb199d43f8336ea49d4cf1e9131a36118f5cb46fd6683a163bcd442a34f5ce8d1591949336b1a2b5a96050fff8b179821749f979609f103c2501", - "transaction": { - "version": 1, - "type": "BIW-BIWMETA-ETY-02", - "senderId": "bLveQAuYRp2Fc8r65HgDxpKMqrkPcgv7Kn", - "senderPublicKey": "bfae68a05baa5584de0da9d48a025be9025b5d9ae359615f7511aa24b333c155", - "fee": "1095", - "timestamp": 0, - "applyBlockHeight": 1, - "effectiveBlockHeight": 1, - "signature": "287c40e7e68cddde48cd7158872744ad2dc772153599e61cac0311d5f0bfa0f38f4c9e81023ee21adf8cc243e6daf0fd059525dccf1f78d464f8fe7dd9785905", - "asset": { - "issueEntity": { - "sourceChainName": "biwmeta", - "sourceChainMagic": "X44FA", - "entityId": "forge_forge0003", - "taxAssetPrealnum": "0", - "entityFactoryPossessor": "bNKbZW7PJFJk5ygb5MDoQUQcqEiKRLpM1G", - "entityFactory": { - "sourceChainName": "biwmeta", - "sourceChainMagic": "X44FA", - "factoryId": "forge", - "entityPrealnum": "1000", - "entityFrozenAssetPrealnum": "0", - "purchaseAssetPrealnum": "0" - } - } - }, - "rangeType": 0, - "range": [], - "fromMagic": "X44FA", - "toMagic": "X44FA", - "remark": {}, - "recipientId": "bLveQAuYRp2Fc8r65HgDxpKMqrkPcgv7Kn", - "storageKey": "entityId", - "storageValue": "forge_forge0003" - } - } - ], - "count": 1, - "cmdLimitPerQuery": 20 - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-lastblock.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-lastblock.json deleted file mode 100644 index b88c0812c..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-lastblock.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "success": true, - "result": { - "height": 2112263, - "timestamp": 52045395, - "blockSize": 496, - "signature": "05bd12892104ff115d81bea08cd40dc7f8a8d2910890e19f83a8c2381449d1122aeb8a88e4c30a203c57b112e490bfcf6fa6bd7f228e279003f26e1941670e05", - "generatorPublicKey": "6ee23562d483475282cdf19fc0d18c9aef8f4ef0c8fa2cf50783aadec281cd60", - "previousBlockSignature": "b880b6e5c330e20c9e3c64219e5f0a8fa25ee77c81ab129e065032b2ff9306f0c21a704a3cbf22a695bdf6ff3fa0400b57c8fdca822ff0f7b0e8e2f20299fb0b", - "reward": "12000000000", - "magic": "X44FA", - "remark": { - "info": "", - "debug": "BIW_linux_v3.8.1_P11_DP1_T0_C0_A0.00 UNTRS_B0_E0_TIME14 LOST 0" - }, - "asset": { - "commonAsset": { - "assetChangeHash": "8bd1e695b169f8dca6071e4e0fced74b162cb125a22a0a38932ad49b758f6724" - } - }, - "version": 1, - "transactionInfo": { - "startTindex": 106995, - "offset": 0, - "numberOfTransactions": 0, - "payloadHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "payloadLength": 0, - "blobSize": 0, - "totalAmount": "0", - "totalFee": "0", - "transactionInBlocks": [], - "statisticInfo": { - "totalFee": "0", - "totalAsset": "0", - "totalChainAsset": "0", - "totalAccount": 0, - "magicAssetTypeTypeStatisticHashMap": {}, - "numberOfTransactionsHashMap": {} - } - } - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/bsc-transactions-history.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/bsc-transactions-history.json deleted file mode 100644 index dbc72c2d3..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/bsc-transactions-history.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "success": true, - "result": { - "status": "0", - "message": "NOTOK", - "result": [] - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/btc-mempool-address-txs.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/btc-mempool-address-txs.json deleted file mode 100644 index 969580768..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/btc-mempool-address-txs.json +++ /dev/null @@ -1,50 +0,0 @@ -[ - { - "txid": "a9fdb0066e83740d2e337a1d6686ae7969ce6e2239c8c19b3c044525808be0e0", - "version": 1, - "locktime": 0, - "vin": [ - { - "txid": "08237ca64c3831c7118e541bd18eecb5775fbc31df57b601476038d917612535", - "vout": 1, - "prevout": { - "scriptpubkey": "76a914e1ed9cbbc3166a25f471e122f078aad15a400df088ac", - "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 e1ed9cbbc3166a25f471e122f078aad15a400df0 OP_EQUALVERIFY OP_CHECKSIG", - "scriptpubkey_type": "p2pkh", - "scriptpubkey_address": "1MbbgkV38RxYxrTTtf7RF8i4DVgQY5CqYp", - "value": 31943 - }, - "scriptsig": "473044022018f82cdfcc7ae32784f98b90dd20813a4aca37e17b144c4948aca8feaa70280802200add01c4fd28e32936bb5201acf153370390fe8b5bec5b9977e056ffed466111012102951a6dd2b16c8292bb3d233c77aaacb141dbc8ff0f82aa9c1dc92bef704e5ecc", - "scriptsig_asm": "OP_PUSHBYTES_71 3044022018f82cdfcc7ae32784f98b90dd20813a4aca37e17b144c4948aca8feaa70280802200add01c4fd28e32936bb5201acf153370390fe8b5bec5b9977e056ffed46611101 OP_PUSHBYTES_33 02951a6dd2b16c8292bb3d233c77aaacb141dbc8ff0f82aa9c1dc92bef704e5ecc", - "is_coinbase": false, - "sequence": 4294967295 - } - ], - "vout": [ - { - "scriptpubkey": "0014e8df018c7e326cc253faac7e46cdc51e68542c42", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 e8df018c7e326cc253faac7e46cdc51e68542c42", - "scriptpubkey_type": "v0_p2wpkh", - "scriptpubkey_address": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", - "value": 4382 - }, - { - "scriptpubkey": "76a914e1ed9cbbc3166a25f471e122f078aad15a400df088ac", - "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 e1ed9cbbc3166a25f471e122f078aad15a400df0 OP_EQUALVERIFY OP_CHECKSIG", - "scriptpubkey_type": "p2pkh", - "scriptpubkey_address": "1MbbgkV38RxYxrTTtf7RF8i4DVgQY5CqYp", - "value": 27334 - } - ], - "size": 222, - "weight": 888, - "sigops": 4, - "fee": 227, - "status": { - "confirmed": true, - "block_height": 930986, - "block_hash": "00000000000000000001b41c34d983450674a54e3d52da18bd3f98e4ec53fa11", - "block_time": 1767602528 - } - } -] diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-approve-tx.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-approve-tx.json deleted file mode 100644 index 6539f471a..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-approve-tx.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "sourceUrl": "https://eth.blockscout.com/api?module=account&action=txlist&address=0xc00eb08fef86e5f74b692813f31bb5957eaa045c&page=1&offset=100&sort=desc", - "tx": { - "blockHash": "0x17addfc1f6580ac701500241c3e6be99cb6a291edf15f2528cff00bf67e95f88", - "blockNumber": "24167739", - "confirmations": "46", - "contractAddress": "", - "cumulativeGasUsed": "24031457", - "from": "0xc00eb08fef86e5f74b692813f31bb5957eaa045c", - "gas": "48506", - "gasPrice": "55030903", - "gasUsed": "29889", - "hash": "0x548fd4e2371f46a0c9af645421fffc58e8d3b0fd56f730fd5e29a65baedc38fd", - "input": "0x095ea7b30000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d00000000000000000000000000000000000000000000e47a32cf051df6a94000", - "isError": "0", - "methodId": "0x", - "nonce": "1196", - "timeStamp": "1767607391", - "to": "0x67466be17df832165f8c80a5a120ccc652bd7e69", - "transactionIndex": "271", - "txreceipt_status": "1", - "value": "0" - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-swap-tx.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-swap-tx.json deleted file mode 100644 index 6bf17752d..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-swap-tx.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "sourceUrl": "https://eth.blockscout.com/api?module=account&action=txlist&address=0xc00eb08fef86e5f74b692813f31bb5957eaa045c&page=1&offset=100&sort=desc", - "tx": { - "blockHash": "0x4ca333d442e829937a7cf3d9aad6f24bef4f5ba3f23c4cdd69b645782881e78e", - "blockNumber": "24167779", - "confirmations": "6", - "contractAddress": "", - "cumulativeGasUsed": "14827577", - "from": "0xc00eb08fef86e5f74b692813f31bb5957eaa045c", - "gas": "204075", - "gasPrice": "50295910", - "gasUsed": "102648", - "hash": "0x08a7938e128fee65c6480c45c94ff8ca4c016e702d364e6a3070f46e884b9b9f", - "input": "0x7ff36ab50000000000000000000000000000000000000000001e9c9d94f155d89652fdd80000000000000000000000000000000000000000000000000000000000000080000000000000000000000000c00eb08fef86e5f74b692813f31bb5957eaa045c00000000000000000000000000000000000000000000000000000000695b92f90000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000067466be17df832165f8c80a5a120ccc652bd7e69", - "isError": "0", - "methodId": "0x", - "nonce": "1199", - "timeStamp": "1767607883", - "to": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", - "transactionIndex": "161", - "txreceipt_status": "1", - "value": "178946130000000000" - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-transfer-tx.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-transfer-tx.json deleted file mode 100644 index b4768fc44..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-transfer-tx.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "sourceUrl": "https://eth.blockscout.com/api?module=account&action=txlist&address=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045&page=1&offset=100&sort=desc", - "tx": { - "blockHash": "0x5f4b2470b2da042363bfbb5540f3a2b4278a3aaf58edebb899b1e78e82d375d6", - "blockNumber": "24164496", - "confirmations": "3289", - "contractAddress": "", - "cumulativeGasUsed": "15997576", - "from": "0x5255bc25bd0f0f7614155b7692dcdf51afa3a5e3", - "gas": "31840", - "gasPrice": "1033656740", - "gasUsed": "21062", - "hash": "0x4ecff1f6fb6b26b06b6cb10fbfad4a15fd7aaef0399cc4b19c6ae54fd661720a", - "input": "0x", - "isError": "0", - "methodId": "0x", - "nonce": "175", - "timeStamp": "1767568295", - "to": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", - "transactionIndex": "102", - "txreceipt_status": "1", - "value": "4000000000000" - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-token-transfer-tx.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-token-transfer-tx.json deleted file mode 100644 index 6ee72e88e..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-token-transfer-tx.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "sourceUrl": "https://eth.blockscout.com/api?module=account&action=tokentx&address=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045&page=1&offset=100&sort=desc", - "tx": { - "value": "310011994000000000", - "blockHash": "0x6fe969bbe69f8edf6ec41aee6486bc20453e8d8473e888d704664a1e29a6bee4", - "blockNumber": "24166842", - "confirmations": "943", - "contractAddress": "0x95af4af910c28e8ece4512bfe46f1f33687424ce", - "cumulativeGasUsed": "11157339", - "from": "0x9642b23ed1e01df1092b92641051881a322f5d4e", - "functionName": "transfer(address _to, uint256 _tokenId)", - "gas": "108750", - "gasPrice": "115528955", - "gasUsed": "54533", - "hash": "0xd119b1137f67ca8e1b58e1f8bedd94f809eb4ec46ec9b5c9844e1a37ede7589f", - "input": "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045000000000000000000000000000000000000000000000000044d62441a7b0400", - "methodId": "a9059cbb", - "nonce": "2444792", - "timeStamp": "1767596591", - "to": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", - "tokenDecimal": "9", - "tokenName": "Manyu", - "tokenSymbol": "MANYU", - "transactionIndex": "94" - } -} diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/tron-trongrid-account-txs.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/tron-trongrid-account-txs.json deleted file mode 100644 index 7b0ee1065..000000000 --- a/src/services/chain-adapter/providers/__tests__/fixtures/real/tron-trongrid-account-txs.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "data": [ - { - "ret": [ - { - "contractRet": "SUCCESS", - "fee": 0 - } - ], - "signature": [ - "740b5a3fb41954c26fdf4953ac4ba2cad7bfa5e0177bbbfefb97acfb4c8acbaf66901a2abaf9c3cd0604786cc2d479ef618fcf887d69dd1249283942ca91842b00" - ], - "txID": "17deac747345af0729f4f1ee3cab56fe0d68bd427fac4b755d6b20833a18cce5", - "net_usage": 345, - "raw_data_hex": "0a0229d72208fc6efb70027abdc940e0fba296b9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a15413c32c7224525e60e43d91c3a87244d414fb1751112154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb00000000000000000000000023eb0658b59e32a200748b65f8a9b8f6c39bd784000000000000000000000000000000000000000000000000000000000036d48f70b9b2f2e8b8339001c094de0f", - "net_fee": 0, - "energy_usage": 14383, - "blockNumber": 78981623, - "block_timestamp": 1767607884000, - "energy_fee": 0, - "energy_usage_total": 14383, - "raw_data": { - "contract": [ - { - "parameter": { - "value": { - "data": "a9059cbb00000000000000000000000023eb0658b59e32a200748b65f8a9b8f6c39bd784000000000000000000000000000000000000000000000000000000000036d48f", - "owner_address": "413c32c7224525e60e43d91c3a87244d414fb17511", - "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" - }, - "type_url": "type.googleapis.com/protocol.TriggerSmartContract" - }, - "type": "TriggerSmartContract" - } - ], - "ref_block_bytes": "29d7", - "ref_block_hash": "fc6efb70027abdc9", - "expiration": 1767694188000, - "fee_limit": 33000000, - "timestamp": 1767599020345 - }, - "internal_transactions": [] - }, - { - "ret": [ - { - "contractRet": "SUCCESS", - "fee": 0 - } - ], - "signature": [ - "3652a61afb20e48d34f033d4af44f79a588e5673c75484a432be5e4c64af8805bdcc6e76a4a9d95c2421b68a92d3b7fe14755093d7ebe086a0e5b34439a0926d00" - ], - "txID": "1be961707b87a74f521fb1f893f4b90173c40c0247a6a3870e4143cd6efd9e3f", - "net_usage": 345, - "raw_data_hex": "0a0228eb2208d6d739afe3aef51940a0fde1ecb8335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a154150e68c2a4af89f80b6eb7ee9fe5042df8307e82d12154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb00000000000000000000000009dc21c4834f0f07f07f98fc64c6181693e51d0700000000000000000000000000000000000000000000000000000000000f424070a7b9deecb833900180c2d72f", - "net_fee": 0, - "energy_usage": 14383, - "blockNumber": 78981356, - "block_timestamp": 1767607083000, - "energy_fee": 0, - "energy_usage_total": 14383, - "raw_data": { - "contract": [ - { - "parameter": { - "value": { - "data": "a9059cbb00000000000000000000000009dc21c4834f0f07f07f98fc64c6181693e51d0700000000000000000000000000000000000000000000000000000000000f4240", - "owner_address": "4150e68c2a4af89f80b6eb7ee9fe5042df8307e82d", - "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" - }, - "type_url": "type.googleapis.com/protocol.TriggerSmartContract" - }, - "type": "TriggerSmartContract" - } - ], - "ref_block_bytes": "28eb", - "ref_block_hash": "d6d739afe3aef519", - "expiration": 1767607140000, - "fee_limit": 100000000, - "timestamp": 1767607082151 - }, - "internal_transactions": [] - }, - { - "ret": [ - { - "contractRet": "SUCCESS", - "fee": 0 - } - ], - "signature": [ - "b5e9394ccbda80d358525c2c71145a4491f123e0188c35d7b3391512962216d28c49f58c06c8c08af8faa248e2e988730ab40ad7f7647aa9ace16e2c6578469c00" - ], - "txID": "5f33de803afec77a38d5efb76324773ec76b00225eb166264f54a9d6c3872e5f", - "net_usage": 345, - "raw_data_hex": "0a0228e9220893c5c917db1b75cd40b0cee1ecb8335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a154150e68c2a4af89f80b6eb7ee9fe5042df8307e82d12154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb000000000000000000000000e8fc7de4f2b2cddabcca7406205048def6a802a500000000000000000000000000000000000000000000000000000000000f424070a682deecb833900180c2d72f", - "net_fee": 0, - "energy_usage": 14383, - "blockNumber": 78981354, - "block_timestamp": 1767607077000, - "energy_fee": 0, - "energy_usage_total": 14383, - "raw_data": { - "contract": [ - { - "parameter": { - "value": { - "data": "a9059cbb000000000000000000000000e8fc7de4f2b2cddabcca7406205048def6a802a500000000000000000000000000000000000000000000000000000000000f4240", - "owner_address": "4150e68c2a4af89f80b6eb7ee9fe5042df8307e82d", - "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" - }, - "type_url": "type.googleapis.com/protocol.TriggerSmartContract" - }, - "type": "TriggerSmartContract" - } - ], - "ref_block_bytes": "28e9", - "ref_block_hash": "93c5c917db1b75cd", - "expiration": 1767607134000, - "fee_limit": 100000000, - "timestamp": 1767607075110 - }, - "internal_transactions": [] - } - ], - "success": true, - "meta": { - "at": 1767607976648, - "fingerprint": "TmGrm87pwxo5LxaKFHALctkQmHPKAfAhHAZu35fchTx2NEawBa92DJAS1KfdbkPfdyBJHux11wZerNQZrYkHFKmH2vhYgHYMNJXRrgKnTVoK5WbNvspkL8gs6z9XuQDmU9YqLGwhhg4CK8bzke7eMEyEKR1WH5LRwc5g43oGF1rCTN1GmDcoEUWghLGBobDhzh1j8jqDjg2MdSGLMFhBZDoT8ASYJ", - "links": { - "next": "https://api.trongrid.io/v1/accounts/TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9/transactions?limit=20&fingerprint=TmGrm87pwxo5LxaKFHALctkQmHPKAfAhHAZu35fchTx2NEawBa92DJAS1KfdbkPfdyBJHux11wZerNQZrYkHFKmH2vhYgHYMNJXRrgKnTVoK5WbNvspkL8gs6z9XuQDmU9YqLGwhhg4CK8bzke7eMEyEKR1WH5LRwc5g43oGF1rCTN1GmDcoEUWghLGBobDhzh1j8jqDjg2MdSGLMFhBZDoT8ASYJ" - }, - "page_size": 20 - } -} diff --git a/src/services/chain-adapter/providers/__tests__/integration.test.ts b/src/services/chain-adapter/providers/__tests__/integration.test.ts deleted file mode 100644 index 28edce2da..000000000 --- a/src/services/chain-adapter/providers/__tests__/integration.test.ts +++ /dev/null @@ -1,8 +0,0 @@ - -import { describe, it } from 'vitest' - -describe('Integration Tests', () => { - it('placeholder', () => { - // Integration tests were temporarily removed. - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/mempool-provider.test.ts b/src/services/chain-adapter/providers/__tests__/mempool-provider.test.ts deleted file mode 100644 index ebf151e66..000000000 --- a/src/services/chain-adapter/providers/__tests__/mempool-provider.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Mempool Provider 测试 - * - * 使用 KeyFetch 架构与真实 fixture 数据 - */ - -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { MempoolProvider, createMempoolProvider } from '../mempool-provider' -import type { ParsedApiEntry } from '@/services/chain-config' -import { keyFetch } from '@biochain/key-fetch' - -// Mock chainConfigService -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: (chainId: string) => chainId === 'bitcoin' ? 'BTC' : 'UNKNOWN', - getDecimals: (chainId: string) => chainId === 'bitcoin' ? 8 : 8, - }, -})) - -// Mock fetch -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }) - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }) -}) - -// 读取真实 fixture 数据 -function readFixture(name: string): T { - const dir = path.dirname(fileURLToPath(import.meta.url)) - const filePath = path.join(dir, 'fixtures/real', name) - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T -} - -// 创建 mock Response 辅助函数 -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('MempoolProvider', () => { - const mockEntry: ParsedApiEntry = { - type: 'mempool-bitcoin', - endpoint: 'https://mempool.space/api', - } - - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - }) - - describe('createMempoolProvider', () => { - it('creates provider for mempool-* type', () => { - const provider = createMempoolProvider(mockEntry, 'bitcoin') - expect(provider).toBeInstanceOf(MempoolProvider) - }) - - it('returns null for non-mempool type', () => { - const rpcEntry: ParsedApiEntry = { - type: 'bitcoin-rpc', - endpoint: 'https://rpc.example.com', - } - const provider = createMempoolProvider(rpcEntry, 'bitcoin') - expect(provider).toBeNull() - }) - }) - - describe('nativeBalance', () => { - it('calculates balance from chain_stats', async () => { - const addressInfo = { - chain_stats: { - funded_txo_sum: 100000000, // 1 BTC received - spent_txo_sum: 50000000, // 0.5 BTC spent - }, - } - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - expect(url).toContain('/address/bc1qtest') - return createMockResponse(addressInfo) - }) - - const provider = new MempoolProvider(mockEntry, 'bitcoin') - const balance = await provider.nativeBalance.fetch({ address: 'bc1qtest' }) - - expect(mockFetch).toHaveBeenCalled() - expect(balance.symbol).toBe('BTC') - // 100000000 - 50000000 = 50000000 (0.5 BTC) - expect(balance.amount.toRawString()).toBe('50000000') - }) - }) - - describe('transactionHistory', () => { - it('converts real BTC mempool transaction to standard format', async () => { - const txList = readFixture('btc-mempool-address-txs.json') - const testAddress = '1MbbgkV38RxYxrTTtf7RF8i4DVgQY5CqYp' - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - if (url.includes('/txs')) { - return createMockResponse(txList) - } - return createMockResponse([]) - }) - - const provider = new MempoolProvider(mockEntry, 'bitcoin') - const txs = await provider.transactionHistory.fetch({ address: testAddress }) - - expect(txs).toHaveLength(1) - expect(txs[0].hash).toBe(txList[0].txid) - expect(txs[0].status).toBe('confirmed') - expect(txs[0].action).toBe('transfer') - // 地址在 vin 中(发送方) - expect(txs[0].direction).toBe('self') // 因为地址同时在 vin 和 vout 中 - expect(txs[0].assets[0].assetType).toBe('native') - const nativeAsset = txs[0].assets[0] - if (nativeAsset.assetType === 'native') { - expect(nativeAsset.symbol).toBe('BTC') - } - }) - - it('correctly determines direction for outgoing transaction', async () => { - const outgoingTx = [{ - txid: '0xout', - vin: [{ prevout: { scriptpubkey_address: '1MySendAddress' } }], - vout: [{ scriptpubkey_address: '1OtherAddress', value: 1000 }], - status: { confirmed: true, block_time: 1700000000 }, - }] - - mockFetch.mockResolvedValue(createMockResponse(outgoingTx)) - - const provider = new MempoolProvider(mockEntry, 'bitcoin') - const txs = await provider.transactionHistory.fetch( - { address: '1MySendAddress' }, - { skipCache: true } - ) - - expect(txs[0].direction).toBe('out') - }) - - it('correctly determines direction for incoming transaction', async () => { - const incomingTx = [{ - txid: '0xin', - vin: [{ prevout: { scriptpubkey_address: '1OtherAddress' } }], - vout: [{ scriptpubkey_address: '1MyReceiveAddress', value: 1000 }], - status: { confirmed: true, block_time: 1700000000 }, - }] - - mockFetch.mockResolvedValue(createMockResponse(incomingTx)) - - const provider = new MempoolProvider(mockEntry, 'bitcoin') - const txs = await provider.transactionHistory.fetch( - { address: '1MyReceiveAddress' }, - { skipCache: true } - ) - - expect(txs[0].direction).toBe('in') - }) - }) - - describe('blockHeight', () => { - it('fetches block height correctly', async () => { - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - if (url.includes('/blocks/tip/height')) { - return createMockResponse(930986) - } - return createMockResponse({}) - }) - - const provider = new MempoolProvider(mockEntry, 'bitcoin') - const height = await provider.blockHeight.fetch({}) - - expect(height).toBe(930986n) - }) - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/provider-capabilities.test.ts b/src/services/chain-adapter/providers/__tests__/provider-capabilities.test.ts deleted file mode 100644 index b9d7eb1e9..000000000 --- a/src/services/chain-adapter/providers/__tests__/provider-capabilities.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Provider 能力测试 - * - * 验证各 provider 的能力声明与实际行为一致 - * - * 核心原则: - * 1. supportsXxx 返回 true 时,调用对应方法应该能返回有意义的数据 - * 2. supportsXxx 返回 false 时,调用 .fetch() 应抛出 NoSupportError - * 3. API 错误(如 429)应该传递到 UI 显示,而非静默失败 - */ - -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import { clearProviderCache } from '../index' -import { ChainProvider } from '../chain-provider' -import { EtherscanProvider, TronRpcProvider } from '../index' -import type { ParsedApiEntry } from '@/services/chain-config' -import { keyFetch, NoSupportError } from '@biochain/key-fetch' - -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getConfig: vi.fn(), - getApi: vi.fn(), - getSymbol: vi.fn().mockReturnValue('TEST'), - getDecimals: vi.fn().mockReturnValue(18), - }, -})) - -// Mock fetch -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }) - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }) -}) - -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('Provider 能力测试', () => { - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - clearProviderCache() - }) - - describe('etherscan-v2 provider', () => { - const entry: ParsedApiEntry = { - type: 'etherscan-v2', - endpoint: 'https://api.etherscan.io/v2/api', - } - - it('should support nativeBalance', () => { - const provider = new EtherscanProvider(entry, 'ethereum') - const chainProvider = new ChainProvider('ethereum', [provider]) - - expect(chainProvider.supportsNativeBalance).toBe(true) - }) - - it('should support transactionHistory', () => { - const provider = new EtherscanProvider(entry, 'ethereum') - const chainProvider = new ChainProvider('ethereum', [provider]) - - expect(chainProvider.supportsTransactionHistory).toBe(true) - }) - - it('should fetch balance when API succeeds', async () => { - mockFetch.mockResolvedValue(createMockResponse({ - status: '1', - message: 'OK', - result: '1000000000000000000', - })) - - const provider = new EtherscanProvider(entry, 'ethereum') - const chainProvider = new ChainProvider('ethereum', [provider]) - - const balance = await chainProvider.nativeBalance.fetch({ address: '0x123' }) - - expect(balance.symbol).toBe('TEST') - }) - }) - - describe('tron-rpc provider', () => { - const entry: ParsedApiEntry = { - type: 'tron-rpc', - endpoint: 'https://api.trongrid.io', - } - - it('should support nativeBalance', () => { - const provider = new TronRpcProvider(entry, 'tron') - const chainProvider = new ChainProvider('tron', [provider]) - - expect(chainProvider.supportsNativeBalance).toBe(true) - }) - - it('should support transactionHistory', () => { - const provider = new TronRpcProvider(entry, 'tron') - const chainProvider = new ChainProvider('tron', [provider]) - - expect(chainProvider.supportsTransactionHistory).toBe(true) - }) - }) - - describe('no provider configured', () => { - it('should throw NoSupportError when no provider supports nativeBalance', async () => { - // 创建空 ChainProvider (没有任何 provider) - const chainProvider = new ChainProvider('empty-chain', []) - - expect(chainProvider.supportsNativeBalance).toBe(false) - - await expect( - chainProvider.nativeBalance.fetch({ address: '0x123' }) - ).rejects.toThrow(NoSupportError) - }) - - it('should throw NoSupportError when no provider supports transactionHistory', async () => { - const chainProvider = new ChainProvider('empty-chain', []) - - expect(chainProvider.supportsTransactionHistory).toBe(false) - - await expect( - chainProvider.transactionHistory.fetch({ address: '0x123' }) - ).rejects.toThrow(NoSupportError) - }) - }) -}) - -describe('API 错误处理', () => { - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - clearProviderCache() - }) - - it('should propagate 429 rate limit error when fetching balance', async () => { - const entry: ParsedApiEntry = { - type: 'tron-rpc', - endpoint: 'https://api.trongrid.io', - } - - // 模拟 fetch 返回 429 错误 - mockFetch.mockRejectedValue(new Error('429 Too Many Requests')) - - const provider = new TronRpcProvider(entry, 'tron') - const chainProvider = new ChainProvider('tron', [provider]) - - // 新 API: 应该抛出错误包含 429 - await expect( - chainProvider.nativeBalance.fetch({ address: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9' }) - ).rejects.toThrow('429') - }) - - it('should propagate 429 rate limit error when fetching transaction history', async () => { - const entry: ParsedApiEntry = { - type: 'tron-rpc', - endpoint: 'https://api.trongrid.io', - } - - // 模拟 fetch 返回 429 错误 - mockFetch.mockRejectedValue(new Error('429 Too Many Requests')) - - const provider = new TronRpcProvider(entry, 'tron') - const chainProvider = new ChainProvider('tron', [provider]) - - // 新 API: 应该抛出错误包含 429 - await expect( - chainProvider.transactionHistory.fetch({ address: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9' }) - ).rejects.toThrow('429') - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts b/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts deleted file mode 100644 index 073ca1211..000000000 --- a/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * TronRPC Provider 测试 - * - * 使用 KeyFetch 架构 - */ - -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import { TronRpcProvider, createTronRpcProvider } from '../tron-rpc-provider' -import type { ParsedApiEntry } from '@/services/chain-config' -import { keyFetch } from '@biochain/key-fetch' - -// Mock chainConfigService -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: (chainId: string) => chainId === 'tron' ? 'TRX' : 'UNKNOWN', - getDecimals: (chainId: string) => chainId === 'tron' ? 6 : 6, - }, -})) - -// Mock fetch -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }) - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }) -}) - -// 创建 mock Response 辅助函数 -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('TronRpcProvider', () => { - const mockEntry: ParsedApiEntry = { - type: 'tron-rpc', - endpoint: 'https://api.trongrid.io', - } - - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - }) - - describe('createTronRpcProvider', () => { - it('creates provider for tron-rpc type', () => { - const provider = createTronRpcProvider(mockEntry, 'tron') - expect(provider).toBeInstanceOf(TronRpcProvider) - }) - - it('creates provider for tron-rpc-pro type', () => { - const proEntry: ParsedApiEntry = { - type: 'tron-rpc-pro', - endpoint: 'https://api.trongrid.io', - } - const provider = createTronRpcProvider(proEntry, 'tron') - expect(provider).toBeInstanceOf(TronRpcProvider) - }) - - it('returns null for non-tron type', () => { - const rpcEntry: ParsedApiEntry = { - type: 'ethereum-rpc', - endpoint: 'https://rpc.example.com', - } - const provider = createTronRpcProvider(rpcEntry, 'tron') - expect(provider).toBeNull() - }) - }) - - describe('nativeBalance', () => { - it('fetches TRX balance correctly', async () => { - const accountResponse = { - balance: 1000000, // 1 TRX (6 decimals) - address: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9', - } - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - if (url.includes('/wallet/getaccount')) { - return createMockResponse(accountResponse) - } - return createMockResponse({}) - }) - - const provider = new TronRpcProvider(mockEntry, 'tron') - const balance = await provider.nativeBalance.fetch({ address: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9' }) - - expect(mockFetch).toHaveBeenCalled() - expect(balance.symbol).toBe('TRX') - expect(balance.amount.toRawString()).toBe('1000000') - }) - - it('returns zero balance when balance is undefined', async () => { - const accountResponse = { - address: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9', - } - - mockFetch.mockResolvedValue(createMockResponse(accountResponse)) - - const provider = new TronRpcProvider(mockEntry, 'tron') - const balance = await provider.nativeBalance.fetch( - { address: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9' }, - { skipCache: true } - ) - - expect(balance.amount.toRawString()).toBe('0') - }) - }) - - describe('blockHeight', () => { - it('fetches block height correctly', async () => { - const blockResponse = { - block_header: { - raw_data: { - number: 78981623, - }, - }, - } - - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - if (url.includes('/wallet/getnowblock')) { - return createMockResponse(blockResponse) - } - return createMockResponse({}) - }) - - const provider = new TronRpcProvider(mockEntry, 'tron') - const height = await provider.blockHeight.fetch({}) - - expect(height).toBe(78981623n) - }) - }) - - describe('transactionHistory', () => { - it('converts TRON transactions to standard format', async () => { - const txListResponse = { - success: true, - data: [ - { - txID: '17deac747345af0729f4f1ee3cab56fe0d68bd427fac4b755d6b20833a18cce5', - raw_data: { - contract: [{ - parameter: { - value: { - amount: 1000000, - owner_address: 'TSenderAddress', - to_address: 'TReceiverAddress', - }, - }, - type: 'TransferContract', - }], - timestamp: 1767607884000, - }, - ret: [{ contractRet: 'SUCCESS' }], - }, - ], - } - - mockFetch.mockResolvedValue(createMockResponse(txListResponse)) - - const provider = new TronRpcProvider(mockEntry, 'tron') - const txs = await provider.transactionHistory.fetch({ address: 'TSenderAddress' }) - - expect(txs).toHaveLength(1) - expect(txs[0].hash).toBe('17deac747345af0729f4f1ee3cab56fe0d68bd427fac4b755d6b20833a18cce5') - expect(txs[0].status).toBe('confirmed') - expect(txs[0].action).toBe('transfer') - expect(txs[0].direction).toBe('out') - expect(txs[0].assets[0].assetType).toBe('native') - const nativeAsset = txs[0].assets[0] - if (nativeAsset.assetType === 'native') { - expect(nativeAsset.symbol).toBe('TRX') - } - }) - - it('correctly determines direction for incoming transaction', async () => { - const txListResponse = { - success: true, - data: [ - { - txID: '0xin', - raw_data: { - contract: [{ - parameter: { - value: { - amount: 1000000, - owner_address: 'TSenderOther', - to_address: 'TMyAddress', - }, - }, - type: 'TransferContract', - }], - timestamp: 1700000000, - }, - ret: [{ contractRet: 'SUCCESS' }], - }, - ], - } - - mockFetch.mockResolvedValue(createMockResponse(txListResponse)) - - const provider = new TronRpcProvider(mockEntry, 'tron') - const txs = await provider.transactionHistory.fetch( - { address: 'tmyaddress' }, // lowercase - { skipCache: true } - ) - - expect(txs[0].direction).toBe('in') - }) - - it('handles empty transaction list', async () => { - const txListResponse = { - success: true, - data: [], - } - - mockFetch.mockResolvedValue(createMockResponse(txListResponse)) - - const provider = new TronRpcProvider(mockEntry, 'tron') - const txs = await provider.transactionHistory.fetch( - { address: 'TEmptyAddress' }, - { skipCache: true } - ) - - expect(txs).toHaveLength(0) - }) - - it('handles failed transaction', async () => { - const txListResponse = { - success: true, - data: [ - { - txID: '0xfailed', - raw_data: { - contract: [{ - parameter: { - value: { - amount: 1000000, - owner_address: 'TSender', - to_address: 'TReceiver', - }, - }, - }], - timestamp: 1700000000, - }, - ret: [{ contractRet: 'FAILED' }], - }, - ], - } - - mockFetch.mockResolvedValue(createMockResponse(txListResponse)) - - const provider = new TronRpcProvider(mockEntry, 'tron') - const txs = await provider.transactionHistory.fetch( - { address: 'TSender' }, - { skipCache: true } - ) - - expect(txs[0].status).toBe('failed') - }) - }) -}) diff --git a/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts b/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts deleted file mode 100644 index 9567e63e0..000000000 --- a/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * TronWallet Provider 测试 - * - * 使用 KeyFetch 架构 - * Mock 格式匹配真实服务器响应 - */ - -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import { TronWalletProvider, createTronwalletProvider } from '../tronwallet-provider' -import type { ParsedApiEntry } from '@/services/chain-config' -import { keyFetch } from '@biochain/key-fetch' - -// Mock chainConfigService -vi.mock('@/services/chain-config', () => ({ - chainConfigService: { - getSymbol: (chainId: string) => chainId === 'tron' ? 'TRX' : 'UNKNOWN', - getDecimals: (chainId: string) => chainId === 'tron' ? 6 : 6, - }, -})) - -// Mock fetch -const mockFetch = vi.fn() -const originalFetch = global.fetch -Object.assign(global, { fetch: mockFetch }) - -afterAll(() => { - Object.assign(global, { fetch: originalFetch }) -}) - -// 创建 mock Response 辅助函数 -function createMockResponse(data: T, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('TronWalletProvider', () => { - const entry: ParsedApiEntry = { - type: 'tronwallet-v1', - endpoint: 'https://walletapi.example.com/wallet/tron', - } - - beforeEach(() => { - vi.clearAllMocks() - keyFetch.clear() - }) - - describe('createTronwalletProvider', () => { - it('creates provider for tronwallet-v1 type', () => { - const provider = createTronwalletProvider(entry, 'tron') - expect(provider).toBeInstanceOf(TronWalletProvider) - }) - - it('returns null for non-tronwallet type', () => { - const rpcEntry: ParsedApiEntry = { - type: 'tron-rpc', - endpoint: 'https://rpc.example.com', - } - const provider = createTronwalletProvider(rpcEntry, 'tron') - expect(provider).toBeNull() - }) - }) - - describe('nativeBalance', () => { - it('fetches balance with walletApi wrapper format', async () => { - // 真实服务器响应: { success: true, result: "balance" } - mockFetch.mockImplementation(async (input: Request | string) => { - const url = typeof input === 'string' ? input : input.url - expect(url).toContain('/balance') - return createMockResponse({ - success: true, - result: '1000000', // 1 TRX (6 decimals) - }) - }) - - const provider = new TronWalletProvider(entry, 'tron') - const balance = await provider.nativeBalance.fetch({ address: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9' }) - - expect(mockFetch).toHaveBeenCalled() - expect(balance.symbol).toBe('TRX') - expect(balance.amount.toRawString()).toBe('1000000') - }) - - it('handles numeric balance result', async () => { - mockFetch.mockResolvedValue(createMockResponse({ - success: true, - result: 2000000, // 数字格式 - })) - - const provider = new TronWalletProvider(entry, 'tron') - const balance = await provider.nativeBalance.fetch( - { address: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9' }, - { skipCache: true } - ) - - expect(balance.amount.toRawString()).toBe('2000000') - }) - }) - - describe('transactionHistory', () => { - it('converts TRON native transactions to standard format', async () => { - const sender = 'TSenderAddress' - - // 真实服务器响应: { success: true, data: [...] } - mockFetch.mockImplementation(async () => { - return createMockResponse({ - success: true, - data: [{ - txID: '17deac747345af0729f4f1ee3cab56fe0d68bd427fac4b755d6b20833a18cce5', - from: sender, - to: 'TReceiverAddress', - amount: 1000000, - timestamp: 1767607884000, - contractRet: 'SUCCESS', - }], - }) - }) - - const provider = new TronWalletProvider(entry, 'tron') - const txs = await provider.transactionHistory.fetch({ address: sender }) - - expect(txs).toHaveLength(1) - expect(txs[0].hash).toBe('17deac747345af0729f4f1ee3cab56fe0d68bd427fac4b755d6b20833a18cce5') - expect(txs[0].status).toBe('confirmed') - expect(txs[0].action).toBe('transfer') - expect(txs[0].direction).toBe('out') - expect(txs[0].assets[0]).toMatchObject({ - assetType: 'native', - value: '1000000', - symbol: 'TRX', - decimals: 6, - }) - }) - - it('correctly determines direction for incoming transaction', async () => { - const receiver = 'TMyAddress' - - mockFetch.mockResolvedValue(createMockResponse({ - success: true, - data: [{ - txID: '0xin', - from: 'TOtherAddress', - to: receiver, - amount: 5000000, - timestamp: 1700000000, - contractRet: 'SUCCESS', - }], - })) - - const provider = new TronWalletProvider(entry, 'tron') - const txs = await provider.transactionHistory.fetch( - { address: receiver }, - { skipCache: true } - ) - - expect(txs[0].direction).toBe('in') - }) - - it('handles failed transaction', async () => { - const address = 'TFailedTxAddress' - - mockFetch.mockResolvedValue(createMockResponse({ - success: true, - data: [{ - txID: '0xfailed', - from: address, - to: 'TContract', - amount: 0, - timestamp: 1700000000, - contractRet: 'FAILED', - }], - })) - - const provider = new TronWalletProvider(entry, 'tron') - const txs = await provider.transactionHistory.fetch( - { address }, - { skipCache: true } - ) - - expect(txs[0].status).toBe('failed') - }) - - it('handles empty transaction list', async () => { - mockFetch.mockResolvedValue(createMockResponse({ - success: true, - data: [], - })) - - const provider = new TronWalletProvider(entry, 'tron') - const txs = await provider.transactionHistory.fetch( - { address: 'TEmptyAddress' }, - { skipCache: true } - ) - - expect(txs).toHaveLength(0) - }) - - it('returns empty when success is false', async () => { - mockFetch.mockResolvedValue(createMockResponse({ - success: false, - data: [], - })) - - const provider = new TronWalletProvider(entry, 'tron') - const txs = await provider.transactionHistory.fetch( - { address: 'TErrorAddress' }, - { skipCache: true } - ) - - expect(txs).toHaveLength(0) - }) - }) -}) diff --git a/src/services/chain-adapter/providers/biowallet-provider.ts b/src/services/chain-adapter/providers/biowallet-provider.ts deleted file mode 100644 index 7845984dc..000000000 --- a/src/services/chain-adapter/providers/biowallet-provider.ts +++ /dev/null @@ -1,651 +0,0 @@ -/** - * BioWallet API Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - */ - -import { z } from 'zod' -import { keyFetch, ttl, derive, transform, postBody, interval, deps, combine } from '@biochain/key-fetch' -import type { KeyFetchInstance } from '@biochain/key-fetch' -import type { ApiProvider, Balance, TokenBalance, Transaction, Direction, Action, BalanceOutput, BlockHeightOutput, TokenBalancesOutput, TransactionOutput, TransactionsOutput, AddressParams, TxHistoryParams, TransactionParams } from './types' -import { - BalanceOutputSchema, - TokenBalancesOutputSchema, - TransactionsOutputSchema, - TransactionOutputSchema, - BlockHeightOutputSchema, - AddressParamsSchema, - TxHistoryParamsSchema, -} from './types' -import { - setForgeInterval, -} from '../bioforest/fetch' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { BioforestIdentityMixin } from '../bioforest/identity-mixin' -import { BioforestTransactionMixin } from '../bioforest/transaction-mixin' -import { BioforestAccountMixin } from '../bioforest/account-mixin' -import { fetchGenesisBlock } from '@/services/bioforest-sdk' - -// ==================== 参数 Schema 定义 ==================== - -// ==================== 参数 Schema 定义 ==================== -// 使用 shared types 中的 Definitions - - -// ==================== 内部 API Schema(原始响应格式,来自 main 分支)==================== - -// 资产余额项(嵌套结构:assets[magic][assetType] = { assetNumber, assetType }) -const BiowalletAssetItemSchema = z.object({ - assetNumber: z.string(), - assetType: z.string(), -}).passthrough() - -const AssetResponseSchema = z.object({ - success: z.boolean(), - result: z.object({ - address: z.string(), - assets: z.record(z.string(), z.record(z.string(), BiowalletAssetItemSchema)), - }).nullish(), // API returns null for addresses without assets -}) - -// 交易项 - 支持所有 BioForest 交易类型 -const BiowalletTxItemSchema = z.object({ - height: z.number(), - signature: z.string(), // 这是区块签名,不是交易签名 - transaction: z.object({ - type: z.string(), - senderId: z.string(), - recipientId: z.string().optional().default(''), - timestamp: z.number(), - signature: z.string(), // 这才是真正的交易签名/ID - asset: z.object({ - transferAsset: z.object({ - assetType: z.string(), - amount: z.string(), - }).optional(), - // 其他 BioForest 资产类型 - giftAsset: z.object({ - totalAmount: z.string(), - assetType: z.string(), - }).passthrough().optional(), - grabAsset: z.object({ - transactionSignature: z.string(), - }).passthrough().optional(), - trustAsset: z.object({ - trustees: z.array(z.string()), - numberOfSignFor: z.number(), - assetType: z.string(), - amount: z.string(), - }).passthrough().optional(), - // BIW / BioForest Meta 其他类型 - signature: z.object({ - publicKey: z.string().optional(), - }).passthrough().optional(), - destroyAsset: z.object({ - assetType: z.string(), - amount: z.string(), - }).passthrough().optional(), - issueEntity: z.object({ - entityId: z.string().optional(), - }).passthrough().optional(), - issueEntityFactory: z.object({ - factoryId: z.string().optional(), - }).passthrough().optional(), - }).passthrough().optional(), - }).passthrough(), -}).passthrough() - -const TxListResponseSchema = z.object({ - success: z.boolean(), - result: z.object({ - trs: z.array(BiowalletTxItemSchema), - count: z.number().optional(), - }).optional(), -}) - -const BlockResponseSchema = z.object({ - success: z.boolean(), - result: z.object({ - height: z.number(), - }).optional(), -}) - -// Pending 交易查询参数 -const PendingTrParamsSchema = z.object({ - senderId: z.string(), - sort: z.number().optional(), -}) - -// Pending 交易响应 Schema -const PendingTrItemSchema = z.object({ - state: z.number(), // InternalTransStateID - trJson: BiowalletTxItemSchema.shape.transaction, - signature: z.string().optional(), // 从外层提取 - createdTime: z.string(), -}) - -const PendingTrResponseSchema = z.object({ - success: z.boolean(), - result: z.array(PendingTrItemSchema).optional(), -}) - -type AssetResponse = z.infer -type TxListResponse = z.infer -type BiowalletTxItem = z.infer - -// ==================== 工具函数 ==================== - -function getDirection(from: string, to: string, address: string): Direction { - const fromLower = from.toLowerCase() - const toLower = to.toLowerCase() - const addrLower = address.toLowerCase() - - if (!toLower) return fromLower === addrLower ? 'out' : 'in' - if (fromLower === addrLower && toLower === addrLower) return 'self' - if (fromLower === addrLower) return 'out' - return 'in' -} - -// 默认 epoch 时间(用于未配置 genesis block 的链,此值不应该被使用) -// 每个链的真实 epoch 应该从创世块的 beginEpochTime 读取 -const DEFAULT_EPOCH_MS = 0 - -/** - * 检测 BioForest 交易类型并映射到标准 Action - * 格式: {CHAIN}-{NETWORK}-{TYPE}-{VERSION} - * 例如: BFM-BFMETA-AST-02 = 资产转账 - */ -function detectAction(txType: string): Action { - const typeMap: Record = { - 'AST-01': 'transfer', // 资产转移 (旧版) - 'AST-02': 'transfer', // 资产转移 - 'AST-03': 'destroyAsset', // 销毁资产 (BIW) - 'BSE-01': 'signature', // 签名/签章 (BIW) - 'ETY-01': 'issueEntity', // 发行实体工厂 (BIW) - 'ETY-02': 'issueEntity', // 发行实体 (BIW) - 'GFT-01': 'gift', // 发红包 - 'GFT-02': 'gift', // 发红包 v2 - 'GRB-01': 'grab', // 抢红包 - 'GRB-02': 'grab', // 抢红包 v2 - 'TRS-01': 'trust', // 委托 - 'TRS-02': 'trust', // 委托 v2 - 'SGN-01': 'signFor', // 代签 - 'SGN-02': 'signFor', // 代签 v2 - 'EMI-01': 'emigrate', // 跨链转出 - 'EMI-02': 'emigrate', // 跨链转出 v2 - 'IMI-01': 'immigrate', // 跨链转入 - 'IMI-02': 'immigrate', // 跨链转入 v2 - 'ISA-01': 'issueAsset', // 发行资产 - 'ICA-01': 'increaseAsset', // 增发资产 - 'DSA-01': 'destroyAsset', // 销毁资产 - 'ISE-01': 'issueEntity', // 发行实体 - 'DSE-01': 'destroyEntity', // 销毁实体 - 'LNS-01': 'locationName', // 位名 - 'DAP-01': 'dapp', // DApp 调用 - 'CRT-01': 'certificate', // 证书 - 'MRK-01': 'mark', // 标记 - } - - // 提取类型后缀 (例如 "BFM-BFMETA-AST-02" -> "AST-02") - const parts = txType.split('-') - if (parts.length >= 4) { - const suffix = `${parts[parts.length - 2]}-${parts[parts.length - 1]}` - return typeMap[suffix] ?? 'unknown' - } - - return 'unknown' -} - -/** 从交易资产中提取金额和类型 */ -function extractAssetInfo( - asset: BiowalletTxItem['transaction']['asset'], - defaultSymbol: string -): { value: string | null; assetType: string } { - if (!asset) return { value: null, assetType: defaultSymbol } - - // 转账 - if (asset.transferAsset) { - return { - value: asset.transferAsset.amount, - assetType: asset.transferAsset.assetType, - } - } - - // 红包 - if (asset.giftAsset) { - return { - value: asset.giftAsset.totalAmount, - assetType: asset.giftAsset.assetType, - } - } - - // 委托 - if (asset.trustAsset) { - return { - value: asset.trustAsset.amount, - assetType: asset.trustAsset.assetType, - } - } - - // 抢红包 (金额需要从其他地方获取) - if (asset.grabAsset) { - return { value: '0', assetType: defaultSymbol } - } - - // 销毁资产 - if (asset.destroyAsset) { - return { - value: asset.destroyAsset.amount, - assetType: asset.destroyAsset.assetType, - } - } - - // 发行实体 / 发行实体工厂:无金额,用 0 占位 - if (asset.issueEntity || asset.issueEntityFactory) { - return { value: '0', assetType: defaultSymbol } - } - - // 签名/签章:无金额,用 0 占位 - if (asset.signature) { - return { value: '0', assetType: defaultSymbol } - } - - return { value: null, assetType: defaultSymbol } -} - -/** - * 统一的 BioChain 交易转换函数 - * 将 BioChain 的交易格式转换为 KeyApp 的 Transaction 格式 - */ -function convertBioTransactionToTransaction( - bioTx: BiowalletTxItem['transaction'], - options: { - signature: string - height?: number - status: 'pending' | 'confirmed' | 'failed' - createdTime?: string // pending 交易的创建时间 - address?: string // 当前钱包地址,用于判断方向 - epochMs: number // 链的创世时间(毫秒),从 genesis block 的 beginEpochTime 获取 - } -): Transaction { - const { signature, height, status, createdTime, address = '', epochMs } = options - - // 提取资产信息 - const { value, assetType } = extractAssetInfo(bioTx.asset, 'BFM') - - // 计算时间戳:使用链特定的 epoch 时间 - const timestamp = createdTime - ? new Date(createdTime).getTime() - : epochMs + bioTx.timestamp * 1000 - - // 判断方向 - const direction = address - ? getDirection(bioTx.senderId, bioTx.recipientId ?? '', address) - : 'out' - - return { - hash: signature, - from: bioTx.senderId, - to: bioTx.recipientId ?? '', - timestamp, - status, - blockNumber: height !== undefined ? BigInt(height) : undefined, - action: detectAction(bioTx.type), - direction, - assets: [{ - assetType: 'native' as const, - value: value ?? '0', - symbol: assetType, - decimals: 8, // TODO: 从 chainConfig 获取 - }], - } -} - - -// ==================== Base Class for Mixins ==================== - -class BiowalletBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class BiowalletProvider extends BioforestAccountMixin(BioforestIdentityMixin(BioforestTransactionMixin(BiowalletBase))) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - private forgeInterval: number = 15000 // 默认 15s - private epochMs: number = DEFAULT_EPOCH_MS // 链的创世时间(毫秒),从 genesis block 的 beginEpochTime 获取 - - // ==================== 私有基础 Fetcher ==================== - - readonly #addressAsset: KeyFetchInstance - readonly #txList: KeyFetchInstance - - // ==================== 公开响应式数据源(派生视图)==================== - - readonly nativeBalance: KeyFetchInstance - readonly tokenBalances: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - readonly blockHeight: KeyFetchInstance - readonly transaction: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const baseUrl = this.endpoint - const symbol = this.symbol - const decimals = this.decimals - - // ==================== 链配置读取(创世块) ==================== - - // 从静态配置中读取创世块以获取 forgeInterval 和 beginEpochTime - const genesisPath = chainConfigService.getBiowalletGenesisBlock(chainId) - if (genesisPath) { - fetchGenesisBlock(chainId, genesisPath) - .then(genesis => { - // Genesis Block JSON 包含 forgeInterval 和 beginEpochTime - const interval = genesis.asset.genesisAsset.forgeInterval - if (typeof interval === 'number') { - this.forgeInterval = interval * 1000 - setForgeInterval(chainId, this.forgeInterval) - } - // 读取创世块的 beginEpochTime 作为 timestamp 的基准 - const beginEpochTime = genesis.asset.genesisAsset.beginEpochTime - if (typeof beginEpochTime === 'number') { - this.epochMs = beginEpochTime - } - }) - .catch(err => { - console.warn('Failed to fetch genesis block:', err) - }) - } - - // ==================== 区块高度(必须先创建,供其他 fetcher 依赖)==================== - - const blockApi = keyFetch.create({ - name: `biowallet.${chainId}.blockApi`, - outputSchema: BlockResponseSchema, - url: `${baseUrl}/block/lastblock`, - // 动态获取 interval 和 TTL - use: [ - interval(() => this.forgeInterval), - ttl(() => Math.max(1000, this.forgeInterval - 2000)) // TTL 略小于 interval,确保能触发重新获取 - ], - }) - - // ==================== 基础 Fetcher(私有)==================== - // 使用 postBody 插件将 params 作为 JSON body 发送 - // 使用 inputSchema 进行类型推导 - // 使用 deps(blockApi) 在区块高度变化时自动刷新 - - this.#addressAsset = keyFetch.create({ - name: `biowallet.${chainId}.addressAsset`, - outputSchema: AssetResponseSchema, - inputSchema: AddressParamsSchema, - url: `${baseUrl}/address/asset`, - method: 'POST', - }).use(deps(blockApi), postBody()) - - this.#txList = keyFetch.create({ - name: `biowallet.${chainId}.txList`, - outputSchema: TxListResponseSchema, - inputSchema: TxHistoryParamsSchema, - url: `${baseUrl}/transactions/query`, - method: 'POST', - use: [ - deps(blockApi), - // 转换通用参数到 BioChain 格式 - postBody({ - transform: (params) => ({ - address: params.address, - page: params.page ?? 1, - pageSize: params.limit ?? 50, - sort: -1, // BioChain 需要 -1 表示逆序(最新在前) - }), - }), - // 移除 TTL,确保与 blockApi 同步 - ], - }) - - // 派生 blockHeight 视图 - this.blockHeight = derive({ - name: `biowallet.${chainId}.blockHeight`, - source: blockApi, - outputSchema: BlockHeightOutputSchema, - use: [ - transform, bigint>({ - transform: (raw) => { - if (!raw.result?.height) return BigInt(0) - return BigInt(raw.result.height) - }, - }), - ], - }) - - // ==================== 派生视图(使用 use 插件系统)==================== - - // 原生余额:从 #addressAsset 派生 - // 原版逻辑:遍历 assets[magic][assetType],找到 assetType === symbol - this.nativeBalance = derive({ - name: `biowallet.${chainId}.nativeBalance`, - source: this.#addressAsset, - outputSchema: BalanceOutputSchema, - use: [ - transform({ - transform: (raw) => { - if (!raw.result?.assets) { - return { amount: Amount.zero(decimals, symbol), symbol } - } - // 遍历嵌套结构 assets[magic][assetType] - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === symbol) { - return { - amount: Amount.fromRaw(asset.assetNumber, decimals, symbol), - symbol, - } - } - } - } - return { amount: Amount.zero(decimals, symbol), symbol } - }, - }), - ], - }) - - // 代币余额列表:从 #addressAsset 派生 - this.tokenBalances = derive({ - name: `biowallet.${chainId}.tokenBalances`, - source: this.#addressAsset, - outputSchema: TokenBalancesOutputSchema, - use: [ - transform({ - transform: (raw) => { - if (!raw.result?.assets) return [] - const tokens: TokenBalance[] = [] - // 遍历嵌套结构 assets[magic][assetType] - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - const isNative = asset.assetType === symbol - tokens.push({ - symbol: asset.assetType, - name: asset.assetType, - amount: Amount.fromRaw(asset.assetNumber, decimals, asset.assetType), - isNative, - decimals, - icon: (asset as Record).iconUrl as string | undefined, - }) - } - } - // 原生代币排前面 - tokens.sort((a, b) => { - if (a.isNative && !b.isNative) return -1 - if (!a.isNative && b.isNative) return 1 - return b.amount.toNumber() - a.amount.toNumber() - }) - return tokens - }, - }), - ], - }) - - // 交易历史:从 #txList 派生 - // 原版逻辑:使用 detectAction 和 extractAssetInfo - // 使用闭包捕获 this 以便在 transform 中访问动态获取的 epochMs - const provider = this - this.transactionHistory = derive({ - name: `biowallet.${chainId}.transactionHistory`, - source: this.#txList, - outputSchema: TransactionsOutputSchema, - }).use(transform({ - transform: (raw: TxListResponse, ctx) => { - if (!raw.result?.trs) return [] - const address = ctx.params.address - const normalizedAddress = address.toLowerCase() - - return raw.result.trs - .map((item): Transaction | null => { - const tx = item.transaction - const action = detectAction(tx.type) - const direction = getDirection(tx.senderId, tx.recipientId ?? '', normalizedAddress) - - const { value, assetType } = extractAssetInfo(tx.asset, symbol) - if (value === null) return null - - return { - // 使用 transaction.signature (交易签名),而不是 item.signature (区块签名) - hash: tx.signature ?? item.signature, - from: tx.senderId, - to: tx.recipientId ?? '', - timestamp: provider.epochMs + tx.timestamp * 1000, // 使用链特定的 epoch 时间 - status: 'confirmed', - blockNumber: BigInt(item.height), - action, - direction, - assets: [{ - assetType: 'native' as const, - value, - symbol: assetType, - decimals, - }], - } - }) - .filter((tx): tx is Transaction => tx !== null) - .sort((a, b) => b.timestamp - a.timestamp) as Transaction[]// 最新交易在前 - }, - }),) - - // Pending 交易列表(独立 fetcher) - const bioPendingTr = keyFetch.create({ - name: `biowallet.${chainId}.pendingTr`, - outputSchema: PendingTrResponseSchema, - inputSchema: PendingTrParamsSchema, - url: `${baseUrl}/pendingTr`, - method: 'POST', - use: [ - deps(blockApi), - postBody({ - transform: (params) => ({ - senderId: params.senderId, - sort: -1, - }), - }), - // 移除 TTL,确保与 blockApi/txList 同步 - ], - }) - - // 单笔交易查询内部 fetcher(使用 signature 参数) - const singleTxApi = keyFetch.create({ - name: `biowallet.${chainId}.singleTx`, - outputSchema: TxListResponseSchema, - inputSchema: z.object({ - txHash: z.string().optional(), - }), - url: `${baseUrl}/transactions/query`, - method: 'POST', - use: [ - deps(blockApi), // 由 blockApi 驱动,确保交易状态实时更新 - postBody({ - transform: (params) => ({ - signature: (params as { txHash?: string }).txHash, - }), - }), - ], - }) - - // transaction: 使用 combine 合并 pendingTr 和 singleTx 的结果 - this.transaction = combine({ - name: `biowallet.${chainId}.transaction`, - outputSchema: TransactionOutputSchema, - sources: { - pending: bioPendingTr, - confirmed: singleTxApi, - }, - // 自定义 inputSchema:外部只需要传 txHash 和 senderId - inputSchema: z.object({ - txHash: z.string(), - senderId: z.string().optional(), - }), - // 将外部 params 转换为各个 source 需要的格式 - transformParams: (params) => ({ - pending: { senderId: params.senderId ?? '' }, - confirmed: { txHash: params.txHash }, - }), - - }).use(transform({ - transform: (results: { - pending?: z.infer, - confirmed?: TxListResponse - }, ctx) => { - // 返回 pending 状态的交易 - if (results.pending?.result && results.pending.result.length > 0) { - const pendingTx = results.pending.result.find(tx => tx.signature === ctx.params.txHash) - if (pendingTx) { - return convertBioTransactionToTransaction(pendingTx.trJson, { - // 使用 trJson.signature (交易签名),而不是 pendingTx.signature - signature: pendingTx.trJson.signature ?? pendingTx.signature ?? '', - status: 'pending', - createdTime: pendingTx.createdTime, - epochMs: provider.epochMs, - }) - } - } - // 优先返回 confirmed 交易 - if (results.confirmed?.result?.trs?.length) { - const item = results.confirmed.result.trs[0] - return convertBioTransactionToTransaction(item.transaction, { - // 使用 transaction.signature (交易签名),而不是 item.signature (区块签名) - signature: item.transaction.signature ?? item.signature, - height: item.height, - status: 'confirmed', - epochMs: provider.epochMs, - }) - } - - // 都没找到 - return null - }, - })); - } -} - -export function createBiowalletProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'biowallet-v1') { - return new BiowalletProvider(entry, chainId) - } - return null -} diff --git a/src/services/chain-adapter/providers/bscwallet-provider.ts b/src/services/chain-adapter/providers/bscwallet-provider.ts deleted file mode 100644 index cb9deae9f..000000000 --- a/src/services/chain-adapter/providers/bscwallet-provider.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * BscWallet API Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform } from '@biochain/key-fetch' -import type { KeyFetchInstance, } from '@biochain/key-fetch' -import { BalanceOutputSchema, TransactionsOutputSchema, AddressParamsSchema, TxHistoryParamsSchema } from './types' -import type { ApiProvider, Balance, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams, Direction } from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { EvmIdentityMixin } from '../evm/identity-mixin' -import { EvmTransactionMixin } from '../evm/transaction-mixin' - -const BalanceApiSchema = z.object({ balance: z.string() }).passthrough() -const TxApiSchema = z.object({ - transactions: z.array(z.object({ - hash: z.string(), from: z.string(), to: z.string(), value: z.string(), - timestamp: z.number(), status: z.string().optional(), - }).passthrough()), -}).passthrough() - -type BalanceApi = z.infer -type TxApi = z.infer - -function getDirection(from: string, to: string, address: string): Direction { - const f = from.toLowerCase(), t = to.toLowerCase() - if (f === address && t === address) return 'self' - return f === address ? 'out' : 'in' -} - -// ==================== Base Class for Mixins ==================== - -class BscWalletBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class BscWalletProvider extends EvmIdentityMixin(EvmTransactionMixin(BscWalletBase)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - - readonly #balanceApi: KeyFetchInstance - readonly #txApi: KeyFetchInstance - readonly nativeBalance: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const { endpoint: base, symbol, decimals } = this - - // 区块高度触发器 - 使用 interval 驱动数据更新 - const blockHeightTrigger = keyFetch.create({ - name: `bscwallet.${chainId}.blockTrigger`, - outputSchema: z.object({ timestamp: z.number() }), - url: 'internal://trigger', - use: [ - interval(30_000), // 节约 API 费用,至少 30s 轮询 - { - name: 'trigger', - onFetch: async (_req, _next, ctx) => { - return ctx.createResponse({ timestamp: Date.now() }) - }, - }, - ], - }) - - this.#balanceApi = keyFetch.create({ - name: `bscwallet.${chainId}.balanceApi`, - outputSchema: BalanceApiSchema, - inputSchema: AddressParamsSchema, - url: `${base}/balance`, - use: [deps(blockHeightTrigger)] - }) - this.#txApi = keyFetch.create({ - name: `bscwallet.${chainId}.txApi`, - outputSchema: TxApiSchema, - inputSchema: TxHistoryParamsSchema, - url: `${base}/transactions`, - use: [deps(blockHeightTrigger)] - }) - - this.nativeBalance = derive({ - name: `bscwallet.${chainId}.nativeBalance`, - source: this.#balanceApi, - outputSchema: BalanceOutputSchema, - use: [transform({ transform: (r) => ({ amount: Amount.fromRaw(r.balance, decimals, symbol), symbol }) })], - }) - - this.transactionHistory = keyFetch.create({ - name: `bscwallet.${chainId}.transactionHistory`, - inputSchema: TxHistoryParamsSchema, - outputSchema: TransactionsOutputSchema, - url: `${base}/transactions`, - }).use( - deps(this.#txApi), - transform({ - transform: (r: z.output, ctx) => { - const addr = ctx.params.address.toLowerCase() - return r.transactions.map(tx => ({ - hash: tx.hash, from: tx.from, to: tx.to, timestamp: tx.timestamp, - status: tx.status === 'success' ? 'confirmed' as const : 'failed' as const, - action: 'transfer' as const, direction: getDirection(tx.from, tx.to, addr), - assets: [{ assetType: 'native' as const, value: tx.value, symbol, decimals }], - })) - }, - }), - ) - } -} - -export function createBscWalletProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'bscwallet-v1') return new BscWalletProvider(entry, chainId) - return null -} diff --git a/src/services/chain-adapter/providers/btcwallet-provider.ts b/src/services/chain-adapter/providers/btcwallet-provider.ts deleted file mode 100644 index 57c2370df..000000000 --- a/src/services/chain-adapter/providers/btcwallet-provider.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * BtcWallet API Provider (Blockbook) - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, pathParams, walletApiUnwrap } from '@biochain/key-fetch' -import type { KeyFetchInstance } from '@biochain/key-fetch' -import { BalanceOutputSchema, TransactionsOutputSchema, AddressParamsSchema, TxHistoryParamsSchema } from './types' -import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { BitcoinIdentityMixin } from '../bitcoin/identity-mixin' -import { BitcoinTransactionMixin } from '../bitcoin/transaction-mixin' - -// ==================== Schema 定义 ==================== - -const AddressInfoSchema = z.object({ - balance: z.string(), - txs: z.number().optional(), - transactions: z.array(z.object({ - txid: z.string(), - vin: z.array(z.object({ addresses: z.array(z.string()).optional() }).passthrough()).optional(), - vout: z.array(z.object({ addresses: z.array(z.string()).optional(), value: z.string().optional() }).passthrough()).optional(), - blockTime: z.number().optional(), - confirmations: z.number().optional(), - }).passthrough()).optional(), -}).passthrough() - -type AddressInfo = z.infer - -// ==================== 工具函数 ==================== - -function getDirection(vin: any[], vout: any[], address: string): Direction { - const isFrom = vin?.some(v => v.addresses?.includes(address)) - const isTo = vout?.some(v => v.addresses?.includes(address)) - if (isFrom && isTo) return 'self' - return isFrom ? 'out' : 'in' -} - -// ==================== Base Class for Mixins ==================== - -class BtcWalletBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class BtcWalletProvider extends BitcoinIdentityMixin(BitcoinTransactionMixin(BtcWalletBase)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - - readonly #addressInfo: KeyFetchInstance - readonly #addressTx: KeyFetchInstance - readonly nativeBalance: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const { endpoint: base, symbol, decimals } = this - - // 区块高度触发器 - 使用 interval 驱动数据更新 - const blockHeightTrigger = keyFetch.create({ - name: `btcwallet.${chainId}.blockTrigger`, - outputSchema: z.object({ timestamp: z.number() }), - url: 'internal://trigger', - use: [ - interval(60_000), // Bitcoin 约 10 分钟出块,60s 轮询合理 - { - name: 'trigger', - onFetch: async (_req, _next, ctx) => { - return ctx.createResponse({ timestamp: Date.now() }) - }, - }, - ], - }) - - this.#addressInfo = keyFetch.create({ - name: `btcwallet.${chainId}.addressInfo`, - outputSchema: AddressInfoSchema, - inputSchema: AddressParamsSchema, - url: `${base}/address/:address`, - use: [deps(blockHeightTrigger), pathParams(), walletApiUnwrap()], - }) - - this.#addressTx = keyFetch.create({ - name: `btcwallet.${chainId}.addressTx`, - outputSchema: AddressInfoSchema, - inputSchema: TxHistoryParamsSchema, - url: `${base}/address/:address`, - use: [deps(blockHeightTrigger), pathParams()], - }) - - this.nativeBalance = derive({ - name: `btcwallet.${chainId}.nativeBalance`, - source: this.#addressInfo, - outputSchema: BalanceOutputSchema, - use: [transform({ - transform: (r) => ({ amount: Amount.fromRaw(r.balance, decimals, symbol), symbol }), - })], - }) - - this.transactionHistory = derive({ - name: `btcwallet.${chainId}.transactionHistory`, - source: this.#addressTx, - outputSchema: TransactionsOutputSchema, - }).use( - transform({ - transform: (r: AddressInfo, ctx) => { - const addr = ctx.params.address ?? '' - return (r.transactions ?? []).map(tx => ({ - hash: tx.txid, - from: tx.vin?.[0]?.addresses?.[0] ?? '', - to: tx.vout?.[0]?.addresses?.[0] ?? '', - timestamp: (tx.blockTime ?? 0) * 1000, - status: (tx.confirmations ?? 0) > 0 ? 'confirmed' as const : 'pending' as const, - action: 'transfer' as const, - direction: getDirection(tx.vin ?? [], tx.vout ?? [], addr), - assets: [{ assetType: 'native' as const, value: tx.vout?.[0]?.value ?? '0', symbol, decimals }], - })) as Transaction[] - }, - }), - ) - } -} - -export function createBtcwalletProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'btcwallet-v1') return new BtcWalletProvider(entry, chainId) - return null -} diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts index bb6c0b768..6c8334049 100644 --- a/src/services/chain-adapter/providers/chain-provider.ts +++ b/src/services/chain-adapter/providers/chain-provider.ts @@ -1,14 +1,14 @@ /** - * Chain Provider + * Chain Provider (Effect TS) * * 聚合多个 ApiProvider,通过能力发现动态代理方法调用。 - * - * 响应式设计:使用 KeyFetchInstance 属性替代异步方法 - * 错误处理:NoSupportError 表示不支持,AggregateError 表示全部失败 + * 使用 Effect TS 实现 fallback 和流式数据聚合。 */ -import { fallback, derive, transform, type KeyFetchInstance } from '@biochain/key-fetch' -import { chainConfigService } from '@/services/chain-config' +import { Effect, Stream } from "effect" +import { createStreamInstance, type StreamInstance, type FetchError } from "@biochain/chain-effect" +import { chainConfigService } from "@/services/chain-config" +import { Amount } from "@/types/amount" import type { ApiProvider, ApiProviderMethod, @@ -22,62 +22,104 @@ import type { TransactionsOutput, TransactionOutput, BlockHeightOutput, -} from './types' -import { - TokenBalancesOutputSchema, -} from './types' + AddressParams, + TxHistoryParams, + TransactionParams, + TransactionStatusParams, + TransactionStatusOutput, + TokenBalance, +} from "./types" -const SYNC_METHODS = new Set(['isValidAddress', 'normalizeAddress']) +const SYNC_METHODS = new Set(["isValidAddress", "normalizeAddress"]) + +/** + * 创建 fallback StreamInstance - 依次尝试多个 source,返回第一个成功的 + */ +function createFallbackStream( + name: string, + sources: StreamInstance[] +): StreamInstance { + if (sources.length === 0) { + // 空 sources - 返回一个总是失败的 stream + return createStreamInstance(name, () => + Stream.fail({ _tag: "HttpError", message: "No providers available" } as FetchError) + ) + } + + if (sources.length === 1) { + return sources[0] + } + + return createStreamInstance(name, (input) => { + // 创建一个 fallback stream:尝试第一个,失败则尝试下一个 + const trySource = (index: number): Stream.Stream => { + if (index >= sources.length) { + return Stream.fail({ _tag: "HttpError", message: "All providers failed" } as FetchError) + } + + const source = sources[index] + // 获取 source 的 stream,使用 subscribe 内部逻辑 + return Stream.fromEffect( + Effect.tryPromise({ + try: () => source.fetch(input), + catch: (e) => ({ _tag: "HttpError", message: String(e) }) as FetchError, + }) + ).pipe( + Stream.catchAll(() => trySource(index + 1)) + ) + } + + return trySource(0) + }) +} export class ChainProvider { readonly chainId: string private readonly providers: ApiProvider[] - // 缓存 - 避免每次访问 getter 时创建新实例(会导致 React 无限重渲染) - private _nativeBalance?: KeyFetchInstance - private _tokenBalances?: KeyFetchInstance - private _transactionHistory?: KeyFetchInstance - private _transaction?: KeyFetchInstance - private _blockHeight?: KeyFetchInstance - private _allBalances?: KeyFetchInstance + // 缓存 + private _nativeBalance?: StreamInstance + private _tokenBalances?: StreamInstance + private _transactionHistory?: StreamInstance + private _transaction?: StreamInstance + private _transactionStatus?: StreamInstance + private _blockHeight?: StreamInstance + private _allBalances?: StreamInstance constructor(chainId: string, providers: ApiProvider[]) { this.chainId = chainId this.providers = providers } - /** - * 检查是否有 Provider 支持某能力 - */ - supports(capability: 'nativeBalance' | 'tokenBalances' | 'transactionHistory' | 'blockHeight' | ApiProviderMethod): boolean { - // 响应式属性检查 - if (capability === 'nativeBalance') { - return this.providers.some(p => p.nativeBalance !== undefined) + + supports( + capability: "nativeBalance" | "tokenBalances" | "transactionHistory" | "blockHeight" | "transactionStatus" | ApiProviderMethod + ): boolean { + if (capability === "nativeBalance") { + return this.providers.some((p) => p.nativeBalance !== undefined) + } + if (capability === "tokenBalances") { + return this.providers.some((p) => p.tokenBalances !== undefined) } - if (capability === 'tokenBalances') { - return this.providers.some(p => p.tokenBalances !== undefined) + if (capability === "transactionHistory") { + return this.providers.some((p) => p.transactionHistory !== undefined) } - if (capability === 'transactionHistory') { - return this.providers.some(p => p.transactionHistory !== undefined) + if (capability === "blockHeight") { + return this.providers.some((p) => p.blockHeight !== undefined) } - if (capability === 'blockHeight') { - return this.providers.some(p => p.blockHeight !== undefined) + if (capability === "transactionStatus") { + return this.providers.some((p) => p.transactionStatus !== undefined) } - // 方法检查(包括 ITransactionService 方法,通过 extends Partial 继承) - return this.providers.some(p => typeof p[capability as ApiProviderMethod] === 'function') + return this.providers.some((p) => typeof p[capability as ApiProviderMethod] === "function") } - - /** - * 查找实现了某方法的 Provider,返回自动 fallback 的方法 - */ private getMethod(method: K): ApiProvider[K] | undefined { - const candidates = this.providers.filter((p) => typeof p[method] === 'function') + const candidates = this.providers.filter((p) => typeof p[method] === "function") if (candidates.length === 0) return undefined const first = candidates[0] const firstFn = first[method] - if (typeof firstFn !== 'function') return undefined + if (typeof firstFn !== "function") return undefined if (SYNC_METHODS.has(method)) { return firstFn.bind(first) as ApiProvider[K] @@ -88,7 +130,7 @@ export class ChainProvider { for (const provider of candidates) { const impl = provider[method] - if (typeof impl !== 'function') continue + if (typeof impl !== "function") continue try { return await (impl as (...args: unknown[]) => Promise).apply(provider, args) } catch (error) { @@ -96,57 +138,44 @@ export class ChainProvider { } } - throw lastError instanceof Error ? lastError : new Error('All providers failed') + throw lastError instanceof Error ? lastError : new Error("All providers failed") }) as ApiProvider[K] return fn } - // ===== 默认值 ===== - - // Preserved for potential future use - // private _getDefaultBalance(): Balance { - // const decimals = chainConfigService.getDecimals(this.chainId) - // const symbol = chainConfigService.getSymbol(this.chainId) - // return { - // amount: Amount.zero(decimals, symbol), - // symbol, - // } - // } - - - // ===== 便捷属性:检查能力 ===== + // ===== 便捷属性 ===== get supportsNativeBalance(): boolean { - return this.supports('nativeBalance') + return this.supports("nativeBalance") } get supportsTokenBalances(): boolean { - return this.supports('tokenBalances') + return this.supports("tokenBalances") } get supportsTransactionHistory(): boolean { - return this.supports('transactionHistory') + return this.supports("transactionHistory") } get supportsBlockHeight(): boolean { - return this.supports('blockHeight') + return this.supports("blockHeight") } get supportsFeeEstimate(): boolean { - return this.supports('estimateFee') + return this.supports("estimateFee") } get supportsBuildTransaction(): boolean { - return this.supports('buildTransaction') + return this.supports("buildTransaction") } get supportsSignTransaction(): boolean { - return this.supports('signTransaction') + return this.supports("signTransaction") } get supportsBroadcast(): boolean { - return this.supports('broadcastTransaction') + return this.supports("broadcastTransaction") } get supportsFullTransaction(): boolean { @@ -154,190 +183,171 @@ export class ChainProvider { } get supportsDeriveAddress(): boolean { - return this.supports('deriveAddress') + return this.supports("deriveAddress") } get supportsAddressValidation(): boolean { - return this.supports('isValidAddress') + return this.supports("isValidAddress") } - // ===== 响应式查询:获取 KeyFetchInstance ===== + // ===== 响应式查询 ===== - /** - * 获取第一个支持 nativeBalance 的 Provider 的 KeyFetchInstance - * 使用 merge() 实现 auto-fallback,返回非可空类型 - */ - get nativeBalance(): KeyFetchInstance { + get nativeBalance(): StreamInstance { if (!this._nativeBalance) { const sources = this.providers - .map(p => p.nativeBalance) + .map((p) => p.nativeBalance) .filter((f): f is NonNullable => f !== undefined) - this._nativeBalance = fallback({ - name: `${this.chainId}.nativeBalance`, - sources, - }) + this._nativeBalance = createFallbackStream(`${this.chainId}.nativeBalance`, sources) } return this._nativeBalance } - get tokenBalances(): KeyFetchInstance { + get tokenBalances(): StreamInstance { if (!this._tokenBalances) { const sources = this.providers - .map(p => p.tokenBalances) + .map((p) => p.tokenBalances) .filter((f): f is NonNullable => f !== undefined) - this._tokenBalances = fallback({ - name: `${this.chainId}.tokenBalances`, - sources, - }) + this._tokenBalances = createFallbackStream(`${this.chainId}.tokenBalances`, sources) } return this._tokenBalances } - /** - * 统一资产列表(合并 nativeBalance 和 tokenBalances) - * - 如果有 tokenBalances,使用 tokenBalances(通常已包含 native) - * - 如果只有 nativeBalance,将其转换为 TokenBalance[] 格式 - * - 统一的 isLoading 状态 - */ - get allBalances(): KeyFetchInstance { + get allBalances(): StreamInstance { if (!this._allBalances) { const hasTokenBalances = this.supportsTokenBalances const hasNativeBalance = this.supportsNativeBalance if (hasTokenBalances) { - // 直接使用 tokenBalances(通常已包含 native) this._allBalances = this.tokenBalances } else if (hasNativeBalance) { - // 只有 nativeBalance,使用 derive 转换为 TokenBalance[] const { chainId } = this const symbol = chainConfigService.getSymbol(chainId) const decimals = chainConfigService.getDecimals(chainId) - this._allBalances = derive({ - name: `${chainId}.allBalances`, - source: this.nativeBalance, - outputSchema: TokenBalancesOutputSchema, - use: [ - transform({ - transform: (balance: { amount: import('@/types/amount').Amount; symbol: string } | null) => { + this._allBalances = createStreamInstance( + `${chainId}.allBalances`, + (params) => { + const nativeStream = this.nativeBalance + return Stream.fromEffect( + Effect.tryPromise({ + try: () => nativeStream.fetch(params), + catch: (e) => ({ _tag: "HttpError", message: String(e) }) as FetchError, + }) + ).pipe( + Stream.map((balance): TokenBalancesOutput => { if (!balance) return [] - return [{ - symbol: balance.symbol || symbol, - name: balance.symbol || symbol, - amount: balance.amount, - isNative: true, - decimals, - }] - }, - }), - ], - }) + return [ + { + symbol: balance.symbol || symbol, + name: balance.symbol || symbol, + amount: balance.amount, + isNative: true, + decimals, + }, + ] + }) + ) + } + ) } else { - // 无任何 balance 能力,返回空 fallback - this._allBalances = fallback({ - name: `${this.chainId}.allBalances`, - sources: [], - }) + this._allBalances = createFallbackStream(`${this.chainId}.allBalances`, []) } } return this._allBalances } - get transactionHistory(): KeyFetchInstance { + get transactionHistory(): StreamInstance { if (!this._transactionHistory) { const sources = this.providers - .map(p => p.transactionHistory) + .map((p) => p.transactionHistory) .filter((f): f is NonNullable => f !== undefined) - this._transactionHistory = fallback({ - name: `${this.chainId}.transactionHistory`, - sources, - }) + this._transactionHistory = createFallbackStream(`${this.chainId}.transactionHistory`, sources) } return this._transactionHistory } - get transaction(): KeyFetchInstance { + get transaction(): StreamInstance { if (!this._transaction) { const sources = this.providers - .map(p => p.transaction) + .map((p) => p.transaction) .filter((f): f is NonNullable => f !== undefined) - this._transaction = fallback({ - name: `${this.chainId}.transaction`, - sources, - }) + this._transaction = createFallbackStream(`${this.chainId}.transaction`, sources) } return this._transaction } - get blockHeight(): KeyFetchInstance { + get transactionStatus(): StreamInstance { + if (!this._transactionStatus) { + const sources = this.providers + .map((p) => p.transactionStatus) + .filter((f): f is NonNullable => f !== undefined) + this._transactionStatus = createFallbackStream(`${this.chainId}.transactionStatus`, sources) + } + return this._transactionStatus + } + + get blockHeight(): StreamInstance { if (!this._blockHeight) { const sources = this.providers - .map(p => p.blockHeight) + .map((p) => p.blockHeight) .filter((f): f is NonNullable => f !== undefined) - this._blockHeight = fallback({ - name: `${this.chainId}.blockHeight`, - sources, - }) + this._blockHeight = createFallbackStream(`${this.chainId}.blockHeight`, sources) } return this._blockHeight } - // 错误处理: - // - 空数组 → NoSupportError (isSupported = !(error instanceof NoSupportError)) - // - 全部失败 → AggregateError (错误列表显示) - - // ===== 代理方法:交易(ITransactionService)===== + // ===== 代理方法 ===== get buildTransaction(): ((intent: TransactionIntent) => Promise) | undefined { - return this.getMethod('buildTransaction') + return this.getMethod("buildTransaction") } get estimateFee(): ((unsignedTx: UnsignedTransaction) => Promise) | undefined { - return this.getMethod('estimateFee') + return this.getMethod("estimateFee") } - get signTransaction(): ((unsignedTx: UnsignedTransaction, options: SignOptions) => Promise) | undefined { - return this.getMethod('signTransaction') + get signTransaction(): + | ((unsignedTx: UnsignedTransaction, options: SignOptions) => Promise) + | undefined { + return this.getMethod("signTransaction") } get broadcastTransaction(): ((signedTx: SignedTransaction) => Promise) | undefined { - return this.getMethod('broadcastTransaction') + return this.getMethod("broadcastTransaction") } - // ===== 代理方法:身份 ===== - get deriveAddress(): ((seed: Uint8Array, index?: number) => Promise) | undefined { - return this.getMethod('deriveAddress') + return this.getMethod("deriveAddress") } get deriveAddresses(): ((seed: Uint8Array, startIndex: number, count: number) => Promise) | undefined { - return this.getMethod('deriveAddresses') + return this.getMethod("deriveAddresses") } get isValidAddress(): ((address: string) => boolean) | undefined { - return this.getMethod('isValidAddress') + return this.getMethod("isValidAddress") } get normalizeAddress(): ((address: string) => string) | undefined { - return this.getMethod('normalizeAddress') + return this.getMethod("normalizeAddress") } - // ===== IBioAccountService 代理(BioChain 专属)===== + // ===== BioChain 专属 ===== get bioGetAccountInfo() { - return this.getMethod('bioGetAccountInfo') + return this.getMethod("bioGetAccountInfo") } get bioVerifyPayPassword() { - return this.getMethod('bioVerifyPayPassword') + return this.getMethod("bioVerifyPayPassword") } get bioGetAssetDetail() { - return this.getMethod('bioGetAssetDetail') + return this.getMethod("bioGetAssetDetail") } get supportsBioAccountInfo(): boolean { - return this.supports('bioGetAccountInfo') + return this.supports("bioGetAccountInfo") } // ===== 工具方法 ===== @@ -347,6 +357,6 @@ export class ChainProvider { } getProviderByType(type: string): ApiProvider | undefined { - return this.providers.find(p => p.type === type) + return this.providers.find((p) => p.type === type) } } diff --git a/src/services/chain-adapter/providers/etherscan-v1-provider.ts b/src/services/chain-adapter/providers/etherscan-v1-provider.ts deleted file mode 100644 index e9be9859c..000000000 --- a/src/services/chain-adapter/providers/etherscan-v1-provider.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Etherscan V1 API Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - * V1 API 作为 V2 的兜底方案 - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, searchParams, throttleError, errorMatchers, ServiceLimitedError } from '@biochain/key-fetch' -import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' -import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' -import { - BalanceOutputSchema, - TransactionsOutputSchema, - AddressParamsSchema, - TxHistoryParamsSchema, -} from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { EvmIdentityMixin } from '../evm/identity-mixin' -import { EvmTransactionMixin } from '../evm/transaction-mixin' -import { getApiKey } from './api-key-picker' - -// ==================== Schema 定义 ==================== - -const ApiResponseSchema = z.object({ - status: z.string(), - message: z.string(), - result: z.unknown(), -}).passthrough() - -const NativeTxSchema = z.object({ - hash: z.string(), - from: z.string(), - to: z.string(), - value: z.string(), - timeStamp: z.string(), - isError: z.string(), - blockNumber: z.string(), - input: z.string().optional(), - methodId: z.string().optional(), - functionName: z.string().optional(), -}).passthrough() - -type ApiResponse = z.infer -type NativeTx = z.infer - -// EVM Chain IDs - preserved for future use -// const EVM_CHAIN_IDS: Record = { -// ethereum: 1, -// binance: 56, -// 'ethereum-sepolia': 11155111, -// 'bsc-testnet': 97, -// } - -// ==================== 工具函数 ==================== - -function getDirection(from: string, to: string, address: string): Direction { - const fromLower = from.toLowerCase() - const toLower = to.toLowerCase() - if (fromLower === address && toLower === address) return 'self' - if (fromLower === address) return 'out' - return 'in' -} - -// ==================== Base Class for Mixins ==================== - -class EtherscanBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class EtherscanV1Provider extends EvmIdentityMixin(EvmTransactionMixin(EtherscanBase)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - - readonly #balanceApi: KeyFetchInstance - readonly #txListApi: KeyFetchInstance - - readonly nativeBalance: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - // EVM chain ID determined by chainId parameter - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const baseUrl = this.endpoint - const symbol = this.symbol - const decimals = this.decimals - - // 读取 API Key - const etherscanApiKey = getApiKey(this.config?.apiKeyEnv as string, `etherscan-${chainId}`) - - // API Key 插件(Etherscan 使用 apikey 查询参数) - const etherscanApiKeyPlugin: FetchPlugin = { - name: 'etherscan-api-key', - onFetch: async (request, next) => { - if (etherscanApiKey) { - const url = new URL(request.url) - url.searchParams.set('apikey', etherscanApiKey) - return next(new Request(url.toString(), request)) - } - return next(request) - }, - } - - // 共享 429 节流 - const etherscanThrottleError = throttleError({ - match: errorMatchers.httpStatus(429), - }) - - // 区块高度 API - 使用 Etherscan 的 proxy 模块 - const blockHeightApi = keyFetch.create({ - name: `etherscan.${chainId}.blockHeight`, - outputSchema: ApiResponseSchema, - url: `${baseUrl}`, - use: [ - interval(30_000), // 节约 API 费用,至少 30s 轮询 - searchParams({ - transform: () => ({ - module: 'proxy', - action: 'eth_blockNumber', - }), - }), - etherscanApiKeyPlugin, - etherscanThrottleError, - ], - }) - - // 余额查询 - 由 blockHeightApi 驱动 - this.#balanceApi = keyFetch.create({ - name: `etherscan.${chainId}.balanceApi`, - outputSchema: ApiResponseSchema, - inputSchema: AddressParamsSchema, - url: `${baseUrl}`, - use: [ - deps(blockHeightApi), - searchParams({ - transform: ((params) => ({ - module: 'account', - action: 'balance', - address: params.address, - tag: 'latest', - })), - }), - etherscanApiKeyPlugin, - etherscanThrottleError, - ], - }) - - // 交易历史 - 由 blockHeightApi 驱动 - this.#txListApi = keyFetch.create({ - name: `etherscan.${chainId}.txListApi`, - outputSchema: ApiResponseSchema, - inputSchema: TxHistoryParamsSchema, - url: `${baseUrl}`, - use: [ - deps(blockHeightApi), - searchParams({ - transform: ((params) => ({ - module: 'account', - action: 'txlist', - address: params.address, - page: '1', - offset: String(params.limit ?? 20), - sort: 'desc', - })), - }), - etherscanApiKeyPlugin, - etherscanThrottleError, - ], - }) - - this.nativeBalance = derive({ - name: `etherscan.${chainId}.nativeBalance`, - source: this.#balanceApi, - outputSchema: BalanceOutputSchema, - use: [ - transform({ - transform: (raw) => { - const result = raw.result - // 检查 API 错误状态 - if (raw.status === '0') { - throw new ServiceLimitedError() - } - if (typeof result !== 'string') { - throw new ServiceLimitedError() - } - return { - amount: Amount.fromRaw(result, decimals, symbol), - symbol, - } - }, - }), - ], - }) - - this.transactionHistory = derive({ - name: `etherscan.${chainId}.transactionHistory`, - source: this.#txListApi, - outputSchema: TransactionsOutputSchema, - }).use(transform({ - transform: (raw: ApiResponse, ctx): Transaction[] => { - const result = raw.result - // 检查 API 错误状态 - if (raw.status === '0') { - throw new ServiceLimitedError() - } - if (!Array.isArray(result)) { - throw new ServiceLimitedError() - } - - const address = ((ctx.params.address as string) ?? '').toLowerCase() - - return result - .map(item => NativeTxSchema.safeParse(item)) - .filter((r): r is z.ZodSafeParseSuccess => r.success) - .map((r): Transaction => { - const tx = r.data - const direction = getDirection(tx.from, tx.to, address) - return { - hash: tx.hash, - from: tx.from, - to: tx.to, - timestamp: parseInt(tx.timeStamp, 10) * 1000, - status: tx.isError === '0' ? 'confirmed' : 'failed', - blockNumber: BigInt(tx.blockNumber), - action: 'transfer' as const, - direction, - assets: [{ - assetType: 'native' as const, - value: tx.value, - symbol, - decimals, - }], - } - }) - }, - }),) - } -} - -export function createEtherscanV1Provider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'etherscan-v1' || entry.type.includes('blockscout')) { - return new EtherscanV1Provider(entry, chainId) - } - return null -} diff --git a/src/services/chain-adapter/providers/etherscan-v2-provider.ts b/src/services/chain-adapter/providers/etherscan-v2-provider.ts deleted file mode 100644 index 8666c3b09..000000000 --- a/src/services/chain-adapter/providers/etherscan-v2-provider.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Etherscan V2 API Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - * V2 API 需要 chainid 参数,支持多链统一端点 - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, searchParams, throttleError, errorMatchers, ServiceLimitedError } from '@biochain/key-fetch' -import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' -import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' -import { - BalanceOutputSchema, - TransactionsOutputSchema, - AddressParamsSchema, - TxHistoryParamsSchema, -} from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { EvmIdentityMixin } from '../evm/identity-mixin' -import { EvmTransactionMixin } from '../evm/transaction-mixin' -import { getApiKey } from './api-key-picker' - -// ==================== Schema 定义 ==================== - -const ApiResponseSchema = z.object({ - status: z.string(), - message: z.string(), - result: z.unknown(), -}).passthrough() - -// JSON-RPC 格式响应 (proxy 模块使用) -const JsonRpcResponseSchema = z.object({ - jsonrpc: z.string(), - id: z.number(), - result: z.string(), -}).passthrough() - -const NativeTxSchema = z.object({ - hash: z.string(), - from: z.string(), - to: z.string(), - value: z.string(), - timeStamp: z.string(), - isError: z.string(), - blockNumber: z.string(), - input: z.string().optional(), - methodId: z.string().optional(), - functionName: z.string().optional(), -}).passthrough() - -type ApiResponse = z.infer -type NativeTx = z.infer - -// ==================== 工具函数 ==================== - -function getDirection(from: string, to: string, address: string): Direction { - const fromLower = from.toLowerCase() - const toLower = to.toLowerCase() - if (fromLower === address && toLower === address) return 'self' - if (fromLower === address) return 'out' - return 'in' -} - -// ==================== Base Class for Mixins ==================== - -class EtherscanV2Base { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class EtherscanV2Provider extends EvmIdentityMixin(EvmTransactionMixin(EtherscanV2Base)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - - readonly #balanceApi: KeyFetchInstance - readonly #txListApi: KeyFetchInstance - - readonly nativeBalance: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const baseUrl = this.endpoint - const symbol = this.symbol - const decimals = this.decimals - - // 读取 EVM Chain ID (V2 必需) - const evmChainId = this.config?.evmChainId as number | undefined - if (!evmChainId) { - throw new Error(`[EtherscanV2Provider] evmChainId is required for chain ${chainId}`) - } - - // 读取 API Key - const etherscanApiKey = getApiKey(this.config?.apiKeyEnv as string, `etherscan-${chainId}`) - - // API Key 插件(Etherscan 使用 apikey 查询参数) - const etherscanApiKeyPlugin: FetchPlugin = { - name: 'etherscan-api-key', - onFetch: async (request, next) => { - if (etherscanApiKey) { - const url = new URL(request.url) - url.searchParams.set('apikey', etherscanApiKey) - return next(new Request(url.toString(), request)) - } - return next(request) - }, - } - - // 共享 400/429/Schema错误 节流 - const etherscanThrottleError = throttleError({ - match: errorMatchers.any( - errorMatchers.httpStatus(400, 429), - errorMatchers.contains('Schema 验证失败'), - ), - }) - - // 区块高度 API - 使用 Etherscan 的 proxy 模块 (返回 JSON-RPC 格式) - const blockHeightApi = keyFetch.create({ - name: `etherscan-v2.${chainId}.blockHeight`, - outputSchema: JsonRpcResponseSchema, - url: `${baseUrl}`, - use: [ - interval(30_000), // 节约 API 费用,至少 30s 轮询 - searchParams({ - transform: () => ({ - chainid: evmChainId.toString(), - module: 'proxy', - action: 'eth_blockNumber', - }), - }), - etherscanApiKeyPlugin, - etherscanThrottleError, - ], - }) - - // 余额查询 - 由 blockHeightApi 驱动 - this.#balanceApi = keyFetch.create({ - name: `etherscan-v2.${chainId}.balanceApi`, - outputSchema: ApiResponseSchema, - inputSchema: AddressParamsSchema, - url: `${baseUrl}`, - use: [ - deps(blockHeightApi), - searchParams({ - transform: ((params) => ({ - chainid: evmChainId.toString(), - module: 'account', - action: 'balance', - address: params.address, - tag: 'latest', - })), - }), - etherscanApiKeyPlugin, - etherscanThrottleError, - ], - }) - - // 交易历史 - 由 blockHeightApi 驱动 - this.#txListApi = keyFetch.create({ - name: `etherscan-v2.${chainId}.txListApi`, - outputSchema: ApiResponseSchema, - inputSchema: TxHistoryParamsSchema, - url: `${baseUrl}`, - use: [ - deps(blockHeightApi), - searchParams({ - transform: ((params) => ({ - chainid: evmChainId.toString(), - module: 'account', - action: 'txlist', - address: params.address, - page: '1', - offset: String(params.limit ?? 20), - sort: 'desc', - })), - }), - etherscanApiKeyPlugin, - etherscanThrottleError, - ], - }) - - this.nativeBalance = derive({ - name: `etherscan-v2.${chainId}.nativeBalance`, - source: this.#balanceApi, - outputSchema: BalanceOutputSchema, - use: [ - transform({ - transform: (raw) => { - const result = raw.result - // 检查 API 错误状态 - if (raw.status === '0') { - throw new ServiceLimitedError() - } - // API 可能返回错误消息而非余额 - if (typeof result !== 'string') { - throw new ServiceLimitedError() - } - // V2 API 可能返回十六进制或十进制字符串 - const balanceValue = result.startsWith('0x') - ? BigInt(result).toString() - : result - return { - amount: Amount.fromRaw(balanceValue, decimals, symbol), - symbol, - } - }, - }), - ], - }) - - this.transactionHistory = derive({ - name: `etherscan-v2.${chainId}.transactionHistory`, - source: this.#txListApi, - outputSchema: TransactionsOutputSchema, - }).use(transform({ - transform: (raw: ApiResponse, ctx): Transaction[] => { - const result = raw.result - // 检查 API 错误状态 - if (raw.status === '0') { - throw new ServiceLimitedError() - } - if (!Array.isArray(result)) { - throw new ServiceLimitedError() - } - - const address = ((ctx.params.address as string) ?? '').toLowerCase() - - return result - .map(item => NativeTxSchema.safeParse(item)) - .filter((r): r is z.ZodSafeParseSuccess => r.success) - .map((r): Transaction => { - const tx = r.data - const direction = getDirection(tx.from, tx.to, address) - return { - hash: tx.hash, - from: tx.from, - to: tx.to, - timestamp: parseInt(tx.timeStamp, 10) * 1000, - status: tx.isError === '0' ? 'confirmed' : 'failed', - blockNumber: BigInt(tx.blockNumber), - action: 'transfer' as const, - direction, - assets: [{ - assetType: 'native' as const, - value: tx.value, - symbol, - decimals, - }], - } - }) - }, - }),) - } -} - -export function createEtherscanV2Provider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'etherscan-v2') { - return new EtherscanV2Provider(entry, chainId) - } - return null -} diff --git a/src/services/chain-adapter/providers/ethwallet-provider.ts b/src/services/chain-adapter/providers/ethwallet-provider.ts deleted file mode 100644 index b2487bd20..000000000 --- a/src/services/chain-adapter/providers/ethwallet-provider.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * EthWallet API Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - * - * API 格式: - * - 余额: { success: boolean, result: string } - * - 交易历史: { success: boolean, result: { status: string, result: NativeTx[] } } - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, walletApiUnwrap, postBody } from '@biochain/key-fetch' -import type { KeyFetchInstance, } from '@biochain/key-fetch' -import type { ApiProvider, Balance, Transaction, Direction, Action, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' -import { BalanceOutputSchema, TransactionsOutputSchema, AddressParamsSchema, TxHistoryParamsSchema } from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { EvmIdentityMixin } from '../evm/identity-mixin' -import { EvmTransactionMixin } from '../evm/transaction-mixin' - -// ==================== Schema 定义 ==================== - -const BalanceResultSchema = z.union([z.string(), z.number().transform(v => String(v))]) - -const NativeTxSchema = z.object({ - blockNumber: z.string(), - timeStamp: z.string(), - hash: z.string(), - from: z.string(), - to: z.string(), - value: z.string(), - isError: z.string().optional(), - input: z.string().optional(), - methodId: z.string().optional(), - functionName: z.string().optional(), -}).passthrough() - -const TxHistoryResultSchema = z.object({ - status: z.string().optional(), - result: z.array(NativeTxSchema), -}).passthrough() - -type BalanceResult = z.infer -type TxHistoryResult = z.infer -type NativeTx = z.infer - -// ==================== 工具函数 ==================== - -function getDirection(from: string, to: string, address: string): Direction { - const fromLower = from.toLowerCase() - const toLower = to.toLowerCase() - if (fromLower === address && toLower === address) return 'self' - if (fromLower === address) return 'out' - return 'in' -} - -function detectAction(tx: NativeTx): Action { - const value = tx.value - if (value && value !== '0') return 'transfer' - return 'contract' -} - -// ==================== Base Class for Mixins ==================== - -class EthWalletBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class EthWalletProvider extends EvmIdentityMixin(EvmTransactionMixin(EthWalletBase)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - - readonly #balanceApi: KeyFetchInstance - readonly #txHistoryApi: KeyFetchInstance - - readonly nativeBalance: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const { endpoint: base, symbol, decimals } = this - - // 区块高度 - 使用简单的轮询机制(第三方 API 可能没有专门的 blockHeight 端点) - // 我们使用 interval 来驱动其他数据源的更新 - const blockHeightTrigger = keyFetch.create({ - name: `ethwallet.${chainId}.blockTrigger`, - outputSchema: z.object({ timestamp: z.number() }), - url: 'internal://trigger', // 虚拟 URL - use: [ - interval(30_000), // 节约 API 费用,至少 30s 轮询 - { - name: 'trigger', - onFetch: async (_req, _next, ctx) => { - return ctx.createResponse({ timestamp: Date.now() }) - }, - }, - ], - }) - - // 余额查询 - 由 blockHeightTrigger 驱动 - this.#balanceApi = keyFetch.create({ - name: `ethwallet.${chainId}.balanceApi`, - outputSchema: BalanceResultSchema, - inputSchema: AddressParamsSchema, - url: `${base}/balance`, - method: 'POST', - use: [deps(blockHeightTrigger), postBody(), walletApiUnwrap()], - }) - - // 交易历史 - 由 blockHeightTrigger 驱动 - this.#txHistoryApi = keyFetch.create({ - name: `ethwallet.${chainId}.txHistoryApi`, - outputSchema: TxHistoryResultSchema, - inputSchema: TxHistoryParamsSchema, - url: `${base}/trans/normal/history`, - method: 'POST', - use: [deps(blockHeightTrigger), postBody(), walletApiUnwrap()], - }) - - this.nativeBalance = derive({ - name: `ethwallet.${chainId}.nativeBalance`, - source: this.#balanceApi, - outputSchema: BalanceOutputSchema, - use: [transform({ - transform: (raw) => ({ - amount: Amount.fromRaw(raw, decimals, symbol), - symbol, - }), - })], - }) - - this.transactionHistory = keyFetch.create({ - name: `ethwallet.${chainId}.transactionHistory`, - inputSchema: TxHistoryParamsSchema, - outputSchema: TransactionsOutputSchema, - url: `${base}/trans/normal/history`, - method: 'POST', - }).use( - deps(this.#txHistoryApi), - postBody(), - transform({ - transform: (raw: TxHistoryResult, ctx) => { - console.log('QAQ', raw) - const address = ctx.params.address.toLowerCase() - return raw.result.map((tx): Transaction => ({ - hash: tx.hash, - from: tx.from, - to: tx.to, - timestamp: parseInt(tx.timeStamp, 10) * 1000, - status: tx.isError === '1' ? 'failed' : 'confirmed', - blockNumber: BigInt(tx.blockNumber), - action: detectAction(tx), - direction: getDirection(tx.from, tx.to, address), - assets: [{ - assetType: 'native' as const, - value: tx.value, - symbol, - decimals, - }], - })) - }, - }), - walletApiUnwrap(), - ) - } -} - -export function createEthwalletProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'ethwallet-v1') { - return new EthWalletProvider(entry, chainId) - } - return null -} diff --git a/src/services/chain-adapter/providers/evm-rpc-provider.ts b/src/services/chain-adapter/providers/evm-rpc-provider.ts deleted file mode 100644 index aacd6cd1f..000000000 --- a/src/services/chain-adapter/providers/evm-rpc-provider.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * EVM RPC Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, combine, postBody, throttleError, errorMatchers } from '@biochain/key-fetch' -import type { KeyFetchInstance } from '@biochain/key-fetch' -import type { ApiProvider, Balance, BalanceOutput, BlockHeightOutput, TransactionOutput, AddressParams, TransactionParams } from './types' -import { BalanceOutputSchema, BlockHeightOutputSchema, TransactionOutputSchema, AddressParamsSchema, TransactionParamsSchema } from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { EvmIdentityMixin } from '../evm/identity-mixin' -import { EvmTransactionMixin } from '../evm/transaction-mixin' -import type { Transaction } from './transaction-schema' - -const RpcResponseSchema = z.object({ - jsonrpc: z.string(), - id: z.number(), - result: z.string(), -}).passthrough() - -// EVM Transaction RPC Response -const EvmTxRpcSchema = z.object({ - jsonrpc: z.string(), - id: z.number(), - result: z.object({ - hash: z.string(), - from: z.string(), - to: z.string().nullable(), - value: z.string(), - blockNumber: z.string().nullable(), - input: z.string(), - }).passthrough().nullable(), -}) - -// EVM Transaction Receipt RPC Response -const EvmReceiptRpcSchema = z.object({ - jsonrpc: z.string(), - id: z.number(), - result: z.object({ - status: z.string(), - blockNumber: z.string(), - transactionHash: z.string(), - }).passthrough().nullable(), -}) - -type RpcResponse = z.infer -type EvmTxRpc = z.infer -type EvmReceiptRpc = z.infer - -// ==================== Base Class for Mixins ==================== - -class EvmRpcBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class EvmRpcProvider extends EvmIdentityMixin(EvmTransactionMixin(EvmRpcBase)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - - readonly #balanceRpc: KeyFetchInstance - readonly #blockRpc: KeyFetchInstance - readonly #txByHashRpc: KeyFetchInstance - readonly #txReceiptRpc: KeyFetchInstance - - readonly nativeBalance: KeyFetchInstance - readonly blockHeight: KeyFetchInstance - readonly transaction: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const { endpoint: rpc, symbol, decimals } = this - - // 共享 429 节流 - const evmThrottleError = throttleError({ - match: errorMatchers.httpStatus(429), - }) - - // 区块高度 RPC - 使用 interval 轮询 - this.#blockRpc = keyFetch.create({ - name: `evm-rpc.${chainId}.blockRpc`, - outputSchema: RpcResponseSchema, - url: rpc, - method: 'POST', - use: [ - interval(30_000), // 节约 API 费用,至少 30s 轮询 - postBody({ - transform: () => ({ - jsonrpc: '2.0', - id: 1, - method: 'eth_blockNumber', - params: [], - }), - }), - evmThrottleError, - ], - }) - - // 余额查询 - 由 blockRpc 驱动 - this.#balanceRpc = keyFetch.create({ - name: `evm-rpc.${chainId}.balanceRpc`, - outputSchema: RpcResponseSchema, - inputSchema: AddressParamsSchema, - url: rpc, - method: 'POST', - use: [ - deps(this.#blockRpc), - postBody({ - transform: (params) => ({ - jsonrpc: '2.0', - id: 1, - method: 'eth_getBalance', - params: [params.address, 'latest'], - }), - }), - evmThrottleError, - ], - }) - - // eth_getTransactionByHash - 由 blockRpc 驱动 - this.#txByHashRpc = keyFetch.create({ - name: `evm-rpc.${chainId}.txByHash`, - outputSchema: EvmTxRpcSchema, - inputSchema: TransactionParamsSchema, - url: rpc, - method: 'POST', - use: [ - deps(this.#blockRpc), // 交易状态会随区块变化 - postBody({ - transform: (params) => ({ - jsonrpc: '2.0', - id: 1, - method: 'eth_getTransactionByHash', - params: [params.txHash], - }), - }), - evmThrottleError, - ], - }) - - // eth_getTransactionReceipt - 由 blockRpc 驱动 - this.#txReceiptRpc = keyFetch.create({ - name: `evm-rpc.${chainId}.txReceipt`, - outputSchema: EvmReceiptRpcSchema, - inputSchema: TransactionParamsSchema, - url: rpc, - method: 'POST', - use: [ - deps(this.#blockRpc), // Receipt 状态会随区块变化 - postBody({ - transform: (params) => ({ - jsonrpc: '2.0', - id: 1, - method: 'eth_getTransactionReceipt', - params: [params.txHash], - }), - }), - evmThrottleError, - ], - }) - - this.nativeBalance = derive({ - name: `evm-rpc.${chainId}.nativeBalance`, - source: this.#balanceRpc, - outputSchema: BalanceOutputSchema, - use: [transform({ - transform: (r) => { - const hex = r.result - const value = BigInt(hex).toString() - return { amount: Amount.fromRaw(value, decimals, symbol), symbol } - }, - })], - }) - - this.blockHeight = derive({ - name: `evm-rpc.${chainId}.blockHeight`, - source: this.#blockRpc, - outputSchema: BlockHeightOutputSchema, - use: [transform({ transform: (r) => BigInt(r.result) })], - }) - - // transaction: combine tx + receipt - this.transaction = combine({ - name: `evm-rpc.${chainId}.transaction`, - outputSchema: TransactionOutputSchema, - sources: { - tx: this.#txByHashRpc, - receipt: this.#txReceiptRpc, - }, - inputSchema: z.object({ txHash: z.string() }), - transformParams: (params) => ({ - tx: { txHash: params.txHash }, - receipt: { txHash: params.txHash }, - }), - use: [ - transform({ - transform: (results: { - tx: z.infer, - receipt: z.infer - }): Transaction | null => { - const tx = results.tx.result - const receipt = results.receipt.result - - if (!tx) return null - - // 判断状态 - let status: 'pending' | 'confirmed' | 'failed' - if (!receipt) { - status = 'pending' - } else { - status = receipt.status === '0x1' ? 'confirmed' : 'failed' - } - - // 解析 value - const value = BigInt(tx.value || '0x0').toString() - - return { - hash: tx.hash, - from: tx.from, - to: tx.to ?? '', - timestamp: Date.now(), // TODO: 从 block 获取真实时间戳 - status, - blockNumber: receipt?.blockNumber ? BigInt(receipt.blockNumber) : undefined, - action: tx.to ? 'transfer' : 'contract', - direction: 'out', // TODO: 根据 address 判断 - assets: [{ - assetType: 'native' as const, - value, - symbol, - decimals, - }], - } - }, - }), - ], - }) - } -} - -export function createEvmRpcProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type.endsWith('-rpc') && (entry.type.includes('ethereum') || entry.type.includes('bsc'))) { - return new EvmRpcProvider(entry, chainId) - } - return null -} diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts index 4c277b977..aaf94acbe 100644 --- a/src/services/chain-adapter/providers/index.ts +++ b/src/services/chain-adapter/providers/index.ts @@ -1,5 +1,5 @@ /** - * Chain Provider Factory + * Chain Provider Factory (Effect TS) * * 为不同链创建对应的 ChainProvider,聚合多个 ApiProvider。 */ @@ -10,19 +10,21 @@ export type { Transaction, Balance, TokenBalance, Direction, Action, ApiProvider // 导出错误类 export { InvalidDataError } from './errors'; -// 导出所有 Provider 实现 +// 导出 ChainProvider export { ChainProvider } from './chain-provider'; -export { EtherscanV1Provider, createEtherscanV1Provider } from './etherscan-v1-provider'; -export { EtherscanV2Provider, createEtherscanV2Provider } from './etherscan-v2-provider'; -export { EvmRpcProvider, createEvmRpcProvider } from './evm-rpc-provider'; -export { BiowalletProvider, createBiowalletProvider } from './biowallet-provider'; -export { BscWalletProvider, createBscWalletProvider } from './bscwallet-provider'; -export { TronRpcProvider, createTronRpcProvider } from './tron-rpc-provider'; -export { MempoolProvider, createMempoolProvider } from './mempool-provider'; -export { EthWalletProvider, createEthwalletProvider } from './ethwallet-provider'; -export { TronWalletProvider, createTronwalletProvider } from './tronwallet-provider'; -export { BtcWalletProvider, createBtcwalletProvider } from './btcwallet-provider'; -export { MoralisProvider, createMoralisProvider } from './moralis-provider'; + +// 导出 Effect 版本的 Providers +export { EtherscanV1ProviderEffect, createEtherscanV1ProviderEffect } from './etherscan-v1-provider.effect'; +export { EtherscanV2ProviderEffect, createEtherscanV2ProviderEffect } from './etherscan-v2-provider.effect'; +export { EvmRpcProviderEffect, createEvmRpcProviderEffect } from './evm-rpc-provider.effect'; +export { BiowalletProviderEffect, createBiowalletProviderEffect } from './biowallet-provider.effect'; +export { BscWalletProviderEffect, createBscWalletProviderEffect } from './bscwallet-provider.effect'; +export { TronRpcProviderEffect, createTronRpcProviderEffect } from './tron-rpc-provider.effect'; +export { MempoolProviderEffect, createMempoolProviderEffect } from './mempool-provider.effect'; +export { EthWalletProviderEffect, createEthwalletProviderEffect } from './ethwallet-provider.effect'; +export { TronWalletProviderEffect, createTronwalletProviderEffect } from './tronwallet-provider.effect'; +export { BtcWalletProviderEffect, createBtcwalletProviderEffect } from './btcwallet-provider.effect'; +export { MoralisProviderEffect, createMoralisProviderEffect } from './moralis-provider.effect'; // 工厂函数 import type { ApiProvider, ApiProviderFactory } from './types'; @@ -30,31 +32,31 @@ import type { ParsedApiEntry } from '@/services/chain-config'; import { chainConfigService } from '@/services/chain-config'; import { ChainProvider } from './chain-provider'; -import { createEtherscanV1Provider } from './etherscan-v1-provider'; -import { createEtherscanV2Provider } from './etherscan-v2-provider'; -import { createEvmRpcProvider } from './evm-rpc-provider'; -import { createBiowalletProvider } from './biowallet-provider'; -import { createBscWalletProvider } from './bscwallet-provider'; -import { createTronRpcProvider } from './tron-rpc-provider'; -import { createMempoolProvider } from './mempool-provider'; -import { createEthwalletProvider } from './ethwallet-provider'; -import { createTronwalletProvider } from './tronwallet-provider'; -import { createBtcwalletProvider } from './btcwallet-provider'; -import { createMoralisProvider } from './moralis-provider'; +import { createEtherscanV1ProviderEffect } from './etherscan-v1-provider.effect'; +import { createEtherscanV2ProviderEffect } from './etherscan-v2-provider.effect'; +import { createEvmRpcProviderEffect } from './evm-rpc-provider.effect'; +import { createBiowalletProviderEffect } from './biowallet-provider.effect'; +import { createBscWalletProviderEffect } from './bscwallet-provider.effect'; +import { createTronRpcProviderEffect } from './tron-rpc-provider.effect'; +import { createMempoolProviderEffect } from './mempool-provider.effect'; +import { createEthwalletProviderEffect } from './ethwallet-provider.effect'; +import { createTronwalletProviderEffect } from './tronwallet-provider.effect'; +import { createBtcwalletProviderEffect } from './btcwallet-provider.effect'; +import { createMoralisProviderEffect } from './moralis-provider.effect'; /** 所有 Provider 工厂函数 */ const PROVIDER_FACTORIES: ApiProviderFactory[] = [ - createMoralisProvider, // Moralis 优先(支持 tokenBalances) - createBiowalletProvider, - createBscWalletProvider, - createEtherscanV2Provider, - createEtherscanV1Provider, - createEvmRpcProvider, - createTronRpcProvider, - createMempoolProvider, - createEthwalletProvider, - createTronwalletProvider, - createBtcwalletProvider, + createMoralisProviderEffect, + createBiowalletProviderEffect, + createBscWalletProviderEffect, + createEtherscanV2ProviderEffect, + createEtherscanV1ProviderEffect, + createEvmRpcProviderEffect, + createTronRpcProviderEffect, + createMempoolProviderEffect, + createEthwalletProviderEffect, + createTronwalletProviderEffect, + createBtcwalletProviderEffect, ]; /** @@ -70,17 +72,11 @@ function createApiProvider(entry: ParsedApiEntry, chainId: string): ApiProvider /** * 为指定链创建 ChainProvider - * - * 根据链配置中的 api 条目,创建对应的 ApiProvider 并聚合到 ChainProvider。 - * - * 注意:TronRpcProvider 已通过 Mixin 继承方式内置 Identity 和 Transaction 能力。 - * 其他链的 Provider 后续将逐步迁移到同样的模式。 */ export function createChainProvider(chainId: string): ChainProvider { const entries = chainConfigService.getApi(chainId); const providers: ApiProvider[] = []; - // 添加 API Providers for (const entry of entries) { const provider = createApiProvider(entry, chainId); if (provider) { diff --git a/src/services/chain-adapter/providers/mempool-provider.ts b/src/services/chain-adapter/providers/mempool-provider.ts deleted file mode 100644 index d29f740ce..000000000 --- a/src/services/chain-adapter/providers/mempool-provider.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Mempool.space API Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, pathParams } from '@biochain/key-fetch' -import type { KeyFetchInstance, } from '@biochain/key-fetch' -import { BalanceOutputSchema, TransactionsOutputSchema, BlockHeightOutputSchema, AddressParamsSchema, TxHistoryParamsSchema } from './types' -import type { ApiProvider, Balance, Direction, BalanceOutput, BlockHeightOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { BitcoinIdentityMixin } from '../bitcoin/identity-mixin' -import { BitcoinTransactionMixin } from '../bitcoin/transaction-mixin' - -const AddressInfoSchema = z.object({ - chain_stats: z.object({ funded_txo_sum: z.number(), spent_txo_sum: z.number() }), -}).passthrough() - -const TxSchema = z.object({ - txid: z.string(), - vin: z.array(z.object({ prevout: z.object({ scriptpubkey_address: z.string().optional() }).optional() }).passthrough()), - vout: z.array(z.object({ scriptpubkey_address: z.string().optional(), value: z.number().optional() }).passthrough()), - status: z.object({ confirmed: z.boolean(), block_time: z.number().optional() }), -}).passthrough() - -const TxListSchema = z.array(TxSchema) -const BlockHeightApiSchema = z.number() - -type AddressInfo = z.infer -type TxList = z.infer -type BlockHeightApi = z.infer - -function getDirection(vin: any[], vout: any[], address: string): Direction { - const isFrom = vin?.some(v => v.prevout?.scriptpubkey_address === address) - const isTo = vout?.some(v => v.scriptpubkey_address === address) - if (isFrom && isTo) return 'self' - return isFrom ? 'out' : 'in' -} - -// ==================== Base Class for Mixins ==================== - -class MempoolBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class MempoolProvider extends BitcoinIdentityMixin(BitcoinTransactionMixin(MempoolBase)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - - readonly #addressInfo: KeyFetchInstance - readonly #txList: KeyFetchInstance - readonly #blockHeight: KeyFetchInstance - - readonly nativeBalance: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - readonly blockHeight: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const { endpoint: base, symbol, decimals } = this - - // 区块高度 - 使用 interval 轮询 - this.#blockHeight = keyFetch.create({ - name: `mempool.${chainId}.blockHeightApi`, - outputSchema: BlockHeightApiSchema, - url: `${base}/blocks/tip/height`, - use: [interval(60_000)] // Bitcoin 约 10 分钟出块,60s 轮询合理 - }) - - // 地址信息 - 由 blockHeight 驱动 - this.#addressInfo = keyFetch.create({ - name: `mempool.${chainId}.addressInfo`, - outputSchema: AddressInfoSchema, - inputSchema: AddressParamsSchema, - url: `${base}/address/:address`, - use: [deps(this.#blockHeight), pathParams()] - }) - - // 交易列表 - 由 blockHeight 驱动 - this.#txList = keyFetch.create({ - name: `mempool.${chainId}.txList`, - outputSchema: TxListSchema, - inputSchema: TxHistoryParamsSchema, - url: `${base}/address/:address/txs`, - use: [deps(this.#blockHeight), pathParams()] - }) - - this.nativeBalance = derive({ - name: `mempool.${chainId}.nativeBalance`, - source: this.#addressInfo, - outputSchema: BalanceOutputSchema, - use: [transform({ - transform: (r, ctx) => { - const balance = r.chain_stats.funded_txo_sum - r.chain_stats.spent_txo_sum - return ctx.createResponse({ amount: Amount.fromRaw(balance.toString(), decimals, symbol), symbol }) - }, - })], - }) - - this.transactionHistory = derive({ - name: `mempool.${chainId}.transactionHistory`, - source: this.#txList, - outputSchema: TransactionsOutputSchema, - use: [ - transform({ - transform: (r: TxList, ctx) => { - const addr = ctx.params.address - return r.map(tx => ({ - hash: tx.txid, - from: tx.vin[0]?.prevout?.scriptpubkey_address ?? '', - to: tx.vout[0]?.scriptpubkey_address ?? '', - timestamp: (tx.status.block_time ?? 0) * 1000, - status: tx.status.confirmed ? 'confirmed' as const : 'pending' as const, - action: 'transfer' as const, - direction: getDirection(tx.vin, tx.vout, addr), - assets: [{ assetType: 'native' as const, value: (tx.vout[0]?.value ?? 0).toString(), symbol, decimals }], - })) - }, - }), - ] - }) - - this.blockHeight = derive({ - name: `mempool.${chainId}.blockHeight`, - source: this.#blockHeight, - outputSchema: BlockHeightOutputSchema, - use: [transform({ transform: (r) => BigInt(r) })], - }) - } -} - -export function createMempoolProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type.startsWith('mempool-')) return new MempoolProvider(entry, chainId) - return null -} diff --git a/src/services/chain-adapter/providers/moralis-provider.ts b/src/services/chain-adapter/providers/moralis-provider.ts deleted file mode 100644 index cce5c4aba..000000000 --- a/src/services/chain-adapter/providers/moralis-provider.ts +++ /dev/null @@ -1,523 +0,0 @@ -/** - * Moralis API Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - * 支持 EVM 链的 Token 余额查询和交易历史 - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, throttleError, errorMatchers, searchParams, pathParams, postBody, dedupe } from '@biochain/key-fetch' -import type { KeyFetchInstance, FetchPlugin } from '@biochain/key-fetch' -import { globalRegistry } from '@biochain/key-fetch' -import type { - ApiProvider, - TokenBalance, - Transaction, - Direction, - Action, - BalanceOutput, - TokenBalancesOutput, - TransactionsOutput, - TransactionStatusOutput, - AddressParams, - TxHistoryParams, - TransactionStatusParams, -} from './types' -import { - BalanceOutputSchema, - TokenBalancesOutputSchema, - TransactionsOutputSchema, - TransactionStatusOutputSchema, - AddressParamsSchema, - TxHistoryParamsSchema, - TransactionStatusParamsSchema, -} from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { EvmIdentityMixin } from '../evm/identity-mixin' -import { EvmTransactionMixin } from '../evm/transaction-mixin' -import { getApiKey } from './api-key-picker' - -// ==================== 链 ID 映射 ==================== - -const MORALIS_CHAIN_MAP: Record = { - 'ethereum': 'eth', - 'binance': 'bsc', - 'polygon': 'polygon', - 'avalanche': 'avalanche', - 'fantom': 'fantom', - 'arbitrum': 'arbitrum', - 'optimism': 'optimism', - 'base': 'base', -} - -// ==================== Schema 定义 ==================== - -// 原生余额响应 -const NativeBalanceResponseSchema = z.object({ - balance: z.string(), -}) - -// RPC 交易回执响应(用于 transactionStatus) -const TxReceiptRpcResponseSchema = z.object({ - jsonrpc: z.string(), - id: z.number(), - result: z.object({ - transactionHash: z.string(), - blockNumber: z.string(), - status: z.string().optional(), // "0x1" = success, "0x0" = failed - }).nullable(), -}) - -// Token 余额响应 -const TokenBalanceItemSchema = z.object({ - token_address: z.string(), - symbol: z.string(), - name: z.string(), - decimals: z.number(), - balance: z.string(), - logo: z.string().nullable().optional(), - thumbnail: z.string().nullable().optional(), - possible_spam: z.boolean().optional(), - verified_contract: z.boolean().optional(), - total_supply: z.string().nullable().optional(), - security_score: z.number().nullable().optional(), -}) - -const TokenBalancesResponseSchema = z.array(TokenBalanceItemSchema) - -// 钱包历史响应 -const NativeTransferSchema = z.object({ - from_address: z.string(), - to_address: z.string(), - value: z.string(), - value_formatted: z.string().optional(), - direction: z.enum(['send', 'receive']).optional(), - token_symbol: z.string().optional(), - token_logo: z.string().optional(), -}) - -const Erc20TransferSchema = z.object({ - from_address: z.string(), - to_address: z.string(), - value: z.string(), - value_formatted: z.string().optional(), - token_name: z.string().optional(), - token_symbol: z.string().optional(), - token_decimals: z.string().optional(), - token_logo: z.string().optional(), - address: z.string(), // token contract address -}) - -const WalletHistoryItemSchema = z.object({ - hash: z.string(), - from_address: z.string(), - to_address: z.string().nullable(), - value: z.string(), - block_timestamp: z.string(), - block_number: z.string(), - receipt_status: z.string().optional(), - transaction_fee: z.string().optional(), - category: z.string().optional(), - summary: z.string().optional(), - possible_spam: z.boolean().optional(), - from_address_entity: z.string().nullable().optional(), - to_address_entity: z.string().nullable().optional(), - native_transfers: z.array(NativeTransferSchema).optional(), - erc20_transfers: z.array(Erc20TransferSchema).optional(), -}) - -const WalletHistoryResponseSchema = z.object({ - result: z.array(WalletHistoryItemSchema), - cursor: z.string().nullable().optional(), - page: z.number().optional(), - page_size: z.number().optional(), -}) - -type NativeBalanceResponse = z.infer -type TxReceiptRpcResponse = z.infer -type TokenBalanceItem = z.infer -type WalletHistoryResponse = z.infer -type WalletHistoryItem = z.infer - -// ==================== 工具函数 ==================== - -function getDirection(from: string, to: string, address: string): Direction { - const fromLower = from.toLowerCase() - const toLower = to.toLowerCase() - const addrLower = address.toLowerCase() - if (fromLower === addrLower && toLower === addrLower) return 'self' - if (fromLower === addrLower) return 'out' - return 'in' -} - -function mapCategory(category: string | undefined): Action { - switch (category) { - case 'send': - case 'receive': - return 'transfer' - case 'token send': - case 'token receive': - return 'transfer' - case 'nft send': - case 'nft receive': - return 'transfer' - case 'approve': - return 'approve' - case 'contract interaction': - return 'contract' - default: - return 'transfer' - } -} - -// ==================== Base Class for Mixins ==================== - -class MoralisBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 ==================== - -export class MoralisProvider extends EvmIdentityMixin(EvmTransactionMixin(MoralisBase)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - private readonly moralisChain: string - - readonly #nativeBalanceApi: KeyFetchInstance - readonly #tokenBalancesApi: KeyFetchInstance - readonly #walletHistoryApi: KeyFetchInstance - readonly #txStatusApi: KeyFetchInstance - - readonly nativeBalance: KeyFetchInstance - readonly tokenBalances: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - readonly transactionStatus: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - // 映射 Moralis 链名 - this.moralisChain = MORALIS_CHAIN_MAP[chainId] - if (!this.moralisChain) { - throw new Error(`[MoralisProvider] Unsupported chain: ${chainId}`) - } - - const baseUrl = this.endpoint - const symbol = this.symbol - const decimals = this.decimals - const moralisChain = this.moralisChain - - // 读取 API Key - const apiKey = getApiKey('MORALIS_API_KEY', `moralis-${chainId}`) - if (!apiKey) { - throw new Error(`[MoralisProvider] MORALIS_API_KEY is required`) - } - - // 从配置读取轮询间隔 - const txStatusInterval = (this.config?.txStatusInterval as number) ?? 3000 - const balanceInterval = (this.config?.balanceInterval as number) ?? 30000 - const erc20Interval = (this.config?.erc20Interval as number) ?? 120000 - - // API Key Header 插件 - const moralisApiKeyPlugin: FetchPlugin = { - name: 'moralis-api-key', - onFetch: async (request, next) => { - const headers = new Headers(request.headers) - headers.set('X-API-Key', apiKey) - headers.set('accept', 'application/json') - return next(new Request(request.url, { - method: request.method, - headers, - body: request.body, - })) - }, - } - - // 429/401 节流 - const moralisThrottleError = throttleError({ - match: errorMatchers.any( - errorMatchers.httpStatus(429), - errorMatchers.httpStatus(401), - errorMatchers.contains('usage has been consumed'), - errorMatchers.contains('Schema 验证失败'), - ), - }) - - // 原生余额 API - this.#nativeBalanceApi = keyFetch.create({ - name: `moralis.${chainId}.nativeBalanceApi`, - outputSchema: NativeBalanceResponseSchema, - inputSchema: AddressParamsSchema, - url: `${baseUrl}/:address/balance?chain=${moralisChain}`, - use: [ - dedupe({ minInterval: balanceInterval }), - interval(balanceInterval), - pathParams(), - moralisApiKeyPlugin, - moralisThrottleError, - ], - }) - - // Token 余额 API(不再独立轮询,由 transactionHistory 驱动) - this.#tokenBalancesApi = keyFetch.create({ - name: `moralis.${chainId}.tokenBalancesApi`, - outputSchema: TokenBalancesResponseSchema, - inputSchema: AddressParamsSchema, - url: `${baseUrl}/:address/erc20?chain=${moralisChain}`, - use: [ - dedupe({ minInterval: erc20Interval }), - pathParams(), - moralisApiKeyPlugin, - moralisThrottleError, - ], - }) - - // 钱包历史 API - this.#walletHistoryApi = keyFetch.create({ - name: `moralis.${chainId}.walletHistoryApi`, - outputSchema: WalletHistoryResponseSchema, - inputSchema: TxHistoryParamsSchema, - url: `${baseUrl}/wallets/:address/history`, - use: [ - dedupe({ minInterval: erc20Interval }), - interval(erc20Interval), - pathParams(), - searchParams({ - transform: (params: TxHistoryParams) => ({ - chain: moralisChain, - limit: String(params.limit ?? 20), - }), - }), - moralisApiKeyPlugin, - moralisThrottleError, - ], - }) - - // 派生:原生余额 - this.nativeBalance = derive({ - name: `moralis.${chainId}.nativeBalance`, - source: this.#nativeBalanceApi, - outputSchema: BalanceOutputSchema, - use: [ - transform({ - transform: (raw) => ({ - amount: Amount.fromRaw(raw.balance, decimals, symbol), - symbol, - }), - }), - ], - }) - - // 派生:Token 余额列表(含原生代币) - // 依赖 nativeBalance 和 transactionHistory,只有交易变化时才触发代币余额更新 - this.tokenBalances = derive({ - name: `moralis.${chainId}.tokenBalances`, - source: this.#tokenBalancesApi, - outputSchema: TokenBalancesOutputSchema, - use: [ - deps([ - { source: this.#nativeBalanceApi, params: ctx => ctx.params }, - { source: this.#walletHistoryApi, params: ctx => ({ address: ctx.params.address, limit: 1 }) }, - ]), - transform({ - transform: (tokens, ctx) => { - const result: TokenBalance[] = [] - - // 添加原生代币(从依赖获取) - const nativeBalanceData = ctx.deps?.get(this.#nativeBalanceApi) as NativeBalanceResponse | undefined - if (nativeBalanceData) { - result.push({ - symbol, - name: symbol, - amount: Amount.fromRaw(nativeBalanceData.balance, decimals, symbol), - isNative: true, - decimals, - }) - } - - // 过滤垃圾代币,只保留非 spam 的代币 - const filteredTokens = tokens.filter(token => !token.possible_spam) - - // 添加 ERC20 代币 - for (const token of filteredTokens) { - // 图标优先级:Moralis logo > thumbnail > tokenIconContract 配置 - const icon = token.logo - ?? token.thumbnail - ?? chainConfigService.getTokenIconByContract(chainId, token.token_address) - ?? undefined - - result.push({ - symbol: token.symbol, - name: token.name, - amount: Amount.fromRaw(token.balance, token.decimals, token.symbol), - isNative: false, - decimals: token.decimals, - icon, - contractAddress: token.token_address, - metadata: { - possibleSpam: token.possible_spam, - securityScore: token.security_score, - verified: token.verified_contract, - totalSupply: token.total_supply ?? undefined, - }, - }) - } - - return result - }, - }), - ], - }) - - // 派生:交易历史 - this.transactionHistory = derive({ - name: `moralis.${chainId}.transactionHistory`, - source: this.#walletHistoryApi, - outputSchema: TransactionsOutputSchema, - }).use(transform({ - transform: (raw: WalletHistoryResponse, ctx): Transaction[] => { - const address = ((ctx.params.address as string) ?? '').toLowerCase() - - return raw.result - .filter(item => !item.possible_spam) - .map((item): Transaction => { - const direction = getDirection(item.from_address, item.to_address ?? '', address) - const action = mapCategory(item.category) - - // 确定资产类型 - const hasErc20 = item.erc20_transfers && item.erc20_transfers.length > 0 - const hasNative = item.native_transfers && item.native_transfers.length > 0 - - // 构建资产列表 - const assets: Transaction['assets'] = [] - - if (hasErc20) { - for (const transfer of item.erc20_transfers!) { - assets.push({ - assetType: 'token', - value: transfer.value, - symbol: transfer.token_symbol ?? 'Unknown', - decimals: parseInt(transfer.token_decimals ?? '18', 10), - contractAddress: transfer.address, - name: transfer.token_name, - logoUrl: transfer.token_logo ?? undefined, - }) - } - } - - if (hasNative || assets.length === 0) { - // 添加原生资产 - const nativeValue = hasNative - ? item.native_transfers![0].value - : item.value - assets.unshift({ - assetType: 'native', - value: nativeValue, - symbol, - decimals, - }) - } - - return { - hash: item.hash, - from: item.from_address, - to: item.to_address ?? '', - timestamp: new Date(item.block_timestamp).getTime(), - status: item.receipt_status === '1' ? 'confirmed' : 'failed', - blockNumber: BigInt(item.block_number), - action, - direction, - assets, - fee: item.transaction_fee ? { - value: item.transaction_fee, - symbol, - decimals, - } : undefined, - fromEntity: item.from_address_entity ?? undefined, - toEntity: item.to_address_entity ?? undefined, - summary: item.summary, - } - }) - }, - })) - - // 获取 RPC URL 用于交易状态查询 - const rpcUrl = chainConfigService.getRpcUrl(chainId) - - // 交易状态 API(通过 RPC 查询交易回执) - this.#txStatusApi = keyFetch.create({ - name: `moralis.${chainId}.txStatusApi`, - outputSchema: TxReceiptRpcResponseSchema, - inputSchema: TransactionStatusParamsSchema, - url: rpcUrl, - method: 'POST', - use: [ - dedupe({ minInterval: txStatusInterval }), - interval(txStatusInterval), - postBody({ - transform: (params: TransactionStatusParams) => ({ - jsonrpc: '2.0', - id: 1, - method: 'eth_getTransactionReceipt', - params: [params.txHash], - }), - }), - moralisThrottleError, - ], - }) - - // 派生:交易状态(交易确认后触发余额和历史刷新) - this.transactionStatus = derive({ - name: `moralis.${chainId}.transactionStatus`, - source: this.#txStatusApi, - outputSchema: TransactionStatusOutputSchema, - use: [ - transform({ - transform: (raw): TransactionStatusOutput => { - const receipt = raw.result - if (!receipt || !receipt.blockNumber) { - return { status: 'pending', confirmations: 0, requiredConfirmations: 1 } - } - - // 交易已上链,触发余额和历史刷新 - globalRegistry.emitUpdate(`moralis.${chainId}.nativeBalanceApi`) - globalRegistry.emitUpdate(`moralis.${chainId}.walletHistoryApi`) - - const isSuccess = receipt.status === '0x1' || receipt.status === undefined // 旧版交易无 status - return { - status: isSuccess ? 'confirmed' : 'failed', - confirmations: 1, - requiredConfirmations: 1, - } - }, - }), - ], - }) - } -} - -export function createMoralisProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'moralis') { - try { - return new MoralisProvider(entry, chainId) - } catch (err) { - console.warn(`[MoralisProvider] Failed to create provider for ${chainId}:`, err) - return null - } - } - return null -} diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.ts b/src/services/chain-adapter/providers/tron-rpc-provider.ts deleted file mode 100644 index 6d45bd5af..000000000 --- a/src/services/chain-adapter/providers/tron-rpc-provider.ts +++ /dev/null @@ -1,364 +0,0 @@ -/** - * Tron RPC Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, pathParams, postBody, throttleError, errorMatchers, apiKey } from '@biochain/key-fetch' -import type { KeyFetchInstance } from '@biochain/key-fetch' -import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, BlockHeightOutput, TransactionOutput, TransactionsOutput, AddressParams, TxHistoryParams, TransactionParams } from './types' -import { - BalanceOutputSchema, - TransactionsOutputSchema, - TransactionOutputSchema, - BlockHeightOutputSchema, - TxHistoryParamsSchema, - TransactionParamsSchema, -} from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { TronIdentityMixin } from '../tron/identity-mixin' -import { TronTransactionMixin } from '../tron/transaction-mixin' -import { pickApiKey, getApiKey } from './api-key-picker' - -// ==================== Schema 定义 ==================== - -const TronAccountSchema = z.object({ - balance: z.number().optional(), - address: z.string().optional(), -}).passthrough() - -const TronNowBlockSchema = z.object({ - block_header: z.object({ - raw_data: z.object({ - number: z.number().optional(), - }).optional(), - }).optional(), -}).passthrough() - -const TronTxSchema = z.object({ - txID: z.string(), - block_timestamp: z.number().optional(), - raw_data: z.object({ - contract: z.array(z.object({ - parameter: z.object({ - value: z.object({ - amount: z.number().optional(), - owner_address: z.string().optional(), - to_address: z.string().optional(), - }).optional(), - }).optional(), - type: z.string().optional(), - })).optional(), - timestamp: z.number().optional(), - }).optional(), - ret: z.array(z.object({ - contractRet: z.string().optional(), - })).optional(), -}).passthrough() - -const TronTxListSchema = z.object({ - success: z.boolean(), - data: z.array(TronTxSchema).optional(), -}).passthrough() - -type TronAccount = z.infer -type TronNowBlock = z.infer -type TronTx = z.infer -type TronTxList = z.infer - -// Params Schema for validation -const AddressParamsSchema = z.object({ - address: z.string().min(1, 'Address is required'), -}) - -// ==================== 工具函数 ==================== - -// Base58 字符表 -const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' - -/** - * Base58 解码 - */ -function base58Decode(input: string): Uint8Array { - const bytes = [0] - for (const char of input) { - const idx = BASE58_ALPHABET.indexOf(char) - if (idx === -1) throw new Error(`Invalid Base58 character: ${char}`) - - let carry = idx - for (let i = 0; i < bytes.length; i++) { - carry += bytes[i] * 58 - bytes[i] = carry & 0xff - carry >>= 8 - } - while (carry > 0) { - bytes.push(carry & 0xff) - carry >>= 8 - } - } - - // 处理前导 '1' - for (const char of input) { - if (char !== '1') break - bytes.push(0) - } - - return new Uint8Array(bytes.reverse()) -} - -/** - * 将 Tron Base58Check 地址转换为 Hex 格式 - * T... -> 41... (不带 0x 前缀) - */ -function tronAddressToHex(address: string): string { - if (address.startsWith('41') && address.length === 42) { - return address // 已经是 hex 格式 - } - if (!address.startsWith('T')) { - throw new Error(`Invalid Tron address: ${address}`) - } - - // Base58Check 解码后取前 21 字节(去掉 4 字节校验和) - const decoded = base58Decode(address) - const addressBytes = decoded.slice(0, 21) - - // 转为 hex - return Array.from(addressBytes) - .map(b => b.toString(16).padStart(2, '0')) - .join('') -} - -function getDirection(from: string, to: string, address: string): Direction { - const fromLower = from.toLowerCase() - const toLower = to.toLowerCase() - if (fromLower === address && toLower === address) return 'self' - if (fromLower === address) return 'out' - return 'in' -} - -// ==================== Base Class for Mixins ==================== - -class TronRpcBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class TronRpcProvider extends TronIdentityMixin(TronTransactionMixin(TronRpcBase)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - - readonly #accountApi: KeyFetchInstance - readonly #blockApi: KeyFetchInstance - readonly #txListApi: KeyFetchInstance - readonly #txByIdApi: KeyFetchInstance - - readonly nativeBalance: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - readonly transaction: KeyFetchInstance - readonly blockHeight: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const baseUrl = this.endpoint - const symbol = this.symbol - const decimals = this.decimals - - // 读取 API Key - const tronApiKey = getApiKey(this.config?.apiKeyEnv as string, `trongrid-${chainId}`) - - // API Key 插件(TronGrid 使用 TRON-PRO-API-KEY 头) - const tronApiKeyPlugin = apiKey({ - header: 'TRON-PRO-API-KEY', - key: tronApiKey, - }) - - // 共享的 429 错误节流插件 - const tronThrottleError = throttleError({ - match: errorMatchers.httpStatus(429), - }) - - // 基础 API fetcher - // 区块 API - 使用 interval 轮询 - this.#blockApi = keyFetch.create({ - name: `tron-rpc.${chainId}.blockApi`, - outputSchema: TronNowBlockSchema, - url: `${baseUrl}/wallet/getnowblock`, - method: 'POST', - use: [interval(30_000), tronApiKeyPlugin, tronThrottleError], // 节约 API 费用,至少 30s 轮询 - }) - - // 账户信息 - 由 blockApi 驱动 - this.#accountApi = keyFetch.create({ - name: `tron-rpc.${chainId}.accountApi`, - outputSchema: TronAccountSchema, - inputSchema: AddressParamsSchema, - url: `${baseUrl}/wallet/getaccount`, - method: 'POST', - use: [ - deps(this.#blockApi), - postBody({ - transform: (params) => ({ - address: tronAddressToHex(params.address as string), - }), - }), - tronApiKeyPlugin, - tronThrottleError, - ], - }) - - // 交易列表 - 由 blockApi 驱动 - this.#txListApi = keyFetch.create({ - name: `tron-rpc.${chainId}.txListApi`, - outputSchema: TronTxListSchema, - inputSchema: TxHistoryParamsSchema, - url: `${baseUrl}/v1/accounts/:address/transactions`, - use: [deps(this.#blockApi), pathParams(), tronApiKeyPlugin, tronThrottleError], - }) - - // 派生视图 - this.nativeBalance = derive({ - name: `tron-rpc.${chainId}.nativeBalance`, - source: this.#accountApi, - outputSchema: BalanceOutputSchema, - use: [ - transform({ - transform: (raw) => ({ - amount: Amount.fromRaw((raw.balance ?? 0).toString(), decimals, symbol), - symbol, - }), - }), - ], - }) - - this.blockHeight = derive({ - name: `tron-rpc.${chainId}.blockHeight`, - source: this.#blockApi, - outputSchema: BlockHeightOutputSchema, - use: [ - transform({ - transform: (raw) => BigInt(raw.block_header?.raw_data?.number ?? 0), - }), - ], - }) - - this.transactionHistory = derive({ - name: `tron-rpc.${chainId}.transactionHistory`, - source: this.#txListApi, - outputSchema: TransactionsOutputSchema, - - }).use(transform({ - transform: (raw: TronTxList, ctx): Transaction[] => { - if (!raw.success || !raw.data) return [] - - const address = (ctx.params.address ?? '').toLowerCase() - - return raw.data.map((tx): Transaction => { - const contract = tx.raw_data?.contract?.[0] - const value = contract?.parameter?.value - const from = value?.owner_address ?? '' - const to = value?.to_address ?? '' - const direction = getDirection(from, to, address) - const status = tx.ret?.[0]?.contractRet === 'SUCCESS' ? 'confirmed' : 'failed' - - return { - hash: tx.txID, - from, - to, - timestamp: tx.block_timestamp ?? tx.raw_data?.timestamp ?? 0, - status, - action: 'transfer' as const, - direction, - assets: [{ - assetType: 'native' as const, - value: (value?.amount ?? 0).toString(), - symbol, - decimals, - }], - } - }) - }, - }),) - - // transaction: 单笔交易查询 - 由 blockApi 驱动 - this.#txByIdApi = keyFetch.create({ - name: `tron-rpc.${chainId}.txById`, - outputSchema: TronTxSchema, - inputSchema: TransactionParamsSchema, - url: `${baseUrl}/wallet/gettransactionbyid`, - method: 'POST', - - }).use(deps(this.#blockApi), - postBody({ - transform: (params) => ({ value: params.txHash }), - }), - tronApiKeyPlugin, - tronThrottleError, - ) - - this.transaction = derive({ - name: `tron-rpc.${chainId}.transaction`, - source: this.#txByIdApi, - outputSchema: TransactionOutputSchema, - use: [ - transform, Transaction | null>({ - transform: (tx) => { - if (!tx.txID) return null - - const contract = tx.raw_data?.contract?.[0] - const value = contract?.parameter?.value - const from = value?.owner_address ?? '' - const to = value?.to_address ?? '' - - // 判断状态:如果没有 ret,说明是 pending - let status: 'pending' | 'confirmed' | 'failed' - if (!tx.ret || tx.ret.length === 0) { - status = 'pending' - } else { - status = tx.ret[0]?.contractRet === 'SUCCESS' ? 'confirmed' : 'failed' - } - - return { - hash: tx.txID, - from, - to, - timestamp: tx.block_timestamp ?? tx.raw_data?.timestamp ?? 0, - status, - action: 'transfer' as const, - direction: 'out', // TODO: 根据 address 判断 - assets: [{ - assetType: 'native' as const, - value: (value?.amount ?? 0).toString(), - symbol, - decimals, - }], - } - }, - }), - ], - }) - } -} - -export function createTronRpcProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'tron-rpc' || entry.type === 'tron-rpc-pro') { - return new TronRpcProvider(entry, chainId) - } - return null -} diff --git a/src/services/chain-adapter/providers/tronwallet-provider.ts b/src/services/chain-adapter/providers/tronwallet-provider.ts deleted file mode 100644 index 3d57d7273..000000000 --- a/src/services/chain-adapter/providers/tronwallet-provider.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * TronWallet API Provider - * - * 使用 Mixin 继承模式组合 Identity 和 Transaction 能力 - * - * API 格式: - * - 余额: { success: boolean, result: string|number } - * - 交易历史: { success: boolean, data: TronNativeTx[] } - */ - -import { z } from 'zod' -import { keyFetch, interval, deps, derive, transform, walletApiUnwrap, postBody } from '@biochain/key-fetch' -import type { KeyFetchInstance } from '@biochain/key-fetch' -import type { ApiProvider, Balance, Transaction, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from './types' -import { BalanceOutputSchema, TransactionsOutputSchema, AddressParamsSchema, TxHistoryParamsSchema } from './types' -import type { ParsedApiEntry } from '@/services/chain-config' -import { chainConfigService } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { TronIdentityMixin } from '../tron/identity-mixin' -import { TronTransactionMixin } from '../tron/transaction-mixin' - -// ==================== Schema 定义 ==================== - -const BalanceResultSchema = z.union([z.string(), z.number().transform(v => String(v))]) - -const TronNativeTxSchema = z.object({ - txID: z.string(), - from: z.string(), - to: z.string(), - amount: z.number(), - timestamp: z.number(), - contractRet: z.string().optional(), -}).passthrough() - -const TxHistoryApiSchema = z.object({ - success: z.boolean(), - data: z.array(TronNativeTxSchema), -}).passthrough() - -type BalanceResult = z.infer -type TxHistoryApi = z.infer - -// ==================== 工具函数 ==================== - -function getDirection(from: string, to: string, address: string): Direction { - const f = from.toLowerCase(), t = to.toLowerCase() - if (f === address && t === address) return 'self' - if (f === address) return 'out' - return 'in' -} - -// ==================== Base Class for Mixins ==================== - -class TronWalletBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record - - constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId - } -} - -// ==================== Provider 实现 (使用 Mixin 继承) ==================== - -export class TronWalletProvider extends TronIdentityMixin(TronTransactionMixin(TronWalletBase)) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - - readonly #balanceApi: KeyFetchInstance - readonly #txHistoryApi: KeyFetchInstance - - readonly nativeBalance: KeyFetchInstance - readonly transactionHistory: KeyFetchInstance - - constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - - const { endpoint: base, symbol, decimals } = this - - // 区块高度触发器 - 使用 interval 驱动数据更新 - const blockHeightTrigger = keyFetch.create({ - name: `tronwallet.${chainId}.blockTrigger`, - outputSchema: z.object({ timestamp: z.number() }), - url: 'internal://trigger', - use: [ - interval(30_000), // 节约 API 费用,至少 30s 轮询 - { - name: 'trigger', - onFetch: async (_req, _next, ctx) => { - return ctx.createResponse({ timestamp: Date.now() }) - }, - }, - ], - }) - - this.#balanceApi = keyFetch.create({ - name: `tronwallet.${chainId}.balanceApi`, - outputSchema: BalanceResultSchema, - inputSchema: AddressParamsSchema, - url: `${base}/balance`, - method: 'POST', - }).use(deps(blockHeightTrigger), postBody(), walletApiUnwrap()) - - this.#txHistoryApi = keyFetch.create({ - name: `tronwallet.${chainId}.txHistoryApi`, - outputSchema: TxHistoryApiSchema, - url: `${base}/transactions`, - use: [deps(blockHeightTrigger)], - }) - - this.nativeBalance = derive({ - name: `tronwallet.${chainId}.nativeBalance`, - source: this.#balanceApi, - outputSchema: BalanceOutputSchema, - use: [transform({ - transform: (raw) => ({ - amount: Amount.fromRaw(raw, decimals, symbol), - symbol, - }), - })], - }) - - this.transactionHistory = keyFetch.create({ - name: `tronwallet.${chainId}.transactionHistory`, - inputSchema: TxHistoryParamsSchema, - outputSchema: TransactionsOutputSchema, - url: `${base}/transactions`, - method: 'POST', - }).use( - deps(this.#txHistoryApi), - transform({ - transform: (raw: TxHistoryApi, ctx) => { - if (!raw.success) return [] - const addr = ctx.params.address.toLowerCase() - return raw.data.map((tx): Transaction => ({ - hash: tx.txID, - from: tx.from, - to: tx.to, - timestamp: tx.timestamp, - status: tx.contractRet === 'SUCCESS' ? 'confirmed' : 'failed', - action: 'transfer' as const, - direction: getDirection(tx.from, tx.to, addr), - assets: [{ - assetType: 'native' as const, - value: String(tx.amount), - symbol, - decimals, - }], - })) - }, - }), - ) - } -} - -export function createTronwalletProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'tronwallet-v1') return new TronWalletProvider(entry, chainId) - return null -} diff --git a/src/services/chain-adapter/providers/types.ts b/src/services/chain-adapter/providers/types.ts index 188fec9e3..1865d5943 100644 --- a/src/services/chain-adapter/providers/types.ts +++ b/src/services/chain-adapter/providers/types.ts @@ -13,23 +13,9 @@ import { Amount, type AmountJSON } from '@/types/amount' import type { ParsedApiEntry } from '@/services/chain-config' -import type { KeyFetchInstance } from '@biochain/key-fetch' -import { keyFetch } from '@biochain/key-fetch' +import type { StreamInstance } from '@biochain/chain-effect' import { z } from 'zod' -// ==================== 注册 Amount 类型序列化 ==================== -// 使用 superjson 的 registerCustom 使 Amount 对象能正确序列化/反序列化 -// 注:Amount 使用 private constructor,所以用 registerCustom 而非 registerClass -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- superjson 内部处理 JSON 序列化 -keyFetch.superjson.registerCustom( - { - isApplicable: (v): v is Amount => v instanceof Amount, - serialize: (v) => v.toJSON(), - deserialize: (v) => Amount.fromJSON(v as AmountJSON), - }, - 'Amount' -) - // 导出 ProviderResult 类型 export { type ProviderResult, @@ -241,22 +227,22 @@ export interface ApiProvider extends Partial + nativeBalance?: StreamInstance /** 所有代币余额 - 参数: { address: string } */ - tokenBalances?: KeyFetchInstance + tokenBalances?: StreamInstance /** 交易历史 - 参数: { address: string, limit?: number } */ - transactionHistory?: KeyFetchInstance + transactionHistory?: StreamInstance /** 单笔交易详情 - 参数: { hash: string } */ - transaction?: KeyFetchInstance + transaction?: StreamInstance /** 交易状态 - 参数: { hash: string } */ - transactionStatus?: KeyFetchInstance + transactionStatus?: StreamInstance /** 当前区块高度 - 参数: {} */ - blockHeight?: KeyFetchInstance + blockHeight?: StreamInstance // ===== 服务接口方法(通过 extends Partial 继承)===== // - ITransactionService: buildTransaction, estimateFee, signTransaction, broadcastTransaction diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index 75265999f..7f45d846e 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -12,7 +12,7 @@ import { usePendingTransactions } from "@/hooks/use-pending-transactions"; import { TransactionItem } from "@/components/transaction/transaction-item"; import { pendingTxToTransactionInfo } from "@/services/transaction/convert"; import { ChainProviderGate, useChainProvider } from "@/contexts"; -import keyFetch from "@biochain/key-fetch"; +import { superjson } from "@biochain/chain-effect"; import { useServiceStatus } from "@/hooks/use-service-status"; import type { TokenInfo, TokenItemContext, TokenMenuItem } from "@/components/token/token-item"; import { @@ -123,7 +123,7 @@ function WalletTabContent({ if (tx.id) { // 从原始数据中找到对应的交易(通过 hash 匹配) const originalTx = txResult?.find(t => t.hash === tx.hash); - const txData = originalTx ? keyFetch.superjson.stringify(originalTx) : undefined; + const txData = originalTx ? superjson.stringify(originalTx) : undefined; push("TransactionDetailActivity", { txId: tx.id, txData }); } }, From bae7ee3e05cf878779fb20e50ce47c9582421626 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 12:52:44 +0800 Subject: [PATCH 21/33] feat(chain-effect): migrate to useSyncExternalStore, remove CJS require - Replace require('react') with ESM import in instance.ts - Use useSyncExternalStore for React 18+ concurrent mode support - Remove dynamic import('effect') in favor of direct Fiber.interrupt - Add reference links to Effect docs (context7.com/effect-ts/effect/llms.txt) - Refactor bioforest/fetch.ts to Effect-TS native DataSource - Refactor pending-tx.ts to Effect-TS with createDependentSource - Refactor ecosystem/registry.ts with native fetch + IndexedDB cache - Remove @biochain/key-fetch imports from main.tsx - Update use-pending-transactions.ts to use Effect-based API - Backward compatible: existing .useState() API unchanged --- packages/chain-effect/src/index.ts | 3 + packages/chain-effect/src/instance.ts | 153 +++++----- packages/chain-effect/src/source.ts | 2 + src/hooks/use-pending-transactions.ts | 70 +++-- src/main.tsx | 2 - src/services/chain-adapter/bioforest/fetch.ts | 262 +++++++++++------- src/services/ecosystem/registry.ts | 190 +++++++++++-- src/services/transaction/index.ts | 4 +- .../transaction/pending-tx-manager.ts | 4 +- src/services/transaction/pending-tx.ts | 122 +++++--- 10 files changed, 551 insertions(+), 261 deletions(-) diff --git a/packages/chain-effect/src/index.ts b/packages/chain-effect/src/index.ts index 120451ca7..17d1366e4 100644 --- a/packages/chain-effect/src/index.ts +++ b/packages/chain-effect/src/index.ts @@ -2,6 +2,9 @@ * @biochain/chain-effect * * Effect TS based reactive chain data fetching + * + * @see https://context7.com/effect-ts/effect/llms.txt - Effect 官方文档 + * @see https://context7.com/tim-smart/effect-atom/llms.txt - Effect Atom React 集成参考 */ // Re-export Effect core types for convenience diff --git a/packages/chain-effect/src/instance.ts b/packages/chain-effect/src/instance.ts index 210e98702..488faa407 100644 --- a/packages/chain-effect/src/instance.ts +++ b/packages/chain-effect/src/instance.ts @@ -2,8 +2,13 @@ * React 桥接层 * * 将 Effect 的 SubscriptionRef 桥接到 React Hook + * + * @see https://context7.com/effect-ts/effect/llms.txt - Effect Stream 官方文档 + * @see https://context7.com/tim-smart/effect-atom/llms.txt - Effect Atom React 集成参考 + * @see https://react.dev/reference/react/useSyncExternalStore - React 18 外部订阅最佳实践 */ +import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore } from "react" import { Effect, Stream, Fiber } from "effect" import type { FetchError } from "./http" import type { DataSource } from "./source" @@ -121,11 +126,6 @@ export function createStreamInstanceFromSource( }, useState(input: TInput, options?: { enabled?: boolean }) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const React = require("react") as typeof import("react") - const { useState, useEffect, useCallback, useRef, useMemo } = React - - const [data, setData] = useState(undefined) const [isLoading, setIsLoading] = useState(true) const [isFetching, setIsFetching] = useState(false) const [error, setError] = useState(undefined) @@ -136,6 +136,39 @@ export function createStreamInstanceFromSource( const enabled = options?.enabled !== false const instanceRef = useRef(this) + + // 使用 ref 存储最新值供 useSyncExternalStore 使用 + const snapshotRef = useRef(undefined) + + // useSyncExternalStore 订阅函数 + const subscribe = useCallback((onStoreChange: () => void) => { + if (!enabled) { + snapshotRef.current = undefined + return () => {} + } + + setIsLoading(true) + setIsFetching(true) + setError(undefined) + + const unsubscribe = instanceRef.current.subscribe( + inputRef.current, + (newData: TOutput) => { + snapshotRef.current = newData + setIsLoading(false) + setIsFetching(false) + setError(undefined) + onStoreChange() + } + ) + + return unsubscribe + }, [enabled, inputKey]) + + const getSnapshot = useCallback(() => snapshotRef.current, []) + + // 使用 useSyncExternalStore 订阅外部状态 (React 18+ 推荐) + const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) const refetch = useCallback(async () => { if (!enabled) return @@ -144,7 +177,7 @@ export function createStreamInstanceFromSource( try { const source = await getOrCreateSource(inputRef.current) const result = await Effect.runPromise(source.refresh) - setData(result) + snapshotRef.current = result } catch (err) { setError(err instanceof Error ? err : new Error(String(err))) } finally { @@ -153,36 +186,15 @@ export function createStreamInstanceFromSource( } }, [enabled]) + // 处理 disabled 状态 useEffect(() => { if (!enabled) { - setData(undefined) + snapshotRef.current = undefined setIsLoading(false) setIsFetching(false) setError(undefined) - return } - - setIsLoading(true) - setIsFetching(true) - setError(undefined) - - let isCancelled = false - const unsubscribe = instanceRef.current.subscribe( - inputRef.current, - (newData: TOutput, _event: "initial" | "update") => { - if (isCancelled) return - setData(newData) - setIsLoading(false) - setIsFetching(false) - setError(undefined) - } - ) - - return () => { - isCancelled = true - unsubscribe() - } - }, [enabled, inputKey]) + }, [enabled]) return { data, isLoading, isFetching, error, refetch } }, @@ -271,18 +283,11 @@ export function createStreamInstance( return () => { cancelled = true - import("effect").then(({ Fiber }) => { - Effect.runFork(Fiber.interrupt(fiber)) - }) + Effect.runFork(Fiber.interrupt(fiber)) } }, useState(input: TInput, options?: { enabled?: boolean }) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const React = require("react") as typeof import("react") - const { useState, useEffect, useCallback, useRef, useMemo } = React - - const [data, setData] = useState(undefined) const [isLoading, setIsLoading] = useState(true) const [isFetching, setIsFetching] = useState(false) const [error, setError] = useState(undefined) @@ -293,37 +298,22 @@ export function createStreamInstance( const enabled = options?.enabled !== false const instanceRef = useRef(this) + + // 使用 ref 存储最新值供 useSyncExternalStore 使用 + const snapshotRef = useRef(undefined) - const refetch = useCallback(async () => { - if (!enabled) return - setIsFetching(true) - setError(undefined) - try { - cache.delete(getInputKey(inputRef.current)) - const result = await instanceRef.current.fetch(inputRef.current) - setData(result) - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))) - } finally { - setIsFetching(false) - setIsLoading(false) - } - }, [enabled]) - - useEffect(() => { + // useSyncExternalStore 订阅函数 + const subscribe = useCallback((onStoreChange: () => void) => { if (!enabled) { - setData(undefined) - setIsLoading(false) - setIsFetching(false) - setError(undefined) - return + snapshotRef.current = undefined + return () => {} } // 检查缓存 const key = getInputKey(inputRef.current) const cached = cache.get(key) if (cached && Date.now() - cached.timestamp < ttl) { - setData(cached.value) + snapshotRef.current = cached.value setIsLoading(false) } else { setIsLoading(true) @@ -331,24 +321,51 @@ export function createStreamInstance( setIsFetching(true) setError(undefined) - let isCancelled = false const unsubscribe = instanceRef.current.subscribe( inputRef.current, (newData: TOutput) => { - if (isCancelled) return - setData(newData) + snapshotRef.current = newData setIsLoading(false) setIsFetching(false) setError(undefined) + onStoreChange() } ) - return () => { - isCancelled = true - unsubscribe() - } + return unsubscribe }, [enabled, inputKey]) + const getSnapshot = useCallback(() => snapshotRef.current, []) + + // 使用 useSyncExternalStore 订阅外部状态 (React 18+ 推荐) + const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + const refetch = useCallback(async () => { + if (!enabled) return + setIsFetching(true) + setError(undefined) + try { + cache.delete(getInputKey(inputRef.current)) + const result = await instanceRef.current.fetch(inputRef.current) + snapshotRef.current = result + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setIsFetching(false) + setIsLoading(false) + } + }, [enabled]) + + // 处理 disabled 状态 + useEffect(() => { + if (!enabled) { + snapshotRef.current = undefined + setIsLoading(false) + setIsFetching(false) + setError(undefined) + } + }, [enabled]) + return { data, isLoading, isFetching, error, refetch } }, diff --git a/packages/chain-effect/src/source.ts b/packages/chain-effect/src/source.ts index cf3afb54f..05d824eae 100644 --- a/packages/chain-effect/src/source.ts +++ b/packages/chain-effect/src/source.ts @@ -5,6 +5,8 @@ * - createPollingSource: 定时轮询 + 事件触发 * - createDependentSource: 依赖变化触发 * - createEventBus: 外部事件总线 + * + * @see https://context7.com/effect-ts/effect/llms.txt - Effect Stream 创建和转换 */ import { Effect, Stream, Schedule, SubscriptionRef, Duration, PubSub, Fiber } from "effect" diff --git a/src/hooks/use-pending-transactions.ts b/src/hooks/use-pending-transactions.ts index d0432329b..2bb2cdf7c 100644 --- a/src/hooks/use-pending-transactions.ts +++ b/src/hooks/use-pending-transactions.ts @@ -2,55 +2,85 @@ * usePendingTransactions Hook * * 获取当前钱包的未上链交易列表 - * 使用 key-fetch 的 useState,依赖 blockHeight 自动刷新 + * 使用 Effect 数据源,依赖 blockHeight 自动刷新 */ import { useCallback, useMemo, useState, useEffect } from 'react' -import { getPendingTxFetcher, pendingTxService, pendingTxManager, type PendingTx } from '@/services/transaction' +import { Effect } from 'effect' +import { pendingTxService, pendingTxManager, getPendingTxSource, type PendingTx } from '@/services/transaction' import { useChainConfigState } from '@/stores' export function usePendingTransactions(walletId: string | undefined, chainId?: string) { const chainConfigState = useChainConfigState() - // 获取 key-fetch 实例(使用 useMemo 保持稳定引用) - const fetcher = useMemo(() => { - if (!walletId || !chainId) return null - return getPendingTxFetcher(chainId, walletId) - }, [walletId, chainId]) - - // 手动管理状态,避免条件调用 Hook + // 手动管理状态 const [transactions, setTransactions] = useState([]) const [isLoading, setIsLoading] = useState(false) useEffect(() => { - if (!fetcher) { + if (!walletId || !chainId) { setTransactions([]) setIsLoading(false) return } + const sourceEffect = getPendingTxSource(chainId, walletId) + if (!sourceEffect) { + // 如果链不支持 blockHeight,回退到直接查询 + pendingTxService.getPending({ walletId }).then(setTransactions) + return + } + setIsLoading(true) - // 初始获取 - fetcher.fetch({}).then((result) => { - setTransactions(result as PendingTx[]) - setIsLoading(false) + // 运行 Effect 获取数据源并订阅变化 + let cleanup: (() => void) | undefined + + Effect.runPromise(sourceEffect).then((source) => { + // 获取初始值 + Effect.runPromise(source.get).then((result) => { + if (result) setTransactions(result) + setIsLoading(false) + }) + + // 订阅变化流 + const fiber = Effect.runFork( + source.changes.pipe( + Effect.tap((newData) => Effect.sync(() => setTransactions(newData))) + ) + ) + + cleanup = () => { + Effect.runPromise(Effect.fiberId.pipe(Effect.flatMap(() => source.stop))) + } }).catch(() => { setIsLoading(false) }) - // 订阅更新 - const unsubscribe = fetcher.subscribe({}, (newData) => { - setTransactions(newData as PendingTx[]) + return () => cleanup?.() + }, [walletId, chainId]) + + // 订阅 pendingTxService 的变化(用于即时更新) + useEffect(() => { + if (!walletId) return + + const unsubscribe = pendingTxService.subscribe((tx, event) => { + if (tx.walletId !== walletId) return + + if (event === 'created') { + setTransactions(prev => [tx, ...prev]) + } else if (event === 'updated') { + setTransactions(prev => prev.map(t => t.id === tx.id ? tx : t)) + } else if (event === 'deleted') { + setTransactions(prev => prev.filter(t => t.id !== tx.id)) + } }) return unsubscribe - }, [fetcher]) + }, [walletId]) const deleteTransaction = useCallback(async (tx: PendingTx) => { await pendingTxService.delete({ id: tx.id }) - // 立即更新本地状态,提供即时 UI 反馈 - setTransactions(prev => prev.filter(t => t.id !== tx.id)) }, []) const retryTransaction = useCallback(async (tx: PendingTx) => { diff --git a/src/main.tsx b/src/main.tsx index 8ff37905e..0a3236cfd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,5 @@ // Global error capture - MUST be first to catch all errors! import './lib/error-capture' -// Enable React useState support for @biochain/key-fetch -import '@biochain/key-fetch/react' import './polyfills' import { startServiceMain } from './service-main' import { startFrontendMain } from './frontend-main' diff --git a/src/services/chain-adapter/bioforest/fetch.ts b/src/services/chain-adapter/bioforest/fetch.ts index 9ae8c7fca..6d33e4231 100644 --- a/src/services/chain-adapter/bioforest/fetch.ts +++ b/src/services/chain-adapter/bioforest/fetch.ts @@ -1,76 +1,78 @@ /** - * BioForest KeyFetch 定义 + * BioForest Effect-TS 数据源 * - * Schema-first 响应式数据获取实例 + * Schema-first 响应式数据获取 - Effect Native */ -import { z } from 'zod' -import { keyFetch, interval, deps } from '@biochain/key-fetch' +import { Effect, Duration } from 'effect' +import { Schema } from 'effect' +import { httpFetch, type FetchError } from '@biochain/chain-effect' +import { createPollingSource, createDependentSource, type DataSource } from '@biochain/chain-effect' -// ==================== Schemas ==================== +// ==================== Schemas (Effect Schema) ==================== /** 最新区块响应 Schema */ -export const LastBlockSchema = z.object({ - success: z.boolean(), - result: z.object({ - height: z.number(), - timestamp: z.number(), - signature: z.string().optional(), +export const LastBlockSchema = Schema.Struct({ + success: Schema.Boolean, + result: Schema.Struct({ + height: Schema.Number, + timestamp: Schema.Number, + signature: Schema.optional(Schema.String), }), }) /** 余额响应 Schema */ -export const BalanceSchema = z.object({ - success: z.boolean(), - result: z.object({ - assets: z.array(z.object({ - symbol: z.string(), - balance: z.string(), - })).optional(), - }).optional(), +export const BalanceSchema = Schema.Struct({ + success: Schema.Boolean, + result: Schema.optional(Schema.Struct({ + assets: Schema.optional(Schema.Array(Schema.Struct({ + symbol: Schema.String, + balance: Schema.String, + }))), + })), }) /** 交易查询响应 Schema */ -export const TransactionQuerySchema = z.object({ - success: z.boolean(), - result: z.object({ - trs: z.array(z.object({ - height: z.number(), - signature: z.string(), - tIndex: z.number(), - transaction: z.object({ - signature: z.string(), - senderId: z.string(), - recipientId: z.string().optional(), - fee: z.string(), - timestamp: z.number(), - type: z.string().optional(), - asset: z.object({ - transferAsset: z.object({ - amount: z.string(), - assetType: z.string().optional(), - }).optional(), - }).optional(), +export const TransactionQuerySchema = Schema.Struct({ + success: Schema.Boolean, + result: Schema.optional(Schema.Struct({ + trs: Schema.optional(Schema.Array(Schema.Struct({ + height: Schema.Number, + signature: Schema.String, + tIndex: Schema.Number, + transaction: Schema.Struct({ + signature: Schema.String, + senderId: Schema.String, + recipientId: Schema.optional(Schema.String), + fee: Schema.String, + timestamp: Schema.Number, + type: Schema.optional(Schema.String), + asset: Schema.optional(Schema.Struct({ + transferAsset: Schema.optional(Schema.Struct({ + amount: Schema.String, + assetType: Schema.optional(Schema.String), + })), + })), }), - })).optional(), - count: z.number().optional(), - }).optional(), + }))), + count: Schema.optional(Schema.Number), + })), }) /** Genesis Block Schema */ -export const GenesisBlockSchema = z.object({ - genesisBlock: z.object({ - forgeInterval: z.number(), - beginEpochTime: z.number().optional(), +export const GenesisBlockSchema = Schema.Struct({ + genesisBlock: Schema.Struct({ + forgeInterval: Schema.Number, + beginEpochTime: Schema.optional(Schema.Number), }), }) // ==================== 类型导出 ==================== -export type LastBlockResponse = z.infer -export type BalanceResponse = z.infer -export type TransactionQueryResponse = z.infer -export type GenesisBlockResponse = z.infer +export type LastBlockResponse = Schema.Schema.Type +export type BalanceResponse = Schema.Schema.Type +export type TransactionQueryResponse = Schema.Schema.Type +export type GenesisBlockResponse = Schema.Schema.Type // ==================== 出块间隔管理 ==================== @@ -79,91 +81,159 @@ const DEFAULT_FORGE_INTERVAL = 15_000 export function setForgeInterval(chainId: string, intervalMs: number): void { forgeIntervals.set(chainId, intervalMs) - } export function getForgeInterval(chainId: string): number { return forgeIntervals.get(chainId) ?? DEFAULT_FORGE_INTERVAL } -// ==================== KeyFetch 实例工厂 ==================== +// ==================== Effect Data Source 工厂 ==================== /** - * 创建链的 lastBlock KeyFetch 实例 + * 创建 lastBlock 的 fetch Effect */ -export function createLastBlockFetch(chainId: string, baseUrl: string) { - return keyFetch.create({ - name: `${chainId}.lastblock`, - outputSchema: LastBlockSchema, +function createLastBlockFetch(baseUrl: string): Effect.Effect { + return httpFetch({ url: `${baseUrl}/lastblock`, method: 'GET', - use: [ - interval(() => getForgeInterval(chainId)), - ], + schema: LastBlockSchema, }) } /** - * 创建链的余额查询 KeyFetch 实例 + * 创建余额查询 fetch Effect */ -export function createBalanceFetch(chainId: string, baseUrl: string, lastBlockFetch: ReturnType) { - return keyFetch.create({ - name: `${chainId}.balance`, - outputSchema: BalanceSchema, +function createBalanceFetch(baseUrl: string, address: string): Effect.Effect { + return httpFetch({ url: `${baseUrl}/address/asset`, method: 'POST', - use: [ - deps(lastBlockFetch), - ], + body: { address }, + schema: BalanceSchema, }) } /** - * 创建链的交易查询 KeyFetch 实例 + * 创建交易查询 fetch Effect */ -export function createTransactionQueryFetch(chainId: string, baseUrl: string, lastBlockFetch: ReturnType) { - return keyFetch.create({ - name: `${chainId}.txQuery`, - outputSchema: TransactionQuerySchema, +function createTransactionQueryFetch( + baseUrl: string, + address: string, + limit = 20 +): Effect.Effect { + return httpFetch({ url: `${baseUrl}/transactions/query`, method: 'POST', - use: [ - deps(lastBlockFetch), - ], + body: { address, limit }, + schema: TransactionQuerySchema, }) } -// ==================== 链实例缓存 ==================== - -interface ChainFetchInstances { - lastBlock: ReturnType - balance: ReturnType - transactionQuery: ReturnType +// ==================== Effect Data Source 实例 ==================== + +export interface ChainEffectSources { + /** lastBlock 轮询源 */ + lastBlock: DataSource + /** 余额依赖源 */ + balance: DataSource + /** 交易查询依赖源 */ + transactionQuery: DataSource + /** 停止所有源 */ + stopAll: Effect.Effect } -const chainInstances = new Map() - /** - * 获取或创建链的 KeyFetch 实例集合 + * 创建链的 Effect 数据源集合 + * + * - lastBlock: 按出块间隔轮询 + * - balance: 依赖 lastBlock 变化 + * - transactionQuery: 依赖 lastBlock 变化 */ -export function getChainFetchInstances(chainId: string, baseUrl: string): ChainFetchInstances { - let instances = chainInstances.get(chainId) - if (!instances) { - const lastBlock = createLastBlockFetch(chainId, baseUrl) - const balance = createBalanceFetch(chainId, baseUrl, lastBlock) - const transactionQuery = createTransactionQueryFetch(chainId, baseUrl, lastBlock) +export function createChainEffectSources( + chainId: string, + baseUrl: string, + address: string +): Effect.Effect { + return Effect.gen(function* () { + const interval = Duration.millis(getForgeInterval(chainId)) + + // lastBlock 轮询源 + const lastBlock = yield* createPollingSource({ + name: `${chainId}.lastBlock`, + fetch: createLastBlockFetch(baseUrl), + interval, + }) + + // balance 依赖 lastBlock + const balance = yield* createDependentSource({ + name: `${chainId}.balance`, + dependsOn: lastBlock.ref, + hasChanged: (prev, next) => prev?.result.height !== next.result.height, + fetch: () => createBalanceFetch(baseUrl, address), + }) + + // transactionQuery 依赖 lastBlock + const transactionQuery = yield* createDependentSource({ + name: `${chainId}.transactionQuery`, + dependsOn: lastBlock.ref, + hasChanged: (prev, next) => prev?.result.height !== next.result.height, + fetch: () => createTransactionQueryFetch(baseUrl, address), + }) + + return { + lastBlock, + balance, + transactionQuery, + stopAll: Effect.all([ + lastBlock.stop, + balance.stop, + transactionQuery.stop, + ]).pipe(Effect.asVoid), + } + }) +} - instances = { lastBlock, balance, transactionQuery } - chainInstances.set(chainId, instances) +// ==================== 链实例缓存 ==================== +const chainSourcesCache = new Map() +/** + * 获取或创建链的 Effect 数据源(缓存) + */ +export function getChainEffectSources( + chainId: string, + baseUrl: string, + address: string +): Effect.Effect { + const cacheKey = `${chainId}:${address}` + const cached = chainSourcesCache.get(cacheKey) + + if (cached) { + return Effect.succeed(cached) } - return instances + + return createChainEffectSources(chainId, baseUrl, address).pipe( + Effect.tap((sources) => Effect.sync(() => { + chainSourcesCache.set(cacheKey, sources) + })) + ) +} + +/** + * 清理链的数据源实例 + */ +export function clearChainEffectSources(): Effect.Effect { + return Effect.gen(function* () { + for (const sources of chainSourcesCache.values()) { + yield* sources.stopAll + } + chainSourcesCache.clear() + }) } /** - * 清理链的 KeyFetch 实例(用于测试) + * 清理链的 KeyFetch 实例(用于测试 - 兼容旧 API) + * @deprecated Use clearChainEffectSources instead */ export function clearChainFetchInstances(): void { - chainInstances.clear() + Effect.runSync(clearChainEffectSources()) } diff --git a/src/services/ecosystem/registry.ts b/src/services/ecosystem/registry.ts index cb35fa91a..93509f82c 100644 --- a/src/services/ecosystem/registry.ts +++ b/src/services/ecosystem/registry.ts @@ -7,7 +7,8 @@ * - Provide merged app catalog + helpers (ranking/search) */ -import { keyFetch, etag, cache, IndexedDBCacheStorage, ttl } from '@biochain/key-fetch'; +import { Effect } from 'effect'; +import { httpFetch, type FetchError } from '@biochain/chain-effect'; import { ecosystemStore, ecosystemSelectors, ecosystemActions } from '@/stores/ecosystem'; import type { EcosystemSource, MiniappManifest, SourceRecord } from './types'; import { EcosystemSearchResponseSchema, EcosystemSourceSchema } from './schema'; @@ -16,24 +17,163 @@ import { createResolver } from '@/lib/url-resolver'; const SUPPORTED_SEARCH_RESPONSE_VERSIONS = new Set(['1', '1.0.0']); -const ecosystemSourceStorage = new IndexedDBCacheStorage('ecosystem-sources', 'sources'); +// ==================== IndexedDB Cache ==================== -function createSourceFetcher(url: string) { - return keyFetch.create({ - name: `ecosystem.source.${url}`, - outputSchema: EcosystemSourceSchema, - url, - use: [etag(), cache({ storage: ecosystemSourceStorage }), ttl(5 * 60 * 1000)], +const DB_NAME = 'ecosystem-sources'; +const STORE_NAME = 'sources'; + +interface CacheEntry { + url: string; + data: EcosystemSource; + etag?: string; + cachedAt: number; +} + +async function openCacheDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'url' }); + } + }; }); } -function createSearchFetcher(url: string) { - return keyFetch.create({ - name: `ecosystem.search.${url}`, - outputSchema: EcosystemSearchResponseSchema, +async function getCachedSource(url: string): Promise { + try { + const db = await openCacheDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const request = store.get(url); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result ?? null); + }); + } catch { + return null; + } +} + +async function setCachedSource(entry: CacheEntry): Promise { + try { + const db = await openCacheDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const request = store.put(entry); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } catch { + // Ignore cache errors + } +} + +// ==================== TTL Cache ==================== + +const TTL_MS = 5 * 60 * 1000; // 5 minutes +const SEARCH_TTL_MS = 30 * 1000; // 30 seconds +const ttlCache = new Map(); + +function getTTLCached(key: string): T | null { + const entry = ttlCache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + ttlCache.delete(key); + return null; + } + return entry.data as T; +} + +function setTTLCached(key: string, data: T, ttlMs: number): void { + ttlCache.set(key, { data, expiresAt: Date.now() + ttlMs }); +} + +function invalidateTTLCache(prefix: string): void { + for (const key of ttlCache.keys()) { + if (key.startsWith(prefix)) { + ttlCache.delete(key); + } + } +} + +// ==================== Fetch with ETag/Cache ==================== + +async function fetchSourceWithEtag(url: string): Promise { + // Check TTL cache first + const ttlCached = getTTLCached(`source:${url}`); + if (ttlCached) return ttlCached; + + // Get cached entry for ETag + const cached = await getCachedSource(url); + const headers: Record = {}; + if (cached?.etag) { + headers['If-None-Match'] = cached.etag; + } + + try { + const response = await fetch(url, { headers }); + + // 304 Not Modified - use cache + if (response.status === 304 && cached) { + setTTLCached(`source:${url}`, cached.data, TTL_MS); + return cached.data; + } + + if (!response.ok) { + // Fall back to cache on error + if (cached) { + setTTLCached(`source:${url}`, cached.data, TTL_MS); + return cached.data; + } + return null; + } + + const json = await response.json(); + const parsed = EcosystemSourceSchema.safeParse(json); + if (!parsed.success) { + if (cached) return cached.data; + return null; + } + + const data = parsed.data; + const etag = response.headers.get('etag') ?? undefined; + + // Update cache + await setCachedSource({ url, data, etag, cachedAt: Date.now() }); + setTTLCached(`source:${url}`, data, TTL_MS); + + return data; + } catch { + // Fall back to cache on error + if (cached) { + setTTLCached(`source:${url}`, cached.data, TTL_MS); + return cached.data; + } + return null; + } +} + +function fetchSearchResults(url: string): Effect.Effect<{ version: string; data: MiniappManifest[] }, FetchError> { + // Check TTL cache first + const ttlCached = getTTLCached<{ version: string; data: MiniappManifest[] }>(`search:${url}`); + if (ttlCached) return Effect.succeed(ttlCached); + + return httpFetch({ url, - use: [ttl(30 * 1000)], - }); + method: 'GET', + schema: EcosystemSearchResponseSchema, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + setTTLCached(`search:${url}`, result, SEARCH_TTL_MS); + }) + ) + ); } let cachedApps: MiniappManifest[] = []; @@ -82,12 +222,7 @@ function normalizeAppFromSource( } async function fetchSourceWithCache(url: string): Promise { - try { - const fetcher = createSourceFetcher(url); - return await fetcher.fetch({}); - } catch { - return null; - } + return fetchSourceWithEtag(url); } async function rebuildCachedAppsFromSources( @@ -151,15 +286,14 @@ export async function refreshSources(options?: { force?: boolean }): Promise { ecosystemActions.updateSourceStatus(source.url, 'loading'); try { - const fetcher = createSourceFetcher(source.url); - const payload = await fetcher.fetch({}); + const payload = await fetchSourceWithEtag(source.url); ecosystemActions.updateSourceStatus(source.url, 'success'); return { source, payload }; } catch (error) { @@ -177,9 +311,8 @@ export async function refreshSources(options?: { force?: boolean }): Promise { ecosystemActions.updateSourceStatus(url, 'loading'); try { - keyFetch.invalidate(`ecosystem.source.${url}`); - const fetcher = createSourceFetcher(url); - const payload = await fetcher.fetch({}); + invalidateTTLCache(`source:${url}`); + const payload = await fetchSourceWithEtag(url); if (payload) { ecosystemActions.updateSourceStatus(url, 'success'); await rebuildCachedAppsFromCache(); @@ -235,9 +368,8 @@ async function fetchRemoteSearch(source: SourceRecord, urlTemplate: string, quer const url = urlTemplate.replace(/%s/g, encoded); try { - const fetcher = createSearchFetcher(url); - const response = await fetcher.fetch({}); - const { version, data } = response as { version: string; data: MiniappManifest[] }; + const result = await Effect.runPromise(fetchSearchResults(url)); + const { version, data } = result; if (!isSupportedSearchResponseVersion(version)) { return []; diff --git a/src/services/transaction/index.ts b/src/services/transaction/index.ts index 05ac34390..67fea00de 100644 --- a/src/services/transaction/index.ts +++ b/src/services/transaction/index.ts @@ -27,7 +27,9 @@ export { PendingTxMetaSchema, CreatePendingTxInputSchema, UpdatePendingTxStatusInputSchema, - getPendingTxFetcher, + getPendingTxSource, + subscribePendingTxChanges, + clearPendingTxSources, } from './pending-tx' export type { PendingTx, diff --git a/src/services/transaction/pending-tx-manager.ts b/src/services/transaction/pending-tx-manager.ts index 22c04787b..193ef8996 100644 --- a/src/services/transaction/pending-tx-manager.ts +++ b/src/services/transaction/pending-tx-manager.ts @@ -296,8 +296,8 @@ class PendingTxManagerImpl { this.sendNotification(updated, 'confirmed'); // Note: 以前这里会调用 invalidateBalance (使用 React Query)。 - // 现在系统完全依赖 key-fetch 的 deps (blockApi) 机制进行自动刷新。 - // 当交易上链 -> blockApi 刷新 -> txList 和 pendingTr 自动刷新。 + // 现在系统完全依赖 Effect 数据源的 deps 机制进行自动刷新。 + // 当交易上链 -> blockApi 刷新 -> txList 和 pendingTx 自动刷新。 } else { diff --git a/src/services/transaction/pending-tx.ts b/src/services/transaction/pending-tx.ts index a2b3a8dd2..2ff09428a 100644 --- a/src/services/transaction/pending-tx.ts +++ b/src/services/transaction/pending-tx.ts @@ -7,7 +7,8 @@ import { z } from 'zod'; import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; -import { derive, transform } from '@biochain/key-fetch'; +import { Effect, Stream } from 'effect'; +import { createDependentSource, type DataSource } from '@biochain/chain-effect'; import { getChainProvider } from '@/services/chain-adapter/providers'; import { defineServiceMeta } from '@/lib/service-meta'; import { SignedTransactionSchema } from '@/services/chain-adapter/types'; @@ -480,60 +481,95 @@ class PendingTxServiceImpl implements IPendingTxService { /** 单例服务实例 */ export const pendingTxService = new PendingTxServiceImpl(); -// ==================== Key-Fetch Instance Factory ==================== +// ==================== Effect Data Source Factory ==================== -// 缓存已创建的 fetcher 实例 -const pendingTxFetchers = new Map>(); +// 缓存已创建的 Effect 数据源实例 +const pendingTxSources = new Map>(); /** - * 获取 pending tx 的 key-fetch 实例 - * 依赖 blockHeight 自动刷新 + * 获取 pending tx 的 Effect 数据源 + * 依赖 blockHeight 变化自动刷新 */ -export function getPendingTxFetcher(chainId: string, walletId: string) { +export function getPendingTxSource( + chainId: string, + walletId: string +): Effect.Effect> | null { const key = `${chainId}:${walletId}`; - if (!pendingTxFetchers.has(key)) { - const chainProvider = getChainProvider(chainId); + const cached = pendingTxSources.get(key); + if (cached) { + return Effect.succeed(cached); + } - if (!chainProvider?.supports('blockHeight')) { - return null; - } + const chainProvider = getChainProvider(chainId); + + if (!chainProvider?.supports('blockHeight')) { + return null; + } - const fetcher = derive({ - name: `pendingTx.${chainId}.${walletId}`, - source: chainProvider.blockHeight, - outputSchema: z.array(PendingTxSchema), - use: [ - transform({ - transform: async () => { - // 检查 pending 交易状态,更新/移除已上链的 - const pending = await pendingTxService.getPending({ walletId }); - - for (const tx of pending) { - if (tx.status === 'broadcasted' && tx.txHash) { - try { - // 检查是否已上链 - const txInfo = await chainProvider.transaction.fetch({ txHash: tx.txHash }); - if (txInfo?.status === 'confirmed') { - // 直接删除已确认的交易 - await pendingTxService.delete({ id: tx.id }); - } - } catch (e) { - console.error('检查pending交易状态失败', e); // i18n-ignore; - // 查询失败,跳过 + // 创建依赖 blockHeight 的数据源 + return createDependentSource({ + name: `pendingTx.${chainId}.${walletId}`, + dependsOn: chainProvider.blockHeight.ref, + hasChanged: (prev, next) => prev !== next, + fetch: () => + Effect.tryPromise({ + try: async () => { + // 检查 pending 交易状态,更新/移除已上链的 + const pending = await pendingTxService.getPending({ walletId }); + + for (const tx of pending) { + if (tx.status === 'broadcasted' && tx.txHash) { + try { + // 检查是否已上链 + const txInfo = await chainProvider.transaction.fetch({ txHash: tx.txHash }); + if (txInfo?.status === 'confirmed') { + // 直接删除已确认的交易 + await pendingTxService.delete({ id: tx.id }); } + } catch { + // 查询失败,跳过 } } + } - // 返回最新的 pending 列表 - return await pendingTxService.getPending({ walletId }); - }, - }), - ], - }); + // 返回最新的 pending 列表 + return await pendingTxService.getPending({ walletId }); + }, + catch: (error) => error as Error, + }), + }).pipe( + Effect.tap((source) => + Effect.sync(() => { + pendingTxSources.set(key, source); + }) + ) + ); +} - pendingTxFetchers.set(key, fetcher); - } +/** + * 订阅 pending tx 变化流 + */ +export function subscribePendingTxChanges( + chainId: string, + walletId: string +): Stream.Stream | null { + const sourceEffect = getPendingTxSource(chainId, walletId); + if (!sourceEffect) return null; + + return Stream.fromEffect(sourceEffect).pipe( + Stream.flatMap((source) => source.changes) + ); +} - return pendingTxFetchers.get(key)!; +/** + * 清理 pending tx 数据源缓存 + */ +export function clearPendingTxSources(): Effect.Effect { + return Effect.gen(function* () { + for (const source of pendingTxSources.values()) { + yield* source.stop; + } + pendingTxSources.clear(); + }); } From b84ae5ab5cf20b23547d1bddc97ec626126f201d Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 12:59:29 +0800 Subject: [PATCH 22/33] fix(chain-effect): add missing vite.config.ts for test runner --- packages/chain-effect/vite.config.ts | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/chain-effect/vite.config.ts diff --git a/packages/chain-effect/vite.config.ts b/packages/chain-effect/vite.config.ts new file mode 100644 index 000000000..42aa8fecf --- /dev/null +++ b/packages/chain-effect/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [ + dts({ + include: ['src'], + rollupTypes: false, + }), + ], + build: { + lib: { + entry: { + index: resolve(__dirname, 'src/index.ts'), + react: resolve(__dirname, 'src/react.ts'), + }, + formats: ['es', 'cjs'], + }, + rollupOptions: { + external: ['react', 'react-dom', 'effect'], + output: { + preserveModules: false, + }, + }, + minify: false, + sourcemap: true, + }, +}) From 34f2b4d1d3a486daa2e21891040f7d77e5567f32 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 13:02:38 +0800 Subject: [PATCH 23/33] chore: remove @biochain/key-fetch package (replaced by chain-effect) - Delete packages/key-fetch/ directory - Remove @biochain/key-fetch from root package.json dependencies - Update pnpm-lock.yaml --- package.json | 1 - packages/key-fetch/package.json | 51 --- packages/key-fetch/src/__tests__/core.test.ts | 171 ---------- packages/key-fetch/src/combine.ts | 254 -------------- packages/key-fetch/src/core.ts | 312 ------------------ packages/key-fetch/src/errors.ts | 11 - packages/key-fetch/src/fallback.ts | 209 ------------ packages/key-fetch/src/index.ts | 57 ---- packages/key-fetch/src/plugins/api-key.ts | 36 -- packages/key-fetch/src/plugins/cache.ts | 199 ----------- packages/key-fetch/src/plugins/dedupe.ts | 83 ----- packages/key-fetch/src/plugins/etag.ts | 42 --- packages/key-fetch/src/plugins/http.ts | 143 -------- packages/key-fetch/src/plugins/index.ts | 26 -- packages/key-fetch/src/plugins/interval.ts | 93 ------ packages/key-fetch/src/plugins/params.ts | 103 ------ packages/key-fetch/src/plugins/tag.ts | 42 --- .../key-fetch/src/plugins/throttle-error.ts | 100 ------ packages/key-fetch/src/plugins/transform.ts | 38 --- packages/key-fetch/src/plugins/ttl.ts | 39 --- packages/key-fetch/src/plugins/unwrap.ts | 82 ----- packages/key-fetch/src/types.ts | 226 ------------- packages/key-fetch/tsconfig.json | 23 -- packages/key-fetch/vite.config.ts | 30 -- packages/key-fetch/vitest.config.ts | 9 - pnpm-lock.yaml | 40 +-- 26 files changed, 3 insertions(+), 2417 deletions(-) delete mode 100644 packages/key-fetch/package.json delete mode 100644 packages/key-fetch/src/__tests__/core.test.ts delete mode 100644 packages/key-fetch/src/combine.ts delete mode 100644 packages/key-fetch/src/core.ts delete mode 100644 packages/key-fetch/src/errors.ts delete mode 100644 packages/key-fetch/src/fallback.ts delete mode 100644 packages/key-fetch/src/index.ts delete mode 100644 packages/key-fetch/src/plugins/api-key.ts delete mode 100644 packages/key-fetch/src/plugins/cache.ts delete mode 100644 packages/key-fetch/src/plugins/dedupe.ts delete mode 100644 packages/key-fetch/src/plugins/etag.ts delete mode 100644 packages/key-fetch/src/plugins/http.ts delete mode 100644 packages/key-fetch/src/plugins/index.ts delete mode 100644 packages/key-fetch/src/plugins/interval.ts delete mode 100644 packages/key-fetch/src/plugins/params.ts delete mode 100644 packages/key-fetch/src/plugins/tag.ts delete mode 100644 packages/key-fetch/src/plugins/throttle-error.ts delete mode 100644 packages/key-fetch/src/plugins/transform.ts delete mode 100644 packages/key-fetch/src/plugins/ttl.ts delete mode 100644 packages/key-fetch/src/plugins/unwrap.ts delete mode 100644 packages/key-fetch/src/types.ts delete mode 100644 packages/key-fetch/tsconfig.json delete mode 100644 packages/key-fetch/vite.config.ts delete mode 100644 packages/key-fetch/vitest.config.ts diff --git a/package.json b/package.json index 88c6d77e6..7bae9c8c1 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ "@bfmeta/sign-util": "^1.3.10", "@biochain/bio-sdk": "workspace:*", "@biochain/chain-effect": "workspace:*", - "@biochain/key-fetch": "workspace:*", "@biochain/key-ui": "workspace:*", "@biochain/key-utils": "workspace:*", "@biochain/plugin-navigation-sync": "workspace:*", diff --git a/packages/key-fetch/package.json b/packages/key-fetch/package.json deleted file mode 100644 index 7fac18570..000000000 --- a/packages/key-fetch/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@biochain/key-fetch", - "version": "0.1.0", - "description": "Plugin-based reactive fetch with subscription support", - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./react": "./src/react.ts", - "./plugins": "./src/plugins/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "typecheck:run": "tsc --noEmit", - "test": "vitest", - "test:run": "vitest run --passWithNoTests", - "lint:run": "oxlint .", - "i18n:run": "echo 'No i18n'", - "theme:run": "echo 'No theme'" - }, - "peerDependencies": { - "react": "^19.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - }, - "devDependencies": { - "@testing-library/react": "^16.3.0", - "@types/react": "^19.0.0", - "jsdom": "^26.1.0", - "oxlint": "^1.32.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "typescript": "^5.9.3", - "vitest": "^4.0.0" - }, - "keywords": [ - "biochain", - "fetch", - "cache", - "reactive", - "subscription" - ], - "license": "MIT", - "dependencies": { - "superjson": "^2.2.6" - } -} \ No newline at end of file diff --git a/packages/key-fetch/src/__tests__/core.test.ts b/packages/key-fetch/src/__tests__/core.test.ts deleted file mode 100644 index fd87d41e3..000000000 --- a/packages/key-fetch/src/__tests__/core.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Key-Fetch v2 Core Tests - */ - -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' -import { z } from 'zod' -import { keyFetch, combine, useHttp, useInterval } from '../index' - -const mockFetch = vi.fn() -const originalFetch = global.fetch -beforeEach(() => { - global.fetch = mockFetch as unknown as typeof fetch - vi.clearAllMocks() -}) -afterEach(() => { - global.fetch = originalFetch -}) - -function createMockResponse(data: unknown, ok = true, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: ok ? 'OK' : 'Error', - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('keyFetch.create', () => { - const TestSchema = z.object({ - success: z.boolean(), - result: z.object({ value: z.string() }).nullable(), - }) - - test('should fetch and parse data', async () => { - const mockData = { success: true, result: { value: 'hello' } } - mockFetch.mockResolvedValueOnce(createMockResponse(mockData)) - - const instance = keyFetch.create({ - name: 'test.basic', - outputSchema: TestSchema, - use: [useHttp('https://api.test.com/data')], - }) - - const result = await instance.fetch({}) - expect(result).toEqual(mockData) - }) - - test('should handle null result', async () => { - const mockData = { success: true, result: null } - mockFetch.mockResolvedValueOnce(createMockResponse(mockData)) - - const instance = keyFetch.create({ - name: 'test.null', - outputSchema: TestSchema, - use: [useHttp('https://api.test.com/data')], - }) - - const result = await instance.fetch({}) - expect((result as { result: null }).result).toBeNull() - }) - - test('should subscribe and receive updates', async () => { - const mockData = { success: true, result: { value: 'test' } } - mockFetch.mockResolvedValue(createMockResponse(mockData)) - - const instance = keyFetch.create({ - name: 'test.subscribe', - outputSchema: TestSchema, - use: [useHttp('https://api.test.com/data')], - }) - - const callback = vi.fn() - const unsubscribe = instance.subscribe({}, callback) - - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(callback).toHaveBeenCalled() - unsubscribe() - }) -}) - -describe('combine with useHttp + transform', () => { - const BalanceSchema = z.object({ - symbol: z.string(), - amount: z.string(), - }) - - const RawBalanceSchema = z.object({ - chain_stats: z.object({ - funded_txo_sum: z.number(), - spent_txo_sum: z.number(), - }), - }) - - test('should fetch with useHttp and apply transform', async () => { - const rawData = { chain_stats: { funded_txo_sum: 1000, spent_txo_sum: 300 } } - mockFetch.mockResolvedValueOnce(createMockResponse(rawData)) - - const balance = combine({ - name: 'test.balance', - outputSchema: BalanceSchema, - use: [useHttp('https://api.test.com/balance')], - transform: (data) => { - const raw = RawBalanceSchema.parse(data) - const amount = raw.chain_stats.funded_txo_sum - raw.chain_stats.spent_txo_sum - return { symbol: 'BTC', amount: amount.toString() } - }, - }) - - const result = await balance.fetch({}) - expect(result).toEqual({ symbol: 'BTC', amount: '700' }) - }) - - test('should subscribe and receive transformed data', async () => { - const rawData = { chain_stats: { funded_txo_sum: 500, spent_txo_sum: 100 } } - mockFetch.mockResolvedValue(createMockResponse(rawData)) - - const balance = combine({ - name: 'test.balance.sub', - outputSchema: BalanceSchema, - use: [useHttp('https://api.test.com/balance')], - transform: (data) => { - const raw = RawBalanceSchema.parse(data) - const amount = raw.chain_stats.funded_txo_sum - raw.chain_stats.spent_txo_sum - return { symbol: 'BTC', amount: amount.toString() } - }, - }) - - const callback = vi.fn() - const unsubscribe = balance.subscribe({}, callback) - - await new Promise(resolve => setTimeout(resolve, 100)) - - expect(callback).toHaveBeenCalled() - expect(callback.mock.calls[0][0]).toEqual({ symbol: 'BTC', amount: '400' }) - - unsubscribe() - }) -}) - -describe('combine with sources as trigger', () => { - const BlockSchema = z.number() - const BalanceSchema = z.object({ symbol: z.string(), amount: z.string() }) - - test('should refetch when source updates', async () => { - // 第一次调用返回区块高度 - mockFetch.mockResolvedValueOnce(createMockResponse(100)) - // 第二次调用返回余额 - mockFetch.mockResolvedValueOnce(createMockResponse({ funded: 1000, spent: 200 })) - - const blockHeight = keyFetch.create({ - name: 'block', - outputSchema: BlockSchema, - use: [useHttp('https://api.test.com/block')], - }) - - const balance = combine({ - name: 'balance', - outputSchema: BalanceSchema, - sources: [{ source: blockHeight, params: () => ({}) }], - use: [useHttp('https://api.test.com/balance/:address')], - transform: (data) => { - const raw = data as { funded: number; spent: number } - return { symbol: 'BTC', amount: (raw.funded - raw.spent).toString() } - }, - }) - - const result = await balance.fetch({ address: 'abc123' }) - expect(result).toEqual({ symbol: 'BTC', amount: '800' }) - expect(mockFetch).toHaveBeenCalledTimes(2) - }) -}) diff --git a/packages/key-fetch/src/combine.ts b/packages/key-fetch/src/combine.ts deleted file mode 100644 index b13f5d5ea..000000000 --- a/packages/key-fetch/src/combine.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Combine - 组合多个 KeyFetchInstance - * - * 核心功能: - * 1. 订阅 sources 作为触发器(任一 source 更新触发重新获取) - * 2. 通过 use 插件(如 useHttp)发起 HTTP 请求 - * 3. 在 transform 中做数据转换 - */ - -import type { z } from 'zod' -import type { - Context, - KeyFetchInstance, - Plugin, - SubscribeCallback, - UseStateOptions, - UseStateResult, -} from './types' -import { superjson } from './core' - -/** Combine 源配置 */ -export interface CombineSourceConfig { - source: KeyFetchInstance - params: (input: TInput) => unknown - key?: string -} - -/** Combine 选项 */ -export interface CombineOptions { - name: string - outputSchema: z.ZodType - inputSchema?: z.ZodType - /** 触发源(可选,为空时直接执行 use 插件) */ - sources?: CombineSourceConfig[] - /** 插件列表(如 useHttp) */ - use?: Plugin[] - /** 转换函数(处理 HTTP 响应或 sources 数据) */ - transform?: (data: unknown, input: TInput) => TOutput | Promise -} - -export function combine( - options: CombineOptions -): KeyFetchInstance { - const { name, outputSchema, inputSchema, sources = [], use = [], transform } = options - - const sourceKeys = sources.map((s, i) => s.key ?? s.source.name ?? `source_${i}`) - const subscribers = new Map>>() - const subscriptionCleanups = new Map void)[]>() - - const buildCacheKey = (input: TInput): string => `${name}::${JSON.stringify(input)}` - - // 执行插件链获取数据 - async function executePlugins(input: TInput): Promise { - // 创建 Context - const ctx: Context = { - input, - req: new Request('about:blank'), - superjson, - self: instance, - state: new Map(), - name, - } - - // 构建洋葱模型中间件链 - const baseFetch = async (): Promise => { - if (ctx.req.url === 'about:blank') { - // 没有 HTTP 插件,返回空响应(用于纯 sources 模式) - return new Response('{}', { headers: { 'Content-Type': 'application/json' } }) - } - return fetch(ctx.req) - } - - let next = baseFetch - for (let i = use.length - 1; i >= 0; i--) { - const plugin = use[i] - if (plugin.onFetch) { - const currentNext = next - const pluginFn = plugin.onFetch - next = async () => pluginFn(ctx, currentNext) - } - } - - const response = await next() - - if (!response.ok && ctx.req.url !== 'about:blank') { - const errorText = await response.text().catch(() => '') - throw new Error(`[${name}] HTTP ${response.status}: ${response.statusText}${errorText ? `\n${errorText.slice(0, 200)}` : ''}`) - } - - // 解析响应 - const text = await response.text() - const isSuperjson = response.headers.get('X-Superjson') === 'true' - const json = text ? (isSuperjson ? superjson.parse(text) : JSON.parse(text)) : {} - - // 应用 transform - const result = transform ? await transform(json, input) : json as TOutput - - return outputSchema.parse(result) as TOutput - } - - const instance: KeyFetchInstance = { - name, - inputSchema, - outputSchema, - - async fetch(input: TInput): Promise { - if (inputSchema) inputSchema.parse(input) - - // 如果有 sources,先获取它们的数据(但不使用,只作为触发条件) - if (sources.length > 0) { - await Promise.all(sources.map(s => s.source.fetch(s.params(input)))) - } - - return executePlugins(input) - }, - - subscribe(input: TInput, callback: SubscribeCallback): () => void { - const cacheKey = buildCacheKey(input) - - let subs = subscribers.get(cacheKey) - if (!subs) { - subs = new Set() - subscribers.set(cacheKey, subs) - } - subs.add(callback) - - if (subs.size === 1) { - const cleanups: (() => void)[] = [] - - const refetch = async () => { - try { - const data = await executePlugins(input) - const currentSubs = subscribers.get(cacheKey) - if (currentSubs) { - currentSubs.forEach(cb => cb(data, 'update')) - } - } catch (error) { - console.error(`[combine] Error fetching ${name}:`, error) - } - } - - // 订阅所有 sources,任一更新时重新获取 - sources.forEach((sourceConfig) => { - const unsub = sourceConfig.source.subscribe(sourceConfig.params(input), () => { - refetch() - }) - cleanups.push(unsub) - }) - - // 执行插件的 onSubscribe - const ctx: Context = { - input, - req: new Request('about:blank'), - superjson, - self: instance, - state: new Map(), - name, - } - - for (const plugin of use) { - if (plugin.onSubscribe) { - const cleanup = plugin.onSubscribe(ctx, (data) => { - const currentSubs = subscribers.get(cacheKey) - if (currentSubs) { - currentSubs.forEach(cb => cb(data, 'update')) - } - }) - if (cleanup) cleanups.push(cleanup) - } - } - - subscriptionCleanups.set(cacheKey, cleanups) - } - - // 立即获取一次 - executePlugins(input) - .then(data => callback(data, 'initial')) - .catch(error => console.error(`[combine] Error fetching ${name}:`, error)) - - return () => { - subs?.delete(callback) - if (subs?.size === 0) { - subscribers.delete(cacheKey) - subscriptionCleanups.get(cacheKey)?.forEach(fn => fn()) - subscriptionCleanups.delete(cacheKey) - } - } - }, - - useState(input: TInput, options?: UseStateOptions): UseStateResult { - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any - const React = require('react') as any - const { useState, useEffect, useCallback, useRef, useMemo } = React - - const [data, setData] = useState(undefined as TOutput | undefined) - const [isLoading, setIsLoading] = useState(true) - const [isFetching, setIsFetching] = useState(false) - const [error, setError] = useState(undefined as Error | undefined) - - const inputKey = useMemo(() => JSON.stringify(input ?? {}), [input]) - const inputRef = useRef(input) - inputRef.current = input - - const enabled = options?.enabled !== false - - const refetch = useCallback(async () => { - if (!enabled) return - setIsFetching(true) - setError(undefined) - try { - const result = await instance.fetch(inputRef.current) - setData(result) - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))) - } finally { - setIsFetching(false) - setIsLoading(false) - } - }, [enabled]) - - useEffect(() => { - if (!enabled) { - setData(undefined) - setIsLoading(false) - setIsFetching(false) - setError(undefined) - return - } - - setIsLoading(true) - setIsFetching(true) - setError(undefined) - - let isCancelled = false - const unsubscribe = instance.subscribe(inputRef.current, (newData) => { - if (isCancelled) return - setData(newData) - setIsLoading(false) - setIsFetching(false) - setError(undefined) - }) - - return () => { - isCancelled = true - unsubscribe() - } - }, [enabled, inputKey]) - - return { data, isLoading, isFetching, error, refetch } - }, - } - - return instance -} diff --git a/packages/key-fetch/src/core.ts b/packages/key-fetch/src/core.ts deleted file mode 100644 index b0637593b..000000000 --- a/packages/key-fetch/src/core.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** - * Key-Fetch v2 Core - * - * 极简主义核心实现 - * - 不包含 HTTP/轮询/缓存逻辑,全部插件化 - * - Schema First:inputSchema + outputSchema 是一等公民 - * - Lifecycle Driven:onInit → onSubscribe → onFetch - */ - -import type { - Context, - Plugin, - KeyFetchDefineOptions, - KeyFetchInstance, - SubscribeCallback, - UseStateOptions, - UseStateResult, -} from './types' -import { SuperJSON } from 'superjson' - -export const superjson = new SuperJSON({ dedupe: true }) - -// ==================== 内部工具 ==================== - -/** 构建缓存 key */ -function buildCacheKey(name: string, input: unknown): string { - if (input === undefined || input === null) { - return name - } - if (typeof input === 'object') { - const sorted = Object.entries(input as Record) - .filter(([, v]) => v !== undefined) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`) - .join('&') - return sorted ? `${name}?${sorted}` : name - } - return `${name}#${JSON.stringify(input)}` -} - -// ==================== KeyFetch 实例实现 ==================== - -class KeyFetchInstanceImpl implements KeyFetchInstance { - readonly name: string - readonly inputSchema: import('zod').ZodType | undefined - readonly outputSchema: import('zod').ZodType - - private plugins: Plugin[] - private initCleanups: (() => void)[] = [] - private subscribers = new Map>>() - private subscriptionCleanups = new Map void)[]>() - private inFlight = new Map>() - - constructor(options: KeyFetchDefineOptions) { - this.name = options.name - this.inputSchema = options.inputSchema - this.outputSchema = options.outputSchema - this.plugins = options.use ?? [] - - // 阶段 1: 执行所有插件的 onInit - for (const plugin of this.plugins) { - if (plugin.onInit) { - const cleanup = plugin.onInit(this) - if (cleanup) { - this.initCleanups.push(cleanup) - } - } - } - } - - async fetch(input: TInput): Promise { - const cacheKey = buildCacheKey(this.name, input) - - // 检查进行中的请求(基础去重) - const pending = this.inFlight.get(cacheKey) - if (pending) { - return pending - } - - const task = this.doFetch(input) - this.inFlight.set(cacheKey, task) - - try { - return await task - } finally { - this.inFlight.delete(cacheKey) - } - } - - private async doFetch(input: TInput): Promise { - // 验证输入 - if (this.inputSchema) { - this.inputSchema.parse(input) - } - - // 创建基础 Request(空 URL,由插件填充) - const baseRequest = new Request('about:blank', { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - - // 创建 Context - const ctx: Context = { - input, - req: baseRequest, - superjson, - self: this, - state: new Map(), - name: this.name, - } - - // 构建洋葱模型中间件链 - // 最内层是默认的 fetch(如果没有插件处理) - const baseFetch = async (): Promise => { - // 如果 URL 仍是 about:blank,说明没有 http 插件 - if (ctx.req.url === 'about:blank') { - throw new Error(`[${this.name}] No HTTP plugin configured. Use useHttp() plugin.`) - } - return fetch(ctx.req) - } - - // 从后往前包装中间件 - let next = baseFetch - for (let i = this.plugins.length - 1; i >= 0; i--) { - const plugin = this.plugins[i] - if (plugin.onFetch) { - const currentNext = next - const pluginFn = plugin.onFetch - next = async () => pluginFn(ctx, currentNext) - } - } - - // 执行中间件链 - const response = await next() - - if (!response.ok) { - const errorText = await response.text().catch(() => '') - throw new Error( - `[${this.name}] HTTP ${response.status}: ${response.statusText}` + - (errorText ? `\n${errorText.slice(0, 200)}` : '') - ) - } - - // 解析响应 - const text = await response.text() - const isSuperjson = response.headers.get('X-Superjson') === 'true' - const json = isSuperjson ? superjson.parse(text) : JSON.parse(text) - - // Schema 验证 - const result = this.outputSchema.parse(json) as TOutput - return result - } - - subscribe(input: TInput, callback: SubscribeCallback): () => void { - const cacheKey = buildCacheKey(this.name, input) - - // 添加订阅者 - let subs = this.subscribers.get(cacheKey) - if (!subs) { - subs = new Set() - this.subscribers.set(cacheKey, subs) - } - subs.add(callback) - - // 首次订阅该 key,初始化插件 - if (subs.size === 1) { - const cleanups: (() => void)[] = [] - - // 创建订阅 Context - const ctx: Context = { - input, - req: new Request('about:blank'), - superjson, - self: this, - state: new Map(), - name: this.name, - } - - // emit 函数:通知所有订阅者 - const emit = (data: TOutput) => { - this.notify(cacheKey, data, 'update') - } - - // 阶段 2: 执行所有插件的 onSubscribe - for (const plugin of this.plugins) { - if (plugin.onSubscribe) { - const cleanup = plugin.onSubscribe(ctx, emit) - if (cleanup) { - cleanups.push(cleanup) - } - } - } - - this.subscriptionCleanups.set(cacheKey, cleanups) - } - - // 立即获取一次 - this.fetch(input) - .then(data => { - callback(data, 'initial') - }) - .catch(error => { - console.error(`[key-fetch] Error fetching ${this.name}:`, error) - }) - - // 返回取消订阅函数 - return () => { - subs?.delete(callback) - - // 最后一个订阅者,清理资源 - if (subs?.size === 0) { - this.subscribers.delete(cacheKey) - const cleanups = this.subscriptionCleanups.get(cacheKey) - if (cleanups) { - cleanups.forEach(fn => fn()) - this.subscriptionCleanups.delete(cacheKey) - } - } - } - } - - /** 通知特定 key 的订阅者 */ - private notify(cacheKey: string, data: TOutput, event: 'initial' | 'update'): void { - const subs = this.subscribers.get(cacheKey) - if (subs) { - subs.forEach(cb => cb(data, event)) - } - } - - /** - * React Hook - useState - * 内部判断 React 环境 - */ - useState(input: TInput, options?: UseStateOptions): UseStateResult { - // 尝试动态导入 React hooks - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any - const React = require('react') as any - - const { useState, useEffect, useCallback, useRef, useMemo } = React - - const [data, setData] = useState(undefined as TOutput | undefined) - const [isLoading, setIsLoading] = useState(true) - const [isFetching, setIsFetching] = useState(false) - const [error, setError] = useState(undefined as Error | undefined) - - const inputKey = useMemo(() => JSON.stringify(input ?? {}), [input]) - const inputRef = useRef(input) - inputRef.current = input - - const enabled = options?.enabled !== false - - const refetch = useCallback(async () => { - if (!enabled) return - - setIsFetching(true) - setError(undefined) - - try { - const result = await this.fetch(inputRef.current) - setData(result) - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))) - } finally { - setIsFetching(false) - setIsLoading(false) - } - }, [enabled]) - - useEffect(() => { - if (!enabled) { - setData(undefined) - setIsLoading(false) - setIsFetching(false) - setError(undefined) - return - } - - setIsLoading(true) - setIsFetching(true) - setError(undefined) - - let isCancelled = false - - // 订阅更新 - const unsubscribe = this.subscribe(inputRef.current, (newData, _event) => { - if (isCancelled) return - setData(newData) - setIsLoading(false) - setIsFetching(false) - setError(undefined) - }) - - return () => { - isCancelled = true - unsubscribe() - } - }, [enabled, inputKey]) - - return { data, isLoading, isFetching, error, refetch } - } -} - -// ==================== 工厂函数 ==================== - -/** - * 创建 KeyFetch 实例 - */ -export function create( - options: KeyFetchDefineOptions -): KeyFetchInstance { - return new KeyFetchInstanceImpl(options) -} diff --git a/packages/key-fetch/src/errors.ts b/packages/key-fetch/src/errors.ts deleted file mode 100644 index 23c0a7e7d..000000000 --- a/packages/key-fetch/src/errors.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Key-Fetch 错误类型 - */ - -/** 服务受限错误 - 用于显示给用户的友好提示 */ -export class ServiceLimitedError extends Error { - constructor(message = '服务受限') { - super(message) - this.name = 'ServiceLimitedError' - } -} diff --git a/packages/key-fetch/src/fallback.ts b/packages/key-fetch/src/fallback.ts deleted file mode 100644 index ee93494dc..000000000 --- a/packages/key-fetch/src/fallback.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Fallback - 多源自动回退 - * - * 当第一个源失败时自动尝试下一个 - */ - -import type { z } from 'zod' -import type { - KeyFetchInstance, - SubscribeCallback, - UseStateOptions, - UseStateResult, -} from './types' - -/** 自定义错误:不支持的能力 */ -export class NoSupportError extends Error { - readonly capability: string - - constructor(capability: string) { - super(`No provider supports: ${capability}`) - this.name = 'NoSupportError' - this.capability = capability - } -} - -/** Fallback 选项 */ -export interface FallbackOptions { - /** 合并后的名称 */ - name: string - /** 源 fetcher 数组(可以是空数组) */ - sources: KeyFetchInstance[] - /** 当 sources 为空时调用,默认抛出 NoSupportError */ - onEmpty?: () => never - /** 当所有 sources 都失败时调用,默认抛出 AggregateError */ - onAllFailed?: (errors: Error[]) => never -} - -/** - * fallback - 多源自动回退 - * - * @example - * ```ts - * const balanceFetcher = fallback({ - * name: 'chain.balance', - * sources: [provider1.balance, provider2.balance], - * onEmpty: () => { throw new NoSupportError('nativeBalance') }, - * }) - * ``` - */ -export function fallback( - options: FallbackOptions -): KeyFetchInstance { - const { name, sources, onEmpty, onAllFailed } = options - - const handleEmpty = onEmpty ?? (() => { - throw new NoSupportError(name) - }) - - const handleAllFailed = onAllFailed ?? ((errors: Error[]) => { - throw new AggregateError(errors, `All ${errors.length} provider(s) failed for: ${name}`) - }) - - // 空数组 - if (sources.length === 0) { - return createEmptyFetcher(name, handleEmpty) - } - - // 单个源 - if (sources.length === 1) { - return sources[0] - } - - // 多个源 - return createFallbackFetcher(name, sources, handleAllFailed) -} - -function createEmptyFetcher( - name: string, - handleEmpty: () => never -): KeyFetchInstance { - return { - name, - inputSchema: undefined, - outputSchema: { parse: () => undefined } as unknown as import('zod').ZodType, - - async fetch(): Promise { - handleEmpty() - }, - - subscribe(): () => void { - return () => {} - }, - - useState(): UseStateResult { - return { - data: undefined, - isLoading: false, - isFetching: false, - error: new NoSupportError(name), - refetch: async () => {}, - } - }, - } -} - -function createFallbackFetcher( - name: string, - sources: KeyFetchInstance[], - handleAllFailed: (errors: Error[]) => never -): KeyFetchInstance { - const first = sources[0] - - // Cooldown: 记录失败源 - const COOLDOWN_MS = 60_000 - const failedSources = new Map, number>() - - const instance: KeyFetchInstance = { - name, - inputSchema: first.inputSchema, - outputSchema: first.outputSchema, - - async fetch(input: TInput): Promise { - const errors: Error[] = [] - const now = Date.now() - - for (const source of sources) { - const cooldownEnd = failedSources.get(source) - if (cooldownEnd && now < cooldownEnd) { - continue - } - - try { - const result = await source.fetch(input) - failedSources.delete(source) - return result - } catch (error) { - errors.push(error instanceof Error ? error : new Error(String(error))) - failedSources.set(source, now + COOLDOWN_MS) - } - } - - handleAllFailed(errors) - }, - - subscribe(input: TInput, callback: SubscribeCallback): () => void { - return first.subscribe(input, callback) - }, - - useState(input: TInput, options?: UseStateOptions): UseStateResult { - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any - const React = require('react') as any - const { useState, useEffect, useCallback, useRef, useMemo } = React - - const [data, setData] = useState(undefined as TOutput | undefined) - const [isLoading, setIsLoading] = useState(true) - const [isFetching, setIsFetching] = useState(false) - const [error, setError] = useState(undefined as Error | undefined) - - const inputKey = useMemo(() => JSON.stringify(input ?? {}), [input]) - const inputRef = useRef(input) - inputRef.current = input - - const enabled = options?.enabled !== false - - const refetch = useCallback(async () => { - if (!enabled) return - setIsFetching(true) - setError(undefined) - try { - const result = await instance.fetch(inputRef.current) - setData(result) - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))) - } finally { - setIsFetching(false) - setIsLoading(false) - } - }, [enabled]) - - useEffect(() => { - if (!enabled) { - setData(undefined) - setIsLoading(false) - return - } - - setIsLoading(true) - setIsFetching(true) - - let isCancelled = false - const unsubscribe = instance.subscribe(inputRef.current, (newData) => { - if (isCancelled) return - setData(newData) - setIsLoading(false) - setIsFetching(false) - }) - - return () => { - isCancelled = true - unsubscribe() - } - }, [enabled, inputKey]) - - return { data, isLoading, isFetching, error, refetch } - }, - } - - return instance -} diff --git a/packages/key-fetch/src/index.ts b/packages/key-fetch/src/index.ts deleted file mode 100644 index f5bbf8143..000000000 --- a/packages/key-fetch/src/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @biochain/key-fetch v2 - * - * Schema-first 插件化响应式 Fetch - */ - -import { create, superjson } from './core' -import { fallback } from './fallback' - -// ==================== 导出类型 ==================== - -export type { - Context, - Plugin, - KeyFetchDefineOptions, - KeyFetchInstance, - SubscribeCallback, - UseStateOptions, - UseStateResult, - InferInput, - InferOutput, -} from './types' - -// ==================== 核心插件 ==================== - -export { useHttp } from './plugins/http' -export type { UseHttpOptions } from './plugins/http' - -export { useInterval } from './plugins/interval' - -export { useDedupe, DedupeThrottledError } from './plugins/dedupe' - -// ==================== 工具插件 ==================== - -export { throttleError, errorMatchers } from './plugins/throttle-error' -export { apiKey } from './plugins/api-key' - -// ==================== Combine & Fallback ==================== - -export { combine } from './combine' -export type { CombineSourceConfig, CombineOptions } from './combine' - -export { fallback, NoSupportError } from './fallback' - -// ==================== 错误类型 ==================== - -export { ServiceLimitedError } from './errors' - -// ==================== 主 API ==================== - -export const keyFetch = { - create, - merge: fallback, - superjson, -} - -export default keyFetch diff --git a/packages/key-fetch/src/plugins/api-key.ts b/packages/key-fetch/src/plugins/api-key.ts deleted file mode 100644 index 16ac85c49..000000000 --- a/packages/key-fetch/src/plugins/api-key.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * API Key Plugin - * - * 通用 API Key 插件 - */ - -import type { Plugin } from '../types' - -export interface ApiKeyOptions { - /** 请求头名称 */ - header: string - /** API Key 值 */ - key: string | undefined - /** 可选前缀 */ - prefix?: string -} - -/** - * API Key 插件 - */ -export function apiKey(options: ApiKeyOptions): Plugin { - const { header, key, prefix = '' } = options - - return { - name: 'api-key', - - async onFetch(ctx, next) { - if (key) { - const headers = new Headers(ctx.req.headers) - headers.set(header, `${prefix}${key}`) - ctx.req = new Request(ctx.req, { headers }) - } - return next() - }, - } -} diff --git a/packages/key-fetch/src/plugins/cache.ts b/packages/key-fetch/src/plugins/cache.ts deleted file mode 100644 index 9aa3dd460..000000000 --- a/packages/key-fetch/src/plugins/cache.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Cache Plugin - 可配置的缓存插件 - */ - -import type { Plugin } from '../types' - -// ==================== 存储后端接口 ==================== - -export interface CacheStorageEntry { - data: T - createdAt: number - expiresAt: number - tags?: string[] -} - -export interface CacheStorage { - get(key: string): Promise | undefined> - set(key: string, entry: CacheStorageEntry): Promise - delete(key: string): Promise - clear(): Promise - keys(): Promise -} - -// ==================== 内存存储实现 ==================== - -export class MemoryCacheStorage implements CacheStorage { - private cache = new Map>() - - async get(key: string): Promise | undefined> { - const entry = this.cache.get(key) as CacheStorageEntry | undefined - if (entry && Date.now() > entry.expiresAt) { - this.cache.delete(key) - return undefined - } - return entry - } - - async set(key: string, entry: CacheStorageEntry): Promise { - this.cache.set(key, entry) - } - - async delete(key: string): Promise { - this.cache.delete(key) - } - - async clear(): Promise { - this.cache.clear() - } - - async keys(): Promise { - return Array.from(this.cache.keys()) - } -} - -// ==================== IndexedDB 存储实现 ==================== - -export class IndexedDBCacheStorage implements CacheStorage { - private dbName: string - private storeName: string - private dbPromise: Promise | null = null - - constructor(dbName = 'key-fetch-cache', storeName = 'cache') { - this.dbName = dbName - this.storeName = storeName - } - - private async getDB(): Promise { - if (this.dbPromise) return this.dbPromise - - this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, 1) - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve(request.result) - request.onupgradeneeded = () => { - const db = request.result - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName) - } - } - }) - - return this.dbPromise - } - - async get(key: string): Promise | undefined> { - const db = await this.getDB() - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readonly') - const store = tx.objectStore(this.storeName) - const request = store.get(key) - request.onerror = () => reject(request.error) - request.onsuccess = () => { - const entry = request.result as CacheStorageEntry | undefined - if (entry && Date.now() > entry.expiresAt) { - resolve(undefined) - } else { - resolve(entry) - } - } - }) - } - - async set(key: string, entry: CacheStorageEntry): Promise { - const db = await this.getDB() - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readwrite') - const store = tx.objectStore(this.storeName) - const request = store.put(entry, key) - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) - } - - async delete(key: string): Promise { - const db = await this.getDB() - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readwrite') - const store = tx.objectStore(this.storeName) - const request = store.delete(key) - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) - } - - async clear(): Promise { - const db = await this.getDB() - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readwrite') - const store = tx.objectStore(this.storeName) - const request = store.clear() - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) - } - - async keys(): Promise { - const db = await this.getDB() - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readonly') - const store = tx.objectStore(this.storeName) - const request = store.getAllKeys() - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve(request.result as string[]) - }) - } -} - -// ==================== 缓存插件工厂 ==================== - -export interface CachePluginOptions { - storage?: CacheStorage - ttlMs?: number - tags?: string[] -} - -const defaultStorage = new MemoryCacheStorage() - -/** - * 创建缓存插件 - */ -export function cache(options: CachePluginOptions = {}): Plugin { - const storage = options.storage ?? defaultStorage - const defaultTtlMs = options.ttlMs ?? 60_000 - const tags = options.tags ?? [] - - return { - name: 'cache', - - async onFetch(ctx, next) { - const cacheKey = `${ctx.name}:${ctx.req.url}` - - const cached = await storage.get(cacheKey) - if (cached) { - return new Response(JSON.stringify(cached.data), { - status: 200, - headers: { 'X-Cache': 'HIT' }, - }) - } - - const response = await next() - - if (response.ok) { - const clonedResponse = response.clone() - const data = await clonedResponse.json() - - const entry: CacheStorageEntry = { - data, - createdAt: Date.now(), - expiresAt: Date.now() + defaultTtlMs, - tags, - } - - void storage.set(cacheKey, entry) - } - - return response - }, - } -} diff --git a/packages/key-fetch/src/plugins/dedupe.ts b/packages/key-fetch/src/plugins/dedupe.ts deleted file mode 100644 index 71be2e39e..000000000 --- a/packages/key-fetch/src/plugins/dedupe.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * useDedupe Plugin - * - * 去重/节流插件 - 在时间窗口内返回缓存结果 - */ - -import type { Context, Plugin } from '../types' - -/** 去重节流错误 */ -export class DedupeThrottledError extends Error { - constructor(key: string, windowMs: number) { - super(`Request throttled: ${key} (window: ${windowMs}ms)`) - this.name = 'DedupeThrottledError' - } -} - -interface CacheEntry { - data: unknown - timestamp: number -} - -/** - * useDedupe - 去重/节流插件 - * - * 在时间窗口内对相同 input 的请求返回缓存结果 - * - * @param windowMs 去重时间窗口(毫秒),默认 5000ms - * - * @example - * ```ts - * keyFetch.create({ - * name: 'balance', - * outputSchema: BalanceSchema, - * use: [useHttp(url), useDedupe(10_000)], // 10秒内不重复请求 - * }) - * ``` - */ -export function useDedupe(windowMs: number = 5000): Plugin { - const cache = new Map() - - const getKey = (ctx: Context): string => { - return `${ctx.name}::${JSON.stringify(ctx.input)}` - } - - return { - name: 'dedupe', - - async onFetch(ctx, next) { - const key = getKey(ctx) - const now = Date.now() - - // 检查缓存 - const cached = cache.get(key) - if (cached && (now - cached.timestamp) < windowMs) { - // 返回缓存的 Mock Response - return new Response(JSON.stringify(cached.data), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'X-Dedupe': 'hit', - }, - }) - } - - // 执行实际请求 - const response = await next() - - // 如果成功,缓存结果 - if (response.ok) { - // 克隆 response 以便读取内容 - const cloned = response.clone() - try { - const data = await cloned.json() - cache.set(key, { data, timestamp: now }) - } catch { - // 解析失败,不缓存 - } - } - - return response - }, - } -} diff --git a/packages/key-fetch/src/plugins/etag.ts b/packages/key-fetch/src/plugins/etag.ts deleted file mode 100644 index c3238a2f8..000000000 --- a/packages/key-fetch/src/plugins/etag.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * ETag Plugin - * - * HTTP ETag 缓存验证插件 - */ - -import type { Plugin } from '../types' - -const etagStore = new Map() - -/** - * ETag 缓存验证插件 - */ -export function etag(): Plugin { - return { - name: 'etag', - - async onFetch(ctx, next) { - const cacheKey = `${ctx.name}:${ctx.req.url}` - const cachedEtag = etagStore.get(cacheKey) - - if (cachedEtag) { - const headers = new Headers(ctx.req.headers) - headers.set('If-None-Match', cachedEtag) - ctx.req = new Request(ctx.req.url, { - method: ctx.req.method, - headers, - body: ctx.req.body, - }) - } - - const response = await next() - - const newEtag = response.headers.get('etag') - if (newEtag) { - etagStore.set(cacheKey, newEtag) - } - - return response - }, - } -} diff --git a/packages/key-fetch/src/plugins/http.ts b/packages/key-fetch/src/plugins/http.ts deleted file mode 100644 index a9b8f4fb6..000000000 --- a/packages/key-fetch/src/plugins/http.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * useHttp Plugin - * - * HTTP 请求插件 - 统一处理 URL、method、headers、body - * 替代旧版的 pathParams() + searchParams() + postBody() - */ - -import type { Context, Plugin } from '../types' - -type ContextAny = Context - -export interface UseHttpOptions { - /** HTTP 方法 */ - method?: 'GET' | 'POST' | ((ctx: Context) => 'GET' | 'POST') - /** 请求头 */ - headers?: HeadersInit | ((ctx: Context) => HeadersInit) - /** 请求体(POST 时使用) */ - body?: unknown | ((ctx: Context) => unknown) -} - -/** - * 替换 URL 中的 :param 占位符 - */ -function replacePathParams(url: string, input: unknown): string { - if (typeof input !== 'object' || input === null) { - return url - } - - let result = url - for (const [key, value] of Object.entries(input)) { - if (value !== undefined) { - result = result.replace(`:${key}`, encodeURIComponent(String(value))) - } - } - return result -} - -/** - * 将 input 添加为 query params(GET 请求) - */ -function appendSearchParams(url: string, input: unknown): string { - if (typeof input !== 'object' || input === null) { - return url - } - - const urlObj = new URL(url) - for (const [key, value] of Object.entries(input)) { - if (value !== undefined && !url.includes(`:${key}`)) { - urlObj.searchParams.set(key, String(value)) - } - } - return urlObj.toString() -} - -/** - * useHttp - HTTP 请求插件 - * - * @example - * ```ts - * // 简单 GET 请求 - * keyFetch.create({ - * name: 'user', - * outputSchema: UserSchema, - * use: [useHttp('https://api.example.com/users/:id')], - * }) - * - * // POST 请求带自定义 body - * keyFetch.create({ - * name: 'createUser', - * outputSchema: UserSchema, - * use: [useHttp('https://api.example.com/users', { - * method: 'POST', - * body: (ctx) => ({ name: ctx.input.name, email: ctx.input.email }), - * })], - * }) - * - * // 动态 URL - * keyFetch.create({ - * name: 'chainData', - * outputSchema: DataSchema, - * use: [useHttp((ctx) => `https://${ctx.input.chain}.api.com/data`)], - * }) - * ``` - */ -export function useHttp( - url: string | ((ctx: Context) => string), - options?: UseHttpOptions -): Plugin { - return { - name: 'http', - - async onFetch(ctx: ContextAny, next) { - const typedCtx = ctx as Context - - // 解析 URL - let finalUrl = typeof url === 'function' ? url(typedCtx) : url - - // 替换 :param 占位符 - finalUrl = replacePathParams(finalUrl, ctx.input) - - // 解析 method - const method = options?.method - ? (typeof options.method === 'function' ? options.method(typedCtx) : options.method) - : 'GET' - - // 解析 headers - const headers = options?.headers - ? (typeof options.headers === 'function' ? options.headers(typedCtx) : options.headers) - : {} - - // 构建请求配置 - const requestInit: RequestInit = { - method, - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - } - - // 处理 body - if (method === 'POST') { - if (options?.body !== undefined) { - const bodyData = typeof options.body === 'function' - ? (options.body as (ctx: Context) => unknown)(typedCtx) - : options.body - requestInit.body = JSON.stringify(bodyData) - } else if (ctx.input !== undefined) { - // 默认使用 input 作为 body - requestInit.body = JSON.stringify(ctx.input) - } - } else { - // GET 请求:将未使用的 input 字段添加为 query params - finalUrl = appendSearchParams(finalUrl, ctx.input) - } - - // 更新 ctx.req - ctx.req = new Request(finalUrl, requestInit) - - // 执行请求 - return fetch(ctx.req) - }, - } -} diff --git a/packages/key-fetch/src/plugins/index.ts b/packages/key-fetch/src/plugins/index.ts deleted file mode 100644 index a7fe743bd..000000000 --- a/packages/key-fetch/src/plugins/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Key-Fetch Plugins - * - * 导出所有内置插件 - */ - -// 新版插件 -export { useHttp } from './http' -export type { UseHttpOptions } from './http' -export { useInterval } from './interval' -export { useDedupe, DedupeThrottledError } from './dedupe' - -// 兼容旧版名称 -export { useInterval as interval } from './interval' -export { useDedupe as dedupe } from './dedupe' - -// 保留的插件 -export { ttl } from './ttl' -export { tag } from './tag' -export { etag } from './etag' -export { throttleError, errorMatchers } from './throttle-error' -export { apiKey } from './api-key' -export { transform } from './transform' -export { cache, MemoryCacheStorage, IndexedDBCacheStorage } from './cache' -export { unwrap, walletApiUnwrap, etherscanApiUnwrap } from './unwrap' -export { searchParams, postBody, pathParams } from './params' diff --git a/packages/key-fetch/src/plugins/interval.ts b/packages/key-fetch/src/plugins/interval.ts deleted file mode 100644 index 5bff6b237..000000000 --- a/packages/key-fetch/src/plugins/interval.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * useInterval Plugin - * - * 定时轮询插件 - 在 onSubscribe 阶段启动定时器 - */ - -import type { Context, Plugin } from '../types' - -/** - * useInterval - 定时轮询插件 - * - * @param ms 轮询间隔(毫秒)或动态获取函数 - * - * @example - * ```ts - * // 固定间隔 - * keyFetch.create({ - * name: 'blockHeight', - * outputSchema: BlockSchema, - * use: [useHttp(url), useInterval(30_000)], - * }) - * - * // 动态间隔 - * keyFetch.create({ - * name: 'blockHeight', - * outputSchema: BlockSchema, - * use: [useHttp(url), useInterval(() => getPollingInterval())], - * }) - * ``` - */ -export function useInterval(ms: number | (() => number)): Plugin { - // 每个 input 独立的轮询状态 - const timers = new Map>() - const active = new Map() - const subscriberCounts = new Map() - - const getKey = (ctx: Context): string => { - return `${ctx.name}::${JSON.stringify(ctx.input)}` - } - - return { - name: 'interval', - - onSubscribe(ctx, emit) { - const key = getKey(ctx) - const count = (subscriberCounts.get(key) ?? 0) + 1 - subscriberCounts.set(key, count) - - // 首个订阅者,启动轮询 - if (count === 1) { - active.set(key, true) - - const poll = async () => { - if (!active.get(key)) return - - try { - const data = await ctx.self.fetch(ctx.input) - emit(data) - } catch { - // 静默处理轮询错误 - } finally { - if (active.get(key)) { - const nextMs = typeof ms === 'function' ? ms() : ms - const timer = setTimeout(poll, nextMs) - timers.set(key, timer) - } - } - } - - const initialMs = typeof ms === 'function' ? ms() : ms - const timer = setTimeout(poll, initialMs) - timers.set(key, timer) - } - - // 返回清理函数 - return () => { - const newCount = (subscriberCounts.get(key) ?? 1) - 1 - subscriberCounts.set(key, newCount) - - // 最后一个订阅者,停止轮询 - if (newCount === 0) { - active.set(key, false) - const timer = timers.get(key) - if (timer) { - clearTimeout(timer) - timers.delete(key) - } - subscriberCounts.delete(key) - } - } - }, - } -} diff --git a/packages/key-fetch/src/plugins/params.ts b/packages/key-fetch/src/plugins/params.ts deleted file mode 100644 index 766476e13..000000000 --- a/packages/key-fetch/src/plugins/params.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Params Plugin - * - * 将请求参数组装到不同位置(兼容旧版) - */ - -import type { Plugin } from '../types' - -/** - * SearchParams 插件 - 将 params 添加到 URL query string - */ -export function searchParams

= Record>(options?: { - defaults?: P - transform?: (params: P) => Record -}): Plugin { - return { - name: 'params:searchParams', - async onFetch(ctx, next) { - const url = new URL(ctx.req.url) - - const mergedParams = { - ...options?.defaults, - ...(ctx.input as P), - } - - const finalParams = options?.transform - ? options.transform(mergedParams) - : mergedParams - - for (const [key, value] of Object.entries(finalParams)) { - if (value !== undefined) { - url.searchParams.set(key, String(value)) - } - } - - ctx.req = new Request(url.toString(), { - method: ctx.req.method, - headers: ctx.req.headers, - body: ctx.req.body, - }) - - return next() - }, - } -} - -/** - * PostBody 插件 - 将 params 设置为 POST body - */ -export function postBody>(options?: { - defaults?: Partial - transform?: (params: TIN) => unknown -}): Plugin { - return { - name: 'params:postBody', - async onFetch(ctx, next) { - const mergedParams = { - ...options?.defaults, - ...(ctx.input as TIN), - } - - const body = options?.transform - ? options.transform(mergedParams) - : mergedParams - - ctx.req = new Request(ctx.req.url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - return next() - }, - } -} - -/** - * PathParams 插件 - 替换 URL 中的 :param 占位符 - */ -export function pathParams(): Plugin { - return { - name: 'params:pathParams', - async onFetch(ctx, next) { - let url = ctx.req.url - - if (typeof ctx.input === 'object' && ctx.input !== null) { - for (const [key, value] of Object.entries(ctx.input)) { - if (value !== undefined) { - url = url.replace(`:${key}`, encodeURIComponent(String(value))) - } - } - } - - ctx.req = new Request(url, { - method: ctx.req.method, - headers: ctx.req.headers, - body: ctx.req.body, - }) - - return next() - }, - } -} diff --git a/packages/key-fetch/src/plugins/tag.ts b/packages/key-fetch/src/plugins/tag.ts deleted file mode 100644 index 75204ddf4..000000000 --- a/packages/key-fetch/src/plugins/tag.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Tag Plugin - * - * 标签插件 - 用于批量失效 - */ - -import type { Plugin } from '../types' - -const tagToInstances = new Map>() - -/** - * 标签插件 - */ -export function tag(...tags: string[]): Plugin { - let initialized = false - - return { - name: 'tag', - - async onFetch(ctx, next) { - if (!initialized) { - initialized = true - for (const t of tags) { - let instances = tagToInstances.get(t) - if (!instances) { - instances = new Set() - tagToInstances.set(t, instances) - } - instances.add(ctx.name) - } - } - - return next() - }, - } -} - -/** 获取标签下的实例名称 */ -export function getInstancesByTag(tagName: string): string[] { - const instances = tagToInstances.get(tagName) - return instances ? [...instances] : [] -} diff --git a/packages/key-fetch/src/plugins/throttle-error.ts b/packages/key-fetch/src/plugins/throttle-error.ts deleted file mode 100644 index 9e7a9b7ab..000000000 --- a/packages/key-fetch/src/plugins/throttle-error.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Throttle Error Plugin - * - * 对匹配的错误进行日志节流,避免终端刷屏 - */ - -import type { Plugin } from '../types' - -export interface ThrottleErrorOptions { - /** 错误匹配器 - 返回 true 表示需要节流 */ - match: (error: Error) => boolean - /** 时间窗口(毫秒),默认 10000ms */ - windowMs?: number - /** 窗口内首次匹配时的处理 */ - onFirstMatch?: (error: Error, name: string) => void - /** 窗口结束时汇总回调 */ - onSummary?: (count: number, name: string) => void -} - -/** 高阶函数:为匹配器添加 AggregateError 支持 */ -const withAggregateError = (matcher: (msg: string) => boolean) => (e: Error): boolean => { - if (matcher(e.message)) return true - if (e instanceof AggregateError) { - return e.errors.some(inner => inner instanceof Error && matcher(inner.message)) - } - return false -} - -export const errorMatchers = { - httpStatus: (...codes: number[]) => - withAggregateError(msg => codes.some(code => msg.includes(`HTTP ${code}`))), - - contains: (...keywords: string[]) => - withAggregateError(msg => keywords.some(kw => msg.toLowerCase().includes(kw.toLowerCase()))), - - any: (...matchers: Array<(e: Error) => boolean>) => (e: Error) => - matchers.some(m => m(e)), -} - -/** - * 错误日志节流插件 - */ -export function throttleError(options: ThrottleErrorOptions): Plugin { - const { - match, - windowMs = 10_000, - onFirstMatch = (err, name) => { - console.warn(`[${name}] ${err.message.split('\n')[0]} (suppressing for ${windowMs / 1000}s)`) - }, - onSummary = (count, name) => { - if (count > 0) { - console.warn(`[${name}] Suppressed ${count} similar errors in last ${windowMs / 1000}s`) - } - }, - } = options - - const throttleState = new Map | null - }>() - - const getState = (name: string) => { - let state = throttleState.get(name) - if (!state) { - state = { inWindow: false, suppressedCount: 0, timer: null } - throttleState.set(name, state) - } - return state - } - - return { - name: 'throttle-error', - - onError(error, _response, ctx) { - if (!match(error)) { - return false - } - - const state = getState(ctx.name) - - if (!state.inWindow) { - state.inWindow = true - state.suppressedCount = 0 - onFirstMatch(error, ctx.name) - - state.timer = setTimeout(() => { - onSummary(state.suppressedCount, ctx.name) - state.inWindow = false - state.suppressedCount = 0 - state.timer = null - }, windowMs) - } else { - state.suppressedCount++ - } - - return true - }, - } -} diff --git a/packages/key-fetch/src/plugins/transform.ts b/packages/key-fetch/src/plugins/transform.ts deleted file mode 100644 index 1c8eb1e9c..000000000 --- a/packages/key-fetch/src/plugins/transform.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Transform Plugin - 响应转换插件 - */ - -import type { Context, Plugin } from '../types' -import { superjson } from '../core' - -export interface TransformOptions { - transform: (input: TInput, ctx: Context) => TOutput | Promise -} - -export function transform( - options: TransformOptions -): Plugin { - return { - name: 'transform', - - async onFetch(ctx, next) { - const response = await next() - - if (!response.ok) { - return response - } - - const text = await response.text() - const isSuperjson = response.headers.get('X-Superjson') === 'true' - const rawData = isSuperjson ? superjson.parse(text) as TInput : JSON.parse(text) as TInput - - const transformed = await options.transform(rawData, ctx) - - return new Response(JSON.stringify(transformed), { - status: response.status, - statusText: response.statusText, - headers: { 'Content-Type': 'application/json' }, - }) - }, - } -} diff --git a/packages/key-fetch/src/plugins/ttl.ts b/packages/key-fetch/src/plugins/ttl.ts deleted file mode 100644 index 3262598f8..000000000 --- a/packages/key-fetch/src/plugins/ttl.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * TTL Plugin - * - * 缓存生存时间插件 - */ - -import type { Plugin } from '../types' - -const cache = new Map() - -/** - * TTL 缓存插件 - */ -export function ttl(ms: number | (() => number)): Plugin { - return { - name: 'ttl', - - async onFetch(ctx, next) { - const cacheKey = `${ctx.name}:${JSON.stringify(ctx.input)}` - const cached = cache.get(cacheKey) - - const ttlMs = typeof ms === 'function' ? ms() : ms - if (cached && Date.now() - cached.timestamp < ttlMs) { - return cached.data.clone() - } - - const response = await next() - - if (response.ok) { - cache.set(cacheKey, { - data: response.clone(), - timestamp: Date.now(), - }) - } - - return response - }, - } -} diff --git a/packages/key-fetch/src/plugins/unwrap.ts b/packages/key-fetch/src/plugins/unwrap.ts deleted file mode 100644 index bd4dc1bfc..000000000 --- a/packages/key-fetch/src/plugins/unwrap.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Unwrap Plugin - 响应解包插件 - * - * 用于处理服务器返回的包装格式 - */ - -import type { Context, Plugin } from '../types' -import { superjson } from '../core' - -export interface UnwrapOptions { - /** - * 解包函数 - * @param wrapped 包装的响应数据 - * @param ctx 上下文 - * @returns 解包后的内部数据 - */ - unwrap: (wrapped: TWrapper, ctx: Context) => TInner | Promise -} - -/** - * 创建解包插件 - */ -export function unwrap( - options: UnwrapOptions -): Plugin { - return { - name: 'unwrap', - - async onFetch(ctx, next) { - const response = await next() - - if (!response.ok) { - return response - } - - // 解析包装响应 - const text = await response.text() - const isSuperjson = response.headers.get('X-Superjson') === 'true' - const wrapped = isSuperjson ? superjson.parse(text) as TWrapper : JSON.parse(text) as TWrapper - - // 解包 - const inner = await options.unwrap(wrapped, ctx) - - // 重新构建响应 - return new Response(JSON.stringify(inner), { - status: response.status, - statusText: response.statusText, - headers: { 'Content-Type': 'application/json' }, - }) - }, - } -} - -/** - * Wallet API 包装格式解包器 - */ -export function walletApiUnwrap(): Plugin { - return unwrap<{ success: boolean; result: T }, T>({ - unwrap: (wrapped, ctx) => { - if (wrapped.success === false) { - throw new Error(`[${ctx.name}]: Wallet API returned success: false`) - } else if (wrapped.success === true) { - return wrapped.result - } - return wrapped as unknown as T - }, - }) -} - -/** - * Etherscan API 包装格式解包器 - */ -export function etherscanApiUnwrap(): Plugin { - return unwrap<{ status: string; message: string; result: T }, T>({ - unwrap: (wrapped) => { - if (wrapped.status !== '1') { - throw new Error(`Etherscan API error: ${wrapped.message}`) - } - return wrapped.result - }, - }) -} diff --git a/packages/key-fetch/src/types.ts b/packages/key-fetch/src/types.ts deleted file mode 100644 index 91b8eee67..000000000 --- a/packages/key-fetch/src/types.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Key-Fetch v2 Types - * - * Schema-first 插件化响应式 Fetch 类型定义 - */ - -import type { z } from 'zod' -import type { SuperJSON } from 'superjson' - -// ==================== Core Types ==================== - -/** - * Context - 贯穿整个生命周期的核心对象 - * - * 严格类型化,无 options: any 模糊字段 - */ -export interface Context { - /** 当前输入参数(类型安全) */ - readonly input: TInput - /** 标准 Request 对象(由插件构建/修改) */ - req: Request - /** SuperJSON 库实例(核心标准) */ - readonly superjson: SuperJSON - /** 允许插件反向操作实例 */ - readonly self: KeyFetchInstance - /** 插件间共享状态 */ - readonly state: Map - /** KeyFetch 实例名称 */ - readonly name: string -} - -/** - * Plugin - 完整生命周期控制器 - */ -export interface Plugin { - /** 插件名称(用于调试) */ - name: string - - /** - * 阶段 1: 实例创建时触发 - * 用于设置全局定时器、全局事件监听等 - * 极少使用,通常用于"没人订阅也要跑"的特殊热流 - * @returns cleanup 函数 - */ - onInit?: (self: KeyFetchInstance) => void | (() => void) - - /** - * 阶段 2: 有人订阅时触发 - * 用于实现"热流"、轮询、依赖监听 - * @param ctx 上下文 - * @param emit 发射数据到订阅者 - * @returns cleanup 函数 - */ - onSubscribe?: ( - ctx: Context, - emit: (data: TOutput) => void - ) => void | (() => void) - - /** - * 阶段 3: 执行 Fetch 时触发(洋葱模型中间件) - * 负责构建 Request -> 执行(或Mock) -> 处理 Response - * @param ctx 上下文 - * @param next 调用下一个中间件 - * @returns Response - */ - onFetch?: ( - ctx: Context, - next: () => Promise - ) => Promise - - /** - * 错误处理钩子(可选) - * 在 HTTP 错误抛出前调用 - * @returns 返回 true 表示错误已处理 - */ - onError?: (error: Error, response: Response | undefined, ctx: Context) => boolean -} - -// ==================== KeyFetch Instance ==================== - -/** - * KeyFetch 定义选项 - */ -export interface KeyFetchDefineOptions { - /** 唯一名称 */ - name: string - /** 输入参数 Zod Schema */ - inputSchema?: z.ZodType - /** 输出结果 Zod Schema(必选) */ - outputSchema: z.ZodType - /** 插件列表 */ - use?: Plugin[] -} - -/** - * KeyFetch 实例 - 工厂函数返回的对象 - */ -export interface KeyFetchInstance { - /** 实例名称 */ - readonly name: string - /** 输入 Schema */ - readonly inputSchema: z.ZodType | undefined - /** 输出 Schema */ - readonly outputSchema: z.ZodType - - /** - * 冷流:单次请求 - * @param input 输入参数(类型安全) - */ - fetch(input: TInput): Promise - - /** - * 热流:订阅数据变化 - * @param input 输入参数 - * @param callback 回调函数 - * @returns 取消订阅函数 - */ - subscribe( - input: TInput, - callback: SubscribeCallback - ): () => void - - /** - * React Hook - 响应式数据绑定 - * 内部判断 React 环境,无需额外注入 - */ - useState( - input: TInput, - options?: UseStateOptions - ): UseStateResult -} - -// ==================== Subscribe Types ==================== - -/** 订阅回调 */ -export type SubscribeCallback = (data: T, event: 'initial' | 'update') => void - -// ==================== React Types ==================== - -/** useState 选项 */ -export interface UseStateOptions { - /** 是否启用(默认 true) */ - enabled?: boolean -} - -/** useState 返回值 */ -export interface UseStateResult { - /** 数据 */ - data: T | undefined - /** 是否正在加载(首次) */ - isLoading: boolean - /** 是否正在获取(包括后台刷新) */ - isFetching: boolean - /** 错误信息 */ - error: Error | undefined - /** 手动刷新 */ - refetch: () => Promise -} - -// ==================== Utility Types ==================== - -/** 从 KeyFetchInstance 推断输出类型 */ -export type InferOutput = T extends KeyFetchInstance ? O : never - -/** 从 KeyFetchInstance 推断输入类型 */ -export type InferInput = T extends KeyFetchInstance ? I : never - -// ==================== 兼容类型(供旧插件使用)==================== - -/** @deprecated 使用 Plugin 代替 */ -export type FetchPlugin = Plugin - -/** 请求参数基础类型 */ -export type FetchParams = Record - -/** 中间件上下文(兼容旧插件) */ -export interface MiddlewareContext { - name: string - params: TInput - skipCache: boolean - superjson: SuperJSON - createResponse: (data: T, init?: ResponseInit) => Response - createRequest: (data: T, url?: string, init?: RequestInit) => Request - body: (input: Request | Response) => Promise - parseBody: (input: string, isSuperjson?: boolean) => T -} - -// ==================== Combine Types ==================== - -/** Combine 源配置 */ -export interface CombineSource { - /** 源 KeyFetch 实例 */ - source: KeyFetchInstance - /** 从外部 input 生成源的 params */ - params: (input: TInput) => TSourceInput - /** 源的 key(用于 results 对象),默认使用 source.name */ - key?: string -} - -/** Combine 选项(简化版) */ -export interface CombineOptions { - /** 合并后的名称 */ - name: string - /** 输出 Schema */ - outputSchema: import('zod').ZodType - /** 源配置数组 */ - sources: CombineSource[] - /** 转换函数:将所有源的结果转换为最终输出 */ - transform: (results: Record, input: TInput) => TOutput - /** 额外插件 */ - use?: Plugin[] -} - -// ==================== Fallback Types ==================== - -/** Fallback 选项 */ -export interface FallbackOptions { - /** 合并后的名称 */ - name: string - /** 源 fetcher 数组(可以是空数组) */ - sources: KeyFetchInstance[] - /** 当 sources 为空时调用 */ - onEmpty?: () => never - /** 当所有 sources 都失败时调用 */ - onAllFailed?: (errors: Error[]) => never -} diff --git a/packages/key-fetch/tsconfig.json b/packages/key-fetch/tsconfig.json deleted file mode 100644 index 7e30356ae..000000000 --- a/packages/key-fetch/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2023", - "lib": [ - "ES2023", - "DOM", - "DOM.Iterable" - ], - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "skipLibCheck": true, - "declaration": true, - "declarationMap": true, - "jsx": "react-jsx", - "isolatedModules": true - }, - "include": [ - "src" - ] -} \ No newline at end of file diff --git a/packages/key-fetch/vite.config.ts b/packages/key-fetch/vite.config.ts deleted file mode 100644 index eb9b8fa3f..000000000 --- a/packages/key-fetch/vite.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { defineConfig } from 'vite' -import dts from 'vite-plugin-dts' -import { resolve } from 'path' - -export default defineConfig({ - plugins: [ - dts({ - include: ['src'], - rollupTypes: false, - }), - ], - build: { - lib: { - entry: { - index: resolve(__dirname, 'src/index.ts'), - react: resolve(__dirname, 'src/react.ts'), - 'plugins/index': resolve(__dirname, 'src/plugins/index.ts'), - }, - formats: ['es', 'cjs'], - }, - rollupOptions: { - external: ['react', 'react-dom'], - output: { - preserveModules: false, - }, - }, - minify: false, - sourcemap: true, - }, -}) diff --git a/packages/key-fetch/vitest.config.ts b/packages/key-fetch/vitest.config.ts deleted file mode 100644 index 77715f458..000000000 --- a/packages/key-fetch/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - globals: true, - environment: 'jsdom', - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], - }, -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3b84fb3e..a8a165e38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: '@biochain/chain-effect': specifier: workspace:* version: link:packages/chain-effect - '@biochain/key-fetch': - specifier: workspace:* - version: link:packages/key-fetch '@biochain/key-ui': specifier: workspace:* version: link:packages/key-ui @@ -838,37 +835,6 @@ importers: specifier: ^4.0.0 version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) - packages/key-fetch: - dependencies: - superjson: - specifier: ^2.2.6 - version: 2.2.6 - devDependencies: - '@testing-library/react': - specifier: ^16.3.0 - version: 16.3.1(@testing-library/dom@10.4.0)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@types/react': - specifier: ^19.0.0 - version: 19.2.7 - jsdom: - specifier: ^26.1.0 - version: 26.1.0 - oxlint: - specifier: ^1.32.0 - version: 1.35.0 - react: - specifier: ^19.0.0 - version: 19.2.3 - react-dom: - specifier: ^19.0.0 - version: 19.2.3(react@19.2.3) - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vitest: - specifier: ^4.0.0 - version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) - packages/key-ui: dependencies: '@biochain/key-utils': @@ -11388,7 +11354,7 @@ snapshots: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vitest@4.0.16) '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vitest@4.0.16) '@vitest/runner': 4.0.16 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) transitivePeerDependencies: - react - react-dom @@ -11964,7 +11930,7 @@ snapshots: '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) playwright: 1.57.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw @@ -11980,7 +11946,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) ws: 8.18.3 transitivePeerDependencies: - bufferutil From 492c4ed8da15b23a155ee4b4de8eb4d103b8d9f7 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 13:32:01 +0800 Subject: [PATCH 24/33] fix(chain-effect): sync immediate fetch and check dependency current value - createPollingSource: execute immediate fetch synchronously before returning - createDependentSource: check dependency current value on creation - changes stream: prepend current value with Stream.concat - Fixes issue where SubscriptionRef.changes only emits future changes --- packages/chain-effect/src/instance.ts | 2 + packages/chain-effect/src/source.ts | 60 ++++++++++++++++++--------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/packages/chain-effect/src/instance.ts b/packages/chain-effect/src/instance.ts index 488faa407..531bd75c9 100644 --- a/packages/chain-effect/src/instance.ts +++ b/packages/chain-effect/src/instance.ts @@ -117,6 +117,8 @@ export function createStreamInstanceFromSource( Effect.runFork(Fiber.interrupt(fiber)) releaseSource(input) } + }).catch((err) => { + console.error(`[${name}] getOrCreateSource failed:`, err) }) return () => { diff --git a/packages/chain-effect/src/source.ts b/packages/chain-effect/src/source.ts index 05d824eae..d14bcb8c0 100644 --- a/packages/chain-effect/src/source.ts +++ b/packages/chain-effect/src/source.ts @@ -106,7 +106,7 @@ export const createPollingSource = ( options: PollingSourceOptions ): Effect.Effect, never, never> => Effect.gen(function* () { - const { fetch, interval, events, walletEvents, immediate = true } = options + const { name, fetch, interval, events, walletEvents, immediate = true } = options const ref = yield* SubscriptionRef.make(null) @@ -115,11 +115,6 @@ export const createPollingSource = ( Stream.schedule(Schedule.spaced(interval)) ) - // 立即执行第一次 - const immediateStream = immediate - ? Stream.fromEffect(fetch) - : Stream.empty - // 简单字符串事件触发流 const simpleEventStream = events ? events.pipe(Stream.mapEffect(() => fetch)) @@ -132,19 +127,14 @@ export const createPollingSource = ( .pipe(Stream.mapEffect(() => fetch)) : Stream.empty - // 合并所有触发源 - const combinedStream = Stream.merge( - Stream.merge( - Stream.concat(immediateStream, pollingStream), - simpleEventStream - ), + // 合并所有触发源(不包括 immediate,因为我们会同步执行) + const ongoingStream = Stream.merge( + Stream.merge(pollingStream, simpleEventStream), walletEventStream ) // 使用 Stream.changes 去重(只有值变化才继续) - const driver = combinedStream.pipe( - Stream.changes - ) + const driver = ongoingStream.pipe(Stream.changes) // 驱动 ref 更新 const fiber = yield* driver.pipe( @@ -152,11 +142,23 @@ export const createPollingSource = ( Effect.fork ) + // 立即执行第一次并等待完成(同步) + if (immediate) { + const initialValue = yield* Effect.catchAll(fetch, () => Effect.succeed(null as T | null)) + if (initialValue !== null) { + yield* SubscriptionRef.set(ref, initialValue) + } + } + return { ref, fiber, get: SubscriptionRef.get(ref), - changes: ref.changes.pipe( + // 先发射当前值,再发射后续变化(SubscriptionRef.changes 只发射未来变化) + changes: Stream.concat( + Stream.fromEffect(SubscriptionRef.get(ref)), + ref.changes + ).pipe( Stream.filter((v): v is T => v !== null) ), refresh: Effect.gen(function* () { @@ -171,7 +173,7 @@ export const createPollingSource = ( // ==================== Dependent Source ==================== export interface DependentSourceOptions { - /** 数据源名称 */ + /** 数据源名称(用于调试) */ name: string /** 依赖的数据源 */ dependsOn: SubscriptionRef.SubscriptionRef @@ -202,7 +204,7 @@ export const createDependentSource = ( options: DependentSourceOptions ): Effect.Effect, never, never> => Effect.gen(function* () { - const { dependsOn, hasChanged, fetch } = options + const { name, dependsOn, hasChanged, fetch } = options const ref = yield* SubscriptionRef.make(null) let prevDep: TDep | null = null @@ -229,11 +231,25 @@ export const createDependentSource = ( Effect.fork ) + // 立即检查依赖的当前值,如果有值则执行 fetch + const currentDep = yield* SubscriptionRef.get(dependsOn) + if (currentDep !== null && hasChanged(null, currentDep)) { + prevDep = currentDep + const initialValue = yield* Effect.catchAll(fetch(currentDep), () => Effect.succeed(null as T | null)) + if (initialValue !== null) { + yield* SubscriptionRef.set(ref, initialValue) + } + } + return { ref, fiber, get: SubscriptionRef.get(ref), - changes: ref.changes.pipe( + // 先发射当前值,再发射后续变化 + changes: Stream.concat( + Stream.fromEffect(SubscriptionRef.get(ref)), + ref.changes + ).pipe( Stream.filter((v): v is T => v !== null) ), refresh: Effect.gen(function* () { @@ -334,7 +350,11 @@ export const createHybridSource = ( ref, fiber, get: SubscriptionRef.get(ref), - changes: ref.changes.pipe( + // 先发射当前值,再发射后续变化 + changes: Stream.concat( + Stream.fromEffect(SubscriptionRef.get(ref)), + ref.changes + ).pipe( Stream.filter((v): v is T => v !== null) ), refresh: Effect.gen(function* () { From 3e05a6df29f5831b85f15b9ac7c7d76176491de3 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 14:17:04 +0800 Subject: [PATCH 25/33] feat(chain-effect): add global source registry with ref counting and caching - http-cache.ts: Cache API wrapper with smart update strategy - poll-meta.ts: IndexedDB persistence for poll timing - source-registry.ts: Global singleton + ref counting for sources - biowallet-provider: Refactored to use shared txHistory source This ensures that subscribing to balance/tokenBalances shares the same txHistory source instead of creating duplicate polling instances. --- packages/chain-effect/src/http-cache.ts | 155 +++++++++++++ packages/chain-effect/src/index.ts | 33 +++ packages/chain-effect/src/poll-meta.ts | 158 +++++++++++++ packages/chain-effect/src/source-registry.ts | 214 ++++++++++++++++++ .../providers/biowallet-provider.effect.ts | 124 +++++----- 5 files changed, 622 insertions(+), 62 deletions(-) create mode 100644 packages/chain-effect/src/http-cache.ts create mode 100644 packages/chain-effect/src/poll-meta.ts create mode 100644 packages/chain-effect/src/source-registry.ts diff --git a/packages/chain-effect/src/http-cache.ts b/packages/chain-effect/src/http-cache.ts new file mode 100644 index 000000000..5f40307e3 --- /dev/null +++ b/packages/chain-effect/src/http-cache.ts @@ -0,0 +1,155 @@ +/** + * HTTP 缓存层 + * + * 使用浏览器 Cache API 实现智能缓存: + * - 始终先返回缓存值(stale-while-revalidate) + * - 成功响应才更新缓存 + * - 失败不影响已有缓存 + */ + +import { Effect, Option } from "effect" + +// ==================== 类型定义 ==================== + +export interface CachedResponse { + data: T + timestamp: number + fromCache: boolean +} + +export interface HttpCacheOptions { + /** 缓存名称 */ + cacheName?: string + /** 是否强制刷新(忽略缓存发起请求,但成功才更新) */ + forceRefresh?: boolean +} + +// ==================== Cache API 封装 ==================== + +const DEFAULT_CACHE_NAME = "chain-effect-http-cache" + +/** + * 将 POST 请求转换为可缓存的 GET 请求 + * Cache API 只能缓存 GET 请求,所以需要将 body 编码到 URL 中 + */ +function makeCacheKey(url: string, body?: unknown): string { + if (!body) return url + const bodyHash = btoa(JSON.stringify(body)).replace(/[+/=]/g, (c) => + c === '+' ? '-' : c === '/' ? '_' : '' + ) + return `${url}?__body=${bodyHash}` +} + +function makeFakeGetRequest(cacheKey: string): Request { + return new Request(cacheKey, { method: "GET" }) +} + +/** + * 从缓存获取响应 + */ +export const getFromCache = ( + url: string, + body?: unknown, + options?: HttpCacheOptions +): Effect.Effect>> => + Effect.tryPromise({ + try: async () => { + const cacheName = options?.cacheName ?? DEFAULT_CACHE_NAME + const cache = await caches.open(cacheName) + const cacheKey = makeCacheKey(url, body) + const request = makeFakeGetRequest(cacheKey) + + const response = await cache.match(request) + if (!response) return Option.none>() + + const cached = await response.json() as { data: T; timestamp: number } + return Option.some({ + data: cached.data, + timestamp: cached.timestamp, + fromCache: true, + }) + }, + catch: () => new Error("Cache read failed"), + }).pipe( + Effect.catchAll(() => Effect.succeed(Option.none>())) + ) + +/** + * 将响应存入缓存 + */ +export const putToCache = ( + url: string, + body: unknown | undefined, + data: T, + options?: HttpCacheOptions +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const cacheName = options?.cacheName ?? DEFAULT_CACHE_NAME + const cache = await caches.open(cacheName) + const cacheKey = makeCacheKey(url, body) + const request = makeFakeGetRequest(cacheKey) + + const cacheData = { + data, + timestamp: Date.now(), + } + + const response = new Response(JSON.stringify(cacheData), { + headers: { + "Content-Type": "application/json", + "X-Cache-Timestamp": String(cacheData.timestamp), + }, + }) + + await cache.put(request, response) + }, + catch: () => new Error("Cache write failed"), + }).pipe( + Effect.catchAll(() => Effect.void) + ) + +/** + * 删除缓存条目 + */ +export const deleteFromCache = ( + url: string, + body?: unknown, + options?: HttpCacheOptions +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const cacheName = options?.cacheName ?? DEFAULT_CACHE_NAME + const cache = await caches.open(cacheName) + const cacheKey = makeCacheKey(url, body) + const request = makeFakeGetRequest(cacheKey) + + return cache.delete(request) + }, + catch: () => new Error("Cache delete failed"), + }).pipe( + Effect.catchAll(() => Effect.succeed(false)) + ) + +/** + * 清空整个缓存 + */ +export const clearCache = ( + options?: HttpCacheOptions +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const cacheName = options?.cacheName ?? DEFAULT_CACHE_NAME + return caches.delete(cacheName) + }, + catch: () => new Error("Cache clear failed"), + }).pipe( + Effect.catchAll(() => Effect.succeed(false)) + ) + +/** + * 生成缓存 key(供外部使用) + */ +export function makeCacheKeyFromRequest(url: string, body?: unknown): string { + return makeCacheKey(url, body) +} diff --git a/packages/chain-effect/src/index.ts b/packages/chain-effect/src/index.ts index 17d1366e4..eaae771c7 100644 --- a/packages/chain-effect/src/index.ts +++ b/packages/chain-effect/src/index.ts @@ -76,3 +76,36 @@ export { createStreamInstanceFromSource, type StreamInstance, } from "./instance" + +// HTTP Cache (Cache API wrapper) +export { + getFromCache, + putToCache, + deleteFromCache, + clearCache, + makeCacheKeyFromRequest, + type CachedResponse, + type HttpCacheOptions, +} from "./http-cache" + +// Poll Meta (IndexedDB persistence) +export { + getPollMeta, + setPollMeta, + deletePollMeta, + updateNextPollTime, + getDelayUntilNextPoll, + makePollKey, + type PollMeta, +} from "./poll-meta" + +// Source Registry (global singleton + ref counting) +export { + acquireSource, + releaseSource, + makeRegistryKey, + getRegistryStatus, + clearRegistry, + type SourceEntry, + type AcquireSourceOptions, +} from "./source-registry" diff --git a/packages/chain-effect/src/poll-meta.ts b/packages/chain-effect/src/poll-meta.ts new file mode 100644 index 000000000..4e58f3fc4 --- /dev/null +++ b/packages/chain-effect/src/poll-meta.ts @@ -0,0 +1,158 @@ +/** + * 轮询元数据管理 + * + * 使用 IndexedDB 持久化轮询时间,确保重建 source 后能恢复轮询计划 + */ + +import { Effect } from "effect" + +// ==================== 类型定义 ==================== + +export interface PollMeta { + /** 缓存 key: chainId:address:sourceType */ + key: string + /** 下次轮询时间戳 */ + nextPollTime: number + /** 上次成功时间戳 */ + lastSuccessTime: number + /** 轮询间隔(毫秒) */ + interval: number +} + +// ==================== IndexedDB 配置 ==================== + +const DB_NAME = "chain-effect-poll-meta" +const DB_VERSION = 1 +const STORE_NAME = "poll-meta" + +let dbPromise: Promise | null = null + +function getDB(): Promise { + if (dbPromise) return dbPromise + + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve(request.result) + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "key" }) + } + } + }) + + return dbPromise +} + +// ==================== Effect 包装的 API ==================== + +/** + * 获取轮询元数据 + */ +export const getPollMeta = (key: string): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const db = await getDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly") + const store = tx.objectStore(STORE_NAME) + const request = store.get(key) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve(request.result ?? null) + }) + }, + catch: (error) => new Error(`Failed to get poll meta: ${error}`), + }).pipe( + Effect.catchAll(() => Effect.succeed(null)) + ) + +/** + * 设置轮询元数据 + */ +export const setPollMeta = (meta: PollMeta): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const db = await getDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite") + const store = tx.objectStore(STORE_NAME) + const request = store.put(meta) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) + }, + catch: (error) => new Error(`Failed to set poll meta: ${error}`), + }).pipe( + Effect.catchAll(() => Effect.void) + ) + +/** + * 删除轮询元数据 + */ +export const deletePollMeta = (key: string): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const db = await getDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite") + const store = tx.objectStore(STORE_NAME) + const request = store.delete(key) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) + }, + catch: (error) => new Error(`Failed to delete poll meta: ${error}`), + }).pipe( + Effect.catchAll(() => Effect.void) + ) + +/** + * 更新下次轮询时间(成功请求后调用) + */ +export const updateNextPollTime = ( + key: string, + interval: number +): Effect.Effect => + Effect.gen(function* () { + const now = Date.now() + const meta: PollMeta = { + key, + nextPollTime: now + interval, + lastSuccessTime: now, + interval, + } + yield* setPollMeta(meta) + }) + +/** + * 计算距离下次轮询的延迟时间 + * + * - 如果有持久化的 nextPollTime 且未过期,返回剩余延迟 + * - 否则返回 0(立即执行) + */ +export const getDelayUntilNextPoll = (key: string): Effect.Effect => + Effect.gen(function* () { + const meta = yield* getPollMeta(key) + if (!meta) return 0 + + const now = Date.now() + const delay = meta.nextPollTime - now + return Math.max(0, delay) + }) + +/** + * 生成轮询 key + */ +export function makePollKey( + chainId: string, + address: string, + sourceType: string +): string { + return `${chainId}:${address.toLowerCase()}:${sourceType}` +} diff --git a/packages/chain-effect/src/source-registry.ts b/packages/chain-effect/src/source-registry.ts new file mode 100644 index 000000000..b059fce90 --- /dev/null +++ b/packages/chain-effect/src/source-registry.ts @@ -0,0 +1,214 @@ +/** + * 全局 Source 注册表 + * + * 实现按 chainId:address 的全局单例 + 引用计数管理 + * - 首次订阅时创建 source 并启动轮询 + * - 引用计数归零时立即停止轮询并清理 + * - 支持恢复轮询计划(从 IndexedDB 读取 nextPollTime) + */ + +import { Effect, Fiber, Duration, SubscriptionRef, Stream, Schedule } from "effect" +import type { FetchError } from "./http" +import type { DataSource } from "./source" +import { getPollMeta, updateNextPollTime, makePollKey, getDelayUntilNextPoll } from "./poll-meta" +import { getFromCache, putToCache } from "./http-cache" +import { Option } from "effect" + +// ==================== 类型定义 ==================== + +export interface SourceEntry { + source: DataSource + refCount: number + pollFiber: Fiber.RuntimeFiber | null +} + +export interface AcquireSourceOptions { + /** 获取数据的 Effect */ + fetch: Effect.Effect + /** 轮询间隔 */ + interval: Duration.DurationInput + /** 缓存 URL(用于 Cache API) */ + cacheUrl?: string + /** 缓存 body(用于 Cache API) */ + cacheBody?: unknown +} + +// ==================== 全局注册表 ==================== + +const registry = new Map>() + +/** + * 生成注册表 key + */ +export function makeRegistryKey( + chainId: string, + address: string, + sourceType: string +): string { + return `${chainId}:${address.toLowerCase()}:${sourceType}` +} + +/** + * 获取或创建共享的 DataSource + * + * - 首次调用:创建 source,启动轮询 fiber,refCount = 1 + * - 后续调用:返回已有 source,refCount++ + * - 支持从 IndexedDB 恢复轮询计划 + * - 支持从 Cache API 返回缓存值 + */ +export function acquireSource( + key: string, + options: AcquireSourceOptions +): Effect.Effect> { + return Effect.gen(function* () { + const existing = registry.get(key) as SourceEntry | undefined + + if (existing) { + existing.refCount++ + return existing.source + } + + // 创建新的 SubscriptionRef + const ref = yield* SubscriptionRef.make(null) + + // 尝试从缓存恢复初始值 + if (options.cacheUrl) { + const cached = yield* getFromCache(options.cacheUrl, options.cacheBody) + if (Option.isSome(cached)) { + yield* SubscriptionRef.set(ref, cached.value.data) + } + } + + // 创建轮询 fiber(延迟启动,基于持久化的 nextPollTime) + const intervalMs = Duration.toMillis(Duration.decode(options.interval)) + const pollKey = key + + const pollEffect = Effect.gen(function* () { + // 计算初始延迟(基于持久化的 nextPollTime) + const delay = yield* getDelayUntilNextPoll(pollKey) + if (delay > 0) { + yield* Effect.sleep(Duration.millis(delay)) + } + + // 开始轮询循环 + yield* Stream.repeatEffect( + Effect.gen(function* () { + const result = yield* Effect.catchAll(options.fetch, () => + Effect.succeed(null as T | null) + ) + + if (result !== null) { + yield* SubscriptionRef.set(ref, result) + // 更新持久化的下次轮询时间 + yield* updateNextPollTime(pollKey, intervalMs) + // 更新缓存 + if (options.cacheUrl) { + yield* putToCache(options.cacheUrl, options.cacheBody, result) + } + } + + return result + }) + ).pipe( + Stream.schedule(Schedule.spaced(options.interval)), + Stream.runDrain + ) + }) + + const pollFiber = yield* Effect.fork(pollEffect) + + // 执行立即获取 + const immediateResult = yield* Effect.catchAll(options.fetch, () => + Effect.succeed(null as T | null) + ) + if (immediateResult !== null) { + yield* SubscriptionRef.set(ref, immediateResult) + yield* updateNextPollTime(pollKey, intervalMs) + if (options.cacheUrl) { + yield* putToCache(options.cacheUrl, options.cacheBody, immediateResult) + } + } + + // 构建 DataSource + const source: DataSource = { + ref, + fiber: pollFiber as Fiber.RuntimeFiber, + get: SubscriptionRef.get(ref), + changes: Stream.concat( + Stream.fromEffect(SubscriptionRef.get(ref)), + ref.changes + ).pipe( + Stream.filter((v): v is T => v !== null) + ), + refresh: Effect.gen(function* () { + const value = yield* options.fetch + yield* SubscriptionRef.set(ref, value) + yield* updateNextPollTime(pollKey, intervalMs) + if (options.cacheUrl) { + yield* putToCache(options.cacheUrl, options.cacheBody, value) + } + return value + }), + stop: Fiber.interrupt(pollFiber).pipe(Effect.asVoid), + } + + // 注册到全局表 + const entry: SourceEntry = { + source, + refCount: 1, + pollFiber, + } + registry.set(key, entry as SourceEntry) + + return source + }) +} + +/** + * 释放 DataSource 引用 + * + * - refCount-- + * - refCount 归零时:停止轮询 fiber,从注册表删除 + */ +export function releaseSource(key: string): Effect.Effect { + return Effect.gen(function* () { + const entry = registry.get(key) + if (!entry) return + + entry.refCount-- + + if (entry.refCount <= 0) { + // 停止轮询 + if (entry.pollFiber) { + yield* Fiber.interrupt(entry.pollFiber) + } + // 从注册表删除 + registry.delete(key) + } + }) +} + +/** + * 获取当前注册表状态(用于调试) + */ +export function getRegistryStatus(): Map { + const status = new Map() + for (const [key, entry] of registry) { + status.set(key, { refCount: entry.refCount }) + } + return status +} + +/** + * 清空所有注册的 source(用于测试/清理) + */ +export function clearRegistry(): Effect.Effect { + return Effect.gen(function* () { + for (const [key, entry] of registry) { + if (entry.pollFiber) { + yield* Fiber.interrupt(entry.pollFiber) + } + } + registry.clear() + }) +} diff --git a/src/services/chain-adapter/providers/biowallet-provider.effect.ts b/src/services/chain-adapter/providers/biowallet-provider.effect.ts index 98ab93d67..3c0b5f7b8 100644 --- a/src/services/chain-adapter/providers/biowallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/biowallet-provider.effect.ts @@ -11,9 +11,11 @@ import { Schema as S } from "effect" import { httpFetch, createStreamInstanceFromSource, - createPollingSource, createDependentSource, createEventBusService, + acquireSource, + releaseSource, + makeRegistryKey, type FetchError, type DataSource, @@ -402,76 +404,74 @@ export class BiowalletProviderEffect // ==================== Source 创建方法 ==================== - private createTransactionHistorySource( - params: TxHistoryParams, + /** + * 获取共享的 transactionHistory source(全局单例 + 引用计数) + */ + private getSharedTxHistorySource( + address: string, symbol: string, decimals: number ): Effect.Effect> { const provider = this - const address = params.address.toLowerCase() - const chainId = this.chainId - - return Effect.gen(function* () { - // 获取或创建 Provider 级别共享的 EventBus - if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService - } - const eventBus = provider._eventBus - - const fetchEffect = provider.fetchTransactionList(params).pipe( - Effect.map((raw): TransactionsOutput => { - if (!raw.result?.trs) return [] - - return raw.result.trs - .map((item): Transaction | null => { - const tx = item.transaction - const action = detectAction(tx.type) - const direction = getDirection(tx.senderId, tx.recipientId ?? "", address) - const { value, assetType } = extractAssetInfo(tx.asset, symbol) - if (value === null) return null - - return { - hash: tx.signature ?? item.signature, - from: tx.senderId, - to: tx.recipientId ?? "", - timestamp: provider.epochMs + tx.timestamp * 1000, - status: "confirmed", - blockNumber: BigInt(item.height), - action, - direction, - assets: [ - { - assetType: "native" as const, - value, - symbol: assetType, - decimals, - }, - ], - } - }) - .filter((tx): tx is Transaction => tx !== null) - .sort((a, b) => b.timestamp - a.timestamp) - }) - ) - - // 使用 createPollingSource 实现定时轮询 + 事件触发 - const source = yield* createPollingSource({ - name: `biowallet.${provider.chainId}.txHistory`, - fetch: fetchEffect, - interval: Duration.millis(provider.forgeInterval), - // 使用 walletEvents 配置,按 chainId + address 过滤事件 - walletEvents: { - eventBus, - chainId, - address: params.address, - types: ["tx:confirmed", "tx:sent"], - }, + const normalizedAddress = address.toLowerCase() + const registryKey = makeRegistryKey(this.chainId, normalizedAddress, "txHistory") + const cacheUrl = `${this.baseUrl}/transactions/query` + const cacheBody = { address: normalizedAddress, limit: 50 } + + const fetchEffect = provider.fetchTransactionList({ address, limit: 50 }).pipe( + Effect.map((raw): TransactionsOutput => { + if (!raw.result?.trs) return [] + + return raw.result.trs + .map((item): Transaction | null => { + const tx = item.transaction + const action = detectAction(tx.type) + const direction = getDirection(tx.senderId, tx.recipientId ?? "", normalizedAddress) + const { value, assetType } = extractAssetInfo(tx.asset, symbol) + if (value === null) return null + + return { + hash: tx.signature ?? item.signature, + from: tx.senderId, + to: tx.recipientId ?? "", + timestamp: provider.epochMs + tx.timestamp * 1000, + status: "confirmed", + blockNumber: BigInt(item.height), + action, + direction, + assets: [ + { + assetType: "native" as const, + value, + symbol: assetType, + decimals, + }, + ], + } + }) + .filter((tx): tx is Transaction => tx !== null) + .sort((a, b) => b.timestamp - a.timestamp) }) + ) - return source + // 使用全局 acquireSource 获取共享的 source + return acquireSource(registryKey, { + fetch: fetchEffect, + interval: Duration.millis(this.forgeInterval), + cacheUrl, + cacheBody, }) } + private createTransactionHistorySource( + params: TxHistoryParams, + symbol: string, + decimals: number + ): Effect.Effect> { + // 直接使用共享的 txHistory source + return this.getSharedTxHistorySource(params.address, symbol, decimals) + } + private createBalanceSource( params: AddressParams, symbol: string, From 50a4d0dd151dcea0537bd706993727bbafc07ca3 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 14:41:47 +0800 Subject: [PATCH 26/33] feat(chain-effect): add httpFetchCached for network request deduplication - Add httpFetchCached() with TTL-based caching using Cache API - Simplify source-registry.ts (remove cacheUrl/cacheBody, cache handled by fetch) - Update biowallet provider to use httpFetchCached with forgeInterval as TTL --- packages/chain-effect/src/http.ts | 40 +++++++++- packages/chain-effect/src/index.ts | 2 + packages/chain-effect/src/source-registry.ts | 78 +++++++++++-------- .../providers/biowallet-provider.effect.ts | 11 ++- 4 files changed, 92 insertions(+), 39 deletions(-) diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts index 65ea175b9..c74c2d9ac 100644 --- a/packages/chain-effect/src/http.ts +++ b/packages/chain-effect/src/http.ts @@ -4,8 +4,9 @@ * 封装 fetch API,提供类型安全的 HTTP 请求 */ -import { Effect, Schedule, Duration } from 'effect'; +import { Effect, Schedule, Duration, Option } from 'effect'; import { Schema } from 'effect'; +import { getFromCache, putToCache } from './http-cache'; // ==================== Error Types ==================== @@ -217,3 +218,40 @@ export function httpFetchWithRetry( ), ); } + +// ==================== Cached HTTP Client ==================== + +export interface CachedFetchOptions extends FetchOptions { + /** 缓存 TTL(毫秒),在此时间内不发起重复请求 */ + cacheTtl?: number; +} + +/** + * 带缓存拦截的 HTTP 请求 + * + * - 缓存未过期:直接返回缓存,不发网络请求 + * - 缓存过期/不存在:发起请求,成功后更新缓存 + */ +export function httpFetchCached(options: CachedFetchOptions): Effect.Effect { + const { cacheTtl = 5000, ...fetchOptions } = options; + + return Effect.gen(function* () { + // 1. 检查缓存 + const cached = yield* getFromCache(options.url, options.body); + if (Option.isSome(cached)) { + const age = Date.now() - cached.value.timestamp; + if (age < cacheTtl) { + // 缓存未过期,直接返回 + return cached.value.data; + } + } + + // 2. 发起真实请求 + const result = yield* httpFetch(fetchOptions); + + // 3. 更新缓存 + yield* putToCache(options.url, options.body, result); + + return result; + }); +} diff --git a/packages/chain-effect/src/index.ts b/packages/chain-effect/src/index.ts index eaae771c7..b963f212f 100644 --- a/packages/chain-effect/src/index.ts +++ b/packages/chain-effect/src/index.ts @@ -22,6 +22,7 @@ export * from "./schema" // HTTP utilities export { httpFetch, + httpFetchCached, httpFetchWithRetry, defaultRetrySchedule, rateLimitRetrySchedule, @@ -31,6 +32,7 @@ export { NoSupportError, ServiceLimitedError, type FetchOptions, + type CachedFetchOptions, type FetchError, } from "./http" diff --git a/packages/chain-effect/src/source-registry.ts b/packages/chain-effect/src/source-registry.ts index b059fce90..4db3ad1ad 100644 --- a/packages/chain-effect/src/source-registry.ts +++ b/packages/chain-effect/src/source-registry.ts @@ -10,9 +10,7 @@ import { Effect, Fiber, Duration, SubscriptionRef, Stream, Schedule } from "effect" import type { FetchError } from "./http" import type { DataSource } from "./source" -import { getPollMeta, updateNextPollTime, makePollKey, getDelayUntilNextPoll } from "./poll-meta" -import { getFromCache, putToCache } from "./http-cache" -import { Option } from "effect" +import { updateNextPollTime, getDelayUntilNextPoll } from "./poll-meta" // ==================== 类型定义 ==================== @@ -23,19 +21,17 @@ export interface SourceEntry { } export interface AcquireSourceOptions { - /** 获取数据的 Effect */ + /** 获取数据的 Effect(建议使用 httpFetchCached 以自动缓存) */ fetch: Effect.Effect /** 轮询间隔 */ interval: Duration.DurationInput - /** 缓存 URL(用于 Cache API) */ - cacheUrl?: string - /** 缓存 body(用于 Cache API) */ - cacheBody?: unknown } // ==================== 全局注册表 ==================== const registry = new Map>() +// 用于防止并发创建的 Promise 锁 +const pendingCreations = new Map>>() /** * 生成注册表 key @@ -53,32 +49,61 @@ export function makeRegistryKey( * * - 首次调用:创建 source,启动轮询 fiber,refCount = 1 * - 后续调用:返回已有 source,refCount++ - * - 支持从 IndexedDB 恢复轮询计划 - * - 支持从 Cache API 返回缓存值 + * - 使用 Promise 锁防止并发创建 */ export function acquireSource( key: string, options: AcquireSourceOptions ): Effect.Effect> { - return Effect.gen(function* () { + return Effect.promise(async () => { + // 1. 检查是否已存在 const existing = registry.get(key) as SourceEntry | undefined - if (existing) { existing.refCount++ + console.log(`[SourceRegistry] acquireSource existing: ${key}, refCount: ${existing.refCount}`) return existing.source } - // 创建新的 SubscriptionRef - const ref = yield* SubscriptionRef.make(null) - - // 尝试从缓存恢复初始值 - if (options.cacheUrl) { - const cached = yield* getFromCache(options.cacheUrl, options.cacheBody) - if (Option.isSome(cached)) { - yield* SubscriptionRef.set(ref, cached.value.data) + // 2. 检查是否有正在创建的 Promise(防止并发) + const pending = pendingCreations.get(key) + if (pending) { + console.log(`[SourceRegistry] acquireSource waiting for pending: ${key}`) + const source = await pending as DataSource + // 增加引用计数 + const entry = registry.get(key) as SourceEntry + if (entry) { + entry.refCount++ + console.log(`[SourceRegistry] acquireSource after wait: ${key}, refCount: ${entry.refCount}`) } + return source } + console.log(`[SourceRegistry] acquireSource NEW: ${key}`) + + // 3. 创建 Promise 锁 + const createPromise = createSourceInternal(key, options) + pendingCreations.set(key, createPromise as Promise>) + + try { + const source = await createPromise + return source + } finally { + pendingCreations.delete(key) + } + }) +} + +/** + * 内部创建函数 + */ +async function createSourceInternal( + key: string, + options: AcquireSourceOptions +): Promise> { + return Effect.runPromise(Effect.gen(function* () { + // 创建新的 SubscriptionRef + const ref = yield* SubscriptionRef.make(null) + // 创建轮询 fiber(延迟启动,基于持久化的 nextPollTime) const intervalMs = Duration.toMillis(Duration.decode(options.interval)) const pollKey = key @@ -99,12 +124,7 @@ export function acquireSource( if (result !== null) { yield* SubscriptionRef.set(ref, result) - // 更新持久化的下次轮询时间 yield* updateNextPollTime(pollKey, intervalMs) - // 更新缓存 - if (options.cacheUrl) { - yield* putToCache(options.cacheUrl, options.cacheBody, result) - } } return result @@ -124,9 +144,6 @@ export function acquireSource( if (immediateResult !== null) { yield* SubscriptionRef.set(ref, immediateResult) yield* updateNextPollTime(pollKey, intervalMs) - if (options.cacheUrl) { - yield* putToCache(options.cacheUrl, options.cacheBody, immediateResult) - } } // 构建 DataSource @@ -144,9 +161,6 @@ export function acquireSource( const value = yield* options.fetch yield* SubscriptionRef.set(ref, value) yield* updateNextPollTime(pollKey, intervalMs) - if (options.cacheUrl) { - yield* putToCache(options.cacheUrl, options.cacheBody, value) - } return value }), stop: Fiber.interrupt(pollFiber).pipe(Effect.asVoid), @@ -161,7 +175,7 @@ export function acquireSource( registry.set(key, entry as SourceEntry) return source - }) + })) } /** diff --git a/src/services/chain-adapter/providers/biowallet-provider.effect.ts b/src/services/chain-adapter/providers/biowallet-provider.effect.ts index 3c0b5f7b8..175864d39 100644 --- a/src/services/chain-adapter/providers/biowallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/biowallet-provider.effect.ts @@ -10,6 +10,7 @@ import { Effect, Duration } from "effect" import { Schema as S } from "effect" import { httpFetch, + httpFetchCached, createStreamInstanceFromSource, createDependentSource, createEventBusService, @@ -415,8 +416,6 @@ export class BiowalletProviderEffect const provider = this const normalizedAddress = address.toLowerCase() const registryKey = makeRegistryKey(this.chainId, normalizedAddress, "txHistory") - const cacheUrl = `${this.baseUrl}/transactions/query` - const cacheBody = { address: normalizedAddress, limit: 50 } const fetchEffect = provider.fetchTransactionList({ address, limit: 50 }).pipe( Effect.map((raw): TransactionsOutput => { @@ -458,8 +457,6 @@ export class BiowalletProviderEffect return acquireSource(registryKey, { fetch: fetchEffect, interval: Duration.millis(this.forgeInterval), - cacheUrl, - cacheBody, }) } @@ -652,16 +649,17 @@ export class BiowalletProviderEffect } private fetchAddressAsset(address: string): Effect.Effect { - return httpFetch({ + return httpFetchCached({ url: `${this.baseUrl}/address/asset`, method: "POST", body: { address }, schema: AssetResponseSchema, + cacheTtl: this.forgeInterval, }) } private fetchTransactionList(params: TxHistoryParams): Effect.Effect { - return httpFetch({ + return httpFetchCached({ url: `${this.baseUrl}/transactions/query`, method: "POST", body: { @@ -671,6 +669,7 @@ export class BiowalletProviderEffect sort: -1, }, schema: TxListResponseSchema, + cacheTtl: this.forgeInterval, }) } From 52323fe468d16d20512564b0d5d44cf387cd5452 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 15:25:40 +0800 Subject: [PATCH 27/33] feat(chain-effect): add cache strategy + biowallet dependency chain refactor - Add cacheStrategy option: 'ttl' | 'cache-first' | 'network-first' - Add Promise lock to prevent concurrent duplicate requests - Biowallet: blockHeight -> txHistory -> balance/tokenBalances dependency chain - blockHeight uses TTL = forgeInterval/2 - txHistory/balance use cache-first (triggered by dependency change) --- packages/chain-effect/src/http.ts | 106 ++++++++++--- packages/chain-effect/src/index.ts | 1 + .../providers/biowallet-provider.effect.ts | 144 ++++++++++-------- 3 files changed, 170 insertions(+), 81 deletions(-) diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts index c74c2d9ac..6c1dadaa5 100644 --- a/packages/chain-effect/src/http.ts +++ b/packages/chain-effect/src/http.ts @@ -221,37 +221,103 @@ export function httpFetchWithRetry( // ==================== Cached HTTP Client ==================== +export type CacheStrategy = + | 'ttl' // 基于时间过期(默认) + | 'cache-first' // 有缓存就用,没有才 fetch + | 'network-first' // 尝试 fetch,成功更新缓存,失败用缓存 + export interface CachedFetchOptions extends FetchOptions { - /** 缓存 TTL(毫秒),在此时间内不发起重复请求 */ + /** 缓存策略 */ + cacheStrategy?: CacheStrategy; + /** 缓存 TTL(毫秒),仅 ttl 策略使用 */ cacheTtl?: number; } +// 用于防止并发请求的 Promise 锁 +const pendingRequests = new Map>(); + +function makeCacheKeyForRequest(url: string, body?: unknown): string { + if (!body) return url; + const bodyHash = btoa(JSON.stringify(body)).replace(/[+/=]/g, (c) => + c === '+' ? '-' : c === '/' ? '_' : '' + ); + return `${url}?__body=${bodyHash}`; +} + /** * 带缓存拦截的 HTTP 请求 * - * - 缓存未过期:直接返回缓存,不发网络请求 - * - 缓存过期/不存在:发起请求,成功后更新缓存 + * 策略说明: + * - ttl: 缓存未过期直接返回,过期才 fetch + * - cache-first: 有缓存就返回,没有才 fetch + * - network-first: 尝试 fetch,成功更新缓存,失败用缓存 */ export function httpFetchCached(options: CachedFetchOptions): Effect.Effect { - const { cacheTtl = 5000, ...fetchOptions } = options; - - return Effect.gen(function* () { - // 1. 检查缓存 - const cached = yield* getFromCache(options.url, options.body); - if (Option.isSome(cached)) { - const age = Date.now() - cached.value.timestamp; - if (age < cacheTtl) { - // 缓存未过期,直接返回 - return cached.value.data; + const { cacheStrategy = 'ttl', cacheTtl = 5000, ...fetchOptions } = options; + const cacheKey = makeCacheKeyForRequest(options.url, options.body); + + // 同步检查是否有正在进行的相同请求 + const pending = pendingRequests.get(cacheKey); + if (pending) { + console.log(`[httpFetchCached] PENDING: ${options.url}`); + return Effect.promise(() => pending as Promise); + } + + // 创建请求 Promise + const requestPromise = (async () => { + try { + const cached = await Effect.runPromise(getFromCache(options.url, options.body)); + + if (cacheStrategy === 'cache-first') { + // Cache-First: 有缓存就返回 + if (Option.isSome(cached)) { + console.log(`[httpFetchCached] CACHE-FIRST HIT: ${options.url}`); + return cached.value.data; + } + console.log(`[httpFetchCached] CACHE-FIRST MISS: ${options.url}`); + const result = await Effect.runPromise(httpFetch(fetchOptions)); + await Effect.runPromise(putToCache(options.url, options.body, result)); + return result; } - } - // 2. 发起真实请求 - const result = yield* httpFetch(fetchOptions); + if (cacheStrategy === 'network-first') { + // Network-First: 尝试 fetch,失败用缓存 + try { + console.log(`[httpFetchCached] NETWORK-FIRST FETCH: ${options.url}`); + const result = await Effect.runPromise(httpFetch(fetchOptions)); + await Effect.runPromise(putToCache(options.url, options.body, result)); + return result; + } catch (error) { + if (Option.isSome(cached)) { + console.log(`[httpFetchCached] NETWORK-FIRST FALLBACK: ${options.url}`); + return cached.value.data; + } + throw error; + } + } - // 3. 更新缓存 - yield* putToCache(options.url, options.body, result); + // TTL 策略(默认) + if (Option.isSome(cached)) { + const age = Date.now() - cached.value.timestamp; + if (age < cacheTtl) { + console.log(`[httpFetchCached] TTL HIT: ${options.url} (age: ${age}ms, ttl: ${cacheTtl}ms)`); + return cached.value.data; + } + console.log(`[httpFetchCached] TTL EXPIRED: ${options.url} (age: ${age}ms, ttl: ${cacheTtl}ms)`); + } else { + console.log(`[httpFetchCached] TTL MISS: ${options.url}`); + } - return result; - }); + const result = await Effect.runPromise(httpFetch(fetchOptions)); + await Effect.runPromise(putToCache(options.url, options.body, result)); + return result; + } finally { + pendingRequests.delete(cacheKey); + } + })(); + + // 同步设置锁 + pendingRequests.set(cacheKey, requestPromise); + + return Effect.promise(() => requestPromise); } diff --git a/packages/chain-effect/src/index.ts b/packages/chain-effect/src/index.ts index b963f212f..b550d7a13 100644 --- a/packages/chain-effect/src/index.ts +++ b/packages/chain-effect/src/index.ts @@ -33,6 +33,7 @@ export { ServiceLimitedError, type FetchOptions, type CachedFetchOptions, + type CacheStrategy, type FetchError, } from "./http" diff --git a/src/services/chain-adapter/providers/biowallet-provider.effect.ts b/src/services/chain-adapter/providers/biowallet-provider.effect.ts index 175864d39..1efea79b1 100644 --- a/src/services/chain-adapter/providers/biowallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/biowallet-provider.effect.ts @@ -12,6 +12,7 @@ import { httpFetch, httpFetchCached, createStreamInstanceFromSource, + createPollingSource, createDependentSource, createEventBusService, acquireSource, @@ -333,6 +334,11 @@ export class BiowalletProviderEffect private forgeInterval: number = 15000 private epochMs: number = DEFAULT_EPOCH_MS + // 缓存 TTL = 出块时间 / 2 + private get cacheTtl(): number { + return this.forgeInterval / 2 + } + // Provider 级别共享的 EventBus(延迟初始化) private _eventBus: EventBusService | null = null @@ -405,8 +411,30 @@ export class BiowalletProviderEffect // ==================== Source 创建方法 ==================== + /** + * 获取共享的 blockHeight source(全局单例 + 引用计数) + * 作为依赖链的根节点 + */ + private getSharedBlockHeightSource(): Effect.Effect> { + const provider = this + const registryKey = makeRegistryKey(this.chainId, 'global', 'blockHeight') + + const fetchEffect = provider.fetchBlockHeight().pipe( + Effect.map((raw): BlockHeightOutput => { + if (!raw.result?.height) return BigInt(0) + return BigInt(raw.result.height) + }) + ) + + return acquireSource(registryKey, { + fetch: fetchEffect, + interval: Duration.millis(this.forgeInterval), + }) + } + /** * 获取共享的 transactionHistory source(全局单例 + 引用计数) + * 依赖 blockHeight 变化触发刷新 */ private getSharedTxHistorySource( address: string, @@ -415,48 +443,56 @@ export class BiowalletProviderEffect ): Effect.Effect> { const provider = this const normalizedAddress = address.toLowerCase() - const registryKey = makeRegistryKey(this.chainId, normalizedAddress, "txHistory") - - const fetchEffect = provider.fetchTransactionList({ address, limit: 50 }).pipe( - Effect.map((raw): TransactionsOutput => { - if (!raw.result?.trs) return [] - - return raw.result.trs - .map((item): Transaction | null => { - const tx = item.transaction - const action = detectAction(tx.type) - const direction = getDirection(tx.senderId, tx.recipientId ?? "", normalizedAddress) - const { value, assetType } = extractAssetInfo(tx.asset, symbol) - if (value === null) return null - - return { - hash: tx.signature ?? item.signature, - from: tx.senderId, - to: tx.recipientId ?? "", - timestamp: provider.epochMs + tx.timestamp * 1000, - status: "confirmed", - blockNumber: BigInt(item.height), - action, - direction, - assets: [ - { - assetType: "native" as const, - value, - symbol: assetType, - decimals, - }, - ], - } - }) - .filter((tx): tx is Transaction => tx !== null) - .sort((a, b) => b.timestamp - a.timestamp) + + return Effect.gen(function* () { + // 获取共享的 blockHeight source 作为依赖 + const blockHeightSource = yield* provider.getSharedBlockHeightSource() + + const fetchEffect = provider.fetchTransactionList({ address, limit: 50 }).pipe( + Effect.map((raw): TransactionsOutput => { + if (!raw.result?.trs) return [] + + return raw.result.trs + .map((item): Transaction | null => { + const tx = item.transaction + const action = detectAction(tx.type) + const direction = getDirection(tx.senderId, tx.recipientId ?? "", normalizedAddress) + const { value, assetType } = extractAssetInfo(tx.asset, symbol) + if (value === null) return null + + return { + hash: tx.signature ?? item.signature, + from: tx.senderId, + to: tx.recipientId ?? "", + timestamp: provider.epochMs + tx.timestamp * 1000, + status: "confirmed", + blockNumber: BigInt(item.height), + action, + direction, + assets: [ + { + assetType: "native" as const, + value, + symbol: assetType, + decimals, + }, + ], + } + }) + .filter((tx): tx is Transaction => tx !== null) + .sort((a, b) => b.timestamp - a.timestamp) + }) + ) + + // 依赖 blockHeight 变化触发刷新 + const source = yield* createDependentSource({ + name: `biowallet.${provider.chainId}.txHistory.${normalizedAddress}`, + dependsOn: blockHeightSource.ref, + hasChanged: (prev, curr) => prev !== curr, + fetch: () => fetchEffect, }) - ) - // 使用全局 acquireSource 获取共享的 source - return acquireSource(registryKey, { - fetch: fetchEffect, - interval: Duration.millis(this.forgeInterval), + return source }) } @@ -571,24 +607,8 @@ export class BiowalletProviderEffect } private createBlockHeightSource(): Effect.Effect> { - const provider = this - - return Effect.gen(function* () { - const fetchEffect = provider.fetchBlockHeight().pipe( - Effect.map((raw): BlockHeightOutput => { - if (!raw.result?.height) return BigInt(0) - return BigInt(raw.result.height) - }) - ) - - const source = yield* createPollingSource({ - name: `biowallet.${provider.chainId}.blockHeight`, - fetch: fetchEffect, - interval: Duration.millis(provider.forgeInterval), - }) - - return source - }) + // 使用共享的 blockHeight source + return this.getSharedBlockHeightSource() } private createTransactionSource( @@ -642,9 +662,11 @@ export class BiowalletProviderEffect // ==================== HTTP Fetch 方法 ==================== private fetchBlockHeight(): Effect.Effect { - return httpFetch({ + return httpFetchCached({ url: `${this.baseUrl}/block/lastblock`, schema: BlockResponseSchema, + cacheStrategy: 'ttl', + cacheTtl: this.cacheTtl, }) } @@ -654,7 +676,7 @@ export class BiowalletProviderEffect method: "POST", body: { address }, schema: AssetResponseSchema, - cacheTtl: this.forgeInterval, + cacheStrategy: 'cache-first', }) } @@ -669,7 +691,7 @@ export class BiowalletProviderEffect sort: -1, }, schema: TxListResponseSchema, - cacheTtl: this.forgeInterval, + cacheStrategy: 'cache-first', }) } From ee271c7b97211aabb04a613e2e2bc144dcb0fa6c Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 15:47:05 +0800 Subject: [PATCH 28/33] fix(chain-effect): add error logging to prevent silent failures - source-registry.ts: Log poll and immediate fetch errors - source.ts: Log dependent source initial fetch errors - http.ts: Log fetch errors in all cache strategies - biowallet: Fix fetch signature for createDependentSource --- packages/chain-effect/src/http.ts | 23 +- packages/chain-effect/src/source-registry.ts | 14 +- packages/chain-effect/src/source.ts | 5 +- .../providers/biowallet-provider.effect.ts | 513 +++++++++--------- 4 files changed, 280 insertions(+), 275 deletions(-) diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts index 6c1dadaa5..ddba5eb99 100644 --- a/packages/chain-effect/src/http.ts +++ b/packages/chain-effect/src/http.ts @@ -275,9 +275,14 @@ export function httpFetchCached(options: CachedFetchOptions): Effect.Effec return cached.value.data; } console.log(`[httpFetchCached] CACHE-FIRST MISS: ${options.url}`); - const result = await Effect.runPromise(httpFetch(fetchOptions)); - await Effect.runPromise(putToCache(options.url, options.body, result)); - return result; + try { + const result = await Effect.runPromise(httpFetch(fetchOptions)); + await Effect.runPromise(putToCache(options.url, options.body, result)); + return result; + } catch (error) { + console.error(`[httpFetchCached] CACHE-FIRST FETCH ERROR: ${options.url}`, error); + throw error; + } } if (cacheStrategy === 'network-first') { @@ -288,6 +293,7 @@ export function httpFetchCached(options: CachedFetchOptions): Effect.Effec await Effect.runPromise(putToCache(options.url, options.body, result)); return result; } catch (error) { + console.error(`[httpFetchCached] NETWORK-FIRST FETCH ERROR: ${options.url}`, error); if (Option.isSome(cached)) { console.log(`[httpFetchCached] NETWORK-FIRST FALLBACK: ${options.url}`); return cached.value.data; @@ -308,9 +314,14 @@ export function httpFetchCached(options: CachedFetchOptions): Effect.Effec console.log(`[httpFetchCached] TTL MISS: ${options.url}`); } - const result = await Effect.runPromise(httpFetch(fetchOptions)); - await Effect.runPromise(putToCache(options.url, options.body, result)); - return result; + try { + const result = await Effect.runPromise(httpFetch(fetchOptions)); + await Effect.runPromise(putToCache(options.url, options.body, result)); + return result; + } catch (error) { + console.error(`[httpFetchCached] TTL FETCH ERROR: ${options.url}`, error); + throw error; + } } finally { pendingRequests.delete(cacheKey); } diff --git a/packages/chain-effect/src/source-registry.ts b/packages/chain-effect/src/source-registry.ts index 4db3ad1ad..3f1b9f0a9 100644 --- a/packages/chain-effect/src/source-registry.ts +++ b/packages/chain-effect/src/source-registry.ts @@ -118,9 +118,10 @@ async function createSourceInternal( // 开始轮询循环 yield* Stream.repeatEffect( Effect.gen(function* () { - const result = yield* Effect.catchAll(options.fetch, () => - Effect.succeed(null as T | null) - ) + const result = yield* Effect.catchAll(options.fetch, (error) => { + console.error(`[SourceRegistry] Poll error for ${pollKey}:`, error) + return Effect.succeed(null as T | null) + }) if (result !== null) { yield* SubscriptionRef.set(ref, result) @@ -138,9 +139,10 @@ async function createSourceInternal( const pollFiber = yield* Effect.fork(pollEffect) // 执行立即获取 - const immediateResult = yield* Effect.catchAll(options.fetch, () => - Effect.succeed(null as T | null) - ) + const immediateResult = yield* Effect.catchAll(options.fetch, (error) => { + console.error(`[SourceRegistry] Immediate fetch error for ${key}:`, error) + return Effect.succeed(null as T | null) + }) if (immediateResult !== null) { yield* SubscriptionRef.set(ref, immediateResult) yield* updateNextPollTime(pollKey, intervalMs) diff --git a/packages/chain-effect/src/source.ts b/packages/chain-effect/src/source.ts index d14bcb8c0..233ac5b1d 100644 --- a/packages/chain-effect/src/source.ts +++ b/packages/chain-effect/src/source.ts @@ -235,7 +235,10 @@ export const createDependentSource = ( const currentDep = yield* SubscriptionRef.get(dependsOn) if (currentDep !== null && hasChanged(null, currentDep)) { prevDep = currentDep - const initialValue = yield* Effect.catchAll(fetch(currentDep), () => Effect.succeed(null as T | null)) + const initialValue = yield* Effect.catchAll(fetch(currentDep), (error) => { + console.error(`[DependentSource] Initial fetch error for ${name}:`, error) + return Effect.succeed(null as T | null) + }) if (initialValue !== null) { yield* SubscriptionRef.set(ref, initialValue) } diff --git a/src/services/chain-adapter/providers/biowallet-provider.effect.ts b/src/services/chain-adapter/providers/biowallet-provider.effect.ts index 1efea79b1..85cb39703 100644 --- a/src/services/chain-adapter/providers/biowallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/biowallet-provider.effect.ts @@ -1,13 +1,13 @@ /** * BioWallet API Provider (Effect TS - 深度重构) - * + * * 使用 Effect 原生 Source API 实现响应式数据获取 * - transactionHistory: 定时轮询 + 事件触发 * - balance/tokenBalances: 依赖 transactionHistory 变化 */ -import { Effect, Duration } from "effect" -import { Schema as S } from "effect" +import { Effect, Duration } from 'effect'; +import { Schema as S } from 'effect'; import { httpFetch, httpFetchCached, @@ -18,12 +18,11 @@ import { acquireSource, releaseSource, makeRegistryKey, - type FetchError, type DataSource, type EventBusService, -} from "@biochain/chain-effect" -import type { StreamInstance } from "@biochain/chain-effect" +} from '@biochain/chain-effect'; +import type { StreamInstance } from '@biochain/chain-effect'; import type { ApiProvider, TokenBalance, @@ -38,71 +37,71 @@ import type { AddressParams, TxHistoryParams, TransactionParams, -} from "./types" -import { setForgeInterval } from "../bioforest/fetch" -import type { ParsedApiEntry } from "@/services/chain-config" -import { chainConfigService } from "@/services/chain-config" -import { Amount } from "@/types/amount" -import { BioforestIdentityMixin } from "../bioforest/identity-mixin" -import { BioforestTransactionMixin } from "../bioforest/transaction-mixin" -import { BioforestAccountMixin } from "../bioforest/account-mixin" -import { fetchGenesisBlock } from "@/services/bioforest-sdk" +} from './types'; +import { setForgeInterval } from '../bioforest/fetch'; +import type { ParsedApiEntry } from '@/services/chain-config'; +import { chainConfigService } from '@/services/chain-config'; +import { Amount } from '@/types/amount'; +import { BioforestIdentityMixin } from '../bioforest/identity-mixin'; +import { BioforestTransactionMixin } from '../bioforest/transaction-mixin'; +import { BioforestAccountMixin } from '../bioforest/account-mixin'; +import { fetchGenesisBlock } from '@/services/bioforest-sdk'; // ==================== Effect Schema 定义 ==================== const BiowalletAssetItemSchema = S.Struct({ assetNumber: S.String, assetType: S.String, -}) +}); const AssetResultSchema = S.Struct({ address: S.String, assets: S.Record({ key: S.String, value: S.Record({ key: S.String, value: BiowalletAssetItemSchema }) }), -}) +}); const AssetResponseSchema = S.Struct({ success: S.Boolean, result: S.optional(S.NullOr(AssetResultSchema)), -}) -type AssetResponse = S.Schema.Type +}); +type AssetResponse = S.Schema.Type; const TransferAssetSchema = S.Struct({ assetType: S.String, amount: S.String, -}) +}); const GiftAssetSchema = S.Struct({ totalAmount: S.String, assetType: S.String, -}) +}); const GrabAssetSchema = S.Struct({ transactionSignature: S.String, -}) +}); const TrustAssetSchema = S.Struct({ trustees: S.Array(S.String), numberOfSignFor: S.Number, assetType: S.String, amount: S.String, -}) +}); const SignatureAssetSchema = S.Struct({ publicKey: S.optional(S.String), -}) +}); const DestroyAssetSchema = S.Struct({ assetType: S.String, amount: S.String, -}) +}); const IssueEntitySchema = S.Struct({ entityId: S.optional(S.String), -}) +}); const IssueEntityFactorySchema = S.Struct({ factoryId: S.optional(S.String), -}) +}); const TxAssetSchema = S.Struct({ transferAsset: S.optional(TransferAssetSchema), @@ -113,170 +112,166 @@ const TxAssetSchema = S.Struct({ destroyAsset: S.optional(DestroyAssetSchema), issueEntity: S.optional(IssueEntitySchema), issueEntityFactory: S.optional(IssueEntityFactorySchema), -}) +}); const BiowalletTxTransactionSchema = S.Struct({ type: S.String, senderId: S.String, - recipientId: S.optionalWith(S.String, { default: () => "" }), + recipientId: S.optionalWith(S.String, { default: () => '' }), timestamp: S.Number, signature: S.String, asset: S.optional(TxAssetSchema), -}) -type BiowalletTxTransaction = S.Schema.Type +}); +type BiowalletTxTransaction = S.Schema.Type; const BiowalletTxItemSchema = S.Struct({ height: S.Number, signature: S.String, transaction: BiowalletTxTransactionSchema, -}) +}); const TxListResultSchema = S.Struct({ trs: S.Array(BiowalletTxItemSchema), count: S.optional(S.Number), -}) +}); const TxListResponseSchema = S.Struct({ success: S.Boolean, result: S.optional(TxListResultSchema), -}) -type TxListResponse = S.Schema.Type +}); +type TxListResponse = S.Schema.Type; const BlockResultSchema = S.Struct({ height: S.Number, -}) +}); const BlockResponseSchema = S.Struct({ success: S.Boolean, result: S.optional(BlockResultSchema), -}) -type BlockResponse = S.Schema.Type +}); +type BlockResponse = S.Schema.Type; const PendingTrItemSchema = S.Struct({ state: S.Number, trJson: BiowalletTxTransactionSchema, signature: S.optional(S.String), createdTime: S.String, -}) +}); const PendingTrResponseSchema = S.Struct({ success: S.Boolean, result: S.optional(S.Array(PendingTrItemSchema)), -}) -type PendingTrResponse = S.Schema.Type +}); +type PendingTrResponse = S.Schema.Type; // ==================== 工具函数 ==================== function getDirection(from: string, to: string, address: string): Direction { - const fromLower = from.toLowerCase() - const toLower = to.toLowerCase() - const addrLower = address.toLowerCase() - - if (!toLower) return fromLower === addrLower ? "out" : "in" - if (fromLower === addrLower && toLower === addrLower) return "self" - if (fromLower === addrLower) return "out" - return "in" + const fromLower = from.toLowerCase(); + const toLower = to.toLowerCase(); + const addrLower = address.toLowerCase(); + + if (!toLower) return fromLower === addrLower ? 'out' : 'in'; + if (fromLower === addrLower && toLower === addrLower) return 'self'; + if (fromLower === addrLower) return 'out'; + return 'in'; } -const DEFAULT_EPOCH_MS = 0 +const DEFAULT_EPOCH_MS = 0; function detectAction(txType: string): Action { const typeMap: Record = { - "AST-01": "transfer", - "AST-02": "transfer", - "AST-03": "destroyAsset", - "BSE-01": "signature", - "ETY-01": "issueEntity", - "ETY-02": "issueEntity", - "GFT-01": "gift", - "GFT-02": "gift", - "GRB-01": "grab", - "GRB-02": "grab", - "TRS-01": "trust", - "TRS-02": "trust", - "SGN-01": "signFor", - "SGN-02": "signFor", - "EMI-01": "emigrate", - "EMI-02": "emigrate", - "IMI-01": "immigrate", - "IMI-02": "immigrate", - "ISA-01": "issueAsset", - "ICA-01": "increaseAsset", - "DSA-01": "destroyAsset", - "ISE-01": "issueEntity", - "DSE-01": "destroyEntity", - "LNS-01": "locationName", - "DAP-01": "dapp", - "CRT-01": "certificate", - "MRK-01": "mark", - } - - const parts = txType.split("-") + 'AST-01': 'transfer', + 'AST-02': 'transfer', + 'AST-03': 'destroyAsset', + 'BSE-01': 'signature', + 'ETY-01': 'issueEntity', + 'ETY-02': 'issueEntity', + 'GFT-01': 'gift', + 'GFT-02': 'gift', + 'GRB-01': 'grab', + 'GRB-02': 'grab', + 'TRS-01': 'trust', + 'TRS-02': 'trust', + 'SGN-01': 'signFor', + 'SGN-02': 'signFor', + 'EMI-01': 'emigrate', + 'EMI-02': 'emigrate', + 'IMI-01': 'immigrate', + 'IMI-02': 'immigrate', + 'ISA-01': 'issueAsset', + 'ICA-01': 'increaseAsset', + 'DSA-01': 'destroyAsset', + 'ISE-01': 'issueEntity', + 'DSE-01': 'destroyEntity', + 'LNS-01': 'locationName', + 'DAP-01': 'dapp', + 'CRT-01': 'certificate', + 'MRK-01': 'mark', + }; + + const parts = txType.split('-'); if (parts.length >= 4) { - const suffix = `${parts[parts.length - 2]}-${parts[parts.length - 1]}` - return typeMap[suffix] ?? "unknown" + const suffix = `${parts[parts.length - 2]}-${parts[parts.length - 1]}`; + return typeMap[suffix] ?? 'unknown'; } - return "unknown" + return 'unknown'; } function extractAssetInfo( - asset: BiowalletTxTransaction["asset"], - defaultSymbol: string + asset: BiowalletTxTransaction['asset'], + defaultSymbol: string, ): { value: string | null; assetType: string } { - if (!asset) return { value: null, assetType: defaultSymbol } + if (!asset) return { value: null, assetType: defaultSymbol }; if (asset.transferAsset) { - return { value: asset.transferAsset.amount, assetType: asset.transferAsset.assetType } + return { value: asset.transferAsset.amount, assetType: asset.transferAsset.assetType }; } if (asset.giftAsset) { - return { value: asset.giftAsset.totalAmount, assetType: asset.giftAsset.assetType } + return { value: asset.giftAsset.totalAmount, assetType: asset.giftAsset.assetType }; } if (asset.trustAsset) { - return { value: asset.trustAsset.amount, assetType: asset.trustAsset.assetType } + return { value: asset.trustAsset.amount, assetType: asset.trustAsset.assetType }; } if (asset.grabAsset) { - return { value: "0", assetType: defaultSymbol } + return { value: '0', assetType: defaultSymbol }; } if (asset.destroyAsset) { - return { value: asset.destroyAsset.amount, assetType: asset.destroyAsset.assetType } + return { value: asset.destroyAsset.amount, assetType: asset.destroyAsset.assetType }; } if (asset.issueEntity || asset.issueEntityFactory) { - return { value: "0", assetType: defaultSymbol } + return { value: '0', assetType: defaultSymbol }; } if (asset.signature) { - return { value: "0", assetType: defaultSymbol } + return { value: '0', assetType: defaultSymbol }; } - return { value: null, assetType: defaultSymbol } + return { value: null, assetType: defaultSymbol }; } function convertBioTransactionToTransaction( bioTx: BiowalletTxTransaction, options: { - signature: string - height?: number - status: "pending" | "confirmed" | "failed" - createdTime?: string - address?: string - epochMs: number - } + signature: string; + height?: number; + status: 'pending' | 'confirmed' | 'failed'; + createdTime?: string; + address?: string; + epochMs: number; + }, ): Transaction { - const { signature, height, status, createdTime, address = "", epochMs } = options - const { value, assetType } = extractAssetInfo(bioTx.asset, "BFM") + const { signature, height, status, createdTime, address = '', epochMs } = options; + const { value, assetType } = extractAssetInfo(bioTx.asset, 'BFM'); - const timestamp = createdTime - ? new Date(createdTime).getTime() - : epochMs + bioTx.timestamp * 1000 + const timestamp = createdTime ? new Date(createdTime).getTime() : epochMs + bioTx.timestamp * 1000; - const direction = address - ? getDirection(bioTx.senderId, bioTx.recipientId ?? "", address) - : "out" + const direction = address ? getDirection(bioTx.senderId, bioTx.recipientId ?? '', address) : 'out'; return { hash: signature, from: bioTx.senderId, - to: bioTx.recipientId ?? "", + to: bioTx.recipientId ?? '', timestamp, status, blockNumber: height !== undefined ? BigInt(height) : undefined, @@ -284,41 +279,38 @@ function convertBioTransactionToTransaction( direction, assets: [ { - assetType: "native" as const, - value: value ?? "0", + assetType: 'native' as const, + value: value ?? '0', symbol: assetType, decimals: 8, }, ], - } + }; } // ==================== 判断交易列表是否变化 ==================== -function hasTransactionListChanged( - prev: TransactionsOutput | null, - next: TransactionsOutput -): boolean { - if (!prev) return true - if (prev.length !== next.length) return true - if (prev.length === 0 && next.length === 0) return false +function hasTransactionListChanged(prev: TransactionsOutput | null, next: TransactionsOutput): boolean { + if (!prev) return true; + if (prev.length !== next.length) return true; + if (prev.length === 0 && next.length === 0) return false; // 比较第一条交易的 hash - return prev[0]?.hash !== next[0]?.hash + return prev[0]?.hash !== next[0]?.hash; } // ==================== Base Class for Mixins ==================== class BiowalletBase { - readonly chainId: string - readonly type: string - readonly endpoint: string - readonly config?: Record + readonly chainId: string; + readonly type: string; + readonly endpoint: string; + readonly config?: Record; constructor(entry: ParsedApiEntry, chainId: string) { - this.type = entry.type - this.endpoint = entry.endpoint - this.config = entry.config - this.chainId = chainId + this.type = entry.type; + this.endpoint = entry.endpoint; + this.config = entry.config; + this.chainId = chainId; } } @@ -328,85 +320,84 @@ export class BiowalletProviderEffect extends BioforestAccountMixin(BioforestIdentityMixin(BioforestTransactionMixin(BiowalletBase))) implements ApiProvider { - private readonly symbol: string - private readonly decimals: number - private readonly baseUrl: string - private forgeInterval: number = 15000 - private epochMs: number = DEFAULT_EPOCH_MS + private readonly symbol: string; + private readonly decimals: number; + private readonly baseUrl: string; + private forgeInterval: number = 15000; + private epochMs: number = DEFAULT_EPOCH_MS; // 缓存 TTL = 出块时间 / 2 private get cacheTtl(): number { - return this.forgeInterval / 2 + return this.forgeInterval / 2; } // Provider 级别共享的 EventBus(延迟初始化) - private _eventBus: EventBusService | null = null + private _eventBus: EventBusService | null = null; // StreamInstance 接口(React 兼容层) - readonly nativeBalance: StreamInstance - readonly tokenBalances: StreamInstance - readonly transactionHistory: StreamInstance - readonly blockHeight: StreamInstance - readonly transaction: StreamInstance + readonly nativeBalance: StreamInstance; + readonly tokenBalances: StreamInstance; + readonly transactionHistory: StreamInstance; + readonly blockHeight: StreamInstance; + readonly transaction: StreamInstance; constructor(entry: ParsedApiEntry, chainId: string) { - super(entry, chainId) - this.symbol = chainConfigService.getSymbol(chainId) - this.decimals = chainConfigService.getDecimals(chainId) - this.baseUrl = this.endpoint + super(entry, chainId); + this.symbol = chainConfigService.getSymbol(chainId); + this.decimals = chainConfigService.getDecimals(chainId); + this.baseUrl = this.endpoint; - const genesisPath = chainConfigService.getBiowalletGenesisBlock(chainId) + const genesisPath = chainConfigService.getBiowalletGenesisBlock(chainId); if (genesisPath) { fetchGenesisBlock(chainId, genesisPath) .then((genesis) => { - const interval = genesis.asset.genesisAsset.forgeInterval - if (typeof interval === "number") { - this.forgeInterval = interval * 1000 - setForgeInterval(chainId, this.forgeInterval) + const interval = genesis.asset.genesisAsset.forgeInterval; + if (typeof interval === 'number') { + this.forgeInterval = interval * 1000; + setForgeInterval(chainId, this.forgeInterval); } - const beginEpochTime = genesis.asset.genesisAsset.beginEpochTime - if (typeof beginEpochTime === "number") { - this.epochMs = beginEpochTime + const beginEpochTime = genesis.asset.genesisAsset.beginEpochTime; + if (typeof beginEpochTime === 'number') { + this.epochMs = beginEpochTime; } }) .catch((err) => { - console.warn("Failed to fetch genesis block:", err) - }) + console.warn('Failed to fetch genesis block:', err); + }); } - const symbol = this.symbol - const decimals = this.decimals - const provider = this + const symbol = this.symbol; + const decimals = this.decimals; + const provider = this; // ==================== transactionHistory: 定时轮询 + 事件触发 ==================== this.transactionHistory = createStreamInstanceFromSource( `biowallet.${chainId}.transactionHistory`, - (params) => provider.createTransactionHistorySource(params, symbol, decimals) - ) + (params) => provider.createTransactionHistorySource(params, symbol, decimals), + ); // ==================== nativeBalance: 依赖 transactionHistory 变化 ==================== this.nativeBalance = createStreamInstanceFromSource( `biowallet.${chainId}.nativeBalance`, - (params) => provider.createBalanceSource(params, symbol, decimals) - ) + (params) => provider.createBalanceSource(params, symbol, decimals), + ); // ==================== tokenBalances: 依赖 transactionHistory 变化 ==================== this.tokenBalances = createStreamInstanceFromSource( `biowallet.${chainId}.tokenBalances`, - (params) => provider.createTokenBalancesSource(params, symbol, decimals) - ) + (params) => provider.createTokenBalancesSource(params, symbol, decimals), + ); // ==================== blockHeight: 简单轮询 ==================== - this.blockHeight = createStreamInstanceFromSource( - `biowallet.${chainId}.blockHeight`, - () => provider.createBlockHeightSource() - ) + this.blockHeight = createStreamInstanceFromSource(`biowallet.${chainId}.blockHeight`, () => + provider.createBlockHeightSource(), + ); // ==================== transaction: 简单查询 ==================== this.transaction = createStreamInstanceFromSource( `biowallet.${chainId}.transaction`, - (params) => provider.createTransactionSource(params) - ) + (params) => provider.createTransactionSource(params), + ); } // ==================== Source 创建方法 ==================== @@ -416,20 +407,20 @@ export class BiowalletProviderEffect * 作为依赖链的根节点 */ private getSharedBlockHeightSource(): Effect.Effect> { - const provider = this - const registryKey = makeRegistryKey(this.chainId, 'global', 'blockHeight') + const provider = this; + const registryKey = makeRegistryKey(this.chainId, 'global', 'blockHeight'); const fetchEffect = provider.fetchBlockHeight().pipe( Effect.map((raw): BlockHeightOutput => { - if (!raw.result?.height) return BigInt(0) - return BigInt(raw.result.height) - }) - ) + if (!raw.result?.height) return BigInt(0); + return BigInt(raw.result.height); + }), + ); return acquireSource(registryKey, { fetch: fetchEffect, interval: Duration.millis(this.forgeInterval), - }) + }); } /** @@ -439,91 +430,91 @@ export class BiowalletProviderEffect private getSharedTxHistorySource( address: string, symbol: string, - decimals: number + decimals: number, ): Effect.Effect> { - const provider = this - const normalizedAddress = address.toLowerCase() + const provider = this; + const normalizedAddress = address.toLowerCase(); return Effect.gen(function* () { // 获取共享的 blockHeight source 作为依赖 - const blockHeightSource = yield* provider.getSharedBlockHeightSource() + const blockHeightSource = yield* provider.getSharedBlockHeightSource(); const fetchEffect = provider.fetchTransactionList({ address, limit: 50 }).pipe( Effect.map((raw): TransactionsOutput => { - if (!raw.result?.trs) return [] + if (!raw.result?.trs) return []; return raw.result.trs .map((item): Transaction | null => { - const tx = item.transaction - const action = detectAction(tx.type) - const direction = getDirection(tx.senderId, tx.recipientId ?? "", normalizedAddress) - const { value, assetType } = extractAssetInfo(tx.asset, symbol) - if (value === null) return null + const tx = item.transaction; + const action = detectAction(tx.type); + const direction = getDirection(tx.senderId, tx.recipientId ?? '', normalizedAddress); + const { value, assetType } = extractAssetInfo(tx.asset, symbol); + if (value === null) return null; return { hash: tx.signature ?? item.signature, from: tx.senderId, - to: tx.recipientId ?? "", + to: tx.recipientId ?? '', timestamp: provider.epochMs + tx.timestamp * 1000, - status: "confirmed", + status: 'confirmed', blockNumber: BigInt(item.height), action, direction, assets: [ { - assetType: "native" as const, + assetType: 'native' as const, value, symbol: assetType, decimals, }, ], - } + }; }) .filter((tx): tx is Transaction => tx !== null) - .sort((a, b) => b.timestamp - a.timestamp) - }) - ) + .sort((a, b) => b.timestamp - a.timestamp); + }), + ); // 依赖 blockHeight 变化触发刷新 const source = yield* createDependentSource({ name: `biowallet.${provider.chainId}.txHistory.${normalizedAddress}`, dependsOn: blockHeightSource.ref, hasChanged: (prev, curr) => prev !== curr, - fetch: () => fetchEffect, - }) + fetch: (_: BlockHeightOutput) => fetchEffect, + }); - return source - }) + return source; + }); } private createTransactionHistorySource( params: TxHistoryParams, symbol: string, - decimals: number + decimals: number, ): Effect.Effect> { // 直接使用共享的 txHistory source - return this.getSharedTxHistorySource(params.address, symbol, decimals) + return this.getSharedTxHistorySource(params.address, symbol, decimals); } private createBalanceSource( params: AddressParams, symbol: string, - decimals: number + decimals: number, ): Effect.Effect> { - const provider = this + const provider = this; return Effect.gen(function* () { // 先创建 transactionHistory source 作为依赖 const txHistorySource = yield* provider.createTransactionHistorySource( { address: params.address, limit: 1 }, symbol, - decimals - ) + decimals, + ); const fetchEffect = provider.fetchAddressAsset(params.address).pipe( Effect.map((raw): BalanceOutput => { if (!raw.result?.assets) { - return { amount: Amount.zero(decimals, symbol), symbol } + return { amount: Amount.zero(decimals, symbol), symbol }; } for (const magic of Object.values(raw.result.assets)) { for (const asset of Object.values(magic)) { @@ -531,13 +522,13 @@ export class BiowalletProviderEffect return { amount: Amount.fromRaw(asset.assetNumber, decimals, symbol), symbol, - } + }; } } } - return { amount: Amount.zero(decimals, symbol), symbol } - }) - ) + return { amount: Amount.zero(decimals, symbol), symbol }; + }), + ); // 依赖 transactionHistory 变化 const source = yield* createDependentSource({ @@ -545,54 +536,54 @@ export class BiowalletProviderEffect dependsOn: txHistorySource.ref, hasChanged: hasTransactionListChanged, fetch: () => fetchEffect, - }) + }); - return source - }) + return source; + }); } private createTokenBalancesSource( params: AddressParams, symbol: string, - decimals: number + decimals: number, ): Effect.Effect> { - const provider = this + const provider = this; return Effect.gen(function* () { // 先创建 transactionHistory source 作为依赖 const txHistorySource = yield* provider.createTransactionHistorySource( { address: params.address, limit: 1 }, symbol, - decimals - ) + decimals, + ); const fetchEffect = provider.fetchAddressAsset(params.address).pipe( Effect.map((raw): TokenBalancesOutput => { - if (!raw.result?.assets) return [] - const tokens: TokenBalance[] = [] + if (!raw.result?.assets) return []; + const tokens: TokenBalance[] = []; for (const magic of Object.values(raw.result.assets)) { for (const asset of Object.values(magic)) { - const isNative = asset.assetType === symbol + const isNative = asset.assetType === symbol; tokens.push({ symbol: asset.assetType, name: asset.assetType, amount: Amount.fromRaw(asset.assetNumber, decimals, asset.assetType), isNative, decimals, - }) + }); } } tokens.sort((a, b) => { - if (a.isNative && !b.isNative) return -1 - if (!a.isNative && b.isNative) return 1 - return b.amount.toNumber() - a.amount.toNumber() - }) + if (a.isNative && !b.isNative) return -1; + if (!a.isNative && b.isNative) return 1; + return b.amount.toNumber() - a.amount.toNumber(); + }); - return tokens - }) - ) + return tokens; + }), + ); // 依赖 transactionHistory 变化 const source = yield* createDependentSource({ @@ -600,90 +591,88 @@ export class BiowalletProviderEffect dependsOn: txHistorySource.ref, hasChanged: hasTransactionListChanged, fetch: () => fetchEffect, - }) + }); - return source - }) + return source; + }); } private createBlockHeightSource(): Effect.Effect> { // 使用共享的 blockHeight source - return this.getSharedBlockHeightSource() + return this.getSharedBlockHeightSource(); } - private createTransactionSource( - params: TransactionParams - ): Effect.Effect> { - const provider = this + private createTransactionSource(params: TransactionParams): Effect.Effect> { + const provider = this; return Effect.gen(function* () { const fetchEffect = Effect.all({ - pending: provider.fetchPendingTransactions(params.senderId ?? ""), + pending: provider.fetchPendingTransactions(params.senderId ?? ''), confirmed: provider.fetchSingleTransaction(params.txHash), }).pipe( Effect.map(({ pending, confirmed }): TransactionOutput => { if (pending.result && pending.result.length > 0) { - const pendingTx = pending.result.find((tx) => tx.signature === params.txHash) + const pendingTx = pending.result.find((tx) => tx.signature === params.txHash); if (pendingTx) { return convertBioTransactionToTransaction(pendingTx.trJson, { - signature: pendingTx.trJson.signature ?? pendingTx.signature ?? "", - status: "pending", + signature: pendingTx.trJson.signature ?? pendingTx.signature ?? '', + status: 'pending', createdTime: pendingTx.createdTime, epochMs: provider.epochMs, - }) + }); } } if (confirmed.result?.trs?.length) { - const item = confirmed.result.trs[0] + const item = confirmed.result.trs[0]; return convertBioTransactionToTransaction(item.transaction, { signature: item.transaction.signature ?? item.signature, height: item.height, - status: "confirmed", + status: 'confirmed', epochMs: provider.epochMs, - }) + }); } - return null - }) - ) + return null; + }), + ); // 交易查询使用轮询(等待确认) const source = yield* createPollingSource({ name: `biowallet.${provider.chainId}.transaction`, fetch: fetchEffect, interval: Duration.millis(provider.forgeInterval), - }) + }); - return source - }) + return source; + }); } // ==================== HTTP Fetch 方法 ==================== private fetchBlockHeight(): Effect.Effect { return httpFetchCached({ - url: `${this.baseUrl}/block/lastblock`, + url: `${this.baseUrl}/lastblock`, schema: BlockResponseSchema, cacheStrategy: 'ttl', cacheTtl: this.cacheTtl, - }) + }); } private fetchAddressAsset(address: string): Effect.Effect { return httpFetchCached({ url: `${this.baseUrl}/address/asset`, - method: "POST", + method: 'POST', body: { address }, schema: AssetResponseSchema, cacheStrategy: 'cache-first', - }) + }); } private fetchTransactionList(params: TxHistoryParams): Effect.Effect { return httpFetchCached({ url: `${this.baseUrl}/transactions/query`, - method: "POST", + method: 'POST', body: { address: params.address, page: params.page ?? 1, @@ -692,31 +681,31 @@ export class BiowalletProviderEffect }, schema: TxListResponseSchema, cacheStrategy: 'cache-first', - }) + }); } private fetchSingleTransaction(txHash: string): Effect.Effect { return httpFetch({ url: `${this.baseUrl}/transactions/query`, - method: "POST", + method: 'POST', body: { signature: txHash }, schema: TxListResponseSchema, - }) + }); } private fetchPendingTransactions(senderId: string): Effect.Effect { return httpFetch({ url: `${this.baseUrl}/pendingTr`, - method: "POST", + method: 'POST', body: { senderId, sort: -1 }, schema: PendingTrResponseSchema, - }) + }); } } export function createBiowalletProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === "biowallet-v1") { - return new BiowalletProviderEffect(entry, chainId) + if (entry.type === 'biowallet-v1') { + return new BiowalletProviderEffect(entry, chainId); } - return null + return null; } From 4927063445c1b263d78ec7cf0a152a7c58fab20a Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 19:16:09 +0800 Subject: [PATCH 29/33] fix(chain-effect): trigger txHistory from pending confirmations --- packages/chain-effect/src/http.ts | 36 +-- packages/chain-effect/src/poll-meta.ts | 8 +- packages/chain-effect/src/source-registry.ts | 31 ++- packages/chain-effect/src/source.ts | 96 +++++--- .../providers/biowallet-provider.effect.ts | 209 ++++++++++-------- .../chain-adapter/wallet-event-bus.ts | 22 ++ src/services/transaction/pending-tx.ts | 22 +- 7 files changed, 263 insertions(+), 161 deletions(-) create mode 100644 src/services/chain-adapter/wallet-event-bus.ts diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts index ddba5eb99..4c4680866 100644 --- a/packages/chain-effect/src/http.ts +++ b/packages/chain-effect/src/http.ts @@ -80,6 +80,8 @@ export interface FetchOptions { schema?: Schema.Schema; /** 超时时间(毫秒)*/ timeout?: number; + /** 缓存策略(浏览器层面的 Request cache)*/ + cache?: RequestCache; } /** @@ -121,7 +123,7 @@ function appendSearchParams(url: string, params?: Record(options: FetchOptions): Effect.Effect { - const { url, method = 'GET', pathParams, searchParams, headers = {}, body, schema, timeout = 30000 } = options; + const { url, method = 'GET', pathParams, searchParams, headers = {}, body, schema, timeout = 30000, cache } = options; // 构建最终 URL let finalUrl = replacePathParams(url, pathParams); @@ -140,6 +142,7 @@ export function httpFetch(options: FetchOptions): Effect.Effect(options: CachedFetchOptions): Effect.Effec const { cacheStrategy = 'ttl', cacheTtl = 5000, ...fetchOptions } = options; const cacheKey = makeCacheKeyForRequest(options.url, options.body); - // 同步检查是否有正在进行的相同请求 - const pending = pendingRequests.get(cacheKey); - if (pending) { - console.log(`[httpFetchCached] PENDING: ${options.url}`); - return Effect.promise(() => pending as Promise); - } + // 重要:请求必须在 Effect 执行时惰性创建,避免首次构建就触发 fetch, + // 否则会导致轮询重复复用同一个 Promise,从而看起来“只有第一次发请求”。 + return Effect.promise(async () => { + const pending = pendingRequests.get(cacheKey); + if (pending) { + console.log(`[httpFetchCached] PENDING: ${options.url}`); + return pending as Promise; + } - // 创建请求 Promise - const requestPromise = (async () => { - try { + const requestPromise = (async () => { const cached = await Effect.runPromise(getFromCache(options.url, options.body)); if (cacheStrategy === 'cache-first') { @@ -322,13 +325,14 @@ export function httpFetchCached(options: CachedFetchOptions): Effect.Effec console.error(`[httpFetchCached] TTL FETCH ERROR: ${options.url}`, error); throw error; } + })(); + + pendingRequests.set(cacheKey, requestPromise); + + try { + return await requestPromise; } finally { pendingRequests.delete(cacheKey); } - })(); - - // 同步设置锁 - pendingRequests.set(cacheKey, requestPromise); - - return Effect.promise(() => requestPromise); + }); } diff --git a/packages/chain-effect/src/poll-meta.ts b/packages/chain-effect/src/poll-meta.ts index 4e58f3fc4..6d33f9da8 100644 --- a/packages/chain-effect/src/poll-meta.ts +++ b/packages/chain-effect/src/poll-meta.ts @@ -136,13 +136,19 @@ export const updateNextPollTime = ( * - 如果有持久化的 nextPollTime 且未过期,返回剩余延迟 * - 否则返回 0(立即执行) */ -export const getDelayUntilNextPoll = (key: string): Effect.Effect => +export const getDelayUntilNextPoll = ( + key: string, + currentInterval?: number +): Effect.Effect => Effect.gen(function* () { const meta = yield* getPollMeta(key) if (!meta) return 0 const now = Date.now() const delay = meta.nextPollTime - now + // 若持久化的 nextPollTime 异常偏大(例如时钟漂移),直接触发轮询 + const interval = currentInterval ?? meta.interval + if (delay > interval) return 0 return Math.max(0, delay) }) diff --git a/packages/chain-effect/src/source-registry.ts b/packages/chain-effect/src/source-registry.ts index 3f1b9f0a9..a06911262 100644 --- a/packages/chain-effect/src/source-registry.ts +++ b/packages/chain-effect/src/source-registry.ts @@ -7,7 +7,7 @@ * - 支持恢复轮询计划(从 IndexedDB 读取 nextPollTime) */ -import { Effect, Fiber, Duration, SubscriptionRef, Stream, Schedule } from "effect" +import { Effect, Fiber, FiberStatus, Duration, SubscriptionRef, Stream, Schedule } from "effect" import type { FetchError } from "./http" import type { DataSource } from "./source" import { updateNextPollTime, getDelayUntilNextPoll } from "./poll-meta" @@ -59,27 +59,32 @@ export function acquireSource( // 1. 检查是否已存在 const existing = registry.get(key) as SourceEntry | undefined if (existing) { - existing.refCount++ - console.log(`[SourceRegistry] acquireSource existing: ${key}, refCount: ${existing.refCount}`) - return existing.source + if (existing.pollFiber) { + const status = await Effect.runPromise(Fiber.status(existing.pollFiber)) + if (!FiberStatus.isDone(status)) { + existing.refCount++ + return existing.source + } + // 轮询 fiber 已结束,清理后重建 + registry.delete(key) + } else { + existing.refCount++ + return existing.source + } } // 2. 检查是否有正在创建的 Promise(防止并发) const pending = pendingCreations.get(key) if (pending) { - console.log(`[SourceRegistry] acquireSource waiting for pending: ${key}`) const source = await pending as DataSource // 增加引用计数 const entry = registry.get(key) as SourceEntry if (entry) { entry.refCount++ - console.log(`[SourceRegistry] acquireSource after wait: ${key}, refCount: ${entry.refCount}`) } return source } - console.log(`[SourceRegistry] acquireSource NEW: ${key}`) - // 3. 创建 Promise 锁 const createPromise = createSourceInternal(key, options) pendingCreations.set(key, createPromise as Promise>) @@ -110,7 +115,8 @@ async function createSourceInternal( const pollEffect = Effect.gen(function* () { // 计算初始延迟(基于持久化的 nextPollTime) - const delay = yield* getDelayUntilNextPoll(pollKey) + const delay = yield* getDelayUntilNextPoll(pollKey, intervalMs) + console.log(`[SourceRegistry] Poll fiber started for ${pollKey}, delay: ${delay}ms, interval: ${intervalMs}ms`) if (delay > 0) { yield* Effect.sleep(Duration.millis(delay)) } @@ -118,12 +124,14 @@ async function createSourceInternal( // 开始轮询循环 yield* Stream.repeatEffect( Effect.gen(function* () { + console.log(`[SourceRegistry] Polling ${pollKey}...`) const result = yield* Effect.catchAll(options.fetch, (error) => { console.error(`[SourceRegistry] Poll error for ${pollKey}:`, error) return Effect.succeed(null as T | null) }) if (result !== null) { + console.log(`[SourceRegistry] Poll success for ${pollKey}, updating ref`) yield* SubscriptionRef.set(ref, result) yield* updateNextPollTime(pollKey, intervalMs) } @@ -136,7 +144,7 @@ async function createSourceInternal( ) }) - const pollFiber = yield* Effect.fork(pollEffect) + const pollFiber = yield* Effect.forkDaemon(pollEffect) // 执行立即获取 const immediateResult = yield* Effect.catchAll(options.fetch, (error) => { @@ -165,7 +173,8 @@ async function createSourceInternal( yield* updateNextPollTime(pollKey, intervalMs) return value }), - stop: Fiber.interrupt(pollFiber).pipe(Effect.asVoid), + // stop 统一走 releaseSource,确保引用计数正确,避免误停共享轮询 + stop: releaseSource(key), } // 注册到全局表 diff --git a/packages/chain-effect/src/source.ts b/packages/chain-effect/src/source.ts index 233ac5b1d..41e94d62b 100644 --- a/packages/chain-effect/src/source.ts +++ b/packages/chain-effect/src/source.ts @@ -139,7 +139,7 @@ export const createPollingSource = ( // 驱动 ref 更新 const fiber = yield* driver.pipe( Stream.runForEach((value) => SubscriptionRef.set(ref, value)), - Effect.fork + Effect.forkDaemon ) // 立即执行第一次并等待完成(同步) @@ -179,8 +179,12 @@ export interface DependentSourceOptions { dependsOn: SubscriptionRef.SubscriptionRef /** 判断依赖是否真的变化了 */ hasChanged: (prev: TDep | null, next: TDep) => boolean - /** 根据依赖值获取数据 */ - fetch: (dep: TDep) => Effect.Effect + /** + * 根据依赖值获取数据 + * @param dep 依赖的当前值 + * @param forceRefresh 是否强制刷新(true=依赖变化触发应使用network-first,false=首次加载可使用cache-first) + */ + fetch: (dep: TDep, forceRefresh?: boolean) => Effect.Effect } /** @@ -189,6 +193,22 @@ export interface DependentSourceOptions { * - 监听依赖变化,只有 hasChanged 返回 true 时才触发请求 * - 依赖不变则永远命中缓存 * + * ## 重要:竞态条件避免 + * + * 官方文档说明:SubscriptionRef.changes 会在订阅时发射当前值。 + * 但 Effect.fork 会导致 fiber 调度延迟,造成以下竞态: + * + * 1. Effect.fork(driver) 创建 fiber,但 fiber 还没开始执行 + * 2. Initial fetch 先执行,设置 prevDep = currentValue + * 3. Fiber 开始后,changes 发射 currentValue + * 4. hasChanged(currentValue, currentValue) → false(错误地跳过更新) + * + * 解决方案:使用 Stream.scanEffect 将状态追踪内化到 Stream 中, + * 不依赖外部 mutable 变量(let prevDep),避免竞态条件。 + * + * @see https://effect.website/docs/state-management/subscriptionref + * @see https://github.com/pauljphilp/effectpatterns - Race Condition with Plain Variables + * * @example * ```ts * const balance = yield* createDependentSource({ @@ -196,7 +216,7 @@ export interface DependentSourceOptions { * dependsOn: txHistory.ref, * hasChanged: (prev, next) => * prev?.length !== next.length || prev?.[0]?.hash !== next[0]?.hash, - * fetch: (txList) => fetchBalance(txList[0]?.from), + * fetch: (txList, _forceRefresh) => fetchBalance(txList[0]?.from), * }) * ``` */ @@ -207,35 +227,54 @@ export const createDependentSource = ( const { name, dependsOn, hasChanged, fetch } = options const ref = yield* SubscriptionRef.make(null) - let prevDep: TDep | null = null - // 监听依赖变化 + const currentDep = yield* SubscriptionRef.get(dependsOn) + + // 使用 Stream.scanEffect 内化状态追踪 + // 关键:不使用外部 mutable 变量(let prevDep),避免与 Effect.fork 的竞态 + // + // ## 重要:Effect.fork 调度延迟问题 + // 官方文档说明:Effect.fork 后 fiber 不会立即执行,需要等待调度。 + // 但 SubscriptionRef.changes 只有在 stream 真正运行时才发射当前值。 + // 所以我们需要: + // 1. 保留 initial fetch 确保首次数据加载 + // 2. 用 scanEffect 内化状态追踪,让 stream 独立管理 prev 状态 + // 3. initial fetch 不影响 stream 的状态(stream 从 null 开始) const driver = dependsOn.changes.pipe( - // 过滤掉 null Stream.filter((value): value is TDep => value !== null), - // 检查是否真的变化了 - Stream.filter((next) => { - const changed = hasChanged(prevDep, next) - if (changed) { - prevDep = next - } - return changed - }), - // 获取数据 - Stream.mapEffect((dep) => fetch(dep)) + Stream.scanEffect( + { prev: currentDep as TDep | null }, + (acc, next) => + Effect.gen(function* () { + const changed = hasChanged(acc.prev, next) + + if (changed) { + // acc.prev !== null 说明是依赖变化触发,需要强制刷新(network-first) + // acc.prev === null 说明是首次,可以用缓存(cache-first) + const forceRefresh = acc.prev !== null + const result = yield* Effect.catchAll(fetch(next, forceRefresh), (error) => { + console.error(`[DependentSource] ${name} fetch error:`, error) + return Effect.succeed(null as T | null) + }) + if (result !== null) { + yield* SubscriptionRef.set(ref, result) + } + } + + // 总是更新 prev,确保状态追踪正确 + return { prev: next } + }) + ), + Stream.runDrain ) - // 驱动 ref 更新 - const fiber = yield* driver.pipe( - Stream.runForEach((value) => SubscriptionRef.set(ref, value)), - Effect.fork - ) + // Fork driver - 状态在 stream 内部,不受 initial fetch 影响 + const fiber = yield* Effect.forkDaemon(driver) - // 立即检查依赖的当前值,如果有值则执行 fetch - const currentDep = yield* SubscriptionRef.get(dependsOn) - if (currentDep !== null && hasChanged(null, currentDep)) { - prevDep = currentDep - const initialValue = yield* Effect.catchAll(fetch(currentDep), (error) => { + // Initial fetch:确保首次数据加载(forceRefresh=false,可使用缓存) + // Stream 的 scanEffect 会从当前依赖值开始追踪,避免首个 changes 触发重复拉取 + if (currentDep !== null) { + const initialValue = yield* Effect.catchAll(fetch(currentDep, false), (error) => { console.error(`[DependentSource] Initial fetch error for ${name}:`, error) return Effect.succeed(null as T | null) }) @@ -260,7 +299,8 @@ export const createDependentSource = ( if (dep === null) { return yield* Effect.fail(new Error("Dependency not available") as unknown as FetchError) } - const value = yield* fetch(dep) + // refresh 是用户主动触发,应该强制刷新 + const value = yield* fetch(dep, true) yield* SubscriptionRef.set(ref, value) return value }), diff --git a/src/services/chain-adapter/providers/biowallet-provider.effect.ts b/src/services/chain-adapter/providers/biowallet-provider.effect.ts index 85cb39703..571aac301 100644 --- a/src/services/chain-adapter/providers/biowallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/biowallet-provider.effect.ts @@ -6,7 +6,7 @@ * - balance/tokenBalances: 依赖 transactionHistory 变化 */ -import { Effect, Duration } from 'effect'; +import { Effect, Duration, Stream, Fiber } from 'effect'; import { Schema as S } from 'effect'; import { httpFetch, @@ -14,7 +14,6 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, acquireSource, releaseSource, makeRegistryKey, @@ -22,6 +21,7 @@ import { type DataSource, type EventBusService, } from '@biochain/chain-effect'; +import { getWalletEventBus } from '@/services/chain-adapter/wallet-event-bus'; import type { StreamInstance } from '@biochain/chain-effect'; import type { ApiProvider, @@ -439,51 +439,68 @@ export class BiowalletProviderEffect // 获取共享的 blockHeight source 作为依赖 const blockHeightSource = yield* provider.getSharedBlockHeightSource(); - const fetchEffect = provider.fetchTransactionList({ address, limit: 50 }).pipe( - Effect.map((raw): TransactionsOutput => { - if (!raw.result?.trs) return []; - - return raw.result.trs - .map((item): Transaction | null => { - const tx = item.transaction; - const action = detectAction(tx.type); - const direction = getDirection(tx.senderId, tx.recipientId ?? '', normalizedAddress); - const { value, assetType } = extractAssetInfo(tx.asset, symbol); - if (value === null) return null; - - return { - hash: tx.signature ?? item.signature, - from: tx.senderId, - to: tx.recipientId ?? '', - timestamp: provider.epochMs + tx.timestamp * 1000, - status: 'confirmed', - blockNumber: BigInt(item.height), - action, - direction, - assets: [ - { - assetType: 'native' as const, - value, - symbol: assetType, - decimals, - }, - ], - }; - }) - .filter((tx): tx is Transaction => tx !== null) - .sort((a, b) => b.timestamp - a.timestamp); - }), - ); - // 依赖 blockHeight 变化触发刷新 const source = yield* createDependentSource({ name: `biowallet.${provider.chainId}.txHistory.${normalizedAddress}`, dependsOn: blockHeightSource.ref, hasChanged: (prev, curr) => prev !== curr, - fetch: (_: BlockHeightOutput) => fetchEffect, + fetch: (_dep: BlockHeightOutput, _forceRefresh?: boolean) => + provider.fetchTransactionList({ address, limit: 50 }, true).pipe( + Effect.map((raw): TransactionsOutput => { + if (!raw.result?.trs) return []; + + return raw.result.trs + .map((item): Transaction | null => { + const tx = item.transaction; + const action = detectAction(tx.type); + const direction = getDirection(tx.senderId, tx.recipientId ?? '', normalizedAddress); + const { value, assetType } = extractAssetInfo(tx.asset, symbol); + if (value === null) return null; + + return { + hash: tx.signature ?? item.signature, + from: tx.senderId, + to: tx.recipientId ?? '', + timestamp: provider.epochMs + tx.timestamp * 1000, + status: 'confirmed', + blockNumber: BigInt(item.height), + action, + direction, + assets: [ + { + assetType: 'native' as const, + value, + symbol: assetType, + decimals, + }, + ], + }; + }) + .filter((tx): tx is Transaction => tx !== null) + .sort((a, b) => b.timestamp - a.timestamp); + }), + ), }); - return source; + // pendingTx 确认事件需要触发 txHistory 刷新(即使高度未变化) + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus(); + } + const eventBus = provider._eventBus; + + const eventFiber = yield* eventBus + .forWalletEvents(provider.chainId, normalizedAddress, ['tx:confirmed']) + .pipe( + Stream.runForEach(() => + Effect.catchAll(source.refresh, () => Effect.void), + ), + Effect.forkDaemon, + ); + + return { + ...source, + stop: Effect.all([source.stop, Fiber.interrupt(eventFiber)]).pipe(Effect.asVoid), + }; }); } @@ -511,31 +528,30 @@ export class BiowalletProviderEffect decimals, ); - const fetchEffect = provider.fetchAddressAsset(params.address).pipe( - Effect.map((raw): BalanceOutput => { - if (!raw.result?.assets) { - return { amount: Amount.zero(decimals, symbol), symbol }; - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === symbol) { - return { - amount: Amount.fromRaw(asset.assetNumber, decimals, symbol), - symbol, - }; - } - } - } - return { amount: Amount.zero(decimals, symbol), symbol }; - }), - ); - // 依赖 transactionHistory 变化 const source = yield* createDependentSource({ name: `biowallet.${provider.chainId}.balance`, dependsOn: txHistorySource.ref, hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, + fetch: (_dep, forceRefresh: boolean) => + provider.fetchAddressAsset(params.address, forceRefresh).pipe( + Effect.map((raw): BalanceOutput => { + if (!raw.result?.assets) { + return { amount: Amount.zero(decimals, symbol), symbol }; + } + for (const magic of Object.values(raw.result.assets)) { + for (const asset of Object.values(magic)) { + if (asset.assetType === symbol) { + return { + amount: Amount.fromRaw(asset.assetNumber, decimals, symbol), + symbol, + }; + } + } + } + return { amount: Amount.zero(decimals, symbol), symbol }; + }), + ), }); return source; @@ -557,40 +573,39 @@ export class BiowalletProviderEffect decimals, ); - const fetchEffect = provider.fetchAddressAsset(params.address).pipe( - Effect.map((raw): TokenBalancesOutput => { - if (!raw.result?.assets) return []; - const tokens: TokenBalance[] = []; - - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - const isNative = asset.assetType === symbol; - tokens.push({ - symbol: asset.assetType, - name: asset.assetType, - amount: Amount.fromRaw(asset.assetNumber, decimals, asset.assetType), - isNative, - decimals, - }); - } - } - - tokens.sort((a, b) => { - if (a.isNative && !b.isNative) return -1; - if (!a.isNative && b.isNative) return 1; - return b.amount.toNumber() - a.amount.toNumber(); - }); - - return tokens; - }), - ); - // 依赖 transactionHistory 变化 const source = yield* createDependentSource({ name: `biowallet.${provider.chainId}.tokenBalances`, dependsOn: txHistorySource.ref, hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, + fetch: (_dep, forceRefresh: boolean) => + provider.fetchAddressAsset(params.address, forceRefresh).pipe( + Effect.map((raw): TokenBalancesOutput => { + if (!raw.result?.assets) return []; + const tokens: TokenBalance[] = []; + + for (const magic of Object.values(raw.result.assets)) { + for (const asset of Object.values(magic)) { + const isNative = asset.assetType === symbol; + tokens.push({ + symbol: asset.assetType, + name: asset.assetType, + amount: Amount.fromRaw(asset.assetNumber, decimals, asset.assetType), + isNative, + decimals, + }); + } + } + + tokens.sort((a, b) => { + if (a.isNative && !b.isNative) return -1; + if (!a.isNative && b.isNative) return 1; + return b.amount.toNumber() - a.amount.toNumber(); + }); + + return tokens; + }), + ), }); return source; @@ -654,22 +669,26 @@ export class BiowalletProviderEffect return httpFetchCached({ url: `${this.baseUrl}/lastblock`, schema: BlockResponseSchema, - cacheStrategy: 'ttl', - cacheTtl: this.cacheTtl, + // 轮询必须走网络,失败时允许缓存兜底 + cacheStrategy: 'network-first', + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + }, }); } - private fetchAddressAsset(address: string): Effect.Effect { + private fetchAddressAsset(address: string, forceRefresh = false): Effect.Effect { return httpFetchCached({ url: `${this.baseUrl}/address/asset`, method: 'POST', body: { address }, schema: AssetResponseSchema, - cacheStrategy: 'cache-first', + cacheStrategy: forceRefresh ? 'network-first' : 'cache-first', }); } - private fetchTransactionList(params: TxHistoryParams): Effect.Effect { + private fetchTransactionList(params: TxHistoryParams, forceRefresh = false): Effect.Effect { return httpFetchCached({ url: `${this.baseUrl}/transactions/query`, method: 'POST', @@ -680,7 +699,7 @@ export class BiowalletProviderEffect sort: -1, }, schema: TxListResponseSchema, - cacheStrategy: 'cache-first', + cacheStrategy: forceRefresh ? 'network-first' : 'cache-first', }); } diff --git a/src/services/chain-adapter/wallet-event-bus.ts b/src/services/chain-adapter/wallet-event-bus.ts new file mode 100644 index 000000000..59babef74 --- /dev/null +++ b/src/services/chain-adapter/wallet-event-bus.ts @@ -0,0 +1,22 @@ +import { Effect } from 'effect'; +import { createEventBusService, type EventBusService } from '@biochain/chain-effect'; + +let sharedEventBus: EventBusService | null = null; + +/** + * 获取共享的 Wallet EventBus(单例) + * - pendingTx 确认事件与 txHistory 更新共用同一条总线 + */ +export const getWalletEventBus = (): Effect.Effect => { + if (sharedEventBus) { + return Effect.succeed(sharedEventBus); + } + + return createEventBusService.pipe( + Effect.tap((bus) => + Effect.sync(() => { + sharedEventBus = bus; + }), + ), + ); +}; diff --git a/src/services/transaction/pending-tx.ts b/src/services/transaction/pending-tx.ts index 2ff09428a..d252618a5 100644 --- a/src/services/transaction/pending-tx.ts +++ b/src/services/transaction/pending-tx.ts @@ -7,9 +7,10 @@ import { z } from 'zod'; import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; -import { Effect, Stream } from 'effect'; -import { createDependentSource, type DataSource } from '@biochain/chain-effect'; +import { Effect, Stream, Duration } from 'effect'; +import { createPollingSource, txConfirmedEvent, type DataSource } from '@biochain/chain-effect'; import { getChainProvider } from '@/services/chain-adapter/providers'; +import { getWalletEventBus } from '@/services/chain-adapter/wallet-event-bus'; import { defineServiceMeta } from '@/lib/service-meta'; import { SignedTransactionSchema } from '@/services/chain-adapter/types'; @@ -485,6 +486,7 @@ export const pendingTxService = new PendingTxServiceImpl(); // 缓存已创建的 Effect 数据源实例 const pendingTxSources = new Map>(); +const PENDING_TX_POLL_INTERVAL = Duration.seconds(30); /** * 获取 pending tx 的 Effect 数据源 @@ -503,18 +505,15 @@ export function getPendingTxSource( const chainProvider = getChainProvider(chainId); - if (!chainProvider?.supports('blockHeight')) { - return null; - } - - // 创建依赖 blockHeight 的数据源 - return createDependentSource({ + // 使用独立轮询(频率不同于出块) + return createPollingSource({ name: `pendingTx.${chainId}.${walletId}`, - dependsOn: chainProvider.blockHeight.ref, - hasChanged: (prev, next) => prev !== next, + interval: PENDING_TX_POLL_INTERVAL, fetch: () => Effect.tryPromise({ try: async () => { + // pending 确认后需触发 txHistory 刷新(不依赖区块高度变化) + const eventBus = await Effect.runPromise(getWalletEventBus()); // 检查 pending 交易状态,更新/移除已上链的 const pending = await pendingTxService.getPending({ walletId }); @@ -524,6 +523,9 @@ export function getPendingTxSource( // 检查是否已上链 const txInfo = await chainProvider.transaction.fetch({ txHash: tx.txHash }); if (txInfo?.status === 'confirmed') { + await Effect.runPromise( + eventBus.emit(txConfirmedEvent(chainId, tx.fromAddress, tx.txHash)), + ); // 直接删除已确认的交易 await pendingTxService.delete({ id: tx.id }); } From 9027374376c70907569f024ac55c16311ca560e3 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 23 Jan 2026 19:31:38 +0800 Subject: [PATCH 30/33] fix(pending-tx): align polling interval with genesis block time --- public/configs/default-chains.json | 4 ++ public/configs/testnet-chains.json | 4 ++ src/services/chain-config/schema.ts | 5 +- src/services/transaction/pending-tx.ts | 68 ++++++++++++++++++++++++-- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index c858ac6b4..48dbf1c47 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -156,6 +156,7 @@ "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/$address/logo.png" ], "decimals": 18, + "blockTime": 12, "apis": [ { "type": "moralis", @@ -197,6 +198,7 @@ "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/smartchain/assets/$address/logo.png" ], "decimals": 18, + "blockTime": 3, "apis": [ { "type": "moralis", @@ -239,6 +241,7 @@ "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/tron/icon-$SYMBOL.png" ], "decimals": 6, + "blockTime": 3, "apis": [ { "type": "tron-rpc-pro", @@ -267,6 +270,7 @@ "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/btcm/icon-$SYMBOL.png" ], "decimals": 8, + "blockTime": 600, "apis": [ { "type": "mempool-v1", "endpoint": "https://mempool.space/api" }, { "type": "btcwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/btc/blockbook" } diff --git a/public/configs/testnet-chains.json b/public/configs/testnet-chains.json index 01e30b89b..936e170bc 100644 --- a/public/configs/testnet-chains.json +++ b/public/configs/testnet-chains.json @@ -9,6 +9,7 @@ "symbol": "SepoliaETH", "icon": "../icons/ethereum/chain.svg", "decimals": 18, + "blockTime": 12, "apis": [ { "type": "ethereum-rpc", "endpoint": "https://rpc.sepolia.org" } ], @@ -26,6 +27,7 @@ "symbol": "tBNB", "icon": "../icons/binance/chain.svg", "decimals": 18, + "blockTime": 3, "apis": [ { "type": "ethereum-rpc", "endpoint": "https://bsc-testnet-rpc.publicnode.com" } ], @@ -43,6 +45,7 @@ "symbol": "TRX", "icon": "../icons/tron/chain.svg", "decimals": 6, + "blockTime": 3, "apis": [ { "type": "tron-rpc", "endpoint": "https://nile.trongrid.io" } ], @@ -60,6 +63,7 @@ "symbol": "sBTC", "icon": "../icons/bitcoin/chain.svg", "decimals": 8, + "blockTime": 600, "apis": [ { "type": "mempool-v1", "endpoint": "https://mempool.space/signet/api" } ], diff --git a/src/services/chain-config/schema.ts b/src/services/chain-config/schema.ts index 7379e897d..021c8c26a 100644 --- a/src/services/chain-config/schema.ts +++ b/src/services/chain-config/schema.ts @@ -2,7 +2,7 @@ * 链配置 Zod Schema * * 设计原则:分离链属性与提供商配置 - * - 链属性:id, chainKind, name, symbol, prefix, decimals(链的固有属性) + * - 链属性:id, chainKind, name, symbol, prefix, decimals, blockTime(链的固有属性) * - 提供商配置:apis, explorer(外部依赖,可替换) * * 说明: @@ -71,6 +71,8 @@ export const ChainConfigSchema = z prefix: z.string().min(1).max(10).optional(), // BioForest 特有 decimals: z.number().int().min(0).max(18), + /** 平均出块时间(秒),用于轮询调度 */ + blockTime: z.number().positive().optional(), // ===== 提供商配置(外部依赖) ===== apis: ApiProvidersSchema.optional(), @@ -100,4 +102,3 @@ export const ChainConfigSubscriptionSchema = z etag: z.string().optional(), }) .strict() - diff --git a/src/services/transaction/pending-tx.ts b/src/services/transaction/pending-tx.ts index d252618a5..b78837872 100644 --- a/src/services/transaction/pending-tx.ts +++ b/src/services/transaction/pending-tx.ts @@ -13,6 +13,8 @@ import { getChainProvider } from '@/services/chain-adapter/providers'; import { getWalletEventBus } from '@/services/chain-adapter/wallet-event-bus'; import { defineServiceMeta } from '@/lib/service-meta'; import { SignedTransactionSchema } from '@/services/chain-adapter/types'; +import { chainConfigService, type ChainConfig, type ChainKind } from '@/services/chain-config'; +import { getForgeInterval } from '@/services/chain-adapter/bioforest/fetch'; // ==================== Schema ==================== @@ -486,11 +488,69 @@ export const pendingTxService = new PendingTxServiceImpl(); // 缓存已创建的 Effect 数据源实例 const pendingTxSources = new Map>(); -const PENDING_TX_POLL_INTERVAL = Duration.seconds(30); +const MIN_PENDING_TX_POLL_MS = 15_000; + +type ChainConfigWithBlockTime = ChainConfig & { + // 约定:blockTime/blockTimeSeconds 为秒,blockTimeMs 为毫秒 + blockTime?: number; + blockTimeSeconds?: number; + blockTimeMs?: number; +}; + +function getDefaultBlockTimeMs(chainKind: ChainKind): number { + switch (chainKind) { + case 'evm': + return 12_000; + case 'tron': + return 3_000; + case 'bitcoin': + return 600_000; + case 'bioforest': + return MIN_PENDING_TX_POLL_MS; + default: + return MIN_PENDING_TX_POLL_MS; + } +} + +function resolveGenesisBlockTimeMs(chainId: string): number { + const config = chainConfigService.getConfig(chainId); + if (!config) return MIN_PENDING_TX_POLL_MS; + + if (config.chainKind === 'bioforest') { + return getForgeInterval(chainId); + } + + const configWithBlockTime = config as ChainConfigWithBlockTime; + if (typeof configWithBlockTime.blockTimeMs === 'number' && configWithBlockTime.blockTimeMs > 0) { + return configWithBlockTime.blockTimeMs; + } + if (typeof configWithBlockTime.blockTimeSeconds === 'number' && configWithBlockTime.blockTimeSeconds > 0) { + return configWithBlockTime.blockTimeSeconds * 1000; + } + if (typeof configWithBlockTime.blockTime === 'number' && configWithBlockTime.blockTime > 0) { + return configWithBlockTime.blockTime * 1000; + } + + return getDefaultBlockTimeMs(config.chainKind); +} + +function getPendingTxPollInterval(chainId: string): Duration.Duration { + const chainKind = chainConfigService.getConfig(chainId)?.chainKind; + const genesisMs = Math.max(1, resolveGenesisBlockTimeMs(chainId)); + + if (chainKind === 'bioforest') { + // BioChain: pending 轮询频率为创世块时间的 1/2 + return Duration.millis(Math.max(1, Math.floor(genesisMs / 2))); + } + + // 其它链:>=15s 且为创世块时间的整数倍 + const multiple = Math.max(1, Math.ceil(MIN_PENDING_TX_POLL_MS / genesisMs)); + return Duration.millis(genesisMs * multiple); +} /** * 获取 pending tx 的 Effect 数据源 - * 依赖 blockHeight 变化自动刷新 + * 独立轮询 + 确认事件触发 txHistory 刷新 */ export function getPendingTxSource( chainId: string, @@ -506,9 +566,11 @@ export function getPendingTxSource( const chainProvider = getChainProvider(chainId); // 使用独立轮询(频率不同于出块) + const interval = getPendingTxPollInterval(chainId); + return createPollingSource({ name: `pendingTx.${chainId}.${walletId}`, - interval: PENDING_TX_POLL_INTERVAL, + interval, fetch: () => Effect.tryPromise({ try: async () => { From 3b6f28793b9f227628cdfaf83421ce2bbd138001 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 02:17:42 +0800 Subject: [PATCH 31/33] feat: complete implementation --- deno.lock | 41 +- packages/chain-effect/src/http-cache.ts | 32 +- packages/chain-effect/src/http.ts | 32 +- packages/chain-effect/src/instance.ts | 91 ++- packages/chain-effect/src/source-registry.ts | 9 +- packages/chain-effect/src/source.ts | 72 ++- .../src/loading-spinner/LoadingSpinner.tsx | 1 - packages/key-ui/src/skeleton/Skeleton.tsx | 1 - scripts/agent-flow/mcps/practice.mcp.ts | 2 +- scripts/agent-flow/mcps/roadmap.mcp.ts | 2 +- scripts/agent-flow/mcps/whitebook.mcp.ts | 2 +- src/components/wallet/wallet-config.tsx | 2 +- src/hooks/use-burn.bioforest.ts | 4 +- src/hooks/use-pending-transactions.ts | 186 ++++-- src/hooks/use-send.bioforest.ts | 37 +- src/hooks/use-send.logic.ts | 5 +- src/hooks/use-send.test.ts | 4 + src/hooks/use-send.ts | 12 +- src/pages/history/index.tsx | 2 +- src/pages/send/index.tsx | 35 +- .../chain-adapter/bioforest/chain-service.ts | 14 +- .../providers/biowallet-provider.effect.ts | 545 ++++++++++++------ .../providers/bscwallet-provider.effect.ts | 1 - .../chain-adapter/providers/chain-provider.ts | 216 ++++++- .../providers/evm-rpc-provider.effect.ts | 9 +- .../providers/moralis-provider.effect.ts | 21 +- src/services/chain-adapter/providers/types.ts | 5 +- src/services/ecosystem/registry.ts | 24 +- src/services/transaction/index.ts | 1 + .../transaction/pending-tx-manager.ts | 70 +-- src/services/transaction/pending-tx.ts | 250 ++++++-- src/services/transaction/web.ts | 2 +- .../sheets/TransferWalletLockJob.tsx | 8 +- src/stackflow/activities/tabs/WalletTab.tsx | 2 +- src/stackflow/components/TabBar.tsx | 10 +- 35 files changed, 1295 insertions(+), 455 deletions(-) diff --git a/deno.lock b/deno.lock index 8bee51e4a..feeb5424b 100644 --- a/deno.lock +++ b/deno.lock @@ -10140,6 +10140,7 @@ "npm:@bnqkl/server-util@^1.3.4", "npm:@bnqkl/wallet-sdk@~0.23.8", "npm:@bnqkl/wallet-typings@~0.23.8", + "npm:@effect/platform@~0.94.2", "npm:@fontsource-variable/dm-sans@^5.2.8", "npm:@fontsource-variable/figtree@^5.2.10", "npm:@fontsource/dm-mono@^5.2.7", @@ -10178,13 +10179,17 @@ "npm:@testing-library/react@^16.3.0", "npm:@testing-library/user-event@^14.6.1", "npm:@types/big.js@^6.2.2", + "npm:@types/bun@^1.3.5", + "npm:@types/ed2curve@~0.2.4", "npm:@types/lodash@^4.17.21", "npm:@types/node@^24.10.1", "npm:@types/qrcode@^1.5.6", "npm:@types/react-dom@19", "npm:@types/react@19", "npm:@types/semver@^7.7.1", + "npm:@types/ssh2-sftp-client@^9.0.6", "npm:@types/yargs@^17.0.35", + "npm:@typescript-eslint/parser@^8.53.0", "npm:@vitejs/plugin-react@^5.1.1", "npm:@vitest/browser-playwright@^4.0.15", "npm:@vitest/browser@^4.0.15", @@ -10198,15 +10203,20 @@ "npm:clsx@^2.1.1", "npm:detect-port@^2.1.0", "npm:dotenv@^17.2.3", + "npm:ed2curve@0.3", + "npm:effect@^3.19.15", "npm:eslint-plugin-i18next@^6.1.3", + "npm:eslint-plugin-unused-imports@^4.3.0", "npm:fake-indexeddb@^6.2.5", + "npm:i18next-cli@^1.36.1", "npm:i18next@^25.7.1", "npm:idb@^8.0.3", "npm:jsdom@^27.2.0", "npm:jsqr@^1.4.0", + "npm:jszip@^3.10.1", "npm:lodash@^4.17.21", "npm:motion@^12.23.26", - "npm:oxlint@^1.32.0", + "npm:oxlint@^1.39.0", "npm:playwright@^1.57.0", "npm:prettier-plugin-tailwindcss@~0.7.2", "npm:prettier@^3.7.4", @@ -10221,6 +10231,7 @@ "npm:semver@^7.7.3", "npm:shadcn@^3.6.1", "npm:sharp@~0.34.5", + "npm:sirv@^3.0.2", "npm:ssh2-sftp-client@^12.0.1", "npm:storybook@^10.1.4", "npm:swiper@^12.0.3", @@ -10228,6 +10239,7 @@ "npm:tailwindcss@4", "npm:turbo@^2.7.1", "npm:tw-animate-css@^1.4.0", + "npm:tweetnacl@^1.0.3", "npm:typescript@~5.9.3", "npm:vaul@^1.1.2", "npm:viem@^2.43.3", @@ -10236,6 +10248,8 @@ "npm:vite@^7.2.7", "npm:vitepress@^1.6.4", "npm:vitest@^4.0.15", + "npm:wujie-react@^1.0.29", + "npm:wujie@^1.0.29", "npm:yargs@18", "npm:zod@^4.1.13" ] @@ -10245,6 +10259,7 @@ "packageJson": { "dependencies": [ "npm:@base-ui/react@1", + "npm:@fontsource-variable/noto-sans-sc@^5.2.10", "npm:@playwright/test@^1.49.1", "npm:@radix-ui/react-avatar@^1.1.11", "npm:@radix-ui/react-separator@^1.1.8", @@ -10256,11 +10271,13 @@ "npm:@tailwindcss/vite@^4.1.0", "npm:@testing-library/jest-dom@^6.6.3", "npm:@testing-library/react@16", + "npm:@types/big.js@^6.2.2", "npm:@types/react-dom@19", "npm:@types/react@19", "npm:@vitejs/plugin-react@5", "npm:@vitest/browser-playwright@^4.0.15", "npm:@vitest/browser@^4.0.15", + "npm:big.js@^7.0.1", "npm:class-variance-authority@~0.7.1", "npm:clsx@^2.1.1", "npm:eslint-plugin-i18next@^6.1.3", @@ -10342,6 +10359,20 @@ ] } }, + "packages/chain-effect": { + "packageJson": { + "dependencies": [ + "npm:@effect/platform@~0.94.2", + "npm:@types/react@19", + "npm:effect@^3.19.15", + "npm:oxlint@^1.32.0", + "npm:react@19", + "npm:superjson@^2.2.6", + "npm:typescript@^5.9.3", + "npm:vitest@4" + ] + } + }, "packages/create-miniapp": { "packageJson": { "dependencies": [ @@ -10357,6 +10388,14 @@ ] } }, + "packages/dweb-compat": { + "packageJson": { + "dependencies": [ + "npm:typescript@^5.9.3", + "npm:vitest@4" + ] + } + }, "packages/e2e-tools": { "packageJson": { "dependencies": [ diff --git a/packages/chain-effect/src/http-cache.ts b/packages/chain-effect/src/http-cache.ts index 5f40307e3..d06cd1f33 100644 --- a/packages/chain-effect/src/http-cache.ts +++ b/packages/chain-effect/src/http-cache.ts @@ -28,13 +28,43 @@ export interface HttpCacheOptions { const DEFAULT_CACHE_NAME = "chain-effect-http-cache" +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null +} + +function toStableJson(value: unknown): unknown { + if (typeof value === "bigint") { + return value.toString() + } + if (!isRecord(value)) { + if (Array.isArray(value)) { + return value.map(toStableJson) + } + return value + } + if (Array.isArray(value)) { + return value.map(toStableJson) + } + const sorted: UnknownRecord = {} + for (const key of Object.keys(value).sort()) { + sorted[key] = toStableJson(value[key]) + } + return sorted +} + +function stableStringify(value: unknown): string { + return JSON.stringify(toStableJson(value)) +} + /** * 将 POST 请求转换为可缓存的 GET 请求 * Cache API 只能缓存 GET 请求,所以需要将 body 编码到 URL 中 */ function makeCacheKey(url: string, body?: unknown): string { if (!body) return url - const bodyHash = btoa(JSON.stringify(body)).replace(/[+/=]/g, (c) => + const bodyHash = btoa(stableStringify(body)).replace(/[+/=]/g, (c) => c === '+' ? '-' : c === '/' ? '_' : '' ) return `${url}?__body=${bodyHash}` diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts index 4c4680866..b3ad6d1b8 100644 --- a/packages/chain-effect/src/http.ts +++ b/packages/chain-effect/src/http.ts @@ -239,9 +239,39 @@ export interface CachedFetchOptions extends FetchOptions { // 用于防止并发请求的 Promise 锁 const pendingRequests = new Map>(); +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null; +} + +function toStableJson(value: unknown): unknown { + if (typeof value === 'bigint') { + return value.toString(); + } + if (!isRecord(value)) { + if (Array.isArray(value)) { + return value.map(toStableJson); + } + return value; + } + if (Array.isArray(value)) { + return value.map(toStableJson); + } + const sorted: UnknownRecord = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = toStableJson(value[key]); + } + return sorted; +} + +function stableStringify(value: unknown): string { + return JSON.stringify(toStableJson(value)); +} + function makeCacheKeyForRequest(url: string, body?: unknown): string { if (!body) return url; - const bodyHash = btoa(JSON.stringify(body)).replace(/[+/=]/g, (c) => + const bodyHash = btoa(stableStringify(body)).replace(/[+/=]/g, (c) => c === '+' ? '-' : c === '/' ? '_' : '' ); return `${url}?__body=${bodyHash}`; diff --git a/packages/chain-effect/src/instance.ts b/packages/chain-effect/src/instance.ts index 531bd75c9..948fe4c53 100644 --- a/packages/chain-effect/src/instance.ts +++ b/packages/chain-effect/src/instance.ts @@ -13,6 +13,70 @@ import { Effect, Stream, Fiber } from "effect" import type { FetchError } from "./http" import type { DataSource } from "./source" +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null +} + +function toStableJson(value: unknown): unknown { + if (typeof value === "bigint") { + return value.toString() + } + if (!isRecord(value)) { + if (Array.isArray(value)) { + return value.map(toStableJson) + } + return value + } + if (Array.isArray(value)) { + return value.map(toStableJson) + } + const sorted: UnknownRecord = {} + for (const key of Object.keys(value).sort()) { + sorted[key] = toStableJson(value[key]) + } + return sorted +} + +function stableStringify(value: unknown): string { + return JSON.stringify(toStableJson(value)) +} + +function summarizeValue(value: unknown): string { + if (Array.isArray(value)) { + const first = value[0] + if (isRecord(first) && "hash" in first) { + const hash = typeof first.hash === "string" ? first.hash : String(first.hash) + return `array(len=${value.length}, firstHash=${hash})` + } + return `array(len=${value.length})` + } + if (isRecord(value)) { + if ("hash" in value) { + const hash = typeof value.hash === "string" ? value.hash : String(value.hash) + return `object(hash=${hash})` + } + if ("symbol" in value) { + const symbol = typeof value.symbol === "string" ? value.symbol : String(value.symbol) + return `object(symbol=${symbol})` + } + return "object" + } + return String(value) +} + +function isDebugEnabled(): boolean { + if (typeof globalThis === "undefined") return false + const store = globalThis as typeof globalThis & { __CHAIN_EFFECT_DEBUG__?: boolean } + return store.__CHAIN_EFFECT_DEBUG__ === true +} + +function debugLog(...args: Array): void { + if (!isDebugEnabled()) return + console.log("[chain-effect]", ...args) +} + /** 兼容旧 API 的 StreamInstance 接口 */ export interface StreamInstance { readonly name: string @@ -48,7 +112,7 @@ export function createStreamInstanceFromSource( const getInputKey = (input: TInput): string => { if (input === undefined || input === null) return "__empty__" - return JSON.stringify(input) + return stableStringify(input) } const getOrCreateSource = async (input: TInput): Promise> => { @@ -56,9 +120,11 @@ export function createStreamInstanceFromSource( const cached = sources.get(key) if (cached) { cached.refCount++ + debugLog(`${name} reuse source`, key, `refs=${cached.refCount}`) return cached.source } + debugLog(`${name} create source`, key) const source = await Effect.runPromise(createSource(input)) sources.set(key, { source, refCount: 1 }) return source @@ -81,12 +147,16 @@ export function createStreamInstanceFromSource( async fetch(input: TInput): Promise { const source = await getOrCreateSource(input) - const value = await Effect.runPromise(source.get) - if (value === null) { - // 强制刷新获取 - return Effect.runPromise(source.refresh) + try { + const value = await Effect.runPromise(source.get) + if (value === null) { + // 强制刷新获取 + return Effect.runPromise(source.refresh) + } + return value + } finally { + releaseSource(input) } - return value }, subscribe( @@ -96,6 +166,7 @@ export function createStreamInstanceFromSource( let cancelled = false let cleanup: (() => void) | null = null + debugLog(`${name} subscribe`, getInputKey(input)) getOrCreateSource(input).then((source) => { if (cancelled) { releaseSource(input) @@ -106,6 +177,11 @@ export function createStreamInstanceFromSource( const program = Stream.runForEach(source.changes, (value) => Effect.sync(() => { if (cancelled) return + debugLog( + `${name} emit`, + isFirst ? "initial" : "update", + summarizeValue(value) + ) callback(value, isFirst ? "initial" : "update") isFirst = false }) @@ -160,6 +236,7 @@ export function createStreamInstanceFromSource( setIsLoading(false) setIsFetching(false) setError(undefined) + debugLog(`${name} storeChange`, summarizeValue(newData)) onStoreChange() } ) @@ -230,7 +307,7 @@ export function createStreamInstance( const getInputKey = (input: TInput): string => { if (input === undefined || input === null) return "__empty__" - return JSON.stringify(input) + return stableStringify(input) } return { diff --git a/packages/chain-effect/src/source-registry.ts b/packages/chain-effect/src/source-registry.ts index a06911262..19d746518 100644 --- a/packages/chain-effect/src/source-registry.ts +++ b/packages/chain-effect/src/source-registry.ts @@ -161,12 +161,7 @@ async function createSourceInternal( ref, fiber: pollFiber as Fiber.RuntimeFiber, get: SubscriptionRef.get(ref), - changes: Stream.concat( - Stream.fromEffect(SubscriptionRef.get(ref)), - ref.changes - ).pipe( - Stream.filter((v): v is T => v !== null) - ), + changes: ref.changes.pipe(Stream.filter((v): v is T => v !== null)), refresh: Effect.gen(function* () { const value = yield* options.fetch yield* SubscriptionRef.set(ref, value) @@ -229,7 +224,7 @@ export function getRegistryStatus(): Map { */ export function clearRegistry(): Effect.Effect { return Effect.gen(function* () { - for (const [key, entry] of registry) { + for (const entry of registry.values()) { if (entry.pollFiber) { yield* Fiber.interrupt(entry.pollFiber) } diff --git a/packages/chain-effect/src/source.ts b/packages/chain-effect/src/source.ts index 41e94d62b..82f82f431 100644 --- a/packages/chain-effect/src/source.ts +++ b/packages/chain-effect/src/source.ts @@ -13,6 +13,35 @@ import { Effect, Stream, Schedule, SubscriptionRef, Duration, PubSub, Fiber } fr import type { FetchError } from "./http" import type { EventBusService, WalletEventType } from "./event-bus" +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null +} + +function summarizeValue(value: unknown): string { + if (Array.isArray(value)) { + return `array(len=${value.length})` + } + if (isRecord(value)) { + if ("hash" in value) return `object(hash=${String(value.hash)})` + if ("symbol" in value) return `object(symbol=${String(value.symbol)})` + return "object" + } + return String(value) +} + +function isDebugEnabled(): boolean { + if (typeof globalThis === "undefined") return false + const store = globalThis as typeof globalThis & { __CHAIN_EFFECT_DEBUG__?: boolean } + return store.__CHAIN_EFFECT_DEBUG__ === true +} + +function debugLog(...args: Array): void { + if (!isDebugEnabled()) return + console.log("[chain-effect]", ...args) +} + // ==================== Event Bus ==================== export interface EventBus { @@ -106,7 +135,7 @@ export const createPollingSource = ( options: PollingSourceOptions ): Effect.Effect, never, never> => Effect.gen(function* () { - const { name, fetch, interval, events, walletEvents, immediate = true } = options + const { fetch, interval, events, walletEvents, immediate = true } = options const ref = yield* SubscriptionRef.make(null) @@ -138,7 +167,12 @@ export const createPollingSource = ( // 驱动 ref 更新 const fiber = yield* driver.pipe( - Stream.runForEach((value) => SubscriptionRef.set(ref, value)), + Stream.runForEach((value) => + Effect.gen(function* () { + debugLog(`${options.name} poll`, summarizeValue(value)) + yield* SubscriptionRef.set(ref, value) + }) + ), Effect.forkDaemon ) @@ -146,6 +180,7 @@ export const createPollingSource = ( if (immediate) { const initialValue = yield* Effect.catchAll(fetch, () => Effect.succeed(null as T | null)) if (initialValue !== null) { + debugLog(`${options.name} initial`, summarizeValue(initialValue)) yield* SubscriptionRef.set(ref, initialValue) } } @@ -154,13 +189,8 @@ export const createPollingSource = ( ref, fiber, get: SubscriptionRef.get(ref), - // 先发射当前值,再发射后续变化(SubscriptionRef.changes 只发射未来变化) - changes: Stream.concat( - Stream.fromEffect(SubscriptionRef.get(ref)), - ref.changes - ).pipe( - Stream.filter((v): v is T => v !== null) - ), + // SubscriptionRef.changes 已包含当前值,避免重复发射 + changes: ref.changes.pipe(Stream.filter((v): v is T => v !== null)), refresh: Effect.gen(function* () { const value = yield* fetch yield* SubscriptionRef.set(ref, value) @@ -246,19 +276,24 @@ export const createDependentSource = ( { prev: currentDep as TDep | null }, (acc, next) => Effect.gen(function* () { + debugLog(`${name} dep`, summarizeValue(next)) const changed = hasChanged(acc.prev, next) if (changed) { // acc.prev !== null 说明是依赖变化触发,需要强制刷新(network-first) // acc.prev === null 说明是首次,可以用缓存(cache-first) const forceRefresh = acc.prev !== null + debugLog(`${name} fetch`, forceRefresh ? "force" : "cache") const result = yield* Effect.catchAll(fetch(next, forceRefresh), (error) => { console.error(`[DependentSource] ${name} fetch error:`, error) return Effect.succeed(null as T | null) }) if (result !== null) { + debugLog(`${name} set`, summarizeValue(result)) yield* SubscriptionRef.set(ref, result) } + } else { + debugLog(`${name} skip`, "unchanged") } // 总是更新 prev,确保状态追踪正确 @@ -279,6 +314,7 @@ export const createDependentSource = ( return Effect.succeed(null as T | null) }) if (initialValue !== null) { + debugLog(`${name} initial`, summarizeValue(initialValue)) yield* SubscriptionRef.set(ref, initialValue) } } @@ -287,13 +323,8 @@ export const createDependentSource = ( ref, fiber, get: SubscriptionRef.get(ref), - // 先发射当前值,再发射后续变化 - changes: Stream.concat( - Stream.fromEffect(SubscriptionRef.get(ref)), - ref.changes - ).pipe( - Stream.filter((v): v is T => v !== null) - ), + // SubscriptionRef.changes 已包含当前值,避免重复发射 + changes: ref.changes.pipe(Stream.filter((v): v is T => v !== null)), refresh: Effect.gen(function* () { const dep = yield* SubscriptionRef.get(dependsOn) if (dep === null) { @@ -393,13 +424,8 @@ export const createHybridSource = ( ref, fiber, get: SubscriptionRef.get(ref), - // 先发射当前值,再发射后续变化 - changes: Stream.concat( - Stream.fromEffect(SubscriptionRef.get(ref)), - ref.changes - ).pipe( - Stream.filter((v): v is T => v !== null) - ), + // SubscriptionRef.changes 已包含当前值,避免重复发射 + changes: ref.changes.pipe(Stream.filter((v): v is T => v !== null)), refresh: Effect.gen(function* () { const value = yield* fetch yield* SubscriptionRef.set(ref, value) diff --git a/packages/key-ui/src/loading-spinner/LoadingSpinner.tsx b/packages/key-ui/src/loading-spinner/LoadingSpinner.tsx index 3e5e0dfe4..f1e7afe93 100644 --- a/packages/key-ui/src/loading-spinner/LoadingSpinner.tsx +++ b/packages/key-ui/src/loading-spinner/LoadingSpinner.tsx @@ -1,5 +1,4 @@ 'use client' -import * as React from 'react' import { cn } from '@biochain/key-utils' export type LoadingSpinnerSize = 'sm' | 'md' | 'lg' diff --git a/packages/key-ui/src/skeleton/Skeleton.tsx b/packages/key-ui/src/skeleton/Skeleton.tsx index dd563da9b..c0f84c2b2 100644 --- a/packages/key-ui/src/skeleton/Skeleton.tsx +++ b/packages/key-ui/src/skeleton/Skeleton.tsx @@ -1,5 +1,4 @@ 'use client' -import * as React from 'react' import { cn } from '@biochain/key-utils' export interface SkeletonProps { diff --git a/scripts/agent-flow/mcps/practice.mcp.ts b/scripts/agent-flow/mcps/practice.mcp.ts index 97ab99d9c..589a372ab 100644 --- a/scripts/agent-flow/mcps/practice.mcp.ts +++ b/scripts/agent-flow/mcps/practice.mcp.ts @@ -8,7 +8,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { z } from "zod"; -import { createMcpServer, defineTool } from "../../../packages/flow/src/common/mcp/base-mcp.js"; +import { createMcpServer, defineTool } from "../../../packages/flow/src/common/mcp/base-mcp.ts"; // ============================================================================= // Constants diff --git a/scripts/agent-flow/mcps/roadmap.mcp.ts b/scripts/agent-flow/mcps/roadmap.mcp.ts index 3961a46f4..d410c1d38 100644 --- a/scripts/agent-flow/mcps/roadmap.mcp.ts +++ b/scripts/agent-flow/mcps/roadmap.mcp.ts @@ -7,7 +7,7 @@ import { execSync } from "node:child_process"; import { z } from "zod"; -import { createMcpServer, defineTool } from "../../../packages/flow/src/common/mcp/base-mcp.js"; +import { createMcpServer, defineTool } from "../../../packages/flow/src/common/mcp/base-mcp.ts"; // ============================================================================= // Types diff --git a/scripts/agent-flow/mcps/whitebook.mcp.ts b/scripts/agent-flow/mcps/whitebook.mcp.ts index e1d75f7e4..761e2382a 100644 --- a/scripts/agent-flow/mcps/whitebook.mcp.ts +++ b/scripts/agent-flow/mcps/whitebook.mcp.ts @@ -11,7 +11,7 @@ import { z } from "zod"; import { createMcpServer, defineTool, -} from "../../../packages/flow/src/common/mcp/base-mcp.js"; +} from "../../../packages/flow/src/common/mcp/base-mcp.ts"; // ============================================================================= // Constants diff --git a/src/components/wallet/wallet-config.tsx b/src/components/wallet/wallet-config.tsx index b9e8135e7..968ac49d7 100644 --- a/src/components/wallet/wallet-config.tsx +++ b/src/components/wallet/wallet-config.tsx @@ -324,7 +324,7 @@ export function WalletConfig({ mode, walletId, onEditOnlyComplete, className }: isLowWeight && 'opacity-50', )} style={{ backgroundColor: color.color }} - title={color.name} + title={t(color.nameKey)} > {isSelected && } diff --git a/src/hooks/use-burn.bioforest.ts b/src/hooks/use-burn.bioforest.ts index d8b53e83f..36bbfaf1e 100644 --- a/src/hooks/use-burn.bioforest.ts +++ b/src/hooks/use-burn.bioforest.ts @@ -8,7 +8,7 @@ import type { ChainConfig } from '@/services/chain-config'; import { Amount } from '@/types/amount'; import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage'; import { getChainProvider } from '@/services/chain-adapter/providers'; -import { pendingTxService } from '@/services/transaction'; +import { pendingTxService, getPendingTxWalletKey } from '@/services/transaction'; import i18n from '@/i18n'; const t = i18n.t.bind(i18n); @@ -180,7 +180,7 @@ export async function submitBioforestBurn({ // 存储到 pendingTxService(使用 ChainProvider 标准格式) const pendingTx = await pendingTxService.create({ - walletId, + walletId: getPendingTxWalletKey(chainConfig.id, fromAddress), chainId: chainConfig.id, fromAddress, rawTx: signedTx, diff --git a/src/hooks/use-pending-transactions.ts b/src/hooks/use-pending-transactions.ts index 2bb2cdf7c..e30240d77 100644 --- a/src/hooks/use-pending-transactions.ts +++ b/src/hooks/use-pending-transactions.ts @@ -5,79 +5,197 @@ * 使用 Effect 数据源,依赖 blockHeight 自动刷新 */ -import { useCallback, useMemo, useState, useEffect } from 'react' -import { Effect } from 'effect' -import { pendingTxService, pendingTxManager, getPendingTxSource, type PendingTx } from '@/services/transaction' +import { useCallback, useState, useEffect } from 'react' +import { Effect, Stream, Fiber } from 'effect' +import { pendingTxService, pendingTxManager, getPendingTxSource, getPendingTxWalletKey, type PendingTx } from '@/services/transaction' import { useChainConfigState } from '@/stores' -export function usePendingTransactions(walletId: string | undefined, chainId?: string) { +function isPendingTxDebugEnabled(): boolean { + if (typeof globalThis === 'undefined') return false + const store = globalThis as typeof globalThis & { __CHAIN_EFFECT_DEBUG__?: boolean } + return store.__CHAIN_EFFECT_DEBUG__ === true +} + +function pendingTxDebugLog(...args: Array): void { + if (!isPendingTxDebugEnabled()) return + console.log('[chain-effect]', 'pending-tx', ...args) +} + +export function usePendingTransactions(walletId: string | undefined, chainId?: string, address?: string) { const chainConfigState = useChainConfigState() + const walletKey = chainId && address ? getPendingTxWalletKey(chainId, address) : walletId + const legacyWalletId = walletId && walletId !== walletKey ? walletId : undefined // 手动管理状态 const [transactions, setTransactions] = useState([]) const [isLoading, setIsLoading] = useState(false) + const loadPendingSnapshot = useCallback(async () => { + if (!walletKey) return [] + const walletIds = new Set([walletKey]) + if (legacyWalletId) { + walletIds.add(legacyWalletId) + } + const lists = await Promise.all( + Array.from(walletIds).map((id) => pendingTxService.getPending({ walletId: id })) + ) + const map = new Map() + for (const list of lists) { + for (const item of list) { + map.set(item.id, item) + } + } + const merged = Array.from(map.values()).sort((a, b) => b.createdAt - a.createdAt) + pendingTxDebugLog('snapshot', walletKey ?? 'none', `len=${merged.length}`) + return merged + }, [walletKey, legacyWalletId]) + useEffect(() => { - if (!walletId || !chainId) { + if (!walletKey) { setTransactions([]) setIsLoading(false) return } - const sourceEffect = getPendingTxSource(chainId, walletId) - if (!sourceEffect) { - // 如果链不支持 blockHeight,回退到直接查询 - pendingTxService.getPending({ walletId }).then(setTransactions) - return + let mounted = true + setIsLoading(true) + + // 先读取本地 DB,避免进入页面时“慢一拍” + void (async () => { + try { + const list = await loadPendingSnapshot() + if (!mounted) return + setTransactions(list) + } finally { + if (!mounted) return + setIsLoading(false) + } + })() + + // 再修正 walletKey 并清理 confirmed,最后刷新一次列表 + const normalizeAndRefresh = async () => { + if (chainId && address) { + await pendingTxService.normalizeWalletKeyByAddress({ + chainId, + address, + walletId: walletKey, + }) + } + await pendingTxService.deleteConfirmed({ walletId: walletKey }) + if (legacyWalletId) { + await pendingTxService.deleteConfirmed({ walletId: legacyWalletId }) + } + const list = await loadPendingSnapshot() + if (!mounted) return + setTransactions(list) } - setIsLoading(true) + void normalizeAndRefresh().catch(() => { + // ignore + }) + + // 无 chainId 时仅展示本地数据,不启动轮询/确认检查 + if (!chainId) { + return () => { + mounted = false + } + } + + const sourceEffect = getPendingTxSource(chainId, walletKey, legacyWalletId) + if (!sourceEffect) { + return () => { + mounted = false + } + } // 运行 Effect 获取数据源并订阅变化 let cleanup: (() => void) | undefined Effect.runPromise(sourceEffect).then((source) => { - // 获取初始值 - Effect.runPromise(source.get).then((result) => { - if (result) setTransactions(result) - setIsLoading(false) - }) - - // 订阅变化流 - const fiber = Effect.runFork( - source.changes.pipe( - Effect.tap((newData) => Effect.sync(() => setTransactions(newData))) + const startStream = () => { + const fiber = Effect.runFork( + source.changes.pipe( + Stream.runForEach((newData) => + Effect.promise(async () => { + if (!mounted) return + pendingTxDebugLog('changes', walletKey ?? 'none', `len=${newData.length}`) + const list = await loadPendingSnapshot() + if (!mounted) return + setTransactions(list) + }) + ) + ) ) - ) - cleanup = () => { - Effect.runPromise(Effect.fiberId.pipe(Effect.flatMap(() => source.stop))) + cleanup = () => { + Effect.runFork(Fiber.interrupt(fiber)) + Effect.runFork(source.stop) + } } + + // 强制刷新一次,避免旧的空快照覆盖本地 snapshot + Effect.runPromise(source.refresh) + .then(async (result) => { + if (!mounted) return + pendingTxDebugLog('refresh', walletKey ?? 'none', `len=${result.length}`) + const list = await loadPendingSnapshot() + if (!mounted) return + setTransactions(list) + }) + .finally(() => { + if (!mounted) return + startStream() + }) }).catch(() => { - setIsLoading(false) + if (!mounted) return }) - return () => cleanup?.() - }, [walletId, chainId]) + return () => { + mounted = false + cleanup?.() + } + }, [walletKey, chainId, address, legacyWalletId, loadPendingSnapshot]) + + useEffect(() => { + if (!walletKey) return + void pendingTxManager.syncWalletPendingTransactions(walletKey, chainConfigState) + if (legacyWalletId) { + void pendingTxManager.syncWalletPendingTransactions(legacyWalletId, chainConfigState) + } + }, [walletKey, legacyWalletId, chainConfigState]) // 订阅 pendingTxService 的变化(用于即时更新) useEffect(() => { - if (!walletId) return + if (!walletKey) return const unsubscribe = pendingTxService.subscribe((tx, event) => { - if (tx.walletId !== walletId) return - + if (tx.walletId !== walletKey && tx.walletId !== legacyWalletId) return + if (event === 'created') { - setTransactions(prev => [tx, ...prev]) + if (tx.status === 'confirmed') return + setTransactions((prev) => { + const map = new Map() + for (const item of prev) map.set(item.id, item) + map.set(tx.id, tx) + return Array.from(map.values()).sort((a, b) => b.createdAt - a.createdAt) + }) } else if (event === 'updated') { - setTransactions(prev => prev.map(t => t.id === tx.id ? tx : t)) + setTransactions((prev) => { + if (tx.status === 'confirmed') { + return prev.filter((t) => t.id !== tx.id) + } + const map = new Map() + for (const item of prev) map.set(item.id, item) + map.set(tx.id, tx) + return Array.from(map.values()).sort((a, b) => b.createdAt - a.createdAt) + }) } else if (event === 'deleted') { - setTransactions(prev => prev.filter(t => t.id !== tx.id)) + setTransactions((prev) => prev.filter((t) => t.id !== tx.id)) } }) return unsubscribe - }, [walletId]) + }, [walletKey, legacyWalletId]) const deleteTransaction = useCallback(async (tx: PendingTx) => { await pendingTxService.delete({ id: tx.id }) diff --git a/src/hooks/use-send.bioforest.ts b/src/hooks/use-send.bioforest.ts index c7c314cd6..92bdf81ea 100644 --- a/src/hooks/use-send.bioforest.ts +++ b/src/hooks/use-send.bioforest.ts @@ -1,9 +1,11 @@ import type { AssetInfo } from '@/types/asset'; import type { ChainConfig } from '@/services/chain-config'; +import { chainConfigService } from '@/services/chain-config'; import { Amount } from '@/types/amount'; import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage'; import { getChainProvider } from '@/services/chain-adapter/providers'; -import { pendingTxService } from '@/services/transaction'; +import { pendingTxService, getPendingTxWalletKey } from '@/services/transaction'; +import { getTransferMinFee } from '@/services/bioforest-sdk'; import i18n from '@/i18n'; const t = i18n.t.bind(i18n); @@ -16,9 +18,13 @@ export interface BioforestFeeResult { export async function fetchBioforestFee(chainConfig: ChainConfig, fromAddress: string): Promise { const provider = getChainProvider(chainConfig.id); if (!provider.estimateFee || !provider.buildTransaction) { - // Fallback to zero fee if provider doesn't support estimateFee + const apiUrl = chainConfigService.getBiowalletApi(chainConfig.id); + if (!apiUrl) { + throw new Error(t('error:transaction.feeEstimateFailed')); + } + const minFeeRaw = await getTransferMinFee(apiUrl, chainConfig.id, fromAddress, '1'); return { - amount: Amount.fromRaw('0', chainConfig.decimals, chainConfig.symbol), + amount: Amount.fromRaw(minFeeRaw, chainConfig.decimals, chainConfig.symbol), symbol: chainConfig.symbol, }; } @@ -32,12 +38,23 @@ export async function fetchBioforestFee(chainConfig: ChainConfig, fromAddress: s amount: Amount.fromRaw('1', chainConfig.decimals, chainConfig.symbol), }); - const feeEstimate = await provider.estimateFee(unsignedTx); - - return { - amount: feeEstimate.standard.amount, - symbol: chainConfig.symbol, - }; + try { + const feeEstimate = await provider.estimateFee(unsignedTx); + return { + amount: feeEstimate.standard.amount, + symbol: chainConfig.symbol, + }; + } catch { + const apiUrl = chainConfigService.getBiowalletApi(chainConfig.id); + if (!apiUrl) { + throw new Error(t('error:transaction.feeEstimateFailed')); + } + const minFeeRaw = await getTransferMinFee(apiUrl, chainConfig.id, fromAddress, '1'); + return { + amount: Amount.fromRaw(minFeeRaw, chainConfig.decimals, chainConfig.symbol), + symbol: chainConfig.symbol, + }; + } } export async function fetchBioforestBalance(chainConfig: ChainConfig, fromAddress: string): Promise { @@ -205,7 +222,7 @@ export async function submitBioforestTransfer({ // 存储到 pendingTxService(使用 ChainProvider 标准格式) const pendingTx = await pendingTxService.create({ - walletId, + walletId: getPendingTxWalletKey(chainConfig.id, fromAddress), chainId: chainConfig.id, fromAddress, rawTx: signedTx, diff --git a/src/hooks/use-send.logic.ts b/src/hooks/use-send.logic.ts index 2995a2929..40a72ffdd 100644 --- a/src/hooks/use-send.logic.ts +++ b/src/hooks/use-send.logic.ts @@ -36,10 +36,11 @@ export function canProceedToConfirm(options: { amount: Amount | null; asset: AssetInfo | null; isBioforestChain: boolean; + feeAmount?: Amount | null; feeLoading?: boolean; }): boolean { - const { toAddress, amount, asset, isBioforestChain, feeLoading } = options; - if (!asset || !amount || feeLoading) return false; + const { toAddress, amount, asset, isBioforestChain, feeAmount, feeLoading } = options; + if (!asset || !amount || feeLoading || !feeAmount) return false; return ( toAddress.trim() !== '' && diff --git a/src/hooks/use-send.test.ts b/src/hooks/use-send.test.ts index 669b4868a..673360369 100644 --- a/src/hooks/use-send.test.ts +++ b/src/hooks/use-send.test.ts @@ -148,6 +148,10 @@ describe('useSend', () => { act(() => { result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') result.current.setAmount(amount) + result.current.setAsset(mockAsset) + }) + act(() => { + vi.advanceTimersByTime(300) }) expect(result.current.canProceed).toBe(true) }) diff --git a/src/hooks/use-send.ts b/src/hooks/use-send.ts index 06544d4c3..ac15ab8b2 100644 --- a/src/hooks/use-send.ts +++ b/src/hooks/use-send.ts @@ -135,7 +135,10 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { // Create asset with current balance for validation const assetWithCurrentBalance = useMemo((): AssetInfo | null => { if (!state.asset || !currentBalance) return state.asset; - return { ...state.asset, amount: currentBalance }; + if (currentBalance.decimals === state.asset.decimals) { + return { ...state.asset, amount: currentBalance }; + } + return { ...state.asset, amount: currentBalance, decimals: currentBalance.decimals }; }, [state.asset, currentBalance]); // Check if can proceed @@ -145,9 +148,10 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { amount: state.amount, asset: assetWithCurrentBalance, isBioforestChain, + feeAmount: state.feeAmount, feeLoading: state.feeLoading, }); - }, [isBioforestChain, state.amount, assetWithCurrentBalance, state.toAddress, state.feeLoading]); + }, [isBioforestChain, state.amount, assetWithCurrentBalance, state.toAddress, state.feeAmount, state.feeLoading]); // Validate and go to confirm const goToConfirm = useCallback((): boolean => { @@ -204,7 +208,9 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { async (password: string) => { if (useMock) { const result = await submitMockTransfer(setState); - return result.status === 'ok' ? { status: 'ok' as const } : { status: 'error' as const }; + return result.status === 'ok' + ? { status: 'ok' as const } + : { status: 'error' as const, message: t('transaction:broadcast.unknown') }; } if (!chainConfig) { diff --git a/src/pages/history/index.tsx b/src/pages/history/index.tsx index 87ae6cacf..2ed1e07cd 100644 --- a/src/pages/history/index.tsx +++ b/src/pages/history/index.tsx @@ -60,7 +60,7 @@ function HistoryContent({ targetChain, address, filter, setFilter, walletId, dec transactions: pendingTransactions, deleteTransaction: deletePendingTx, retryTransaction: retryPendingTx, - } = usePendingTransactions(walletId); + } = usePendingTransactions(walletId, targetChain, address); // 客户端过滤:按时间段 const transactions = useMemo(() => { diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index cf673679c..3c42017ff 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -220,21 +220,10 @@ function SendPageContent() { }, [push, selectedChain]); // Derive formatted values for display - get balance from tokens (single source of truth) - const currentToken = useMemo( - () => (state.asset ? tokens.find((t) => t.symbol === state.asset?.assetType) : null), - [state.asset, tokens], - ); - const balance = useMemo( - () => - currentToken - ? Amount.fromFormatted( - currentToken.balance, - currentToken.decimals ?? state.asset?.decimals ?? 8, - currentToken.symbol, - ) - : null, - [currentToken, state.asset?.decimals], - ); + const balance = useMemo(() => { + if (!state.asset) return null; + return getBalance(state.asset.assetType); + }, [getBalance, state.asset]); const symbol = state.asset?.assetType ?? 'TOKEN'; const handleOpenScanner = useCallback(() => { @@ -291,10 +280,14 @@ function SendPageContent() { } if (result.status === 'error') { - return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId }; + return { + status: 'error' as const, + message: result.message ?? t('transaction:broadcast.unknown'), + pendingTxId: result.pendingTxId, + }; } - return { status: 'error' as const, message: t('error:transaction.transferFailed') }; + return { status: 'error' as const, message: t('transaction:broadcast.unknown') }; } // 第二次调用:有钱包锁和二次签名 @@ -313,10 +306,14 @@ function SendPageContent() { } if (result.status === 'error') { - return { status: 'error' as const, message: result.message, pendingTxId: result.pendingTxId }; + return { + status: 'error' as const, + message: result.message ?? t('transaction:broadcast.unknown'), + pendingTxId: result.pendingTxId, + }; } - return { status: 'error' as const, message: t('error:transaction.unknownError') }; + return { status: 'error' as const, message: t('transaction:broadcast.unknown') }; }); push('TransferWalletLockJob', { diff --git a/src/services/chain-adapter/bioforest/chain-service.ts b/src/services/chain-adapter/bioforest/chain-service.ts index d47d25883..dea611660 100644 --- a/src/services/chain-adapter/bioforest/chain-service.ts +++ b/src/services/chain-adapter/bioforest/chain-service.ts @@ -2,14 +2,16 @@ * BioForest Chain Service */ +import { Effect } from 'effect' import type { ChainConfig } from '@/services/chain-config' import { chainConfigService } from '@/services/chain-config' +import { httpFetch } from '@biochain/chain-effect' import { Amount } from '@/types/amount' import type { IChainService, ChainInfo, GasPrice, HealthStatus } from '../types' import { ChainServiceError, ChainErrorCodes } from '../types' import type { BioforestBlockInfo } from './types' import { getTransferMinFee } from '@/services/bioforest-sdk' -import { getChainFetchInstances } from './fetch' +import { LastBlockSchema } from './fetch' export class BioforestChainService implements IChainService { private readonly chainId: string @@ -58,9 +60,13 @@ export class BioforestChainService implements IChainService { } try { - // 使用 keyFetch 实例获取区块高度(Schema 验证 + 响应式轮询) - const instances = getChainFetchInstances(this.chainId, this.baseUrl) - const json = await instances.lastBlock.fetch({}) + const json = await Effect.runPromise( + httpFetch({ + url: `${this.baseUrl}/lastblock`, + method: 'GET', + schema: LastBlockSchema, + }) + ) if (!json.success) { throw new ChainServiceError(ChainErrorCodes.NETWORK_ERROR, 'API returned success=false') diff --git a/src/services/chain-adapter/providers/biowallet-provider.effect.ts b/src/services/chain-adapter/providers/biowallet-provider.effect.ts index 571aac301..8aa9f6816 100644 --- a/src/services/chain-adapter/providers/biowallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/biowallet-provider.effect.ts @@ -9,13 +9,11 @@ import { Effect, Duration, Stream, Fiber } from 'effect'; import { Schema as S } from 'effect'; import { - httpFetch, httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, acquireSource, - releaseSource, makeRegistryKey, type FetchError, type DataSource, @@ -288,16 +286,6 @@ function convertBioTransactionToTransaction( }; } -// ==================== 判断交易列表是否变化 ==================== - -function hasTransactionListChanged(prev: TransactionsOutput | null, next: TransactionsOutput): boolean { - if (!prev) return true; - if (prev.length !== next.length) return true; - if (prev.length === 0 && next.length === 0) return false; - // 比较第一条交易的 hash - return prev[0]?.hash !== next[0]?.hash; -} - // ==================== Base Class for Mixins ==================== class BiowalletBase { @@ -326,13 +314,40 @@ export class BiowalletProviderEffect private forgeInterval: number = 15000; private epochMs: number = DEFAULT_EPOCH_MS; - // 缓存 TTL = 出块时间 / 2 - private get cacheTtl(): number { - return this.forgeInterval / 2; + // pendingTx 轮询去重 TTL = pendingTxInterval / 2(BioChain: forgeInterval/4) + private get pendingTxCacheTtl(): number { + return Math.max(1000, Math.floor(this.forgeInterval / 4)); } // Provider 级别共享的 EventBus(延迟初始化) private _eventBus: EventBusService | null = null; + private _txHistorySources = new Map< + string, + { + source: DataSource; + refCount: number; + stopAll: Effect.Effect; + } + >(); + private _txHistoryCreations = new Map>>(); + private _balanceSources = new Map< + string, + { + source: DataSource; + refCount: number; + stopAll: Effect.Effect; + } + >(); + private _balanceCreations = new Map>>(); + private _tokenBalanceSources = new Map< + string, + { + source: DataSource; + refCount: number; + stopAll: Effect.Effect; + } + >(); + private _tokenBalanceCreations = new Map>>(); // StreamInstance 接口(React 兼容层) readonly nativeBalance: StreamInstance; @@ -434,184 +449,366 @@ export class BiowalletProviderEffect ): Effect.Effect> { const provider = this; const normalizedAddress = address.toLowerCase(); + const cacheKey = normalizedAddress; - return Effect.gen(function* () { - // 获取共享的 blockHeight source 作为依赖 - const blockHeightSource = yield* provider.getSharedBlockHeightSource(); - - // 依赖 blockHeight 变化触发刷新 - const source = yield* createDependentSource({ - name: `biowallet.${provider.chainId}.txHistory.${normalizedAddress}`, - dependsOn: blockHeightSource.ref, - hasChanged: (prev, curr) => prev !== curr, - fetch: (_dep: BlockHeightOutput, _forceRefresh?: boolean) => - provider.fetchTransactionList({ address, limit: 50 }, true).pipe( - Effect.map((raw): TransactionsOutput => { - if (!raw.result?.trs) return []; - - return raw.result.trs - .map((item): Transaction | null => { - const tx = item.transaction; - const action = detectAction(tx.type); - const direction = getDirection(tx.senderId, tx.recipientId ?? '', normalizedAddress); - const { value, assetType } = extractAssetInfo(tx.asset, symbol); - if (value === null) return null; - - return { - hash: tx.signature ?? item.signature, - from: tx.senderId, - to: tx.recipientId ?? '', - timestamp: provider.epochMs + tx.timestamp * 1000, - status: 'confirmed', - blockNumber: BigInt(item.height), - action, - direction, - assets: [ - { - assetType: 'native' as const, - value, - symbol: assetType, - decimals, - }, - ], - }; - }) - .filter((tx): tx is Transaction => tx !== null) - .sort((a, b) => b.timestamp - a.timestamp); - }), - ), + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTxHistorySource(cacheKey), + }); + + const cached = provider._txHistorySources.get(cacheKey); + if (cached) { + cached.refCount += 1; + return Effect.succeed(wrapSharedSource(cached.source)); + } + + const pending = provider._txHistoryCreations.get(cacheKey); + if (pending) { + return Effect.promise(async () => { + const source = await pending; + const entry = provider._txHistorySources.get(cacheKey); + if (entry) { + entry.refCount += 1; + } + return wrapSharedSource(source); }); + } + + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + // 获取共享的 blockHeight source 作为依赖 + const blockHeightSource = yield* provider.getSharedBlockHeightSource(); + + // 依赖 blockHeight 变化触发刷新 + const source = yield* createDependentSource({ + name: `biowallet.${provider.chainId}.txHistory.${normalizedAddress}`, + dependsOn: blockHeightSource.ref, + hasChanged: () => true, + fetch: (_dep: BlockHeightOutput, _forceRefresh?: boolean) => + provider.fetchTransactionList({ address, limit: 50 }, true).pipe( + Effect.map((raw): TransactionsOutput => { + if (!raw.result?.trs) return []; + + return raw.result.trs + .map((item): Transaction | null => { + const tx = item.transaction; + const action = detectAction(tx.type); + const direction = getDirection(tx.senderId, tx.recipientId ?? '', normalizedAddress); + const { value, assetType } = extractAssetInfo(tx.asset, symbol); + if (value === null) return null; + + return { + hash: tx.signature ?? item.signature, + from: tx.senderId, + to: tx.recipientId ?? '', + timestamp: provider.epochMs + tx.timestamp * 1000, + status: 'confirmed', + blockNumber: BigInt(item.height), + action, + direction, + assets: [ + { + assetType: 'native' as const, + value, + symbol: assetType, + decimals, + }, + ], + }; + }) + .filter((tx): tx is Transaction => tx !== null) + .sort((a, b) => b.timestamp - a.timestamp); + }), + ), + }); + + // pendingTx 确认事件需要触发 txHistory 刷新(即使高度未变化) + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus(); + } + const eventBus = provider._eventBus; + + const eventFiber = yield* eventBus + .forWalletEvents(provider.chainId, normalizedAddress, ['tx:confirmed']) + .pipe( + Stream.runForEach(() => + Effect.catchAll(source.refresh, () => Effect.void), + ), + Effect.forkDaemon, + ); + + const stopAll = Effect.all([source.stop, Fiber.interrupt(eventFiber)]).pipe(Effect.asVoid); + + provider._txHistorySources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }); + + return source; + }) + ); - // pendingTx 确认事件需要触发 txHistory 刷新(即使高度未变化) - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus(); + provider._txHistoryCreations.set(cacheKey, createPromise); + + try { + const source = await createPromise; + return wrapSharedSource(source); + } finally { + provider._txHistoryCreations.delete(cacheKey); } - const eventBus = provider._eventBus; - - const eventFiber = yield* eventBus - .forWalletEvents(provider.chainId, normalizedAddress, ['tx:confirmed']) - .pipe( - Stream.runForEach(() => - Effect.catchAll(source.refresh, () => Effect.void), - ), - Effect.forkDaemon, - ); - - return { - ...source, - stop: Effect.all([source.stop, Fiber.interrupt(eventFiber)]).pipe(Effect.asVoid), - }; }); } - private createTransactionHistorySource( - params: TxHistoryParams, - symbol: string, - decimals: number, - ): Effect.Effect> { - // 直接使用共享的 txHistory source - return this.getSharedTxHistorySource(params.address, symbol, decimals); + private releaseSharedTxHistorySource(key: string): Effect.Effect { + const provider = this; + return Effect.gen(function* () { + const entry = provider._txHistorySources.get(key); + if (!entry) return; + + entry.refCount -= 1; + if (entry.refCount <= 0) { + yield* entry.stopAll; + provider._txHistorySources.delete(key); + } + }); } - private createBalanceSource( - params: AddressParams, + private getSharedBalanceSource( + address: string, symbol: string, decimals: number, ): Effect.Effect> { const provider = this; + const normalizedAddress = address.toLowerCase(); + const cacheKey = normalizedAddress; - return Effect.gen(function* () { - // 先创建 transactionHistory source 作为依赖 - const txHistorySource = yield* provider.createTransactionHistorySource( - { address: params.address, limit: 1 }, - symbol, - decimals, - ); + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }); - // 依赖 transactionHistory 变化 - const source = yield* createDependentSource({ - name: `biowallet.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: (_dep, forceRefresh: boolean) => - provider.fetchAddressAsset(params.address, forceRefresh).pipe( - Effect.map((raw): BalanceOutput => { - if (!raw.result?.assets) { - return { amount: Amount.zero(decimals, symbol), symbol }; - } - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === symbol) { - return { - amount: Amount.fromRaw(asset.assetNumber, decimals, symbol), - symbol, - }; - } - } - } - return { amount: Amount.zero(decimals, symbol), symbol }; - }), - ), + const cached = provider._balanceSources.get(cacheKey); + if (cached) { + cached.refCount += 1; + return Effect.succeed(wrapSharedSource(cached.source)); + } + + const pending = provider._balanceCreations.get(cacheKey); + if (pending) { + return Effect.promise(async () => { + const source = await pending; + const entry = provider._balanceSources.get(cacheKey); + if (entry) { + entry.refCount += 1; + } + return wrapSharedSource(source); }); + } - return source; + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address, symbol, decimals); + + const source = yield* createDependentSource({ + name: `biowallet.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + // 强制跟随 txHistory 刷新,避免余额卡住 + hasChanged: () => true, + fetch: (_dep, forceRefresh?: boolean) => + provider.fetchAddressAsset(address, forceRefresh ?? false).pipe( + Effect.map((raw): BalanceOutput => { + if (!raw.result?.assets) { + return { amount: Amount.zero(decimals, symbol), symbol }; + } + for (const magic of Object.values(raw.result.assets)) { + for (const asset of Object.values(magic)) { + if (asset.assetType === symbol) { + return { + amount: Amount.fromRaw(asset.assetNumber, decimals, symbol), + symbol, + }; + } + } + } + return { amount: Amount.zero(decimals, symbol), symbol }; + }), + ), + }); + + const stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid); + + provider._balanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }); + + return source; + }) + ); + + provider._balanceCreations.set(cacheKey, createPromise); + + try { + const source = await createPromise; + return wrapSharedSource(source); + } finally { + provider._balanceCreations.delete(cacheKey); + } }); } - private createTokenBalancesSource( - params: AddressParams, + private releaseSharedBalanceSource(key: string): Effect.Effect { + const provider = this; + return Effect.gen(function* () { + const entry = provider._balanceSources.get(key); + if (!entry) return; + + entry.refCount -= 1; + if (entry.refCount <= 0) { + yield* entry.stopAll; + provider._balanceSources.delete(key); + } + }); + } + + private getSharedTokenBalancesSource( + address: string, symbol: string, decimals: number, ): Effect.Effect> { const provider = this; + const normalizedAddress = address.toLowerCase(); + const cacheKey = normalizedAddress; - return Effect.gen(function* () { - // 先创建 transactionHistory source 作为依赖 - const txHistorySource = yield* provider.createTransactionHistorySource( - { address: params.address, limit: 1 }, - symbol, - decimals, - ); + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTokenBalancesSource(cacheKey), + }); + + const cached = provider._tokenBalanceSources.get(cacheKey); + if (cached) { + cached.refCount += 1; + return Effect.succeed(wrapSharedSource(cached.source)); + } - // 依赖 transactionHistory 变化 - const source = yield* createDependentSource({ - name: `biowallet.${provider.chainId}.tokenBalances`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: (_dep, forceRefresh: boolean) => - provider.fetchAddressAsset(params.address, forceRefresh).pipe( - Effect.map((raw): TokenBalancesOutput => { - if (!raw.result?.assets) return []; - const tokens: TokenBalance[] = []; - - for (const magic of Object.values(raw.result.assets)) { - for (const asset of Object.values(magic)) { - const isNative = asset.assetType === symbol; - tokens.push({ - symbol: asset.assetType, - name: asset.assetType, - amount: Amount.fromRaw(asset.assetNumber, decimals, asset.assetType), - isNative, - decimals, + const pending = provider._tokenBalanceCreations.get(cacheKey); + if (pending) { + return Effect.promise(async () => { + const source = await pending; + const entry = provider._tokenBalanceSources.get(cacheKey); + if (entry) { + entry.refCount += 1; + } + return wrapSharedSource(source); + }); + } + + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address, symbol, decimals); + + const source = yield* createDependentSource({ + name: `biowallet.${provider.chainId}.tokenBalances`, + dependsOn: txHistorySource.ref, + // 强制跟随 txHistory 刷新,避免余额卡住 + hasChanged: () => true, + fetch: (_dep, forceRefresh?: boolean) => + provider.fetchAddressAsset(address, forceRefresh ?? false).pipe( + Effect.map((raw): TokenBalancesOutput => { + if (!raw.result?.assets) return []; + const tokens: TokenBalance[] = []; + + for (const magic of Object.values(raw.result.assets)) { + for (const asset of Object.values(magic)) { + const isNative = asset.assetType === symbol; + tokens.push({ + symbol: asset.assetType, + name: asset.assetType, + amount: Amount.fromRaw(asset.assetNumber, decimals, asset.assetType), + isNative, + decimals, + }); + } + } + + tokens.sort((a, b) => { + if (a.isNative && !b.isNative) return -1; + if (!a.isNative && b.isNative) return 1; + return b.amount.toNumber() - a.amount.toNumber(); }); - } - } - tokens.sort((a, b) => { - if (a.isNative && !b.isNative) return -1; - if (!a.isNative && b.isNative) return 1; - return b.amount.toNumber() - a.amount.toNumber(); - }); + return tokens; + }), + ), + }); - return tokens; - }), - ), - }); + const stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid); - return source; + provider._tokenBalanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }); + + return source; + }) + ); + + provider._tokenBalanceCreations.set(cacheKey, createPromise); + + try { + const source = await createPromise; + return wrapSharedSource(source); + } finally { + provider._tokenBalanceCreations.delete(cacheKey); + } + }); + } + + private releaseSharedTokenBalancesSource(key: string): Effect.Effect { + const provider = this; + return Effect.gen(function* () { + const entry = provider._tokenBalanceSources.get(key); + if (!entry) return; + + entry.refCount -= 1; + if (entry.refCount <= 0) { + yield* entry.stopAll; + provider._tokenBalanceSources.delete(key); + } }); } + private createTransactionHistorySource( + params: TxHistoryParams, + symbol: string, + decimals: number, + ): Effect.Effect> { + // 直接使用共享的 txHistory source + return this.getSharedTxHistorySource(params.address, symbol, decimals); + } + + private createBalanceSource( + params: AddressParams, + symbol: string, + decimals: number, + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address, symbol, decimals); + } + + private createTokenBalancesSource( + params: AddressParams, + symbol: string, + decimals: number, + ): Effect.Effect> { + return this.getSharedTokenBalancesSource(params.address, symbol, decimals); + } + private createBlockHeightSource(): Effect.Effect> { // 使用共享的 blockHeight source return this.getSharedBlockHeightSource(); @@ -626,6 +823,16 @@ export class BiowalletProviderEffect confirmed: provider.fetchSingleTransaction(params.txHash), }).pipe( Effect.map(({ pending, confirmed }): TransactionOutput => { + if (confirmed.result?.trs?.length) { + const item = confirmed.result.trs[0]; + return convertBioTransactionToTransaction(item.transaction, { + signature: item.transaction.signature ?? item.signature, + height: item.height, + status: 'confirmed', + epochMs: provider.epochMs, + }); + } + if (pending.result && pending.result.length > 0) { const pendingTx = pending.result.find((tx) => tx.signature === params.txHash); if (pendingTx) { @@ -638,16 +845,6 @@ export class BiowalletProviderEffect } } - if (confirmed.result?.trs?.length) { - const item = confirmed.result.trs[0]; - return convertBioTransactionToTransaction(item.transaction, { - signature: item.transaction.signature ?? item.signature, - height: item.height, - status: 'confirmed', - epochMs: provider.epochMs, - }); - } - return null; }), ); @@ -704,20 +901,24 @@ export class BiowalletProviderEffect } private fetchSingleTransaction(txHash: string): Effect.Effect { - return httpFetch({ + return httpFetchCached({ url: `${this.baseUrl}/transactions/query`, method: 'POST', body: { signature: txHash }, schema: TxListResponseSchema, + cacheStrategy: 'ttl', + cacheTtl: this.pendingTxCacheTtl, }); } private fetchPendingTransactions(senderId: string): Effect.Effect { - return httpFetch({ + return httpFetchCached({ url: `${this.baseUrl}/pendingTr`, method: 'POST', body: { senderId, sort: -1 }, schema: PendingTrResponseSchema, + cacheStrategy: 'ttl', + cacheTtl: this.pendingTxCacheTtl, }); } } diff --git a/src/services/chain-adapter/providers/bscwallet-provider.effect.ts b/src/services/chain-adapter/providers/bscwallet-provider.effect.ts index 8de8089dd..4cfc5a35a 100644 --- a/src/services/chain-adapter/providers/bscwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/bscwallet-provider.effect.ts @@ -47,7 +47,6 @@ const TxItemSchema = S.Struct({ timestamp: S.Number, status: S.optional(S.String), }) -type TxItem = S.Schema.Type const TxApiSchema = S.Struct({ transactions: S.Array(TxItemSchema), diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts index 6c8334049..103c588dc 100644 --- a/src/services/chain-adapter/providers/chain-provider.ts +++ b/src/services/chain-adapter/providers/chain-provider.ts @@ -6,9 +6,9 @@ */ import { Effect, Stream } from "effect" +import { useState, useEffect, useMemo, useRef, useCallback, useSyncExternalStore } from "react" import { createStreamInstance, type StreamInstance, type FetchError } from "@biochain/chain-effect" import { chainConfigService } from "@/services/chain-config" -import { Amount } from "@/types/amount" import type { ApiProvider, ApiProviderMethod, @@ -27,11 +27,67 @@ import type { TransactionParams, TransactionStatusParams, TransactionStatusOutput, - TokenBalance, } from "./types" const SYNC_METHODS = new Set(["isValidAddress", "normalizeAddress"]) +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null +} + +function toStableJson(value: unknown): unknown { + if (typeof value === "bigint") { + return value.toString() + } + if (!isRecord(value)) { + if (Array.isArray(value)) { + return value.map(toStableJson) + } + return value + } + if (Array.isArray(value)) { + return value.map(toStableJson) + } + const sorted: UnknownRecord = {} + for (const key of Object.keys(value).sort()) { + sorted[key] = toStableJson(value[key]) + } + return sorted +} + +function stableStringify(value: unknown): string { + return JSON.stringify(toStableJson(value)) +} + +function isDebugEnabled(): boolean { + if (typeof globalThis === "undefined") return false + const store = globalThis as typeof globalThis & { __CHAIN_EFFECT_DEBUG__?: boolean } + return store.__CHAIN_EFFECT_DEBUG__ === true +} + +function debugLog(...args: string[]): void { + if (!isDebugEnabled()) return + console.log("[chain-provider]", ...args) +} + +function summarizeValue(value: unknown): string { + if (Array.isArray(value)) { + const first = value[0] + if (isRecord(first) && "hash" in first) { + return `array(len=${value.length}, firstHash=${String(first.hash)})` + } + return `array(len=${value.length})` + } + if (isRecord(value)) { + if ("hash" in value) return `object(hash=${String(value.hash)})` + if ("symbol" in value) return `object(symbol=${String(value.symbol)})` + return "object" + } + return String(value) +} + /** * 创建 fallback StreamInstance - 依次尝试多个 source,返回第一个成功的 */ @@ -50,27 +106,151 @@ function createFallbackStream( return sources[0] } - return createStreamInstance(name, (input) => { - // 创建一个 fallback stream:尝试第一个,失败则尝试下一个 - const trySource = (index: number): Stream.Stream => { - if (index >= sources.length) { - return Stream.fail({ _tag: "HttpError", message: "All providers failed" } as FetchError) + debugLog(`${name} fallback`, `sources=${sources.map((s) => s.name).join(",")}`) + + let resolvedSource: StreamInstance | null = null + + const resolveSource = async (input: TInput): Promise> => { + if (resolvedSource) return resolvedSource + + for (const source of sources) { + try { + await source.fetch(input) + resolvedSource = source + debugLog(`${name} resolved`, source.name) + return source + } catch { + // try next } + } - const source = sources[index] - // 获取 source 的 stream,使用 subscribe 内部逻辑 - return Stream.fromEffect( - Effect.tryPromise({ - try: () => source.fetch(input), - catch: (e) => ({ _tag: "HttpError", message: String(e) }) as FetchError, + // fallback to first source to keep behavior stable + resolvedSource = sources[0] + debugLog(`${name} resolved`, sources[0].name) + return sources[0] + } + + const fetch = async (input: TInput): Promise => { + for (const source of sources) { + try { + const result = await source.fetch(input) + resolvedSource = source + return result + } catch { + // try next + } + } + throw new Error("All providers failed") + } + + const subscribe = ( + input: TInput, + callback: (data: TOutput, event: "initial" | "update") => void + ): (() => void) => { + let cancelled = false + let cleanup: (() => void) | null = null + + const key = input === undefined || input === null ? "__empty__" : stableStringify(input) + debugLog(`${name} subscribe`, key) + resolveSource(input) + .then((source) => { + if (cancelled) return + cleanup = source.subscribe(input, (data, event) => { + debugLog(`${name} emit`, event, summarizeValue(data)) + callback(data, event) }) - ).pipe( - Stream.catchAll(() => trySource(index + 1)) - ) + }) + .catch((err) => { + console.error(`[${name}] resolveSource failed:`, err) + }) + + return () => { + cancelled = true + cleanup?.() } + } + + return { + name, + fetch, + subscribe, + useState(input: TInput, options?: { enabled?: boolean }) { + const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) + const [error, setError] = useState(undefined) + + const getInputKey = (value: TInput): string => { + if (value === undefined || value === null) return "__empty__" + return stableStringify(value) + } + + const inputKey = useMemo(() => getInputKey(input), [input]) + const inputRef = useRef(input) + inputRef.current = input + + const enabled = options?.enabled !== false + + const snapshotRef = useRef(undefined) - return trySource(0) - }) + const subscribeFn = useCallback((onStoreChange: () => void) => { + if (!enabled) { + snapshotRef.current = undefined + return () => {} + } + + setIsLoading(true) + setIsFetching(true) + setError(undefined) + + const unsubscribe = subscribe(inputRef.current, (newData: TOutput) => { + snapshotRef.current = newData + setIsLoading(false) + setIsFetching(false) + setError(undefined) + debugLog(`${name} storeChange`, summarizeValue(newData)) + onStoreChange() + }) + + return unsubscribe + }, [enabled, inputKey]) + + const getSnapshot = useCallback(() => snapshotRef.current, []) + + const data = useSyncExternalStore(subscribeFn, getSnapshot, getSnapshot) + + const refetch = useCallback(async () => { + if (!enabled) return + setIsFetching(true) + setError(undefined) + try { + const result = await fetch(inputRef.current) + snapshotRef.current = result + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setIsFetching(false) + setIsLoading(false) + } + }, [enabled]) + + useEffect(() => { + if (!enabled) { + snapshotRef.current = undefined + setIsLoading(false) + setIsFetching(false) + setError(undefined) + } + }, [enabled]) + + return { data, isLoading, isFetching, error, refetch } + }, + invalidate(): void { + resolvedSource = null + for (const source of sources) { + source.invalidate() + } + }, + } } export class ChainProvider { diff --git a/src/services/chain-adapter/providers/evm-rpc-provider.effect.ts b/src/services/chain-adapter/providers/evm-rpc-provider.effect.ts index 65a10e63b..c745a504b 100644 --- a/src/services/chain-adapter/providers/evm-rpc-provider.effect.ts +++ b/src/services/chain-adapter/providers/evm-rpc-provider.effect.ts @@ -18,7 +18,7 @@ import { type DataSource, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import type { ApiProvider, BalanceOutput, BlockHeightOutput, TransactionOutput, AddressParams, TransactionParams, Action } from "./types" +import type { ApiProvider, BalanceOutput, BlockHeightOutput, TransactionOutput, AddressParams, TransactionParams, Action, Transaction } from "./types" import type { ParsedApiEntry } from "@/services/chain-config" import { chainConfigService } from "@/services/chain-config" import { Amount } from "@/types/amount" @@ -205,13 +205,14 @@ export class EvmRpcProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(E const value = BigInt(tx.value || "0x0").toString() - return { + const blockNumber = receipt?.blockNumber ? BigInt(receipt.blockNumber) : undefined + + const base: Transaction = { hash: tx.hash, from: tx.from, to: tx.to ?? "", timestamp: Date.now(), status, - blockNumber: receipt?.blockNumber ? BigInt(receipt.blockNumber) : undefined, action: (tx.to ? "transfer" : "contract") as Action, direction: "out" as const, assets: [{ @@ -221,6 +222,8 @@ export class EvmRpcProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(E decimals, }], } + + return blockNumber === undefined ? base : { ...base, blockNumber } }) const source = yield* createDependentSource({ diff --git a/src/services/chain-adapter/providers/moralis-provider.effect.ts b/src/services/chain-adapter/providers/moralis-provider.effect.ts index c0fc3b5a5..d77031cf6 100644 --- a/src/services/chain-adapter/providers/moralis-provider.effect.ts +++ b/src/services/chain-adapter/providers/moralis-provider.effect.ts @@ -6,7 +6,7 @@ * - balance/tokenBalances: 依赖 transactionHistory 变化 */ -import { Effect, Stream, Schedule, Duration } from "effect" +import { Effect, Schedule, Duration } from "effect" import { Schema as S } from "effect" import { httpFetch, @@ -218,7 +218,6 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( private readonly baseUrl: string private readonly txStatusInterval: number - private readonly balanceInterval: number private readonly erc20Interval: number private readonly rpcUrl: string @@ -250,7 +249,6 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( this.apiKey = apiKey this.txStatusInterval = (this.config?.txStatusInterval as number) ?? 3000 - this.balanceInterval = (this.config?.balanceInterval as number) ?? 30000 this.erc20Interval = (this.config?.erc20Interval as number) ?? 120000 this.rpcUrl = chainConfigService.getRpcUrl(chainId) @@ -492,13 +490,17 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( } const eventBus = provider._eventBus - const fetchEffect = provider.fetchTransactionReceipt(params.txHash).pipe( + const fetchEffect: Effect.Effect = provider.fetchTransactionReceipt( + params.txHash + ).pipe( Effect.retry(rateLimitRetrySchedule), Effect.flatMap((raw) => Effect.gen(function* () { const receipt = raw.result - if (!receipt || !receipt.blockNumber) { - return { status: "pending" as const, confirmations: 0, requiredConfirmations: 1 } + const isConfirmed = !!receipt?.blockNumber + if (!isConfirmed) { + const status: TransactionStatusOutput["status"] = "pending" + return { status, confirmations: 0, requiredConfirmations: 1 } } // 交易已确认,发送事件通知(带钱包标识) @@ -507,8 +509,9 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( } const isSuccess = receipt.status === "0x1" || receipt.status === undefined + const status: TransactionStatusOutput["status"] = isSuccess ? "confirmed" : "failed" return { - status: isSuccess ? ("confirmed" as const) : ("failed" as const), + status, confirmations: 1, requiredConfirmations: 1, } @@ -549,7 +552,9 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( accept: "application/json", }, schema: TokenBalancesResponseSchema, - }) + }).pipe( + Effect.map((tokens) => tokens.slice()) + ) } private fetchWalletHistory(params: TxHistoryParams): Effect.Effect { diff --git a/src/services/chain-adapter/providers/types.ts b/src/services/chain-adapter/providers/types.ts index 1865d5943..5cca37692 100644 --- a/src/services/chain-adapter/providers/types.ts +++ b/src/services/chain-adapter/providers/types.ts @@ -11,7 +11,7 @@ * Provider 内部通过 transform 插件转换为标准输出类型 */ -import { Amount, type AmountJSON } from '@/types/amount' +import { Amount } from '@/types/amount' import type { ParsedApiEntry } from '@/services/chain-config' import type { StreamInstance } from '@biochain/chain-effect' import { z } from 'zod' @@ -69,12 +69,14 @@ export type TxHistoryParams = z.infer /** 单笔交易查询参数 */ export const TransactionParamsSchema = z.object({ txHash: z.string(), + senderId: z.string().optional(), }) export type TransactionParams = z.infer /** 交易状态查询参数 */ export const TransactionStatusParamsSchema = z.object({ txHash: z.string(), + address: z.string().optional(), }) export type TransactionStatusParams = z.infer @@ -256,4 +258,3 @@ export type ApiProviderMethod = keyof Omit ApiProvider | null - diff --git a/src/services/ecosystem/registry.ts b/src/services/ecosystem/registry.ts index 93509f82c..5b8d0334e 100644 --- a/src/services/ecosystem/registry.ts +++ b/src/services/ecosystem/registry.ts @@ -8,7 +8,7 @@ */ import { Effect } from 'effect'; -import { httpFetch, type FetchError } from '@biochain/chain-effect'; +import { HttpError, SchemaError, type FetchError } from '@biochain/chain-effect'; import { ecosystemStore, ecosystemSelectors, ecosystemActions } from '@/stores/ecosystem'; import type { EcosystemSource, MiniappManifest, SourceRecord } from './types'; import { EcosystemSearchResponseSchema, EcosystemSourceSchema } from './schema'; @@ -163,10 +163,24 @@ function fetchSearchResults(url: string): Effect.Effect<{ version: string; data: const ttlCached = getTTLCached<{ version: string; data: MiniappManifest[] }>(`search:${url}`); if (ttlCached) return Effect.succeed(ttlCached); - return httpFetch({ - url, - method: 'GET', - schema: EcosystemSearchResponseSchema, + return Effect.tryPromise({ + try: async () => { + const response = await fetch(url); + if (!response.ok) { + throw new HttpError(`Search request failed: ${response.status}`, response.status); + } + const json = await response.json(); + const parsed = EcosystemSearchResponseSchema.safeParse(json); + if (!parsed.success) { + throw new SchemaError('Invalid ecosystem search response', parsed.error); + } + return parsed.data; + }, + catch: (error) => { + if (error instanceof HttpError || error instanceof SchemaError) return error; + if (error instanceof Error) return new HttpError(error.message); + return new HttpError('Unknown search error'); + }, }).pipe( Effect.tap((result) => Effect.sync(() => { diff --git a/src/services/transaction/index.ts b/src/services/transaction/index.ts index 67fea00de..3ad972faa 100644 --- a/src/services/transaction/index.ts +++ b/src/services/transaction/index.ts @@ -27,6 +27,7 @@ export { PendingTxMetaSchema, CreatePendingTxInputSchema, UpdatePendingTxStatusInputSchema, + getPendingTxWalletKey, getPendingTxSource, subscribePendingTxChanges, clearPendingTxSources, diff --git a/src/services/transaction/pending-tx-manager.ts b/src/services/transaction/pending-tx-manager.ts index 193ef8996..e5c54d3ef 100644 --- a/src/services/transaction/pending-tx-manager.ts +++ b/src/services/transaction/pending-tx-manager.ts @@ -164,8 +164,7 @@ class PendingTxManagerImpl { break; case 'broadcasted': - // 已广播,检查是否已上链 - await this.checkConfirmation(tx, chainConfigState); + // 已广播的确认检查由 pendingTx polling source 统一处理,避免重复查询 break; case 'broadcasting': @@ -246,73 +245,6 @@ class PendingTxManagerImpl { } } - /** - * 检查交易是否已上链 - */ - private async checkConfirmation(tx: PendingTx, chainConfigState: ReturnType) { - if (!tx.txHash) return; - - const chainConfig = chainConfigSelectors.getChainById(chainConfigState, tx.chainId); - if (!chainConfig) return; - - const biowallet = chainConfig.apis?.find((p) => p.type === 'biowallet-v1'); - const apiUrl = biowallet?.endpoint; - if (!apiUrl) return; - - try { - // 查询交易状态 - const queryUrl = `${apiUrl}/transactions/query`; - const queryBody = { - signature: tx.txHash, - page: 1, - pageSize: 1, - maxHeight: Number.MAX_SAFE_INTEGER, - }; - - const response = await fetch(queryUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(queryBody), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const json = (await response.json()) as { success: boolean; result?: { count: number } }; - - if (json.success && json.result && json.result.count > 0) { - // 交易已上链 - const updated = await pendingTxService.updateStatus({ - id: tx.id, - status: 'confirmed', - }); - this.notifySubscribers(updated); - - // 发送交易确认通知 - this.sendNotification(updated, 'confirmed'); - - // 发送交易确认通知 - this.sendNotification(updated, 'confirmed'); - - // Note: 以前这里会调用 invalidateBalance (使用 React Query)。 - // 现在系统完全依赖 Effect 数据源的 deps 机制进行自动刷新。 - // 当交易上链 -> blockApi 刷新 -> txList 和 pendingTx 自动刷新。 - - - } else { - // 检查是否超时 - const elapsed = Date.now() - tx.updatedAt; - if (elapsed > CONFIG.CONFIRM_TIMEOUT) { - - // 不自动标记失败,只记录日志,让用户决定 - } - } - } catch (error) { - - } - } - /** * 手动重试广播 */ diff --git a/src/services/transaction/pending-tx.ts b/src/services/transaction/pending-tx.ts index b78837872..440fa923e 100644 --- a/src/services/transaction/pending-tx.ts +++ b/src/services/transaction/pending-tx.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; import { Effect, Stream, Duration } from 'effect'; -import { createPollingSource, txConfirmedEvent, type DataSource } from '@biochain/chain-effect'; +import { createPollingSource, txConfirmedEvent, HttpError, type FetchError, type DataSource } from '@biochain/chain-effect'; import { getChainProvider } from '@/services/chain-adapter/providers'; import { getWalletEventBus } from '@/services/chain-adapter/wallet-event-bus'; import { defineServiceMeta } from '@/lib/service-meta'; @@ -18,6 +18,17 @@ import { getForgeInterval } from '@/services/chain-adapter/bioforest/fetch'; // ==================== Schema ==================== +function isPendingTxDebugEnabled(): boolean { + if (typeof globalThis === 'undefined') return false; + const store = globalThis as typeof globalThis & { __CHAIN_EFFECT_DEBUG__?: boolean }; + return store.__CHAIN_EFFECT_DEBUG__ === true; +} + +function pendingTxDebugLog(...args: Array): void { + if (!isPendingTxDebugEnabled()) return; + console.log('[chain-effect]', 'pending-tx', ...args); +} + /** 未上链交易状态 */ export const PendingTxStatusSchema = z.enum([ 'created', // 交易已创建,待广播 @@ -115,6 +126,10 @@ export type PendingTx = z.infer; export type PendingTxStatus = z.infer; export type PendingTxMeta = z.infer; +export function getPendingTxWalletKey(chainId: string, address: string): string { + return `${chainId}:${address.trim().toLowerCase()}`; +} + /** 创建 pending tx 的输入 */ export const CreatePendingTxInputSchema = z.object({ walletId: z.string(), @@ -315,11 +330,13 @@ class PendingTxServiceImpl implements IPendingTxService { async getAll({ walletId }: { walletId: string }): Promise { const db = await this.ensureDb(); const records = await db.getAllFromIndex(STORE_NAME, 'by-wallet', walletId); - return records + const list = records .map((r) => PendingTxSchema.safeParse(r)) .filter((r) => r.success) .map((r) => r.data) .sort((a, b) => b.createdAt - a.createdAt); + pendingTxDebugLog('getAll', walletId, `len=${list.length}`); + return list; } async getById({ id }: { id: string }): Promise { @@ -333,16 +350,20 @@ class PendingTxServiceImpl implements IPendingTxService { async getByStatus({ walletId, status }: { walletId: string; status: PendingTxStatus }): Promise { const db = await this.ensureDb(); const records = await db.getAllFromIndex(STORE_NAME, 'by-wallet-status', [walletId, status]); - return records + const list = records .map((r) => PendingTxSchema.safeParse(r)) .filter((r) => r.success) .map((r) => r.data) .sort((a, b) => b.createdAt - a.createdAt); + pendingTxDebugLog('getByStatus', walletId, status, `len=${list.length}`); + return list; } async getPending({ walletId }: { walletId: string }): Promise { const all = await this.getAll({ walletId }); - return all.filter((tx) => tx.status !== 'confirmed'); + const list = all.filter((tx) => tx.status !== 'confirmed'); + pendingTxDebugLog('getPending', walletId, `len=${list.length}`); + return list; } // ===== 生命周期管理 ===== @@ -368,6 +389,7 @@ class PendingTxServiceImpl implements IPendingTxService { }; await db.put(STORE_NAME, pendingTx); + pendingTxDebugLog('create', pendingTx.walletId, pendingTx.status, pendingTx.id); this.notify(pendingTx, 'created'); return pendingTx; } @@ -392,6 +414,7 @@ class PendingTxServiceImpl implements IPendingTxService { }; await db.put(STORE_NAME, updated); + pendingTxDebugLog('updateStatus', updated.walletId, `${existing.status}->${updated.status}`, updated.id); this.notify(updated, 'updated'); return updated; } @@ -422,6 +445,7 @@ class PendingTxServiceImpl implements IPendingTxService { const existing = await db.get(STORE_NAME, id); await db.delete(STORE_NAME, id); if (existing) { + pendingTxDebugLog('delete', existing.walletId, existing.status, existing.id); this.notify(existing, 'deleted'); } } @@ -479,6 +503,38 @@ class PendingTxServiceImpl implements IPendingTxService { await Promise.all(all.map((item) => tx.store.delete(item.id))); await tx.done; } + + async normalizeWalletKeyByAddress({ + chainId, + address, + walletId, + }: { + chainId: string; + address: string; + walletId: string; + }): Promise { + const db = await this.ensureDb(); + const normalizedAddress = address.trim().toLowerCase(); + const records = await db.getAll(STORE_NAME); + let updatedCount = 0; + + for (const record of records) { + if (record.chainId !== chainId) continue; + if (record.fromAddress?.toLowerCase() !== normalizedAddress) continue; + if (record.walletId === walletId) continue; + + const updated: PendingTx = { + ...(record as PendingTx), + walletId, + updatedAt: Date.now(), + }; + await db.put(STORE_NAME, updated); + this.notify(updated, 'updated'); + updatedCount += 1; + } + + return updatedCount; + } } /** 单例服务实例 */ @@ -487,7 +543,15 @@ export const pendingTxService = new PendingTxServiceImpl(); // ==================== Effect Data Source Factory ==================== // 缓存已创建的 Effect 数据源实例 -const pendingTxSources = new Map>(); +type PendingTxSourceEntry = { + source: DataSource; + refCount: number; + walletIds: Set; +}; + +const pendingTxSources = new Map(); +const pendingTxCreations = new Map>>(); +const pendingTxCreationWalletIds = new Map>(); const MIN_PENDING_TX_POLL_MS = 15_000; type ChainConfigWithBlockTime = ChainConfig & { @@ -554,61 +618,133 @@ function getPendingTxPollInterval(chainId: string): Duration.Duration { */ export function getPendingTxSource( chainId: string, - walletId: string + walletId: string, + legacyWalletId?: string ): Effect.Effect> | null { const key = `${chainId}:${walletId}`; + const normalizedLegacy = + legacyWalletId && legacyWalletId !== walletId ? legacyWalletId : undefined; + + const mergePendingLists = (lists: PendingTx[][]): PendingTx[] => { + const map = new Map(); + for (const list of lists) { + for (const tx of list) { + map.set(tx.id, tx); + } + } + return Array.from(map.values()).sort((a, b) => b.createdAt - a.createdAt); + }; + + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: releasePendingTxSource(key), + }); const cached = pendingTxSources.get(key); if (cached) { - return Effect.succeed(cached); + if (normalizedLegacy) { + cached.walletIds.add(normalizedLegacy); + } + cached.refCount += 1; + return Effect.succeed(wrapSharedSource(cached.source)); + } + + const pending = pendingTxCreations.get(key); + if (pending) { + const walletIds = pendingTxCreationWalletIds.get(key); + if (walletIds && normalizedLegacy) { + walletIds.add(normalizedLegacy); + } + return Effect.promise(async () => { + const source = await pending; + const entry = pendingTxSources.get(key); + if (entry) { + if (normalizedLegacy) { + entry.walletIds.add(normalizedLegacy); + } + entry.refCount += 1; + } + return wrapSharedSource(source); + }); } const chainProvider = getChainProvider(chainId); // 使用独立轮询(频率不同于出块) const interval = getPendingTxPollInterval(chainId); - - return createPollingSource({ - name: `pendingTx.${chainId}.${walletId}`, - interval, - fetch: () => - Effect.tryPromise({ - try: async () => { - // pending 确认后需触发 txHistory 刷新(不依赖区块高度变化) - const eventBus = await Effect.runPromise(getWalletEventBus()); - // 检查 pending 交易状态,更新/移除已上链的 - const pending = await pendingTxService.getPending({ walletId }); - - for (const tx of pending) { - if (tx.status === 'broadcasted' && tx.txHash) { - try { - // 检查是否已上链 - const txInfo = await chainProvider.transaction.fetch({ txHash: tx.txHash }); - if (txInfo?.status === 'confirmed') { - await Effect.runPromise( - eventBus.emit(txConfirmedEvent(chainId, tx.fromAddress, tx.txHash)), - ); - // 直接删除已确认的交易 - await pendingTxService.delete({ id: tx.id }); - } - } catch { - // 查询失败,跳过 - } + const walletIds = new Set([walletId]); + if (normalizedLegacy) { + walletIds.add(normalizedLegacy); + } + pendingTxCreationWalletIds.set(key, walletIds); + + const fetchEffect: Effect.Effect = Effect.tryPromise({ + try: async () => { + // pending 确认后需触发 txHistory 刷新(不依赖区块高度变化) + const eventBus = await Effect.runPromise(getWalletEventBus()); + // 检查 pending 交易状态,更新/移除已上链的 + const walletIdList = Array.from(walletIds); + const pendingLists = await Promise.all( + walletIdList.map((id) => pendingTxService.getPending({ walletId: id })) + ); + const pending = mergePendingLists(pendingLists); + pendingTxDebugLog('poll', `walletIds=${walletIdList.join(',')}`, `len=${pending.length}`); + const deletedIds = new Set(); + + for (const tx of pending) { + if (tx.status === 'broadcasted' && tx.txHash) { + try { + // 检查是否已上链 + const txInfo = await chainProvider.transaction.fetch({ + txHash: tx.txHash, + senderId: tx.fromAddress, + }); + if (txInfo?.status === 'confirmed') { + await Effect.runPromise( + eventBus.emit(txConfirmedEvent(chainId, tx.fromAddress, tx.txHash)), + ); + // 直接删除已确认的交易 + await pendingTxService.delete({ id: tx.id }); + deletedIds.add(tx.id); } + } catch { + // 查询失败,跳过 } + } + } - // 返回最新的 pending 列表 - return await pendingTxService.getPending({ walletId }); - }, - catch: (error) => error as Error, - }), - }).pipe( - Effect.tap((source) => - Effect.sync(() => { - pendingTxSources.set(key, source); - }) - ) + // 返回最新的 pending 列表 + if (deletedIds.size === 0) { + return pending; + } + pendingTxDebugLog('poll', 'confirmed-removed', `count=${deletedIds.size}`); + return pending.filter((tx) => !deletedIds.has(tx.id)); + }, + catch: (error) => + new HttpError( + error instanceof Error ? error.message : 'Pending transaction fetch failed' + ), + }); + + const createPromise = Effect.runPromise( + createPollingSource({ + name: `pendingTx.${chainId}.${walletId}`, + interval, + fetch: fetchEffect, + }) ); + pendingTxCreations.set(key, createPromise); + + return Effect.promise(async () => { + try { + const source = await createPromise; + pendingTxSources.set(key, { source, refCount: 1, walletIds }); + return wrapSharedSource(source); + } finally { + pendingTxCreations.delete(key); + pendingTxCreationWalletIds.delete(key); + } + }); } /** @@ -616,9 +752,10 @@ export function getPendingTxSource( */ export function subscribePendingTxChanges( chainId: string, - walletId: string + walletId: string, + legacyWalletId?: string ): Stream.Stream | null { - const sourceEffect = getPendingTxSource(chainId, walletId); + const sourceEffect = getPendingTxSource(chainId, walletId, legacyWalletId); if (!sourceEffect) return null; return Stream.fromEffect(sourceEffect).pipe( @@ -631,9 +768,24 @@ export function subscribePendingTxChanges( */ export function clearPendingTxSources(): Effect.Effect { return Effect.gen(function* () { - for (const source of pendingTxSources.values()) { - yield* source.stop; + for (const entry of pendingTxSources.values()) { + yield* entry.source.stop; } pendingTxSources.clear(); + pendingTxCreations.clear(); + pendingTxCreationWalletIds.clear(); + }); +} + +function releasePendingTxSource(key: string): Effect.Effect { + return Effect.gen(function* () { + const entry = pendingTxSources.get(key); + if (!entry) return; + + entry.refCount -= 1; + if (entry.refCount <= 0) { + yield* entry.source.stop; + pendingTxSources.delete(key); + } }); } diff --git a/src/services/transaction/web.ts b/src/services/transaction/web.ts index e84cd01b5..8ac8c9278 100644 --- a/src/services/transaction/web.ts +++ b/src/services/transaction/web.ts @@ -153,7 +153,7 @@ export const transactionService = transactionServiceMeta.impl({ const provider = getChainProvider(config.id) if (!provider.supports('transaction')) return null - const tx = await provider.transaction.fetch({ hash: parsed.hash }) + const tx = await provider.transaction.fetch({ txHash: parsed.hash }) if (!tx) return null const record = mapProviderTransaction(tx, config) diff --git a/src/stackflow/activities/sheets/TransferWalletLockJob.tsx b/src/stackflow/activities/sheets/TransferWalletLockJob.tsx index b313eaf17..040e3adbc 100644 --- a/src/stackflow/activities/sheets/TransferWalletLockJob.tsx +++ b/src/stackflow/activities/sheets/TransferWalletLockJob.tsx @@ -184,17 +184,19 @@ function TransferWalletLockJobContent() { if (result.status === 'wallet_lock_invalid') { setPatternError(true); setPattern([]); + setTxStatus("idle"); return; } if (result.status === 'two_step_secret_required') { setStep('two_step_secret'); setError(undefined); + setTxStatus("idle"); return; } if (result.status === 'error') { - setError(result.message ?? t("security:walletLock.error")); + setError(result.message ?? t("transaction:broadcast.unknown")); setTxStatus("failed"); if (result.pendingTxId) { setPendingTxId(result.pendingTxId); @@ -202,8 +204,8 @@ function TransferWalletLockJobContent() { return; } } catch { - setPatternError(true); - setPattern([]); + setError(t("transaction:broadcast.unknown")); + setTxStatus("failed"); } finally { setIsVerifying(false); } diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index 7f45d846e..ebcd24807 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -109,7 +109,7 @@ function WalletTabContent({ transactions: pendingTransactions, deleteTransaction: deletePendingTx, retryTransaction: retryPendingTx, - } = usePendingTransactions(currentWalletId ?? undefined, selectedChain); + } = usePendingTransactions(currentWalletId ?? undefined, selectedChain, address); // 转换交易历史格式 const transactions = useMemo(() => { diff --git a/src/stackflow/components/TabBar.tsx b/src/stackflow/components/TabBar.tsx index 7bdcb06d1..1b3f58257 100644 --- a/src/stackflow/components/TabBar.tsx +++ b/src/stackflow/components/TabBar.tsx @@ -17,7 +17,7 @@ import { useSwiperMember } from '@/components/common/swiper-sync-context'; import { ecosystemStore, type EcosystemSubPage } from '@/stores/ecosystem'; import { miniappRuntimeStore, miniappRuntimeSelectors, openStackView } from '@/services/miniapp-runtime'; import { usePendingTransactions } from '@/hooks/use-pending-transactions'; -import { useCurrentWallet } from '@/stores'; +import { useCurrentWallet, useCurrentChainAddress, useSelectedChain } from '@/stores'; /** 生态页面顺序 */ const ECOSYSTEM_PAGE_ORDER: EcosystemSubPage[] = ['discover', 'mine', 'stack']; @@ -139,7 +139,13 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { // Pending transactions count for wallet tab badge const currentWallet = useCurrentWallet(); - const { transactions: pendingTxs } = usePendingTransactions(currentWallet?.id); + const currentChainAddress = useCurrentChainAddress(); + const selectedChain = useSelectedChain(); + const { transactions: pendingTxs } = usePendingTransactions( + currentWallet?.id, + selectedChain, + currentChainAddress?.address, + ); const pendingTxCount = pendingTxs.filter((tx) => tx.status !== 'confirmed').length; // 生态 Tab 是否激活 From 654a47879a323609919f0015ef884c4cb00b60e0 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 02:50:07 +0800 Subject: [PATCH 32/33] feat: complete implementation --- packages/chain-effect/src/http.ts | 2 +- packages/chain-effect/src/source.ts | 3 +- .../src/index.ts | 3 +- src/hooks/use-pending-transactions.ts | 31 ++++++++++++++++-- src/pages/history/index.tsx | 2 +- src/services/transaction/pending-tx.ts | 32 +++++++++++++++++++ src/stackflow/activities/tabs/WalletTab.tsx | 2 +- 7 files changed, 68 insertions(+), 7 deletions(-) diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts index b3ad6d1b8..b24342420 100644 --- a/packages/chain-effect/src/http.ts +++ b/packages/chain-effect/src/http.ts @@ -77,7 +77,7 @@ export interface FetchOptions { /** 请求体(POST)*/ body?: unknown; /** 响应 Schema */ - schema?: Schema.Schema; + schema?: Schema.Schema; /** 超时时间(毫秒)*/ timeout?: number; /** 缓存策略(浏览器层面的 Request cache)*/ diff --git a/packages/chain-effect/src/source.ts b/packages/chain-effect/src/source.ts index 82f82f431..693ae304f 100644 --- a/packages/chain-effect/src/source.ts +++ b/packages/chain-effect/src/source.ts @@ -9,7 +9,8 @@ * @see https://context7.com/effect-ts/effect/llms.txt - Effect Stream 创建和转换 */ -import { Effect, Stream, Schedule, SubscriptionRef, Duration, PubSub, Fiber } from "effect" +import { Effect, Stream, Schedule, SubscriptionRef, PubSub, Fiber } from "effect" +import type { Duration } from "effect" import type { FetchError } from "./http" import type { EventBusService, WalletEventType } from "./event-bus" diff --git a/packages/eslint-plugin-file-component-constraints/src/index.ts b/packages/eslint-plugin-file-component-constraints/src/index.ts index 74cd21415..af4c10758 100644 --- a/packages/eslint-plugin-file-component-constraints/src/index.ts +++ b/packages/eslint-plugin-file-component-constraints/src/index.ts @@ -5,9 +5,10 @@ * 根据文件路径模式,强制要求或禁止使用特定组件 */ +import type { ESLint } from 'eslint' import { enforceRule } from './rules/enforce' -const plugin = { +const plugin: ESLint.Plugin = { meta: { name: 'eslint-plugin-file-component-constraints', version: '0.1.0', diff --git a/src/hooks/use-pending-transactions.ts b/src/hooks/use-pending-transactions.ts index e30240d77..1aaa1463d 100644 --- a/src/hooks/use-pending-transactions.ts +++ b/src/hooks/use-pending-transactions.ts @@ -5,10 +5,11 @@ * 使用 Effect 数据源,依赖 blockHeight 自动刷新 */ -import { useCallback, useState, useEffect } from 'react' +import { useCallback, useState, useEffect, useMemo } from 'react' import { Effect, Stream, Fiber } from 'effect' import { pendingTxService, pendingTxManager, getPendingTxSource, getPendingTxWalletKey, type PendingTx } from '@/services/transaction' import { useChainConfigState } from '@/stores' +import type { Transaction } from '@/services/chain-adapter/providers' function isPendingTxDebugEnabled(): boolean { if (typeof globalThis === 'undefined') return false @@ -21,10 +22,26 @@ function pendingTxDebugLog(...args: Array): void { console.log('[chain-effect]', 'pending-tx', ...args) } -export function usePendingTransactions(walletId: string | undefined, chainId?: string, address?: string) { +export function usePendingTransactions( + walletId: string | undefined, + chainId?: string, + address?: string, + txHistory?: ReadonlyArray, +) { const chainConfigState = useChainConfigState() const walletKey = chainId && address ? getPendingTxWalletKey(chainId, address) : walletId const legacyWalletId = walletId && walletId !== walletKey ? walletId : undefined + const confirmedTxHashes = useMemo(() => { + if (!txHistory?.length) return [] + const set = new Set() + for (const tx of txHistory) { + if (tx?.hash) { + set.add(tx.hash.toLowerCase()) + } + } + return Array.from(set).sort() + }, [txHistory]) + const confirmedTxHashKey = useMemo(() => confirmedTxHashes.join('|'), [confirmedTxHashes]) // 手动管理状态 const [transactions, setTransactions] = useState([]) @@ -164,6 +181,16 @@ export function usePendingTransactions(walletId: string | undefined, chainId?: s } }, [walletKey, legacyWalletId, chainConfigState]) + useEffect(() => { + if (!walletKey || confirmedTxHashes.length === 0) return + void (async () => { + await pendingTxService.deleteByTxHash({ walletId: walletKey, txHashes: confirmedTxHashes }) + if (legacyWalletId) { + await pendingTxService.deleteByTxHash({ walletId: legacyWalletId, txHashes: confirmedTxHashes }) + } + })() + }, [walletKey, legacyWalletId, confirmedTxHashKey]) + // 订阅 pendingTxService 的变化(用于即时更新) useEffect(() => { if (!walletKey) return diff --git a/src/pages/history/index.tsx b/src/pages/history/index.tsx index 2ed1e07cd..84289bd13 100644 --- a/src/pages/history/index.tsx +++ b/src/pages/history/index.tsx @@ -60,7 +60,7 @@ function HistoryContent({ targetChain, address, filter, setFilter, walletId, dec transactions: pendingTransactions, deleteTransaction: deletePendingTx, retryTransaction: retryPendingTx, - } = usePendingTransactions(walletId, targetChain, address); + } = usePendingTransactions(walletId, targetChain, address, rawTransactions); // 客户端过滤:按时间段 const transactions = useMemo(() => { diff --git a/src/services/transaction/pending-tx.ts b/src/services/transaction/pending-tx.ts index 440fa923e..a9b71ea4f 100644 --- a/src/services/transaction/pending-tx.ts +++ b/src/services/transaction/pending-tx.ts @@ -180,6 +180,14 @@ export const pendingTxServiceMeta = defineServiceMeta('pendingTx', (s) => // ===== 清理 ===== .api('delete', z.object({ id: z.string() }), z.void()) + .api( + 'deleteByTxHash', + z.object({ + walletId: z.string(), + txHashes: z.array(z.string()), + }), + z.void(), + ) .api('deleteConfirmed', z.object({ walletId: z.string() }), z.void()) .api('deleteAll', z.object({ walletId: z.string() }), z.void()), ); @@ -458,6 +466,30 @@ class PendingTxServiceImpl implements IPendingTxService { await tx.done; } + async deleteByTxHash({ walletId, txHashes }: { walletId: string; txHashes: string[] }): Promise { + if (txHashes.length === 0) return; + const normalized = new Set(txHashes.map((hash) => hash.trim().toLowerCase()).filter(Boolean)); + if (normalized.size === 0) return; + + const all = await this.getAll({ walletId }); + const matched = all.filter((item) => { + if (!item.txHash) return false; + return normalized.has(item.txHash.toLowerCase()); + }); + + if (matched.length === 0) return; + + const db = await this.ensureDb(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + await Promise.all(matched.map((item) => tx.store.delete(item.id))); + await tx.done; + + for (const item of matched) { + pendingTxDebugLog('delete', item.walletId, item.status, item.id); + this.notify(item, 'deleted'); + } + } + async deleteExpired({ walletId, maxAge, diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index ebcd24807..069d8555c 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -109,7 +109,7 @@ function WalletTabContent({ transactions: pendingTransactions, deleteTransaction: deletePendingTx, retryTransaction: retryPendingTx, - } = usePendingTransactions(currentWalletId ?? undefined, selectedChain, address); + } = usePendingTransactions(currentWalletId ?? undefined, selectedChain, address, txResult); // 转换交易历史格式 const transactions = useMemo(() => { From 4f148c4427991547574b9c062a177cceb8475da3 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 02:55:36 +0800 Subject: [PATCH 33/33] feat: complete implementation --- deno.lock | 1 + packages/chain-effect/package.json | 3 ++- pnpm-lock.yaml | 9 ++++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/deno.lock b/deno.lock index feeb5424b..9a9f17119 100644 --- a/deno.lock +++ b/deno.lock @@ -10369,6 +10369,7 @@ "npm:react@19", "npm:superjson@^2.2.6", "npm:typescript@^5.9.3", + "npm:vite-plugin-dts@^4.5.4", "npm:vitest@4" ] } diff --git a/packages/chain-effect/package.json b/packages/chain-effect/package.json index 676ab8b69..7423bce8a 100644 --- a/packages/chain-effect/package.json +++ b/packages/chain-effect/package.json @@ -35,7 +35,8 @@ "oxlint": "^1.32.0", "react": "^19.0.0", "typescript": "^5.9.3", - "vitest": "^4.0.0" + "vitest": "^4.0.0", + "vite-plugin-dts": "^4.5.4" }, "keywords": [ "biochain", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8a165e38..ba911530e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -732,6 +732,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(@types/node@24.10.4)(rollup@4.54.0)(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) vitest: specifier: ^4.0.0 version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) @@ -11354,7 +11357,7 @@ snapshots: '@vitest/browser': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vitest@4.0.16) '@vitest/browser-playwright': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vitest@4.0.16) '@vitest/runner': 4.0.16 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) transitivePeerDependencies: - react - react-dom @@ -11930,7 +11933,7 @@ snapshots: '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) playwright: 1.57.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw @@ -11946,7 +11949,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2) ws: 8.18.3 transitivePeerDependencies: - bufferutil