From 0227362f6106de64f8e238c9afb95e9f8dc72fc7 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 10:21:00 +0800 Subject: [PATCH 1/6] feat: align providers with shared wallet event bus --- .../changes/update-provider-tx-sync/proposal.md | 13 +++++++++++++ .../specs/sync-transactions/spec.md | 15 +++++++++++++++ openspec/changes/update-provider-tx-sync/tasks.md | 4 ++++ .../providers/bscwallet-provider.effect.ts | 4 ++-- .../providers/btcwallet-provider.effect.ts | 4 ++-- .../providers/etherscan-v1-provider.effect.ts | 6 +++--- .../providers/etherscan-v2-provider.effect.ts | 6 +++--- .../providers/ethwallet-provider.effect.ts | 4 ++-- .../providers/mempool-provider.effect.ts | 4 ++-- .../providers/moralis-provider.effect.ts | 8 ++++---- .../providers/tron-rpc-provider.effect.ts | 6 +++--- .../providers/tronwallet-provider.effect.ts | 4 ++-- 12 files changed, 55 insertions(+), 23 deletions(-) create mode 100644 openspec/changes/update-provider-tx-sync/proposal.md create mode 100644 openspec/changes/update-provider-tx-sync/specs/sync-transactions/spec.md create mode 100644 openspec/changes/update-provider-tx-sync/tasks.md diff --git a/openspec/changes/update-provider-tx-sync/proposal.md b/openspec/changes/update-provider-tx-sync/proposal.md new file mode 100644 index 000000000..dfbf71c21 --- /dev/null +++ b/openspec/changes/update-provider-tx-sync/proposal.md @@ -0,0 +1,13 @@ +# Change: Align providers with biowallet pendingTx/txHistory sync + +## Why +Pending transaction confirmations currently trigger txHistory refresh only for biowallet because other providers use isolated event buses. This causes stale transaction history and pending list mismatches across chains. + +## What Changes +- Use the shared wallet event bus for all providers that register walletEvents. +- Ensure transactionHistory sources for every provider react to tx:confirmed/tx:sent events. +- Keep biowallet behavior as the benchmark for sync semantics. + +## Impact +- Affected specs: sync-transactions (new) +- Affected code: provider effect files using walletEvents, shared wallet-event-bus usage diff --git a/openspec/changes/update-provider-tx-sync/specs/sync-transactions/spec.md b/openspec/changes/update-provider-tx-sync/specs/sync-transactions/spec.md new file mode 100644 index 000000000..b0c73441d --- /dev/null +++ b/openspec/changes/update-provider-tx-sync/specs/sync-transactions/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements +### Requirement: Provider txHistory reacts to pending confirmations +The system SHALL refresh transaction history for any provider when a pending transaction is confirmed for the same chainId and address. + +#### Scenario: Pending confirmation triggers refresh +- **WHEN** a tx:confirmed event is emitted for a chainId/address +- **THEN** the provider transactionHistory source refreshes for that chainId/address + +### Requirement: Shared wallet event bus for provider events +The system SHALL use a shared wallet event bus so pendingTx updates and provider sources observe the same events. + +#### Scenario: Shared bus used by provider sources +- **GIVEN** a provider creates a transactionHistory source with walletEvents +- **WHEN** an event is emitted on the shared wallet event bus +- **THEN** the provider source reacts without provider-specific event bus instances diff --git a/openspec/changes/update-provider-tx-sync/tasks.md b/openspec/changes/update-provider-tx-sync/tasks.md new file mode 100644 index 000000000..3a132a01a --- /dev/null +++ b/openspec/changes/update-provider-tx-sync/tasks.md @@ -0,0 +1,4 @@ +## 1. Implementation +- [x] 1.1 Replace provider-local event bus creation with shared wallet event bus +- [x] 1.2 Verify walletEvents include tx:confirmed/tx:sent for all providers +- [x] 1.3 Run full verify (lint/typecheck/test/storybook) and ensure green diff --git a/src/services/chain-adapter/providers/bscwallet-provider.effect.ts b/src/services/chain-adapter/providers/bscwallet-provider.effect.ts index 4cfc5a35a..cce38fd1b 100644 --- a/src/services/chain-adapter/providers/bscwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/bscwallet-provider.effect.ts @@ -11,12 +11,12 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Transaction, @@ -131,7 +131,7 @@ export class BscWalletProviderEffect extends EvmIdentityMixin(EvmTransactionMixi return Effect.gen(function* () { if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus diff --git a/src/services/chain-adapter/providers/btcwallet-provider.effect.ts b/src/services/chain-adapter/providers/btcwallet-provider.effect.ts index 474a86e47..20453133b 100644 --- a/src/services/chain-adapter/providers/btcwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/btcwallet-provider.effect.ts @@ -13,12 +13,12 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from "./types" import type { ParsedApiEntry } from "@/services/chain-config" import { chainConfigService } from "@/services/chain-config" @@ -134,7 +134,7 @@ export class BtcWalletProviderEffect extends BitcoinIdentityMixin(BitcoinTransac return Effect.gen(function* () { if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus diff --git a/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts b/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts index 4c789865a..96a78e7e2 100644 --- a/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts +++ b/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts @@ -13,13 +13,13 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, - HttpError, + HttpError, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams, Transaction } from "./types" import type { ParsedApiEntry } from "@/services/chain-config" import { chainConfigService } from "@/services/chain-config" @@ -161,7 +161,7 @@ export class EtherscanV1ProviderEffect extends EvmIdentityMixin(EvmTransactionMi return Effect.gen(function* () { if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus diff --git a/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts b/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts index a6ebfcff1..b4eea4d15 100644 --- a/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts +++ b/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts @@ -13,13 +13,13 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, - HttpError, + HttpError, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams, Transaction } from "./types" import type { ParsedApiEntry } from "@/services/chain-config" import { chainConfigService } from "@/services/chain-config" @@ -169,7 +169,7 @@ export class EtherscanV2ProviderEffect extends EvmIdentityMixin(EvmTransactionMi return Effect.gen(function* () { if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus diff --git a/src/services/chain-adapter/providers/ethwallet-provider.effect.ts b/src/services/chain-adapter/providers/ethwallet-provider.effect.ts index feb645f8f..4afabce16 100644 --- a/src/services/chain-adapter/providers/ethwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/ethwallet-provider.effect.ts @@ -13,12 +13,12 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Transaction, @@ -158,7 +158,7 @@ export class EthWalletProviderEffect extends EvmIdentityMixin(EvmTransactionMixi return Effect.gen(function* () { if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus diff --git a/src/services/chain-adapter/providers/mempool-provider.effect.ts b/src/services/chain-adapter/providers/mempool-provider.effect.ts index 28e68a4ab..b0b78c072 100644 --- a/src/services/chain-adapter/providers/mempool-provider.effect.ts +++ b/src/services/chain-adapter/providers/mempool-provider.effect.ts @@ -14,12 +14,12 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Direction, BalanceOutput, BlockHeightOutput, TransactionsOutput, AddressParams, TxHistoryParams } from "./types" import type { ParsedApiEntry } from "@/services/chain-config" import { chainConfigService } from "@/services/chain-config" @@ -168,7 +168,7 @@ export class MempoolProviderEffect extends BitcoinIdentityMixin(BitcoinTransacti return Effect.gen(function* () { if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus diff --git a/src/services/chain-adapter/providers/moralis-provider.effect.ts b/src/services/chain-adapter/providers/moralis-provider.effect.ts index d77031cf6..5e36035aa 100644 --- a/src/services/chain-adapter/providers/moralis-provider.effect.ts +++ b/src/services/chain-adapter/providers/moralis-provider.effect.ts @@ -13,13 +13,13 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, - txConfirmedEvent, + txConfirmedEvent, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, TokenBalance, @@ -294,7 +294,7 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( return Effect.gen(function* () { // 获取或创建 Provider 级别共享的 EventBus if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus @@ -486,7 +486,7 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( return Effect.gen(function* () { // 获取或创建 Provider 级别共享的 EventBus if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts b/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts index 2ffc5681d..ea890bef8 100644 --- a/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts +++ b/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts @@ -13,13 +13,13 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, - + type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Direction, @@ -249,7 +249,7 @@ export class TronRpcProviderEffect extends TronIdentityMixin(TronTransactionMixi return Effect.gen(function* () { // 获取或创建 Provider 级别共享的 EventBus if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus diff --git a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts index 6778d49d4..ffc815eb4 100644 --- a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts @@ -11,12 +11,12 @@ import { createStreamInstanceFromSource, createPollingSource, createDependentSource, - createEventBusService, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Transaction, @@ -134,7 +134,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM return Effect.gen(function* () { if (!provider._eventBus) { - provider._eventBus = yield* createEventBusService + provider._eventBus = yield* getWalletEventBus() } const eventBus = provider._eventBus From f21f094ec1952c94fdb5f93e6c2b58e6bf8a1977 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 10:46:17 +0800 Subject: [PATCH 2/6] fix(agent-flow): sync env and install deps for worktree --- scripts/agent-flow/workflows/task.workflow.ts | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/scripts/agent-flow/workflows/task.workflow.ts b/scripts/agent-flow/workflows/task.workflow.ts index b2ccaedb8..3675d344a 100755 --- a/scripts/agent-flow/workflows/task.workflow.ts +++ b/scripts/agent-flow/workflows/task.workflow.ts @@ -52,12 +52,52 @@ import { getLabels, } from "../mcps/git-workflow.mcp.ts"; import { getRelatedChapters } from "../mcps/whitebook.mcp.ts"; +import { join } from "jsr:@std/path"; +import { exists } from "jsr:@std/fs"; // ============================================================================= // Constants // ============================================================================= const WORKTREE_BASE = ".git-worktree"; +const ENV_EXCLUDES = new Set([".env.example"]); + +async function syncEnvFiles(root: string, worktreePath: string): Promise { + const copied: string[] = []; + for await (const entry of Deno.readDir(root)) { + if (!entry.isFile) continue; + if (!entry.name.startsWith(".env")) continue; + if (ENV_EXCLUDES.has(entry.name)) continue; + + const src = join(root, entry.name); + const dest = join(worktreePath, entry.name); + if (await exists(dest)) continue; + + await Deno.copyFile(src, dest); + copied.push(entry.name); + } + return copied; +} + +async function ensurePnpmInstall(path: string) { + const nodeModules = join(path, "node_modules"); + if (await exists(nodeModules)) { + console.log(" ℹ️ node_modules 已存在,跳过 pnpm install"); + return; + } + + const command = new Deno.Command("pnpm", { + args: ["install"], + cwd: path, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const { code } = await command.output(); + if (code !== 0) { + throw new Error("pnpm install failed"); + } +} // ============================================================================= // Templates @@ -224,8 +264,19 @@ const startWorkflow = defineWorkflow({ message: `chore: start issue #${issueId} [skip ci]`, }); - // 7. 创建 Draft PR - console.log("\n4️⃣ 创建 Draft PR..."); + // 7. 同步 env 与安装依赖 + console.log("\n4️⃣ 同步开发环境..."); + const copiedEnv = await syncEnvFiles(Deno.cwd(), path); + if (copiedEnv.length > 0) { + console.log(` ✅ 已同步 env 文件: ${copiedEnv.join(", ")}`); + } else { + console.log(" ℹ️ 未发现可同步的 env 文件"); + } + console.log(" 🔧 安装依赖..."); + await ensurePnpmInstall(path); + + // 8. 创建 Draft PR + console.log("\n5️⃣ 创建 Draft PR..."); const { url: prUrl } = await createPr({ title, body: `Closes #${issueId}\n\n${description}`, From d3f003c9a0bdc85e6dcc38324f5a8087e14d8d3f Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 10:49:52 +0800 Subject: [PATCH 3/6] fix(agent-flow): always copy env files into worktree --- scripts/agent-flow/workflows/task.workflow.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/agent-flow/workflows/task.workflow.ts b/scripts/agent-flow/workflows/task.workflow.ts index 3675d344a..ac4558f11 100755 --- a/scripts/agent-flow/workflows/task.workflow.ts +++ b/scripts/agent-flow/workflows/task.workflow.ts @@ -71,7 +71,9 @@ async function syncEnvFiles(root: string, worktreePath: string): Promise Date: Sat, 24 Jan 2026 11:02:48 +0800 Subject: [PATCH 4/6] fix(tronwallet): align wallet api endpoints and address handling --- public/configs/default-chains.json | 147 ++++++++++++++--- .../providers/tronwallet-provider.effect.ts | 149 ++++++++++++++---- 2 files changed, 242 insertions(+), 54 deletions(-) diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index 48dbf1c47..de5887ecc 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -15,7 +15,15 @@ ], "prefix": "b", "decimals": 8, - "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bf-meta.org/wallet/bfmetav2", "config": { "genesisBlock": "./genesis/bfmetav2.json" } }], + "apis": [ + { + "type": "biowallet-v1", + "endpoint": "https://walletapi.bf-meta.org/wallet/bfmetav2", + "config": { + "genesisBlock": "./genesis/bfmetav2.json" + } + } + ], "explorer": { "url": "https://tracker.bf-meta.org", "queryTx": "https://tracker.bf-meta.org/#/info/event-details/:signature", @@ -37,7 +45,15 @@ ], "prefix": "b", "decimals": 8, - "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/bfm", "config": { "genesisBlock": "./genesis/bfmeta.json" } }], + "apis": [ + { + "type": "biowallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/bfm", + "config": { + "genesisBlock": "./genesis/bfmeta.json" + } + } + ], "explorer": { "url": "https://tracker.bfmeta.org", "queryTx": "https://tracker.bfmeta.org/#/info/event-details/:signature", @@ -59,7 +75,15 @@ ], "prefix": "b", "decimals": 8, - "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/ccchain", "config": { "genesisBlock": "./genesis/ccchain.json" } }] + "apis": [ + { + "type": "biowallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/ccchain", + "config": { + "genesisBlock": "./genesis/ccchain.json" + } + } + ] }, { "id": "pmchain", @@ -75,7 +99,15 @@ ], "prefix": "b", "decimals": 8, - "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/pmchain", "config": { "genesisBlock": "./genesis/pmchain.json" } }] + "apis": [ + { + "type": "biowallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/pmchain", + "config": { + "genesisBlock": "./genesis/pmchain.json" + } + } + ] }, { "id": "bfchainv2", @@ -91,7 +123,15 @@ ], "prefix": "b", "decimals": 8, - "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/bfchainv2", "config": { "genesisBlock": "./genesis/bfchainv2.json" } }] + "apis": [ + { + "type": "biowallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/bfchainv2", + "config": { + "genesisBlock": "./genesis/bfchainv2.json" + } + } + ] }, { "id": "btgmeta", @@ -107,7 +147,15 @@ ], "prefix": "b", "decimals": 8, - "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/btgmeta", "config": { "genesisBlock": "./genesis/btgmeta.json" } }] + "apis": [ + { + "type": "biowallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/btgmeta", + "config": { + "genesisBlock": "./genesis/btgmeta.json" + } + } + ] }, { "id": "biwmeta", @@ -121,7 +169,15 @@ ], "prefix": "b", "decimals": 8, - "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/biwmeta", "config": { "genesisBlock": "./genesis/biwmeta.json" } }], + "apis": [ + { + "type": "biowallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/biwmeta", + "config": { + "genesisBlock": "./genesis/biwmeta.json" + } + } + ], "explorer": { "url": "https://tracker.biw-meta.info", "queryTx": "https://tracker.biw-meta.info/#/info/event-details/:signature", @@ -143,7 +199,15 @@ ], "prefix": "b", "decimals": 8, - "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/ethmeta", "config": { "genesisBlock": "./genesis/ethmeta.json" } }] + "apis": [ + { + "type": "biowallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/ethmeta", + "config": { + "genesisBlock": "./genesis/ethmeta.json" + } + } + ] }, { "id": "ethereum", @@ -170,16 +234,30 @@ { "type": "etherscan-v2", "endpoint": "https://api.etherscan.io/v2/api", - "config": { "apiKeyEnv": "ETHERSCAN_API_KEY", "evmChainId": 1 } + "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": "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" } + "config": { + "apiKeyEnv": "ETHERSCAN_API_KEY" + } }, - { "type": "ethwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/eth" } + { + "type": "ethwallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/eth" + } ], "explorer": { "url": "https://eth.blockscout.com", @@ -209,18 +287,29 @@ "erc20Interval": 120000 } }, - { "type": "bsc-rpc", "endpoint": "https://bsc-rpc.publicnode.com" }, + { + "type": "bsc-rpc", + "endpoint": "https://bsc-rpc.publicnode.com" + }, { "type": "etherscan-v2", "endpoint": "https://api.etherscan.io/v2/api", - "config": { "apiKeyEnv": "ETHERSCAN_API_KEY", "evmChainId": 56 } + "config": { + "apiKeyEnv": "ETHERSCAN_API_KEY", + "evmChainId": 56 + } }, { "type": "etherscan-v1", "endpoint": "https://api.bscscan.com/api", - "config": { "apiKeyEnv": "ETHERSCAN_API_KEY" } + "config": { + "apiKeyEnv": "ETHERSCAN_API_KEY" + } }, - { "type": "bscwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/bsc" } + { + "type": "bscwallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/bsc" + } ], "explorer": { "url": "https://bscscan.com", @@ -243,13 +332,21 @@ "decimals": 6, "blockTime": 3, "apis": [ + { + "type": "tronwallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/tron" + }, { "type": "tron-rpc-pro", "endpoint": "https://api.trongrid.io", - "config": { "apiKeyEnv": "TRONGRID_API_KEY" } + "config": { + "apiKeyEnv": "TRONGRID_API_KEY" + } }, - { "type": "tron-rpc", "endpoint": "https://api.trongrid.io" }, - { "type": "tronwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/tron" } + { + "type": "tron-rpc", + "endpoint": "https://api.trongrid.io" + } ], "explorer": { "url": "https://tronscan.org", @@ -272,8 +369,14 @@ "decimals": 8, "blockTime": 600, "apis": [ - { "type": "mempool-v1", "endpoint": "https://mempool.space/api" }, - { "type": "btcwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/btc/blockbook" } + { + "type": "mempool-v1", + "endpoint": "https://mempool.space/api" + }, + { + "type": "btcwallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/btc/blockbook" + } ], "explorer": { "url": "https://mempool.space", diff --git a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts index ffc815eb4..c2ced842a 100644 --- a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts @@ -34,17 +34,28 @@ import { TronTransactionMixin } from "../tron/transaction-mixin" // ==================== Effect Schema 定义 ==================== -const BalanceResponseSchema = S.Struct({ - success: S.Boolean, - result: S.Union(S.String, S.Number), -}) +const BalanceResponseSchema = S.Union( + S.Struct({ + success: S.Boolean, + result: S.Union(S.String, S.Number), + }), + S.Struct({ + success: S.Boolean, + data: S.Union(S.String, S.Number), + }), + S.Struct({ + balance: S.Union(S.String, S.Number), + }), + 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, + amount: S.Union(S.Number, S.String), timestamp: S.Number, contractRet: S.optional(S.String), }) @@ -57,11 +68,72 @@ type TxHistoryResponse = 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 base58Encode(bytes: Uint8Array): string { + let num = 0n + for (const byte of bytes) { + num = num * 256n + BigInt(byte) + } + let result = "" + while (num > 0n) { + const rem = Number(num % 58n) + num = num / 58n + result = BASE58_ALPHABET[rem] + result + } + for (const byte of bytes) { + if (byte === 0) { + result = BASE58_ALPHABET[0] + result + } else { + break + } + } + return result +} + +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 tronAddressFromHex(address: string): string { + if (address.startsWith("T")) return address + const normalized = address.startsWith("0x") ? address.slice(2) : address + const hex = normalized.startsWith("41") ? normalized : `41${normalized}` + if (hex.length !== 42) throw new Error(`Invalid Tron hex address: ${address}`) + const bytes = new Uint8Array(hex.match(/.{1,2}/g)!.map((b) => Number.parseInt(b, 16))) + return base58Encode(bytes) +} + 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" + if (from === address && to === address) return "self" + if (from === address) return "out" return "in" } @@ -75,6 +147,14 @@ function hasTransactionListChanged( return prev[0]?.hash !== next[0]?.hash } +function normalizeBalanceResponse(raw: BalanceResponse): string { + if (typeof raw === "string" || typeof raw === "number") return String(raw) + if ("balance" in raw) return String(raw.balance) + if ("result" in raw) return String(raw.result) + if ("data" in raw) return String(raw.data) + return "0" +} + // ==================== Base Class ==================== class TronWalletBase { @@ -127,7 +207,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM params: TxHistoryParams ): Effect.Effect> { const provider = this - const address = params.address.toLowerCase() + const address = params.address const symbol = this.symbol const decimals = this.decimals const chainId = this.chainId @@ -140,22 +220,26 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM 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, - }], - })) + if (typeof raw === "object" && "success" in raw && raw.success === false) return [] + return raw.data.map((tx): Transaction => { + const from = tronAddressFromHex(tx.from) + const to = tronAddressFromHex(tx.to) + return { + hash: tx.txID, + from, + to, + timestamp: tx.timestamp, + status: tx.contractRet ? (tx.contractRet === "SUCCESS" ? "confirmed" : "failed") : "confirmed", + action: "transfer" as const, + direction: getDirection(from, to, address), + assets: [{ + assetType: "native" as const, + value: String(tx.amount), + symbol, + decimals, + }], + } + }) }) ) @@ -190,7 +274,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM const fetchEffect = provider.fetchBalance(params.address).pipe( Effect.map((raw): BalanceOutput => ({ - amount: Amount.fromRaw(String(raw.result), decimals, symbol), + amount: Amount.fromRaw(normalizeBalanceResponse(raw), decimals, symbol), symbol, })) ) @@ -208,18 +292,19 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM private fetchBalance(address: string): Effect.Effect { return httpFetch({ - url: `${this.baseUrl}/balance`, - method: "POST", - body: { address }, + url: `${this.baseUrl}/balance?address=${tronAddressToHex(address)}`, schema: BalanceResponseSchema, }) } private fetchTransactions(params: TxHistoryParams): Effect.Effect { return httpFetch({ - url: `${this.baseUrl}/transactions`, + url: `${this.baseUrl}/trans/common/history`, method: "POST", - body: { address: params.address, limit: params.limit ?? 20 }, + body: { + address: tronAddressToHex(params.address), + limit: params.limit ?? 20, + }, schema: TxHistoryResponseSchema, }) } From 528fc25ff0874d22889b4949697714600946fa13 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 11:31:54 +0800 Subject: [PATCH 5/6] fix(tronwallet): align schema and share txHistory source --- .../providers/tronwallet-provider.effect.ts | 191 ++++++++++++------ 1 file changed, 134 insertions(+), 57 deletions(-) diff --git a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts index c2ced842a..31ccb582c 100644 --- a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts @@ -7,7 +7,7 @@ import { Effect, Duration } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, @@ -58,11 +58,20 @@ const TronNativeTxSchema = S.Struct({ amount: S.Union(S.Number, S.String), timestamp: S.Number, contractRet: S.optional(S.String), + blockNumber: S.optional(S.Number), + fee: S.optional(S.Number), + net_usage: S.optional(S.Number), + net_fee: S.optional(S.Number), + energy_usage: S.optional(S.Number), + energy_fee: S.optional(S.Number), + expiration: S.optional(S.Number), }) const TxHistoryResponseSchema = S.Struct({ success: S.Boolean, data: S.Array(TronNativeTxSchema), + pageSize: S.optional(S.Number), + fingerprint: S.optional(S.String), }) type TxHistoryResponse = S.Schema.Type @@ -180,6 +189,8 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM private readonly pollingInterval: number = 30000 private _eventBus: EventBusService | null = null + private _txHistorySources = new Map; refCount: number }>() + private _txHistoryCreations = new Map>>() readonly nativeBalance: StreamInstance readonly transactionHistory: StreamInstance @@ -203,7 +214,20 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM ) } - private createTransactionHistorySource( + private releaseSharedTxHistorySource(cacheKey: string) { + const provider = this + return Effect.gen(function* () { + const entry = provider._txHistorySources.get(cacheKey) + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + provider._txHistorySources.delete(cacheKey) + yield* entry.source.stop + } + }) + } + + private getSharedTxHistorySource( params: TxHistoryParams ): Effect.Effect> { const provider = this @@ -211,54 +235,97 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM const symbol = this.symbol const decimals = this.decimals const chainId = this.chainId + const cacheKey = address - return Effect.gen(function* () { - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus() - } - const eventBus = provider._eventBus - - const fetchEffect = provider.fetchTransactions(params).pipe( - Effect.map((raw): TransactionsOutput => { - if (typeof raw === "object" && "success" in raw && raw.success === false) return [] - return raw.data.map((tx): Transaction => { - const from = tronAddressFromHex(tx.from) - const to = tronAddressFromHex(tx.to) - return { - hash: tx.txID, - from, - to, - timestamp: tx.timestamp, - status: tx.contractRet ? (tx.contractRet === "SUCCESS" ? "confirmed" : "failed") : "confirmed", - action: "transfer" as const, - direction: getDirection(from, to, address), - assets: [{ - assetType: "native" as const, - value: String(tx.amount), - symbol, - decimals, - }], - } + 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* () { + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus() + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactions({ address, limit: 50 }, true).pipe( + Effect.map((raw): TransactionsOutput => { + if (typeof raw === "object" && "success" in raw && raw.success === false) return [] + return raw.data.map((tx): Transaction => { + const from = tronAddressFromHex(tx.from) + const to = tronAddressFromHex(tx.to) + return { + hash: tx.txID, + from, + to, + timestamp: tx.timestamp, + status: tx.contractRet ? (tx.contractRet === "SUCCESS" ? "confirmed" : "failed") : "confirmed", + action: "transfer" as const, + direction: getDirection(from, to, address), + assets: [{ + assetType: "native" as const, + value: String(tx.amount), + symbol, + decimals, + }], + } + }) + }) + ) + + const source = yield* createPollingSource({ + name: `tronwallet.${provider.chainId}.txHistory.${address}`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, }) + + return source }) ) - 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 + provider._txHistoryCreations.set(cacheKey, createPromise) + try { + const source = await createPromise + provider._txHistorySources.set(cacheKey, { source, refCount: 1 }) + return wrapSharedSource(source) + } finally { + provider._txHistoryCreations.delete(cacheKey) + } }) } + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + return this.getSharedTxHistorySource(params) + } + private createBalanceSource( params: AddressParams ): Effect.Effect> { @@ -267,45 +334,55 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM const decimals = this.decimals return Effect.gen(function* () { - const txHistorySource = yield* provider.createTransactionHistorySource({ + const txHistorySource = yield* provider.getSharedTxHistorySource({ address: params.address, - limit: 1, + limit: 50, }) - const fetchEffect = provider.fetchBalance(params.address).pipe( - Effect.map((raw): BalanceOutput => ({ - amount: Amount.fromRaw(normalizeBalanceResponse(raw), decimals, symbol), - symbol, - })) - ) + const fetchBalance = (forceRefresh?: boolean) => + provider.fetchBalance(params.address, forceRefresh).pipe( + Effect.map((raw): BalanceOutput => ({ + amount: Amount.fromRaw(normalizeBalanceResponse(raw), decimals, symbol), + symbol, + })) + ) const source = yield* createDependentSource({ name: `tronwallet.${provider.chainId}.balance`, dependsOn: txHistorySource.ref, hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, + fetch: (_dep, forceRefresh) => fetchBalance(forceRefresh), }) - return source + const stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid) + return { + ...source, + stop: stopAll, + } }) } - private fetchBalance(address: string): Effect.Effect { - return httpFetch({ + private fetchBalance(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/balance?address=${tronAddressToHex(address)}`, schema: BalanceResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchTransactions(params: TxHistoryParams): Effect.Effect { - return httpFetch({ + private fetchTransactions( + params: TxHistoryParams, + forceRefresh = false + ): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/trans/common/history`, method: "POST", body: { address: tronAddressToHex(params.address), - limit: params.limit ?? 20, + limit: params.limit ?? 50, }, schema: TxHistoryResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } } From ec37698abc9022e19fe9c7a6423fde7eed4013d4 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 18:38:31 +0800 Subject: [PATCH 6/6] feat: complete implementation --- AGENTS.md | 3 - CLAUDE.md | 3 - .../02-EVM/03-Wallet-Provider.md | 64 + .../02-Driver-Ref/03-UTXO/01-BTC-Provider.md | 58 +- .../02-Driver-Ref/04-TVM/01-Tron-Provider.md | 50 +- docs/white-book/02-Driver-Ref/README.md | 5 +- .../99-Appendix/05-API-Providers.md | 32 +- .../update-walletapi-fallback/proposal.md | 15 + .../specs/sync-transactions/spec.md | 41 + .../update-walletapi-fallback/tasks.md | 8 + packages/chain-effect/src/debug.ts | 49 + packages/chain-effect/src/http.ts | 45 +- packages/chain-effect/src/index.ts | 3 + packages/chain-effect/src/instance.ts | 28 +- packages/chain-effect/src/source.ts | 10 +- public/configs/default-chains.json | 24 +- scripts/agent-flow/mcps/git-workflow.mcp.ts | 22 +- src/hooks/use-pending-transactions.ts | 10 +- src/services/chain-adapter/debug.ts | 49 + .../providers/bscwallet-provider.effect.ts | 1274 +++++++++++++++- .../providers/btcwallet-provider.effect.ts | 661 +++++++- .../chain-adapter/providers/chain-provider.ts | 75 +- .../providers/etherscan-v1-provider.effect.ts | 282 +++- .../providers/etherscan-v2-provider.effect.ts | 288 +++- .../providers/ethwallet-provider.effect.ts | 1255 +++++++++++++++- .../providers/evm-rpc-provider.effect.ts | 312 +++- .../providers/mempool-provider.effect.ts | 293 +++- .../providers/moralis-provider.effect.ts | 540 ++++--- .../providers/tron-rpc-provider.effect.ts | 385 +++-- .../providers/tronwallet-provider.effect.ts | 1332 ++++++++++++++--- src/services/chain-adapter/providers/types.ts | 1 + src/services/chain-adapter/tron/address.ts | 80 + src/services/transaction/pending-tx.ts | 10 +- 33 files changed, 6170 insertions(+), 1137 deletions(-) create mode 100644 docs/white-book/02-Driver-Ref/02-EVM/03-Wallet-Provider.md create mode 100644 openspec/changes/update-walletapi-fallback/proposal.md create mode 100644 openspec/changes/update-walletapi-fallback/specs/sync-transactions/spec.md create mode 100644 openspec/changes/update-walletapi-fallback/tasks.md create mode 100644 packages/chain-effect/src/debug.ts create mode 100644 src/services/chain-adapter/debug.ts create mode 100644 src/services/chain-adapter/tron/address.ts diff --git a/AGENTS.md b/AGENTS.md index bb4c4abb7..caf163fe2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,19 +110,16 @@ gh pr merge --squash --delete-branch - **Schema-first**: 服务开发必须先定义 `types.ts`。 - # OpenSpec Instructions These instructions are for AI assistants working in this project. Always open `@/openspec/AGENTS.md` when the request: - - Mentions planning or proposals (words like proposal, spec, change, plan) - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work - Sounds ambiguous and you need the authoritative spec before coding Use `@/openspec/AGENTS.md` to learn: - - How to create and apply change proposals - Spec format and conventions - Project structure and guidelines diff --git a/CLAUDE.md b/CLAUDE.md index 54e15dae4..fdbe660b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,19 +65,16 @@ rm -rf .turbo && pnpm typecheck --- - # OpenSpec Instructions These instructions are for AI assistants working in this project. Always open `@/openspec/AGENTS.md` when the request: - - Mentions planning or proposals (words like proposal, spec, change, plan) - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work - Sounds ambiguous and you need the authoritative spec before coding Use `@/openspec/AGENTS.md` to learn: - - How to create and apply change proposals - Spec format and conventions - Project structure and guidelines diff --git a/docs/white-book/02-Driver-Ref/02-EVM/03-Wallet-Provider.md b/docs/white-book/02-Driver-Ref/02-EVM/03-Wallet-Provider.md new file mode 100644 index 000000000..153448cc4 --- /dev/null +++ b/docs/white-book/02-Driver-Ref/02-EVM/03-Wallet-Provider.md @@ -0,0 +1,64 @@ +# EVM Wallet Provider (ethwallet / bscwallet) + +> **Code Source**: +> - `src/services/chain-adapter/providers/ethwallet-provider.effect.ts` +> - `src/services/chain-adapter/providers/bscwallet-provider.effect.ts` + +## Overview + +`ethwallet-v1` 与 `bscwallet-v1` 是基于 walletapi 的 EVM Indexer Provider。 +它们负责: + +- **交易列表**:原生交易 + 代币交易混合输出 +- **余额/资产**:依赖交易列表变化触发刷新,减少无效请求 +- **缓存策略**:使用 `httpFetchCached` 的 TTL 策略避免重复请求 + +## API Endpoints (walletapi) + +### Ethereum (`ethwallet-v1`) + +- 交易列表(原生):`/wallet/eth/trans/normal/history` +- 交易列表(ERC20):`/wallet/eth/trans/erc20/history` +- 余额(原生):`/wallet/eth/balance` +- 余额(代币):`/wallet/eth/account/balance/v2` +- 代币列表:`/wallet/eth/contract/tokens` + +### BSC (`bscwallet-v1`) + +- 交易列表(原生):`/wallet/bsc/trans/normal/history` +- 交易列表(BEP20):`/wallet/bsc/trans/bep20/history` +- 余额(原生):`/wallet/bsc/balance` +- 余额(代币):`/wallet/bsc/account/balance/v2` +- 代币列表:`/wallet/bsc/contract/tokens` + +## Behavior Notes + +1. **交易列表混合** + 原生/代币交易各自独立轮询,每个 contract 变化都会触发合并输出,避免等待全量返回。 + +2. **余额刷新策略** + 余额请求仅在交易列表发生变化时触发;通过 TTL 复用缓存减少重复请求。 + +3. **异常处理** + `success: true + message: NOTOK` 会触发错误路径,避免缓存无效结果; + `No transactions found` 视为正常空结果。 + +## Configuration + +在 `public/configs/default-chains.json` 中配置: + +```json +{ + "type": "ethwallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/eth" +} +``` + +或: + +```json +{ + "type": "bscwallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/bsc" +} +``` diff --git a/docs/white-book/02-Driver-Ref/03-UTXO/01-BTC-Provider.md b/docs/white-book/02-Driver-Ref/03-UTXO/01-BTC-Provider.md index b0a1c422d..ad05401c1 100644 --- a/docs/white-book/02-Driver-Ref/03-UTXO/01-BTC-Provider.md +++ b/docs/white-book/02-Driver-Ref/03-UTXO/01-BTC-Provider.md @@ -1,17 +1,17 @@ # Bitcoin Provider (UTXO) -> **Code Source**: [`src/services/chain-adapter/providers/btcwallet-provider.ts`](https://github.com/BioforestChain/KeyApp/blob/main/src/services/chain-adapter/providers/btcwallet-provider.ts) +> **Code Source**: `src/services/chain-adapter/providers/btcwallet-provider.effect.ts` ## Overview -The `BtcWalletProvider` implements the `ApiProvider` interface for Bitcoin-like UTXO chains. It relies on a Blockbook-compatible backend API (btcwallet) to fetch balances and transaction history. +The `BtcWalletProviderEffect` implements the `ApiProvider` interface for Bitcoin-like UTXO chains. It relies on a Blockbook-compatible backend API (btcwallet) to fetch balances, transaction history, and single-transaction detail. It also uses Blockbook endpoints for fee estimation, UTXO collection, and raw-transaction broadcast. ## Architecture ```mermaid graph TD - A[ChainProvider] --> B[BtcWalletProvider] - B --> C[Blockbook API] + A[ChainProvider] --> B[BtcWalletProviderEffect] + B --> C[btcwallet-v1 (Blockbook Proxy)] C --> D[Bitcoin Node] ``` @@ -19,29 +19,40 @@ graph TD ### Class Structure -- **Class**: `BtcWalletProvider` +- **Class**: `BtcWalletProviderEffect` - **Implements**: `ApiProvider` -- **Location**: [`src/services/chain-adapter/providers/btcwallet-provider.ts`](https://github.com/BioforestChain/KeyApp/blob/main/src/services/chain-adapter/providers/btcwallet-provider.ts) +- **Location**: `src/services/chain-adapter/providers/btcwallet-provider.effect.ts` ### Key Features 1. **Native Balance**: - - Fetches balance via `/api/v2/address/{address}`. - - Combines `balance` (confirmed) and `unconfirmedBalance`. - - Caching: 60 seconds. + - Fetches address info via `/api/v2/address/{address}?details=txs`. + - Balance derived from Blockbook `balance` field. + - Cache: TTL 5s (browser cache). 2. **Transaction History**: - - Fetches history via `/api/v2/address/{address}?details=txs`. - - Parses inputs (`vin`) and outputs (`vout`) to determine direction and net value. - - Supports pagination (limit). - - Caching: 5 minutes. + - Uses the same address info endpoint (`details=txs`) and maps `vin/vout`. + - Cache: TTL 5s (browser cache). + +3. **Single Transaction Detail**: + - Fetches `/api/v2/tx/{txid}` and maps to standard `TransactionOutput`. + - Cache: TTL 5s (browser cache). + +4. **Fee Estimate / UTXO / Broadcast**: + - Fee estimate: `/api/v2/estimatefee/{blocks}` + - UTXO list: `/api/v2/utxo/{address}` + - Broadcast raw tx: `/api/v2/sendtx/{hex}` ### Data Models -#### Blockbook Response Schemas +#### Blockbook Response Schemas (Proxy) -- `BlockbookAddressInfoSchema`: Contains balance and transaction list. -- `BlockbookTxSchema`: Detailed transaction info (inputs, outputs, block height). +btcwallet 使用 Blockbook 代理,所有请求通过 walletapi POST 代理执行: + +```ts +POST /wallet/btc/blockbook +body: { url: "/api/v2/address/{address}", method: "GET" } +``` #### Logic: Direction & Value @@ -63,12 +74,14 @@ const net = outSum - inSum - **Endpoint**: URL of the Blockbook API (e.g., `https://btc.bioforest.io`). - **Chain ID**: Passed during initialization to fetch symbol and decimals. -## Caching Strategy +## Caching Strategy (httpFetchCached) -| Data | TTL | Invalidation Tags | -|------|-----|-------------------| -| Balance | 60s | `balance:{chainId}:{address}` | -| History | 5m | `txhistory:{chainId}:{address}` | +| Data | Strategy | TTL | +|------|----------|-----| +| Address Info / History | ttl | 5s | +| Transaction Detail | ttl | 5s | +| UTXO | ttl | 15s | +| Estimate Fee | ttl | 30s | ## Error Handling @@ -78,5 +91,4 @@ const net = outSum - inSum ## Future Improvements - [ ] Support for XPUB derived addresses. -- [ ] Fee estimation via Blockbook. -- [ ] Raw transaction broadcasting. +- [ ] Enhanced fee strategy (multi-target). diff --git a/docs/white-book/02-Driver-Ref/04-TVM/01-Tron-Provider.md b/docs/white-book/02-Driver-Ref/04-TVM/01-Tron-Provider.md index 123aeef8a..bcf99cc94 100644 --- a/docs/white-book/02-Driver-Ref/04-TVM/01-Tron-Provider.md +++ b/docs/white-book/02-Driver-Ref/04-TVM/01-Tron-Provider.md @@ -1,17 +1,18 @@ # Tron Provider (TVM) -> **Code Source**: [`src/services/chain-adapter/providers/tronwallet-provider.ts`](https://github.com/BioforestChain/KeyApp/blob/main/src/services/chain-adapter/providers/tronwallet-provider.ts) +> **Code Source**: `src/services/chain-adapter/providers/tronwallet-provider.effect.ts` +> **API Spec**: `docs/white-book/99-Appendix/05-API-Providers.md#波场-api` ## Overview -The `TronWalletProvider` implements the `ApiProvider` interface for the Tron blockchain. It interacts with a TronGrid-compatible API to handle native (TRX) and TRC20 token operations. +The `TronWalletProviderEffect` implements the `ApiProvider` interface for the Tron blockchain. It interacts with `tronwallet-v1` (walletapi.bfmeta) to handle native (TRX) and TRC20 token operations. ## Architecture ```mermaid graph TD A[ChainProvider] --> B[TronWalletProvider] - B --> C[TronGrid API] + B --> C[TronWallet API] C --> D[Tron Node] ``` @@ -19,24 +20,32 @@ graph TD ### Class Structure -- **Class**: `TronWalletProvider` +- **Class**: `TronWalletProviderEffect` - **Implements**: `ApiProvider` -- **Location**: [`src/services/chain-adapter/providers/tronwallet-provider.ts`](https://github.com/BioforestChain/KeyApp/blob/main/src/services/chain-adapter/providers/tronwallet-provider.ts) +- **Location**: `src/services/chain-adapter/providers/tronwallet-provider.effect.ts` ### Key Features 1. **Native Balance**: - - Fetches TRX balance via `/balance`. - - Supports both Base58 and Hex address formats. - - Caching: 60 seconds. + - Fetches TRX balance via `/wallet/tron/balance`. + - Supports Base58 + Hex address conversion. + - Caching handled by `httpFetchCached`. 2. **Transaction History**: - Aggregates data from two endpoints: - - Native: `/trans/common/history` - - TRC20: `/trans/trc20/history` + - Native: `/wallet/tron/trans/common/history` + - TRC20: `/wallet/tron/trans/trc20/history` - Merges and sorts transactions by timestamp. - Deduplicates native transactions triggered by TRC20 transfers. - - Caching: 5 minutes. + +3. **Token Balances (Local Mix)**: + - Uses `/wallet/tron/contract/tokens` to obtain TRC20 contract list (TTL cache). + - Batches balances via `/wallet/tron/account/balance/v2` with `contracts[]`. + - Mixes native + TRC20 balances into a single output list. + +4. **Create & Broadcast**: + - Native transfer uses `/wallet/tron/trans/create` + `/wallet/tron/trans/broadcast` + - TRC20 transfer uses `/wallet/tron/trans/contract` + `/wallet/tron/trans/trc20/broadcast` ### Address Handling @@ -46,8 +55,8 @@ Tron uses a dual-format address system. The provider includes utilities for conv - **Hex**: Internal format used by the API (starts with `41...`). Utilities: -- `tronBase58ToHex(address)`: Converts Base58 to Hex. -- `toTronBase58Address(address)`: Ensures Base58 format for UI display. +- `tronAddressToHex(address)`: Converts Base58 to Hex. +- `tronHexToAddress(address)`: Converts Hex to Base58. ### Data Models @@ -71,20 +80,21 @@ TRC20 transfers often generate a corresponding native transaction record. The pr ## Configuration - **Type**: `tronwallet-v1` -- **Endpoint**: URL of the Tron API proxy. +- **Endpoint**: URL of the TronWallet API proxy. - **Chain ID**: Passed during initialization. ## Caching Strategy -| Data | TTL | Invalidation Tags | -|------|-----|-------------------| -| Balance | 60s | `balance:{chainId}:{address}` | -| History | 5m | `txhistory:{chainId}:{address}` | +Caching is controlled by `httpFetchCached` and depends on source usage and `forceRefresh`. ## Error Handling -- **Zod Validation**: Ensures API responses match expected schemas. -- **Upstream Errors**: Throws `Upstream API error` if the API reports failure. +- **Schema Validation**: Ensures API responses match expected schemas. +- **Upstream Errors**: Non-2xx responses throw `HttpError`. + +## Reference + +- Legacy service: `/Users/kzf/Dev/bioforestChain/legacy-apps/libs/wallet-base/services/wallet/tron/tron.service.ts` ## Future Improvements diff --git a/docs/white-book/02-Driver-Ref/README.md b/docs/white-book/02-Driver-Ref/README.md index c82debc14..7617d4ac9 100644 --- a/docs/white-book/02-Driver-Ref/README.md +++ b/docs/white-book/02-Driver-Ref/README.md @@ -12,7 +12,8 @@ * **02-EVM (以太坊生态)** * [01-RPC-Provider.md](./02-EVM/01-RPC-Provider.md) - JSON-RPC 实现 * [02-Etherscan-Provider.md](./02-EVM/02-Etherscan-Provider.md) - Etherscan 实现 + * [03-Wallet-Provider.md](./02-EVM/03-Wallet-Provider.md) - walletapi (ethwallet/bscwallet) * **03-UTXO (比特币生态)** - * [01-BTC-Provider.md](./03-UTXO/01-BTC-Provider.md) - Mempool.space 实现 + * [01-BTC-Provider.md](./03-UTXO/01-BTC-Provider.md) - btcwallet (Blockbook) 实现 * **04-TVM (波场生态)** - * [01-Tron-Provider.md](./04-TVM/01-Tron-Provider.md) - TronGrid 实现 + * [01-Tron-Provider.md](./04-TVM/01-Tron-Provider.md) - tronwallet 实现 diff --git a/docs/white-book/99-Appendix/05-API-Providers.md b/docs/white-book/99-Appendix/05-API-Providers.md index 903f3e7f9..430d25bad 100644 --- a/docs/white-book/99-Appendix/05-API-Providers.md +++ b/docs/white-book/99-Appendix/05-API-Providers.md @@ -118,6 +118,13 @@ interface ApiResponse { | POST | `/wallet/eth/account/balance/v2` | 批量查余额 | | GET | `/wallet/eth/getChainId` | 获取 chainId | +### 关键约定 + +- `/wallet/{eth|bsc}/balance` 为 **GET** 请求,使用查询参数 `address`。 +- `/wallet/{eth|bsc}/trans/erc20/history` 与 `/wallet/bsc/trans/bep20/history` **必须**携带 `contractaddress`,否则可能返回 `success:false`。 +- 交易历史返回结构为 `{ success, result: { status, message, result: [] } }`,当 `status !== "1"` 或 `message === "NOTOK"` 视为上游失败,需要触发 fallback 并禁止缓存。 +- `/wallet/{eth|bsc}/account/balance/v2` 返回 `{ success, result: [...] }`。 + --- ## 比特币 API @@ -126,7 +133,7 @@ interface ApiResponse { **特点**: - UTXO 模型 -- 代理 blockbook API +- 代理 blockbook API(POST 方式转发) - 本地 PSBT 签名 ### API 端点 @@ -151,6 +158,7 @@ interface ApiResponse { - 使用 tronweb - 地址有 hex/base58 转换 - TRC20 合约 +- **参考实现**: `/Users/kzf/Dev/bioforestChain/legacy-apps/libs/wallet-base/services/wallet/tron/tron.service.ts` ### API 端点 @@ -165,12 +173,30 @@ interface ApiResponse { | POST | `/wallet/tron/trans/broadcast` | 广播 TRX 交易 | | POST | `/wallet/tron/trans/trc20/broadcast` | 广播 TRC20 交易 | | POST | `/wallet/tron/trans/common/history` | TRX 交易历史 | -| POST | `/wallet/tron/trans/trc20/history` | TRC20 交易历史 | +| POST | `/wallet/tron/trans/trc20/history` | TRC20 交易历史(需要 `contract_address`) | | POST | `/wallet/tron/trans/pending` | pending 交易 | | POST | `/wallet/tron/trans/receipt` | 交易回执 | | POST | `/wallet/tron/contract/tokens` | 代币列表 | | POST | `/wallet/tron/contract/token/detail` | 代币详情 | -| POST | `/wallet/tron/account/balance/v2` | 批量查余额 | +| POST | `/wallet/tron/account/balance/v2` | 批量查余额(`contracts` 必填数组) | + +### 请求/响应约定 (TronWallet) + +- **地址格式**: + - `trans/*/history`, `trans/pending`: `address` 使用 Hex(`41...`) + - `account/balance/v2`: `address` 使用 Base58 (T-address) +- **/wallet/tron/balance** + - HTTP 方法: `GET` (query 参数 `address`) + - 响应常见为字符串或数字 (TRX in SUN) + - 兼容返回 `{ success, result }` 结构 +- **/wallet/tron/account/balance/v2** + - `contracts` 必填,必须是数组;省略会返回 `success:false` / 400 + - 返回 TRC20 余额列表 (数组或 `{ success, result }`) +- **/wallet/tron/trans/trc20/history** + - 需要 `contract_address`,否则返回 `success:false` / PARAMETER ERROR +- **/wallet/tron/contract/tokens** + - 返回 `{ success, result }` 包裹结构 + - `result.data[].address` 为 Hex 地址,使用前需转 Base58 ### TronGrid API Key diff --git a/openspec/changes/update-walletapi-fallback/proposal.md b/openspec/changes/update-walletapi-fallback/proposal.md new file mode 100644 index 000000000..435fbdebc --- /dev/null +++ b/openspec/changes/update-walletapi-fallback/proposal.md @@ -0,0 +1,15 @@ +# Change: WalletAPI tx fallback + cache gating + +## Why +WalletAPI BSC tx history can return `success:true` with `result.status:"0"/message:"NOTOK"`, which is a logical failure. Treating it as success causes excessive requests, stale UI, and unnecessary caching. We need deterministic fallback and cache rules without breaking balance refresh. + +## What Changes +- Treat WalletAPI `NOTOK` (or status != "1") as a failure for txHistory and skip dependent token-history requests. +- Trigger fallback to alternative providers (e.g., Moralis) when txHistory is unavailable. +- Emit txHistory updates incrementally as each source resolves instead of waiting for all sources. +- Ensure cache policies respect `canCache` for all strategies to prevent caching logical failures. +- Keep balance/asset refresh independent of txHistory failures. + +## Impact +- Affected specs: `sync-transactions` +- Affected code: provider effects (`bscwallet`, `ethwallet`), tx merge utilities, `httpFetchCached` cache policy diff --git a/openspec/changes/update-walletapi-fallback/specs/sync-transactions/spec.md b/openspec/changes/update-walletapi-fallback/specs/sync-transactions/spec.md new file mode 100644 index 000000000..138c8b40b --- /dev/null +++ b/openspec/changes/update-walletapi-fallback/specs/sync-transactions/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements +### Requirement: WalletAPI logical failures trigger txHistory fallback +The system SHALL treat WalletAPI `success:true` responses with `result.status != "1"` or `message == "NOTOK"` as failures for transaction history and trigger fallback providers without caching the failed response. + +#### Scenario: NOTOK response triggers fallback +- **GIVEN** a WalletAPI tx history request returns `success:true` with `result.status:"0"` or `message:"NOTOK"` +- **WHEN** the provider processes the response +- **THEN** the txHistory source surfaces an error to trigger fallback +- **AND** the response is not cached + +### Requirement: Token history queries are suppressed on base failure +The system SHALL skip token-history requests when the base (normal) txHistory request fails, to avoid unnecessary upstream traffic. + +#### Scenario: Base history failure suppresses token queries +- **GIVEN** the normal txHistory request fails (network or NOTOK) +- **WHEN** the provider would otherwise fetch token histories +- **THEN** token-history requests are not executed + +### Requirement: Transaction history emits incrementally +The system SHALL emit merged txHistory updates as each individual source resolves, without waiting for all sources to complete. + +#### Scenario: Incremental merge updates +- **GIVEN** multiple txHistory sources (native + token) +- **WHEN** any source returns a new result +- **THEN** a merged txHistory update is emitted immediately + +### Requirement: Cache policy respects canCache across strategies +The system SHALL apply `canCache` checks for all httpFetchCached strategies to prevent caching logical failures. + +#### Scenario: canCache prevents caching NOTOK +- **GIVEN** `canCache` rejects a response +- **WHEN** a request uses network-first or other cache strategies +- **THEN** the response is not cached and future requests are not served from cache + +### Requirement: Balance refresh is independent of txHistory failure +The system SHALL continue balance refresh even when txHistory requests fail or fall back. + +#### Scenario: Balance refresh continues on txHistory error +- **GIVEN** txHistory failed or is using a fallback +- **WHEN** balance refresh is due +- **THEN** balance and token balances are still refreshed diff --git a/openspec/changes/update-walletapi-fallback/tasks.md b/openspec/changes/update-walletapi-fallback/tasks.md new file mode 100644 index 000000000..206e34685 --- /dev/null +++ b/openspec/changes/update-walletapi-fallback/tasks.md @@ -0,0 +1,8 @@ +## 1. Implementation +- [ ] 1.1 Add WalletAPI logical-failure detection (NOTOK/status!=1) and surface as errors in txHistory sources +- [ ] 1.2 Skip token-history sources when base txHistory fails +- [ ] 1.3 Ensure txHistory emits incremental merged updates per source +- [ ] 1.4 Decouple balance refresh from txHistory failure (balances still refresh on schedule) +- [ ] 1.5 Extend httpFetchCached to apply canCache across strategies (incl. network-first) +- [ ] 1.6 Add/adjust provider docs and inline references to legacy endpoints +- [ ] 1.7 Add tests or verification notes for fallback + cache gating diff --git a/packages/chain-effect/src/debug.ts b/packages/chain-effect/src/debug.ts new file mode 100644 index 000000000..18295094a --- /dev/null +++ b/packages/chain-effect/src/debug.ts @@ -0,0 +1,49 @@ +type DebugSetting = boolean | string | undefined + +function readLocalStorageSetting(): DebugSetting { + if (typeof globalThis === "undefined") return undefined + const storage = (globalThis as typeof globalThis & { localStorage?: Storage }).localStorage + if (!storage) return undefined + try { + const raw = storage.getItem("__CHAIN_EFFECT_DEBUG__") + if (raw === null) return undefined + const value = raw.trim() + if (value.length === 0) return undefined + if (value === "true" || value === "1") return true + if (value === "false" || value === "0") return false + return value + } catch { + return undefined + } +} + +function readGlobalSetting(): DebugSetting { + if (typeof globalThis === "undefined") return undefined + const store = globalThis as typeof globalThis & { __CHAIN_EFFECT_DEBUG__?: unknown } + const value = store.__CHAIN_EFFECT_DEBUG__ + if (typeof value === "boolean") return value + if (typeof value === "string") return value.trim() === "" ? undefined : value + return undefined +} + +function parseRegex(pattern: string): RegExp | null { + if (!pattern.startsWith("/")) return null + const lastSlash = pattern.lastIndexOf("/") + if (lastSlash <= 0) return null + const body = pattern.slice(1, lastSlash) + const flags = pattern.slice(lastSlash + 1) + try { + return new RegExp(body, flags) + } catch { + return null + } +} + +export function isChainEffectDebugEnabled(message: string): boolean { + const setting = readGlobalSetting() ?? readLocalStorageSetting() + if (setting === true) return true + if (!setting) return false + const regex = parseRegex(setting) + if (regex) return regex.test(message) + return message.includes(setting) +} diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts index b24342420..78ce4c82f 100644 --- a/packages/chain-effect/src/http.ts +++ b/packages/chain-effect/src/http.ts @@ -6,7 +6,7 @@ import { Effect, Schedule, Duration, Option } from 'effect'; import { Schema } from 'effect'; -import { getFromCache, putToCache } from './http-cache'; +import { getFromCache, putToCache, deleteFromCache } from './http-cache'; // ==================== Error Types ==================== @@ -234,6 +234,8 @@ export interface CachedFetchOptions extends FetchOptions { cacheStrategy?: CacheStrategy; /** 缓存 TTL(毫秒),仅 ttl 策略使用 */ cacheTtl?: number; + /** 是否允许缓存当前响应(network-first 场景) */ + canCache?: (result: T) => Promise; } // 用于防止并发请求的 Promise 锁 @@ -286,7 +288,7 @@ function makeCacheKeyForRequest(url: string, body?: unknown): string { * - network-first: 尝试 fetch,成功更新缓存,失败用缓存 */ export function httpFetchCached(options: CachedFetchOptions): Effect.Effect { - const { cacheStrategy = 'ttl', cacheTtl = 5000, ...fetchOptions } = options; + const { cacheStrategy = 'ttl', cacheTtl = 5000, canCache, ...fetchOptions } = options; const cacheKey = makeCacheKeyForRequest(options.url, options.body); // 重要:请求必须在 Effect 执行时惰性创建,避免首次构建就触发 fetch, @@ -298,19 +300,36 @@ export function httpFetchCached(options: CachedFetchOptions): Effect.Effec return pending as Promise; } + const shouldCache = async (result: T): Promise => { + if (!canCache) return true; + try { + return await canCache(result); + } catch (error) { + console.error(`[httpFetchCached] CAN-CACHE ERROR: ${options.url}`, error); + return false; + } + }; + const requestPromise = (async () => { const cached = await Effect.runPromise(getFromCache(options.url, options.body)); + const cachedValue = Option.isSome(cached) ? cached.value.data : null; + const cachedUsable = Option.isSome(cached) ? await shouldCache(cached.value.data) : false; + if (Option.isSome(cached) && !cachedUsable) { + await Effect.runPromise(deleteFromCache(options.url, options.body)); + } if (cacheStrategy === 'cache-first') { // Cache-First: 有缓存就返回 - if (Option.isSome(cached)) { + if (Option.isSome(cached) && cachedUsable && cachedValue !== null) { console.log(`[httpFetchCached] CACHE-FIRST HIT: ${options.url}`); - return cached.value.data; + return cachedValue; } console.log(`[httpFetchCached] CACHE-FIRST MISS: ${options.url}`); try { const result = await Effect.runPromise(httpFetch(fetchOptions)); - await Effect.runPromise(putToCache(options.url, options.body, result)); + if (await shouldCache(result)) { + await Effect.runPromise(putToCache(options.url, options.body, result)); + } return result; } catch (error) { console.error(`[httpFetchCached] CACHE-FIRST FETCH ERROR: ${options.url}`, error); @@ -323,24 +342,26 @@ export function httpFetchCached(options: CachedFetchOptions): Effect.Effec 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)); + if (await shouldCache(result)) { + 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)) { + if (Option.isSome(cached) && cachedUsable && cachedValue !== null) { console.log(`[httpFetchCached] NETWORK-FIRST FALLBACK: ${options.url}`); - return cached.value.data; + return cachedValue; } throw error; } } // TTL 策略(默认) - if (Option.isSome(cached)) { + if (Option.isSome(cached) && cachedUsable) { 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; + return cachedValue as T; } console.log(`[httpFetchCached] TTL EXPIRED: ${options.url} (age: ${age}ms, ttl: ${cacheTtl}ms)`); } else { @@ -349,7 +370,9 @@ export function httpFetchCached(options: CachedFetchOptions): Effect.Effec try { const result = await Effect.runPromise(httpFetch(fetchOptions)); - await Effect.runPromise(putToCache(options.url, options.body, result)); + if (await shouldCache(result)) { + await Effect.runPromise(putToCache(options.url, options.body, result)); + } return result; } catch (error) { console.error(`[httpFetchCached] TTL FETCH ERROR: ${options.url}`, error); diff --git a/packages/chain-effect/src/index.ts b/packages/chain-effect/src/index.ts index b550d7a13..d609b5613 100644 --- a/packages/chain-effect/src/index.ts +++ b/packages/chain-effect/src/index.ts @@ -11,6 +11,9 @@ export { Effect, Stream, Schedule, Duration, Ref, SubscriptionRef, PubSub, Fiber } from "effect" export { Schema } from "effect" +// Debug utilities +export { isChainEffectDebugEnabled } from "./debug" + // SuperJSON for serialization (handles BigInt, Amount, etc.) import { SuperJSON } from "superjson" export const superjson = new SuperJSON({ dedupe: true }) diff --git a/packages/chain-effect/src/instance.ts b/packages/chain-effect/src/instance.ts index 948fe4c53..93f07ac93 100644 --- a/packages/chain-effect/src/instance.ts +++ b/packages/chain-effect/src/instance.ts @@ -11,6 +11,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore } from "react" import { Effect, Stream, Fiber } from "effect" import type { FetchError } from "./http" +import { isChainEffectDebugEnabled } from "./debug" import type { DataSource } from "./source" type UnknownRecord = Record @@ -66,14 +67,9 @@ function summarizeValue(value: unknown): string { 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 + const message = `[chain-effect] ${args.join(" ")}` + if (!isChainEffectDebugEnabled(message)) return console.log("[chain-effect]", ...args) } @@ -81,7 +77,11 @@ function debugLog(...args: Array): void { export interface StreamInstance { readonly name: string fetch(input: TInput): Promise - subscribe(input: TInput, callback: (data: TOutput, event: "initial" | "update") => void): () => void + subscribe( + input: TInput, + callback: (data: TOutput, event: "initial" | "update") => void, + onError?: (error: unknown) => void + ): () => void useState( input: TInput, options?: { enabled?: boolean } @@ -161,7 +161,8 @@ export function createStreamInstanceFromSource( subscribe( input: TInput, - callback: (data: TOutput, event: "initial" | "update") => void + callback: (data: TOutput, event: "initial" | "update") => void, + onError?: (error: unknown) => void ): () => void { let cancelled = false let cleanup: (() => void) | null = null @@ -185,6 +186,14 @@ export function createStreamInstanceFromSource( callback(value, isFirst ? "initial" : "update") isFirst = false }) + ).pipe( + Effect.catchAllCause((cause) => + Effect.sync(() => { + if (cancelled) return + console.error(`[${name}] changes stream failed:`, cause) + onError?.(cause) + }) + ) ) const fiber = Effect.runFork(program) @@ -195,6 +204,7 @@ export function createStreamInstanceFromSource( } }).catch((err) => { console.error(`[${name}] getOrCreateSource failed:`, err) + onError?.(err) }) return () => { diff --git a/packages/chain-effect/src/source.ts b/packages/chain-effect/src/source.ts index 693ae304f..0171faf0c 100644 --- a/packages/chain-effect/src/source.ts +++ b/packages/chain-effect/src/source.ts @@ -13,6 +13,7 @@ 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" +import { isChainEffectDebugEnabled } from "./debug" type UnknownRecord = Record @@ -32,14 +33,9 @@ function summarizeValue(value: unknown): string { 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 + const message = `[chain-effect] ${args.join(" ")}` + if (!isChainEffectDebugEnabled(message)) return console.log("[chain-effect]", ...args) } diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index de5887ecc..ae62d44c1 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -222,6 +222,10 @@ "decimals": 18, "blockTime": 12, "apis": [ + { + "type": "ethwallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/eth" + }, { "type": "moralis", "endpoint": "https://deep-index.moralis.io/api/v2.2", @@ -253,10 +257,6 @@ "config": { "apiKeyEnv": "ETHERSCAN_API_KEY" } - }, - { - "type": "ethwallet-v1", - "endpoint": "https://walletapi.bfmeta.info/wallet/eth" } ], "explorer": { @@ -278,6 +278,10 @@ "decimals": 18, "blockTime": 3, "apis": [ + { + "type": "bscwallet-v1", + "endpoint": "https://walletapi.bfmeta.info/wallet/bsc" + }, { "type": "moralis", "endpoint": "https://deep-index.moralis.io/api/v2.2", @@ -305,10 +309,6 @@ "config": { "apiKeyEnv": "ETHERSCAN_API_KEY" } - }, - { - "type": "bscwallet-v1", - "endpoint": "https://walletapi.bfmeta.info/wallet/bsc" } ], "explorer": { @@ -369,13 +369,13 @@ "decimals": 8, "blockTime": 600, "apis": [ - { - "type": "mempool-v1", - "endpoint": "https://mempool.space/api" - }, { "type": "btcwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/btc/blockbook" + }, + { + "type": "mempool-v1", + "endpoint": "https://mempool.space/api" } ], "explorer": { diff --git a/scripts/agent-flow/mcps/git-workflow.mcp.ts b/scripts/agent-flow/mcps/git-workflow.mcp.ts index 0a16722c5..01cfcae6b 100755 --- a/scripts/agent-flow/mcps/git-workflow.mcp.ts +++ b/scripts/agent-flow/mcps/git-workflow.mcp.ts @@ -27,7 +27,8 @@ */ import { execSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { copyFileSync, existsSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; import { z } from "zod"; import { defineTool, @@ -222,6 +223,25 @@ export async function createWorktree(args: { name: string; baseBranch?: string } } exec(`git worktree add -b ${branchName} ${worktreePath} ${baseBranch}`); + + const repoRoot = exec("git rev-parse --show-toplevel"); + const envFiles = readdirSync(repoRoot).filter((file) => + file.startsWith(".env") && file !== ".env.example" + ); + for (const file of envFiles) { + const src = join(repoRoot, file); + const dest = join(worktreePath, file); + if (existsSync(dest)) { + rmSync(dest, { force: true }); + } + copyFileSync(src, dest); + } + + const nodeModulesPath = join(worktreePath, "node_modules"); + if (!existsSync(nodeModulesPath)) { + exec("pnpm install", worktreePath); + } + return { path: worktreePath, branch: branchName }; } diff --git a/src/hooks/use-pending-transactions.ts b/src/hooks/use-pending-transactions.ts index 1aaa1463d..e4c635df1 100644 --- a/src/hooks/use-pending-transactions.ts +++ b/src/hooks/use-pending-transactions.ts @@ -10,15 +10,11 @@ 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 - const store = globalThis as typeof globalThis & { __CHAIN_EFFECT_DEBUG__?: boolean } - return store.__CHAIN_EFFECT_DEBUG__ === true -} +import { isChainDebugEnabled } from '@/services/chain-adapter/debug' function pendingTxDebugLog(...args: Array): void { - if (!isPendingTxDebugEnabled()) return + const message = `[chain-effect] pending-tx ${args.join(' ')}` + if (!isChainDebugEnabled(message)) return console.log('[chain-effect]', 'pending-tx', ...args) } diff --git a/src/services/chain-adapter/debug.ts b/src/services/chain-adapter/debug.ts new file mode 100644 index 000000000..67e6b30a3 --- /dev/null +++ b/src/services/chain-adapter/debug.ts @@ -0,0 +1,49 @@ +type DebugSetting = boolean | string | undefined + +function readLocalStorageSetting(): DebugSetting { + if (typeof globalThis === "undefined") return undefined + const storage = (globalThis as typeof globalThis & { localStorage?: Storage }).localStorage + if (!storage) return undefined + try { + const raw = storage.getItem("__CHAIN_EFFECT_DEBUG__") + if (raw === null) return undefined + const value = raw.trim() + if (value.length === 0) return undefined + if (value === "true" || value === "1") return true + if (value === "false" || value === "0") return false + return value + } catch { + return undefined + } +} + +function readGlobalSetting(): DebugSetting { + if (typeof globalThis === "undefined") return undefined + const store = globalThis as typeof globalThis & { __CHAIN_EFFECT_DEBUG__?: unknown } + const value = store.__CHAIN_EFFECT_DEBUG__ + if (typeof value === "boolean") return value + if (typeof value === "string") return value.trim() === "" ? undefined : value + return undefined +} + +function parseRegex(pattern: string): RegExp | null { + if (!pattern.startsWith("/")) return null + const lastSlash = pattern.lastIndexOf("/") + if (lastSlash <= 0) return null + const body = pattern.slice(1, lastSlash) + const flags = pattern.slice(lastSlash + 1) + try { + return new RegExp(body, flags) + } catch { + return null + } +} + +export function isChainDebugEnabled(message: string): boolean { + const setting = readGlobalSetting() ?? readLocalStorageSetting() + if (setting === true) return true + if (!setting) return false + const regex = parseRegex(setting) + if (regex) return regex.test(message) + return message.includes(setting) +} diff --git a/src/services/chain-adapter/providers/bscwallet-provider.effect.ts b/src/services/chain-adapter/providers/bscwallet-provider.effect.ts index cce38fd1b..c2db0c9b8 100644 --- a/src/services/chain-adapter/providers/bscwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/bscwallet-provider.effect.ts @@ -2,21 +2,25 @@ * BscWallet API Provider - Effect TS Version (深度重构) * * 使用 Effect 原生 Source API 实现响应式数据获取 + * + * Docs: + * - docs/white-book/02-Driver-Ref/02-EVM/03-Wallet-Provider.md + * - docs/white-book/99-Appendix/05-API-Providers.md */ -import { Effect, Duration } from "effect" +import { Effect, Duration, Stream, SubscriptionRef, Fiber } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, + HttpError, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Transaction, @@ -25,33 +29,113 @@ import type { TransactionsOutput, AddressParams, TxHistoryParams, + TokenBalancesOutput, + TokenBalance, } 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 { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" // ==================== Effect Schema 定义 ==================== -const BalanceApiSchema = S.Struct({ - balance: S.String, +const BalanceResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.NullOr(S.Union(S.String, S.Number))), +}) +const BalanceRawSchema = S.Union(BalanceResponseSchema, S.String, S.Number) +type BalanceRaw = S.Schema.Type + +const NativeTxSchema = S.Struct({ + blockNumber: S.optional(S.String), + timeStamp: S.String, + hash: S.String, + from: S.String, + to: S.String, + value: S.String, + isError: S.optional(S.String), + txreceipt_status: S.optional(S.String), }) -type BalanceApi = S.Schema.Type -const TxItemSchema = S.Struct({ +const TokenTxSchema = S.Struct({ + blockNumber: S.optional(S.String), + timeStamp: S.String, hash: S.String, from: S.String, to: S.String, value: S.String, - timestamp: S.Number, + tokenSymbol: S.optional(S.String), + tokenName: S.optional(S.String), + tokenDecimal: S.optional(S.String), + contractAddress: S.optional(S.String), +}) + +const TxHistoryResultSchema = S.Struct({ status: S.optional(S.String), + message: S.optional(S.String), + result: S.Array(NativeTxSchema), +}) + +const TokenHistoryResultSchema = S.Struct({ + status: S.optional(S.String), + message: S.optional(S.String), + result: S.Array(TokenTxSchema), +}) + +const TxHistoryResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(TxHistoryResultSchema), }) +type TxHistoryResponse = S.Schema.Type -const TxApiSchema = S.Struct({ - transactions: S.Array(TxItemSchema), +const TokenHistoryResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(TokenHistoryResultSchema), }) -type TxApi = S.Schema.Type +type TokenHistoryResponse = S.Schema.Type + +const BalanceV2ItemSchema = S.Struct({ + amount: S.NullOr(S.Union(S.String, S.Number)), + contractAddress: S.optional(S.NullOr(S.String)), + decimals: S.optional(S.NullOr(S.Number)), + icon: S.optional(S.NullOr(S.String)), + symbol: S.optional(S.NullOr(S.String)), + name: S.optional(S.NullOr(S.String)), +}) + +const BalanceV2ResponseSchema = S.Array(BalanceV2ItemSchema) +const BalanceV2WrappedSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.NullOr(S.Array(BalanceV2ItemSchema))), +}) +const BalanceV2RawSchema = S.Union(BalanceV2ResponseSchema, BalanceV2WrappedSchema) +type BalanceV2Raw = S.Schema.Type +type BalanceV2Response = S.Schema.Type + +const TokenListItemSchema = S.Struct({ + chain: S.optional(S.String), + address: S.String, + name: S.String, + icon: S.optional(S.String), + symbol: S.String, + decimals: S.Number, +}) + +const TokenListResultSchema = S.Struct({ + data: S.Array(TokenListItemSchema), + page: S.Number, + pageSize: S.Number, + total: S.Number, + pages: S.optional(S.Number), +}) + +const TokenListResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(TokenListResultSchema), +}) +type TokenListResponse = S.Schema.Type // ==================== 工具函数 ==================== @@ -62,6 +146,73 @@ function getDirection(from: string, to: string, address: string): Direction { return f === address ? "out" : "in" } +function toRawString(value: string | number | undefined | null): string { + if (value === undefined || value === null) return "0" + return String(value) +} + +function normalizeBalance(raw: BalanceRaw): { success: boolean; amount: string } { + if (typeof raw === "string" || typeof raw === "number") { + return { success: true, amount: toRawString(raw) } + } + return { success: raw.success, amount: toRawString(raw.result) } +} + +function canCacheSuccess(result: { success: boolean }): Promise { + return Promise.resolve(result.success) +} + +function isWalletApiOk(result?: { status?: string; message?: string }): boolean { + if (!result) return false + const status = result.status?.toUpperCase() + const message = result.message?.toUpperCase() + if (message === "NO TRANSACTIONS FOUND") return true + if (status === "0") return false + if (message === "NOTOK") return false + return true +} + +function canCacheHistory(result: { success: boolean; result?: { status?: string; message?: string } }): Promise { + if (!result.success) return Promise.resolve(false) + return Promise.resolve(isWalletApiOk(result.result)) +} + +function canCacheBalance(result: BalanceRaw): Promise { + if (typeof result === "string" || typeof result === "number") return Promise.resolve(true) + return Promise.resolve(result.success) +} + +function canCacheBalanceV2(result: BalanceV2Raw): Promise { + if (Array.isArray(result)) return Promise.resolve(true) + return Promise.resolve(result.success) +} + +function normalizeBalanceV2(raw: BalanceV2Raw): BalanceV2Response { + if (Array.isArray(raw)) return raw + return raw.result ?? [] +} + +function isHistoryHealthy(raw: TxHistoryResponse | null): boolean { + if (!raw || !raw.success) return false + return isWalletApiOk(raw.result) +} + +function logApiFailure(name: string, payload: { success: boolean; error?: unknown }): void { + if (payload.success) return + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[bscwallet] ${name} success=false`, payload.error ?? payload) + } +} + +function parseTokenDecimals(value: string | number | undefined): number { + if (typeof value === "number") return value + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? 0 : parsed + } + return 0 +} + function hasTransactionListChanged( prev: TransactionsOutput | null, next: TransactionsOutput @@ -72,6 +223,83 @@ function hasTransactionListChanged( return prev[0]?.hash !== next[0]?.hash } +function mergeTransactions(nativeTxs: Transaction[], tokenTxs: Transaction[]): Transaction[] { + const map = new Map() + for (const tx of nativeTxs) { + map.set(tx.hash, tx) + } + for (const tx of tokenTxs) { + const existing = map.get(tx.hash) + if (existing) { + existing.assets = [...existing.assets, ...tx.assets] + map.set(tx.hash, existing) + continue + } + map.set(tx.hash, tx) + } + return Array.from(map.values()).sort((a, b) => b.timestamp - a.timestamp) +} + +function getContractAddressesFromHistory(txs: TransactionsOutput): string[] { + const contracts = new Set() + for (const tx of txs) { + for (const asset of tx.assets) { + if (asset.assetType !== "token") continue + if (!asset.contractAddress) continue + contracts.add(asset.contractAddress) + } + } + return [...contracts].sort() +} + +function normalizeTokenContracts(result: TokenListResponse): string[] { + if (!result.success || !result.result) return [] + return result.result.data + .map((item) => item.address) + .filter((address) => address.length > 0) + .map((address) => address.toLowerCase()) + .sort() +} + +function ensureTokenListSuccess(raw: TokenListResponse): TokenListResponse { + if (raw.success && raw.result) return raw + return { + success: true, + result: { + data: [], + page: 1, + pageSize: 0, + total: 0, + }, + } +} + +function assertHistoryHealthy( + name: string, + raw: TxHistoryResponse +): Effect.Effect { + if (raw.success && isWalletApiOk(raw.result)) { + return Effect.succeed(raw) + } + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[bscwallet] ${name} NOTOK`, raw) + } + return Effect.fail(new HttpError(`[bscwallet] ${name} NOTOK`)) +} + +function assertTokenHistoryHealthy( + name: string, + raw: TokenHistoryResponse +): Effect.Effect { + if (raw.success && isWalletApiOk(raw.result)) { + return Effect.succeed(raw) + } + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[bscwallet] ${name} NOTOK`, raw) + } + return Effect.fail(new HttpError(`[bscwallet] ${name} NOTOK`)) +} + // ==================== Base Class ==================== class BscWalletBase { @@ -95,10 +323,47 @@ export class BscWalletProviderEffect extends EvmIdentityMixin(EvmTransactionMixi private readonly decimals: number private readonly baseUrl: string private readonly pollingInterval: number = 30000 + private readonly balanceCacheTtlMs: number = 5000 + private readonly tokenListCacheTtl: number = 10 * 60 * 1000 + private _normalHistoryDisabled = false 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>>() + private _tokenListSource: { + source: DataSource + refCount: number + stopAll: Effect.Effect + } | null = null + private _tokenListCreation: Promise> | null = null readonly nativeBalance: StreamInstance + readonly tokenBalances: StreamInstance readonly transactionHistory: StreamInstance constructor(entry: ParsedApiEntry, chainId: string) { @@ -118,103 +383,968 @@ export class BscWalletProviderEffect extends EvmIdentityMixin(EvmTransactionMixi `bscwallet.${chainId}.nativeBalance`, (params) => provider.createBalanceSource(params) ) + + this.tokenBalances = createStreamInstanceFromSource( + `bscwallet.${chainId}.tokenBalances`, + (params) => provider.createTokenBalancesSource(params) + ) } private createTransactionHistorySource( params: TxHistoryParams ): Effect.Effect> { + return this.getSharedTxHistorySource(params.address) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address).pipe( + Effect.catchAllCause((error) => { + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn("[bscwallet] balance fallback", error) + } + return this.createBalanceFallbackSource(params.address) + }) + ) + } + + private createTokenBalancesSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedTokenBalanceSource(params.address).pipe( + Effect.catchAllCause((error) => { + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn("[bscwallet] tokenBalances fallback", error) + } + return this.createTokenBalancesFallbackSource(params.address) + }) + ) + } + + private createBalanceFallbackSource(address: string): Effect.Effect> { + const symbol = this.symbol + const decimals = this.decimals + return createPollingSource({ + name: `bscwallet.${this.chainId}.balance.fallback`, + interval: Duration.millis(this.pollingInterval), + immediate: true, + fetch: this.fetchBalance(address, true).pipe( + Effect.map((raw): BalanceOutput => { + const balance = normalizeBalance(raw) + return { + amount: Amount.fromRaw(balance.amount, decimals, symbol), + symbol, + } + }) + ), + }) + } + + private createTokenBalancesFallbackSource(address: string): Effect.Effect> { + const symbol = this.symbol + const decimals = this.decimals + return createPollingSource({ + name: `bscwallet.${this.chainId}.tokenBalances.fallback`, + interval: Duration.millis(this.pollingInterval), + immediate: true, + fetch: this.fetchTokenBalances(address, [], true).pipe( + Effect.map((balances): TokenBalancesOutput => { + const list: TokenBalance[] = [] + const balance = normalizeBalance(balances.native) + list.push({ + symbol, + name: symbol, + amount: Amount.fromRaw(balance.amount, decimals, symbol), + isNative: true, + decimals, + }) + + for (const item of balances.tokens) { + const tokenSymbol = item.symbol ?? "BEP20" + const tokenDecimals = item.decimals ?? 0 + list.push({ + symbol: tokenSymbol, + name: item.name ?? tokenSymbol, + amount: Amount.fromRaw(toRawString(item.amount), tokenDecimals, tokenSymbol), + isNative: false, + decimals: tokenDecimals, + icon: item.icon, + contractAddress: item.contractAddress, + }) + } + + return list + }) + ), + }) + } + + private getSharedTxHistorySource(address: string): Effect.Effect> { const provider = this - const address = params.address.toLowerCase() + const normalizedAddress = address.toLowerCase() + const cacheKey = normalizedAddress const symbol = this.symbol const decimals = this.decimals - const chainId = this.chainId + 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* () { + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus() + } + const eventBus = provider._eventBus + + const nativeSource = yield* createPollingSource({ + name: `bscwallet.${provider.chainId}.txHistory.native.${cacheKey}`, + fetch: provider.fetchNativeHistory({ address, limit: 50 }, true), + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId: provider.chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + const initialNative = yield* nativeSource.get + if (!isHistoryHealthy(initialNative)) { + yield* nativeSource.stop + return yield* Effect.fail(new HttpError("[bscwallet] trans/normal/history NOTOK")) + } + + const tokenListSource = yield* provider.getSharedTokenListSource() + + const mergeSignal = yield* SubscriptionRef.make(0) + const bump = SubscriptionRef.update(mergeSignal, (value) => value + 1).pipe(Effect.asVoid) + const tokenHistoryCache = yield* SubscriptionRef.make>( + new Map() + ) + + const changeFibers: Fiber.RuntimeFiber[] = [] + const registerChanges = (stream: Stream.Stream) => + Effect.forkDaemon( + stream.pipe( + Stream.tap(() => bump), + Stream.runDrain + ) + ) + + changeFibers.push(yield* registerChanges(nativeSource.changes)) + + const tokenSources = new Map>() + const tokenFibers = new Map>() + + const attachContract = (contract: string) => + Effect.gen(function* () { + if (tokenSources.has(contract)) return + const source = yield* createPollingSource({ + name: `bscwallet.${provider.chainId}.txHistory.bep20.${cacheKey}.${contract}`, + fetch: provider.fetchTokenHistory({ address, limit: 50, contractAddress: contract }, true), + interval: Duration.millis(provider.pollingInterval), + immediate: false, + walletEvents: { + eventBus, + chainId: provider.chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + tokenSources.set(contract, source) + const fiber = yield* Effect.forkDaemon( + source.changes.pipe( + Stream.runForEach((value) => + SubscriptionRef.update(tokenHistoryCache, (map) => { + const next = new Map(map) + next.set(contract, value) + return next + }).pipe( + Effect.zipRight(bump) + ) + ) + ) + ) + tokenFibers.set(contract, fiber) + yield* Effect.forkDaemon(source.refresh.pipe(Effect.asVoid)) + }) + + const detachContract = (contract: string) => + Effect.gen(function* () { + const source = tokenSources.get(contract) + if (source) { + yield* source.stop + tokenSources.delete(contract) + } + const fiber = tokenFibers.get(contract) + if (fiber) { + yield* Fiber.interrupt(fiber) + tokenFibers.delete(contract) + } + yield* SubscriptionRef.update(tokenHistoryCache, (map) => { + const next = new Map(map) + next.delete(contract) + return next + }).pipe(Effect.zipRight(bump)) + }) + + const syncContracts = (tokenList: TokenListResponse | null) => + Effect.gen(function* () { + const nextContracts = tokenList ? normalizeTokenContracts(tokenList) : [] + const nextSet = new Set(nextContracts) + for (const contract of tokenSources.keys()) { + if (!nextSet.has(contract)) { + yield* detachContract(contract) + } + } + for (const contract of nextSet) { + if (!tokenSources.has(contract)) { + yield* attachContract(contract) + } + } + }) + + yield* syncContracts(yield* tokenListSource.get) + + changeFibers.push(yield* Effect.forkDaemon( + tokenListSource.changes.pipe( + Stream.runForEach((next) => + syncContracts(next).pipe( + Effect.zipRight(bump) + ) + ) + ) + )) + + const mergeSource = yield* createDependentSource({ + name: `bscwallet.${provider.chainId}.txHistory.${cacheKey}`, + dependsOn: mergeSignal, + hasChanged: (prev, next) => prev !== next, + fetch: () => + Effect.gen(function* () { + const native = yield* nativeSource.get + const nativeTxs = (native?.result?.result ?? []).map((tx): Transaction => { + const status = + tx.isError === "1" || tx.txreceipt_status === "0" ? "failed" : "confirmed" + return { + hash: tx.hash, + from: tx.from, + to: tx.to, + timestamp: parseInt(tx.timeStamp, 10) * 1000, + status, + blockNumber: tx.blockNumber ? BigInt(tx.blockNumber) : undefined, + action: "transfer" as const, + direction: getDirection(tx.from, tx.to, normalizedAddress), + assets: [{ + assetType: "native" as const, + value: tx.value, + symbol, + decimals, + }], + } + }) + + const tokenValues = [...(yield* SubscriptionRef.get(tokenHistoryCache)).values()] + const tokenTxs = tokenValues.flatMap((raw) => + (raw?.result?.result ?? []).map((tx): Transaction => { + const tokenSymbol = tx.tokenSymbol ?? "BEP20" + const tokenDecimals = parseTokenDecimals(tx.tokenDecimal) + const contractAddress = tx.contractAddress?.toLowerCase() + return { + hash: tx.hash, + from: tx.from, + to: tx.to, + timestamp: parseInt(tx.timeStamp, 10) * 1000, + status: "confirmed", + blockNumber: tx.blockNumber ? BigInt(tx.blockNumber) : undefined, + action: "transfer" as const, + direction: getDirection(tx.from, tx.to, normalizedAddress), + assets: [{ + assetType: "token" as const, + value: tx.value, + symbol: tokenSymbol, + decimals: tokenDecimals, + contractAddress, + name: tx.tokenName, + }], + } + }) + ) + + return mergeTransactions(nativeTxs, tokenTxs) + }), + }) + + const stopAll = Effect.gen(function* () { + for (const fiber of changeFibers) { + yield* Fiber.interrupt(fiber) + } + for (const source of tokenSources.values()) { + yield* source.stop + } + for (const fiber of tokenFibers.values()) { + yield* Fiber.interrupt(fiber) + } + yield* mergeSource.stop + yield* nativeSource.stop + yield* provider.releaseSharedTokenListSource() + }) + + provider._txHistorySources.set(cacheKey, { + source: mergeSource, + refCount: 1, + stopAll, + }) + + return mergeSource + }) + ) + + provider._txHistoryCreations.set(cacheKey, createPromise) + + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._txHistoryCreations.delete(cacheKey) + } + }) + } + + private releaseSharedTxHistorySource(key: string): Effect.Effect { + const provider = this return Effect.gen(function* () { - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus() + const entry = provider._txHistorySources.get(key) + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + yield* entry.stopAll + provider._txHistorySources.delete(key) } - 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, + }) + } + + private getSharedBalanceSource(address: string): Effect.Effect> { + const provider = this + const cacheKey = address.toLowerCase() + const symbol = this.symbol + const decimals = this.decimals + + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) + + 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 Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* Effect.catchAll( + provider.getSharedTxHistorySource(address), + (error) => + Effect.gen(function* () { + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn("[bscwallet] txHistory unavailable for balance", error) + } + return null + }) + ) + + if (txHistorySource === null) { + const source = yield* createPollingSource({ + name: `bscwallet.${provider.chainId}.balance.poll`, + interval: Duration.millis(provider.pollingInterval), + immediate: true, + fetch: provider.fetchBalance(address, true).pipe( + Effect.map((raw): BalanceOutput => { + const balance = normalizeBalance(raw) + return { + amount: Amount.fromRaw(balance.amount, decimals, symbol), + symbol, + } + }) + ), + }) + + provider._balanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll: source.stop, + }) + + return source + } + const balanceCache = yield* SubscriptionRef.make(null) + const mergeSignal = yield* SubscriptionRef.make(0) + const bump = SubscriptionRef.update(mergeSignal, (value) => value + 1).pipe(Effect.asVoid) + + const buildBalance = (raw: BalanceRaw): BalanceOutput => { + const balance = normalizeBalance(raw) + return { + amount: Amount.fromRaw(balance.amount, decimals, symbol), symbol, - decimals, - }], - })) - ) + } + } + + const dependentSource = yield* createDependentSource({ + name: `bscwallet.${provider.chainId}.balance.dep`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (_dep, forceRefresh) => + provider.fetchBalance(address, forceRefresh).pipe( + Effect.map(buildBalance), + Effect.tap((next) => SubscriptionRef.set(balanceCache, next)) + ), + }) + + const shouldImmediatePoll = (yield* txHistorySource.get) === null + const pollingSource = yield* createPollingSource({ + name: `bscwallet.${provider.chainId}.balance.poll`, + interval: Duration.millis(provider.pollingInterval), + immediate: shouldImmediatePoll, + fetch: Effect.gen(function* () { + const history = yield* txHistorySource.get + const cached = yield* SubscriptionRef.get(balanceCache) + if (history !== null && cached !== null) return cached + const raw = yield* provider.fetchBalance(address, true) + const next = buildBalance(raw) + yield* SubscriptionRef.set(balanceCache, next) + return next + }), + }) + + const changeFibers: Fiber.RuntimeFiber[] = [] + const registerChanges = (stream: Stream.Stream) => + Effect.forkDaemon( + stream.pipe( + Stream.tap(() => bump), + Stream.runDrain + ) + ) + + changeFibers.push(yield* registerChanges(dependentSource.changes)) + changeFibers.push(yield* registerChanges(pollingSource.changes)) + + const mergeSource = yield* createDependentSource({ + name: `bscwallet.${provider.chainId}.balance`, + dependsOn: mergeSignal, + hasChanged: (prev, next) => prev !== next, + fetch: () => + Effect.gen(function* () { + const cached = yield* SubscriptionRef.get(balanceCache) + if (!cached) { + return yield* Effect.fail(new HttpError("[bscwallet] balance cache empty")) + } + return cached + }), + }) + + const stopAll = Effect.gen(function* () { + for (const fiber of changeFibers) { + yield* Fiber.interrupt(fiber) + } + yield* dependentSource.stop + yield* pollingSource.stop + yield* mergeSource.stop + yield* txHistorySource.stop + }) + + provider._balanceSources.set(cacheKey, { + source: mergeSource, + refCount: 1, + stopAll, + }) + + return mergeSource + }) ) - 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"], - }, - }) + provider._balanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._balanceCreations.delete(cacheKey) + } }) } - private createBalanceSource( - params: AddressParams - ): Effect.Effect> { + 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 getSharedTokenBalanceSource(address: string): Effect.Effect> { + const provider = this + const cacheKey = address.toLowerCase() const symbol = this.symbol const decimals = this.decimals - return Effect.gen(function* () { - const txHistorySource = yield* provider.createTransactionHistorySource({ - address: params.address, - limit: 1, + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTokenBalanceSource(cacheKey), + }) + + const cached = provider._tokenBalanceSources.get(cacheKey) + if (cached) { + cached.refCount += 1 + return Effect.succeed(wrapSharedSource(cached.source)) + } + + 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* Effect.catchAll( + provider.getSharedTxHistorySource(address), + (error) => + Effect.gen(function* () { + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn("[bscwallet] txHistory unavailable for tokenBalances", error) + } + return null + }) + ) + + if (txHistorySource === null) { + const source = yield* createPollingSource({ + name: `bscwallet.${provider.chainId}.tokenBalances.poll`, + interval: Duration.millis(provider.pollingInterval), + immediate: true, + fetch: provider.fetchTokenBalances(address, [], true).pipe( + Effect.map((balances): TokenBalancesOutput => { + const list: TokenBalance[] = [] + const balance = normalizeBalance(balances.native) + list.push({ + symbol, + name: symbol, + amount: Amount.fromRaw(balance.amount, decimals, symbol), + isNative: true, + decimals, + }) + + for (const item of balances.tokens) { + const tokenSymbol = item.symbol ?? "BEP20" + const tokenDecimals = item.decimals ?? 0 + list.push({ + symbol: tokenSymbol, + name: item.name ?? tokenSymbol, + amount: Amount.fromRaw(toRawString(item.amount), tokenDecimals, tokenSymbol), + isNative: false, + decimals: tokenDecimals, + icon: item.icon, + contractAddress: item.contractAddress, + }) + } + + return list + }) + ), + }) - const fetchEffect = provider.fetchBalance(params.address).pipe( - Effect.map((raw): BalanceOutput => ({ - amount: Amount.fromRaw(raw.balance, decimals, symbol), - symbol, - })) + provider._tokenBalanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll: source.stop, + }) + + return source + } + + const balanceCache = yield* SubscriptionRef.make(null) + const mergeSignal = yield* SubscriptionRef.make(0) + const bump = SubscriptionRef.update(mergeSignal, (value) => value + 1).pipe(Effect.asVoid) + + const buildTokenBalances = (balances: { native: BalanceRaw; tokens: BalanceV2Response }): TokenBalancesOutput => { + const list: TokenBalance[] = [] + const balance = normalizeBalance(balances.native) + list.push({ + symbol, + name: symbol, + amount: Amount.fromRaw(balance.amount, decimals, symbol), + isNative: true, + decimals, + }) + + for (const item of balances.tokens) { + const tokenSymbol = item.symbol ?? "BEP20" + const tokenDecimals = item.decimals ?? 0 + list.push({ + symbol: tokenSymbol, + name: item.name ?? tokenSymbol, + amount: Amount.fromRaw(toRawString(item.amount), tokenDecimals, tokenSymbol), + isNative: false, + decimals: tokenDecimals, + icon: item.icon, + contractAddress: item.contractAddress, + }) + } + + return list + } + + const dependentSource = yield* createDependentSource({ + name: `bscwallet.${provider.chainId}.tokenBalances.dep`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (dep, forceRefresh) => + provider.fetchTokenBalances(address, getContractAddressesFromHistory(dep), forceRefresh).pipe( + Effect.map(buildTokenBalances), + Effect.tap((next) => SubscriptionRef.set(balanceCache, next)) + ), + }) + + const shouldImmediatePoll = (yield* txHistorySource.get) === null + const pollingSource = yield* createPollingSource({ + name: `bscwallet.${provider.chainId}.tokenBalances.poll`, + interval: Duration.millis(provider.pollingInterval), + immediate: shouldImmediatePoll, + fetch: Effect.gen(function* () { + const history = yield* txHistorySource.get + const cached = yield* SubscriptionRef.get(balanceCache) + if (history !== null && cached !== null) return cached + const raw = yield* provider.fetchTokenBalances(address, [], true) + const next = buildTokenBalances(raw) + yield* SubscriptionRef.set(balanceCache, next) + return next + }), + }) + + const changeFibers: Fiber.RuntimeFiber[] = [] + const registerChanges = (stream: Stream.Stream) => + Effect.forkDaemon( + stream.pipe( + Stream.tap(() => bump), + Stream.runDrain + ) + ) + + changeFibers.push(yield* registerChanges(dependentSource.changes)) + changeFibers.push(yield* registerChanges(pollingSource.changes)) + + const mergeSource = yield* createDependentSource({ + name: `bscwallet.${provider.chainId}.tokenBalances`, + dependsOn: mergeSignal, + hasChanged: (prev, next) => prev !== next, + fetch: () => + Effect.gen(function* () { + const cached = yield* SubscriptionRef.get(balanceCache) + if (!cached) { + return yield* Effect.fail(new HttpError("[bscwallet] tokenBalances cache empty")) + } + return cached + }), + }) + + const stopAll = Effect.gen(function* () { + for (const fiber of changeFibers) { + yield* Fiber.interrupt(fiber) + } + yield* dependentSource.stop + yield* pollingSource.stop + yield* mergeSource.stop + yield* txHistorySource.stop + }) + + provider._tokenBalanceSources.set(cacheKey, { + source: mergeSource, + refCount: 1, + stopAll, + }) + + return mergeSource + }) ) - const source = yield* createDependentSource({ - name: `bscwallet.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, + provider._tokenBalanceCreations.set(cacheKey, createPromise) + + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._tokenBalanceCreations.delete(cacheKey) + } + }) + } + + private releaseSharedTokenBalanceSource(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 getSharedTokenListSource(): Effect.Effect> { + const provider = this + if (provider._tokenListSource) { + provider._tokenListSource.refCount += 1 + return Effect.succeed(provider._tokenListSource.source) + } + if (provider._tokenListCreation) { + return Effect.promise(async () => { + const source = await provider._tokenListCreation + if (provider._tokenListSource) { + provider._tokenListSource.refCount += 1 + } + return source }) + } + + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const source = yield* createPollingSource({ + name: `bscwallet.${provider.chainId}.tokenList`, + fetch: provider.fetchTokenList(false), + interval: Duration.millis(provider.tokenListCacheTtl), + immediate: true, + }) + + provider._tokenListSource = { + source, + refCount: 1, + stopAll: source.stop, + } - return source + return source + }) + ) + + provider._tokenListCreation = createPromise + try { + return await createPromise + } finally { + provider._tokenListCreation = null + } }) } - private fetchBalance(address: string): Effect.Effect { - return httpFetch({ - url: `${this.baseUrl}/balance?address=${address}`, - schema: BalanceApiSchema, + private releaseSharedTokenListSource(): Effect.Effect { + const provider = this + return Effect.gen(function* () { + const entry = provider._tokenListSource + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + yield* entry.stopAll + provider._tokenListSource = null + } }) } - private fetchTransactions(params: TxHistoryParams): Effect.Effect { - return httpFetch({ - url: `${this.baseUrl}/transactions?address=${params.address}&limit=${params.limit ?? 20}`, - schema: TxApiSchema, + private fetchBalance(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ + url: `${this.baseUrl}/balance?address=${address}`, + schema: BalanceRawSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: this.balanceCacheTtlMs, + canCache: canCacheBalance, + }).pipe( + Effect.tap((raw) => { + if (typeof raw === "object" && raw !== null && "success" in raw) { + logApiFailure("balance", raw as { success: boolean; error?: unknown }) + } + }) + ) + } + + private fetchNativeHistory( + params: TxHistoryParams, + forceRefresh = false + ): Effect.Effect { + if (this._normalHistoryDisabled) { + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn("[bscwallet] trans/normal/history disabled for session") + } + return Effect.fail(new HttpError("[bscwallet] trans/normal/history disabled")) + } + return httpFetchCached({ + url: `${this.baseUrl}/trans/normal/history`, + method: "POST", + body: { + address: params.address, + page: 1, + offset: params.limit ?? 50, + }, + schema: TxHistoryResponseSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: this.balanceCacheTtlMs, + canCache: canCacheHistory, + }).pipe( + Effect.tap((raw) => logApiFailure("trans/normal/history", raw)), + Effect.flatMap((raw) => { + if (raw.success && raw.result && !isWalletApiOk(raw.result)) { + this._normalHistoryDisabled = true + } + return assertHistoryHealthy("trans/normal/history", raw) + }) + ) + } + + private fetchTokenHistory( + params: TxHistoryParams, + forceRefresh = false + ): Effect.Effect { + if (!params.contractAddress) { + return Effect.succeed({ success: true, result: { result: [] } }) + } + + return httpFetchCached({ + url: `${this.baseUrl}/trans/bep20/history`, + method: "POST", + body: { + address: params.address, + contractaddress: params.contractAddress, + page: 1, + offset: params.limit ?? 50, + }, + schema: TokenHistoryResponseSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: this.balanceCacheTtlMs, + canCache: canCacheHistory, + }).pipe( + Effect.tap((raw) => logApiFailure("trans/bep20/history", raw)), + Effect.flatMap((raw) => assertTokenHistoryHealthy("trans/bep20/history", raw)) + ) + } + + private fetchTokenBalances( + address: string, + contracts: string[], + forceRefresh = false + ): Effect.Effect<{ native: BalanceRaw; tokens: BalanceV2Response }, FetchError> { + const sortedContracts = [...contracts].sort() + if (sortedContracts.length === 0) { + const provider = this + return Effect.gen(function* () { + const tokenList = yield* provider.fetchTokenList(false) + const nextContracts = normalizeTokenContracts(tokenList) + const native = yield* provider.fetchBalance(address, forceRefresh) + if (nextContracts.length === 0) { + return { native, tokens: [] } + } + const tokens = yield* httpFetchCached({ + url: `${provider.baseUrl}/account/balance/v2`, + method: "POST", + body: { address, contracts: nextContracts }, + schema: BalanceV2RawSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: provider.balanceCacheTtlMs, + canCache: canCacheBalanceV2, + }).pipe( + Effect.tap((raw) => { + if (!Array.isArray(raw)) logApiFailure("account/balance/v2", raw) + }), + Effect.map(normalizeBalanceV2), + Effect.catchAll(() => Effect.succeed([] as BalanceV2Response)) + ) + return { native, tokens } + }) + } + + return Effect.all({ + native: this.fetchBalance(address, forceRefresh), + tokens: httpFetchCached({ + url: `${this.baseUrl}/account/balance/v2`, + method: "POST", + body: { address, contracts: sortedContracts }, + schema: BalanceV2RawSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: this.balanceCacheTtlMs, + canCache: canCacheBalanceV2, + }).pipe( + Effect.tap((raw) => { + if (!Array.isArray(raw)) logApiFailure("account/balance/v2", raw) + }), + Effect.map(normalizeBalanceV2), + Effect.catchAll(() => Effect.succeed([] as BalanceV2Response)) + ), }) } + + private fetchTokenList(forceRefresh = false): Effect.Effect { + return httpFetchCached({ + url: `${this.baseUrl}/contract/tokens`, + method: "POST", + body: { + page: 1, + pageSize: 50, + chain: "BSC", + }, + schema: TokenListResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "ttl", + cacheTtl: this.tokenListCacheTtl, + canCache: canCacheSuccess, + }).pipe( + Effect.tap((raw) => logApiFailure("contract/tokens", raw)), + Effect.map(ensureTokenListSuccess) + ) + } } export function createBscWalletProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | null { diff --git a/src/services/chain-adapter/providers/btcwallet-provider.effect.ts b/src/services/chain-adapter/providers/btcwallet-provider.effect.ts index 20453133b..0b77ded8e 100644 --- a/src/services/chain-adapter/providers/btcwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/btcwallet-provider.effect.ts @@ -4,27 +4,50 @@ * 使用 Effect 原生 Source API 实现响应式数据获取 * - transactionHistory: 定时轮询 + 事件触发 * - nativeBalance: 依赖 transactionHistory 变化 + * + * Docs: + * - docs/white-book/02-Driver-Ref/03-UTXO/01-BTC-Provider.md + * - docs/white-book/99-Appendix/05-API-Providers.md */ import { Effect, Duration } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, + HttpError, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" -import type { ApiProvider, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams } from "./types" +import type { + ApiProvider, + Direction, + BalanceOutput, + TransactionsOutput, + AddressParams, + TxHistoryParams, + TransactionParams, + TransactionOutput, +} from "./types" +import type { + TransactionIntent, + UnsignedTransaction, + SignedTransaction, + TransactionHash, + FeeEstimate, + Fee, + TransferIntent, +} 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" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" // ==================== Effect Schema 定义 ==================== @@ -47,9 +70,56 @@ const AddressInfoSchema = S.Struct({ transactions: S.optional(S.Array(TxItemSchema)), }) +const AddressInfoResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(AddressInfoSchema), +}) + type AddressInfo = S.Schema.Type +type AddressInfoResponse = S.Schema.Type type TxItem = S.Schema.Type +const TxDetailSchema = 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), + blockHeight: S.optional(S.Number), +}) + +const TxDetailResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(TxDetailSchema), +}) + +const UtxoSchema = S.Struct({ + txid: S.String, + vout: S.Number, + value: S.Union(S.String, S.Number), + scriptPubKey: S.optional(S.String), +}) + +const UtxoResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.Array(UtxoSchema)), +}) + +const FeeRateResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.Union(S.String, S.Number)), +}) + +const BroadcastResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.String), +}) + // ==================== 工具函数 ==================== function getDirection(vin: TxItem["vin"], vout: TxItem["vout"], address: string): Direction { @@ -71,6 +141,47 @@ function hasTransactionListChanged( return prev[0]?.hash !== next[0]?.hash } +function normalizeAddressInfo(response: AddressInfoResponse): AddressInfo { + return response.result ?? { balance: "0", transactions: [] } +} + +function canCacheSuccess(result: AddressInfoResponse): Promise { + return Promise.resolve(result.success) +} + +function unwrapBlockbookResult( + name: string, + raw: { success: boolean; result?: T } +): Effect.Effect { + if (raw.success && raw.result !== undefined) { + return Effect.succeed(raw.result) + } + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[btcwallet] ${name} failed`, raw) + } + return Effect.fail(new HttpError(`[btcwallet] ${name} failed`)) +} + +function unwrapBlockbookResultOrNull( + name: string, + raw: { success: boolean; result?: T } +): Effect.Effect { + if (raw.success) { + return Effect.succeed(raw.result ?? null) + } + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[btcwallet] ${name} failed`, raw) + } + return Effect.fail(new HttpError(`[btcwallet] ${name} failed`)) +} + +function logApiFailure(name: string, payload: { success: boolean; error?: unknown }): void { + if (payload.success) return + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[btcwallet] ${name} success=false`, payload.error ?? payload) + } +} + // ==================== Base Class ==================== class BtcWalletBase { @@ -96,9 +207,37 @@ export class BtcWalletProviderEffect extends BitcoinIdentityMixin(BitcoinTransac private readonly pollingInterval: number = 60000 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 _transactionSources = new Map< + string, + { + source: DataSource + refCount: number + stopAll: Effect.Effect + } + >() + private _transactionCreations = new Map>>() readonly nativeBalance: StreamInstance readonly transactionHistory: StreamInstance + readonly transaction: StreamInstance constructor(entry: ParsedApiEntry, chainId: string) { super(entry, chainId) @@ -119,6 +258,12 @@ export class BtcWalletProviderEffect extends BitcoinIdentityMixin(BitcoinTransac `btcwallet.${chainId}.nativeBalance`, (params) => provider.createBalanceSource(params) ) + + // transaction: 单笔交易查询 + this.transaction = createStreamInstanceFromSource( + `btcwallet.${chainId}.transaction`, + (params) => provider.createTransactionSource(params) + ) } // ==================== Source 创建方法 ==================== @@ -126,92 +271,480 @@ export class BtcWalletProviderEffect extends BitcoinIdentityMixin(BitcoinTransac private createTransactionHistorySource( params: TxHistoryParams ): Effect.Effect> { + return this.getSharedTxHistorySource(params.address) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address) + } + + private createTransactionSource( + params: TransactionParams + ): Effect.Effect> { + return this.getSharedTransactionSource(params.txHash, params.senderId) + } + + private getSharedTxHistorySource(address: string): Effect.Effect> { const provider = this - const address = params.address + const cacheKey = address const symbol = this.symbol const decimals = this.decimals - const chainId = this.chainId + 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* () { + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus() + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchAddressInfo(address, true).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.${cacheKey}`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId: provider.chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + const stopAll = source.stop + provider._txHistorySources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source + }) + ) + + provider._txHistoryCreations.set(cacheKey, createPromise) + + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._txHistoryCreations.delete(cacheKey) + } + }) + } + + private releaseSharedTxHistorySource(key: string): Effect.Effect { + const provider = this return Effect.gen(function* () { - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus() + const entry = provider._txHistorySources.get(key) + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + yield* entry.stopAll + provider._txHistorySources.delete(key) } - 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, - }], - })) - ) - ) + }) + } + + private getSharedBalanceSource(address: string): Effect.Effect> { + const provider = this + const cacheKey = address + const symbol = this.symbol + const decimals = this.decimals + + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) - 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"], - }, + 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 Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address) + + const source = yield* createDependentSource({ + name: `btcwallet.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (_dep, forceRefresh) => + provider.fetchAddressInfo(address, forceRefresh).pipe( + Effect.map((info): BalanceOutput => ({ + amount: Amount.fromRaw(info.balance, 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) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._balanceCreations.delete(cacheKey) + } }) } - private createBalanceSource( - params: AddressParams - ): Effect.Effect> { + 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 getSharedTransactionSource(txHash: string, address?: string): Effect.Effect> { + const provider = this + const cacheKey = address ? `${txHash}:${address}` : txHash const symbol = this.symbol const decimals = this.decimals - return Effect.gen(function* () { - const txHistorySource = yield* provider.createTransactionHistorySource({ - address: params.address, - limit: 1, - }) + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTransactionSource(cacheKey), + }) - const fetchEffect = provider.fetchAddressInfo(params.address).pipe( - Effect.map((info): BalanceOutput => ({ - amount: Amount.fromRaw(info.balance, decimals, symbol), - symbol, - })) + const cached = provider._transactionSources.get(cacheKey) + if (cached) { + cached.refCount += 1 + return Effect.succeed(wrapSharedSource(cached.source)) + } + + const pending = provider._transactionCreations.get(cacheKey) + if (pending) { + return Effect.promise(async () => { + const source = await pending + const entry = provider._transactionSources.get(cacheKey) + if (entry) { + entry.refCount += 1 + } + return wrapSharedSource(source) + }) + } + + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const fetchEffect = provider.fetchTransactionDetail(txHash, true).pipe( + Effect.map((detail): TransactionOutput => { + if (!detail) return null + const direction: Direction = address ? getDirection(detail.vin, detail.vout, address) : "self" + return { + hash: detail.txid, + from: detail.vin?.[0]?.addresses?.[0] ?? "", + to: detail.vout?.[0]?.addresses?.[0] ?? "", + timestamp: (detail.blockTime ?? 0) * 1000, + status: (detail.confirmations ?? 0) > 0 ? ("confirmed" as const) : ("pending" as const), + action: "transfer" as const, + direction, + assets: [{ + assetType: "native" as const, + value: detail.vout?.[0]?.value ?? "0", + symbol, + decimals, + }], + } + }) + ) + + const source = yield* createPollingSource({ + name: `btcwallet.${provider.chainId}.transaction.${cacheKey}`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + }) + + const stopAll = source.stop + + provider._transactionSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source + }) ) - const source = yield* createDependentSource({ - name: `btcwallet.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, - }) + provider._transactionCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._transactionCreations.delete(cacheKey) + } + }) + } + + private releaseSharedTransactionSource(key: string): Effect.Effect { + const provider = this + return Effect.gen(function* () { + const entry = provider._transactionSources.get(key) + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + yield* entry.stopAll + provider._transactionSources.delete(key) + } }) } // ==================== HTTP Fetch Effects ==================== - private fetchAddressInfo(address: string): Effect.Effect { - return httpFetch({ - url: `${this.baseUrl}/api/v2/address/${address}`, - schema: AddressInfoSchema, - }) + private fetchAddressInfo(address: string, forceRefresh = false): Effect.Effect { + const url = `/api/v2/address/${address}?page=1&pageSize=50&details=txs` + return httpFetchCached({ + url: this.baseUrl, + method: "POST", + body: { + url, + method: "GET", + }, + schema: AddressInfoResponseSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: 5000, + canCache: canCacheSuccess, + }).pipe( + Effect.tap((raw) => logApiFailure("blockbook/address", raw)), + Effect.map(normalizeAddressInfo) + ) + } + + private fetchTransactionDetail(txHash: string, forceRefresh = false): Effect.Effect | null, FetchError> { + const url = `/api/v2/tx/${txHash}` + return httpFetchCached({ + url: this.baseUrl, + method: "POST", + body: { + url, + method: "GET", + }, + schema: TxDetailResponseSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: 5000, + canCache: canCacheSuccess, + }).pipe( + Effect.tap((raw) => logApiFailure("blockbook/tx", raw)), + Effect.flatMap((raw) => unwrapBlockbookResultOrNull("blockbook/tx", raw)) + ) + } + + private fetchUtxos(address: string, forceRefresh = false): Effect.Effect[], FetchError> { + const url = `/api/v2/utxo/${address}` + return httpFetchCached({ + url: this.baseUrl, + method: "POST", + body: { + url, + method: "GET", + }, + schema: UtxoResponseSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: 15000, + canCache: canCacheSuccess, + }).pipe( + Effect.tap((raw) => logApiFailure("blockbook/utxo", raw)), + Effect.flatMap((raw) => unwrapBlockbookResult("blockbook/utxo", raw)) + ) + } + + private fetchFeeRate(blocks = 6, forceRefresh = false): Effect.Effect { + const url = `/api/v2/estimatefee/${blocks}` + return httpFetchCached({ + url: this.baseUrl, + method: "POST", + body: { + url, + method: "GET", + }, + schema: FeeRateResponseSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: 30000, + canCache: canCacheSuccess, + }).pipe( + Effect.tap((raw) => logApiFailure("blockbook/estimatefee", raw)), + Effect.flatMap((raw) => unwrapBlockbookResult("blockbook/estimatefee", raw)), + Effect.map((value) => String(value)) + ) + } + + private broadcastRawTransaction(txHex: string): Effect.Effect { + const url = `/api/v2/sendtx/${txHex}` + return httpFetchCached({ + url: this.baseUrl, + method: "POST", + body: { + url, + method: "GET", + }, + schema: BroadcastResponseSchema, + cacheStrategy: "network-first", + cacheTtl: 0, + canCache: async () => false, + }).pipe( + Effect.tap((raw) => logApiFailure("blockbook/sendtx", raw)), + Effect.flatMap((raw) => unwrapBlockbookResult("blockbook/sendtx", raw)) + ) + } + + async buildTransaction(intent: TransactionIntent): Promise { + if (intent.type !== "transfer") { + throw new Error(`Transaction type not supported: ${intent.type}`) + } + + const transferIntent = intent as TransferIntent + const utxos = await Effect.runPromise(this.fetchUtxos(transferIntent.from, true)) + + if (utxos.length === 0) { + throw new Error("No UTXOs available") + } + + const feeRateBtcPerKb = await Effect.runPromise(this.fetchFeeRate(6, true)) + const feeRateBtc = Number(feeRateBtcPerKb) + const satPerKb = Number.isFinite(feeRateBtc) ? feeRateBtc * 1e8 : 0 + const feeRate = satPerKb > 0 ? Math.ceil(satPerKb / 1000) : 1 + + const totalInput = utxos.reduce((sum, u) => sum + Number(u.value), 0) + const sendAmount = Number(transferIntent.amount.raw) + const estimatedVsize = 10 + utxos.length * 68 + 2 * 31 + const fee = feeRate * estimatedVsize + + if (totalInput < sendAmount + fee) { + throw new Error(`Insufficient balance: need ${sendAmount + fee}, have ${totalInput}`) + } + + const change = totalInput - sendAmount - fee + const txData = { + inputs: utxos.map((u) => ({ + txid: u.txid, + vout: u.vout, + value: Number(u.value), + scriptPubKey: u.scriptPubKey ?? "", + })), + outputs: [{ address: transferIntent.to, value: sendAmount }], + fee, + changeAddress: transferIntent.from, + } + + if (change > 546) { + txData.outputs.push({ address: transferIntent.from, value: change }) + } + + return { + chainId: this.chainId, + intentType: "transfer", + data: txData, + } + } + + async estimateFee(_unsignedTx: UnsignedTransaction): Promise { + const feeRateBtcPerKb = await Effect.runPromise(this.fetchFeeRate(6, true)) + const feeRateBtc = Number(feeRateBtcPerKb) + const satPerKb = Number.isFinite(feeRateBtc) ? feeRateBtc * 1e8 : 0 + const satPerByte = satPerKb > 0 ? Math.ceil(satPerKb / 1000) : 1 + + const config = chainConfigService.getConfig(this.chainId)! + const typicalVsize = 140 + const baseFee = BigInt(satPerByte * typicalVsize) + + const slow: Fee = { + amount: Amount.fromRaw(((baseFee * 80n) / 100n).toString(), config.decimals, config.symbol), + estimatedTime: 3600, + } + const standard: Fee = { + amount: Amount.fromRaw(baseFee.toString(), config.decimals, config.symbol), + estimatedTime: 1800, + } + const fast: Fee = { + amount: Amount.fromRaw(((baseFee * 120n) / 100n).toString(), config.decimals, config.symbol), + estimatedTime: 600, + } + return { slow, standard, fast } + } + + async broadcastTransaction(signedTx: SignedTransaction): Promise { + const txHex = signedTx.data as string + return Effect.runPromise(this.broadcastRawTransaction(txHex)) } } diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts index 103c588dc..288b986ef 100644 --- a/src/services/chain-adapter/providers/chain-provider.ts +++ b/src/services/chain-adapter/providers/chain-provider.ts @@ -8,6 +8,7 @@ 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 { isChainDebugEnabled } from "@/services/chain-adapter/debug" import { chainConfigService } from "@/services/chain-config" import type { ApiProvider, @@ -61,14 +62,9 @@ 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 + const message = `[chain-provider] ${args.join(" ")}` + if (!isChainDebugEnabled(message)) return console.log("[chain-provider]", ...args) } @@ -110,26 +106,6 @@ function createFallbackStream( 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 - } - } - - // 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 { @@ -145,24 +121,49 @@ function createFallbackStream( const subscribe = ( input: TInput, - callback: (data: TOutput, event: "initial" | "update") => void + callback: (data: TOutput, event: "initial" | "update") => void, + onError?: (error: unknown) => 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) => { + + const subscribeToSource = (index: number) => { + if (cancelled) return + if (index >= sources.length) { + onError?.(new Error(`[${name}] all providers failed`)) + return + } + + const source = sources[index] + debugLog(`${name} probe`, source.name) + let received = false + cleanup = source.subscribe( + input, + (data, event) => { + if (cancelled) return + if (!received) { + received = true + resolvedSource = source + debugLog(`${name} resolved`, source.name) + } debugLog(`${name} emit`, event, summarizeValue(data)) callback(data, event) - }) - }) - .catch((err) => { - console.error(`[${name}] resolveSource failed:`, err) - }) + }, + (error) => { + if (cancelled || received) return + debugLog(`${name} error`, source.name, String(error)) + cleanup?.() + cleanup = null + subscribeToSource(index + 1) + } + ) + } + + const resolvedIndex = resolvedSource ? sources.indexOf(resolvedSource) : -1 + subscribeToSource(resolvedIndex >= 0 ? resolvedIndex : 0) return () => { cancelled = true diff --git a/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts b/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts index 96a78e7e2..13c1c016c 100644 --- a/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts +++ b/src/services/chain-adapter/providers/etherscan-v1-provider.effect.ts @@ -9,17 +9,16 @@ import { Effect, Duration } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, - HttpError, + HttpError, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams, Transaction } from "./types" import type { ParsedApiEntry } from "@/services/chain-config" import { chainConfigService } from "@/services/chain-config" @@ -27,6 +26,7 @@ import { Amount } from "@/types/amount" import { EvmIdentityMixin } from "../evm/identity-mixin" import { EvmTransactionMixin } from "../evm/transaction-mixin" import { getApiKey } from "./api-key-picker" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" // ==================== Effect Schema 定义 ==================== @@ -109,6 +109,24 @@ export class EtherscanV1ProviderEffect extends EvmIdentityMixin(EvmTransactionMi private readonly pollingInterval: number = 30000 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>>() readonly nativeBalance: StreamInstance readonly transactionHistory: StreamInstance @@ -153,101 +171,219 @@ export class EtherscanV1ProviderEffect extends EvmIdentityMixin(EvmTransactionMi private createTransactionHistorySource( params: TxHistoryParams ): Effect.Effect> { + return this.getSharedTxHistorySource(params.address) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address) + } + + private getSharedTxHistorySource(address: string): Effect.Effect> { const provider = this - const address = params.address.toLowerCase() + const normalizedAddress = address.toLowerCase() + const cacheKey = normalizedAddress const symbol = this.symbol const decimals = this.decimals - const chainId = this.chainId - return Effect.gen(function* () { - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus() - } - const eventBus = provider._eventBus + 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) + }) + } - 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 Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus() } - 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 eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionHistory({ address, limit: 50 }, true).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, normalizedAddress), + assets: [{ + assetType: "native" as const, + value: tx.value, + symbol, + decimals, + }], + })) + }) + ) + + const source = yield* createPollingSource({ + name: `etherscan.${provider.chainId}.txHistory.${cacheKey}`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId: provider.chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + const stopAll = source.stop + provider._txHistorySources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source }) ) - 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"], - }, - }) + provider._txHistoryCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._txHistoryCreations.delete(cacheKey) + } }) } - private createBalanceSource( - params: AddressParams - ): Effect.Effect> { + 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 getSharedBalanceSource(address: string): Effect.Effect> { + const provider = this + const cacheKey = address.toLowerCase() const symbol = this.symbol const decimals = this.decimals - return Effect.gen(function* () { - const txHistorySource = yield* provider.createTransactionHistorySource({ - address: params.address, - limit: 1, + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) + + 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) }) + } - 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, - } + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address) + + const source = yield* createDependentSource({ + name: `etherscan.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (_dep, forceRefresh) => + provider.fetchBalance(address, forceRefresh).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 stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid) + + provider._balanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source }) ) - const source = yield* createDependentSource({ - name: `etherscan.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, - }) + provider._balanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._balanceCreations.delete(cacheKey) + } + }) + } + + 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) + } }) } // ==================== HTTP Fetch Effects ==================== - private fetchBalance(address: string): Effect.Effect { - return httpFetch({ + private fetchBalance(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: this.buildUrl({ module: "account", action: "balance", @@ -255,11 +391,12 @@ export class EtherscanV1ProviderEffect extends EvmIdentityMixin(EvmTransactionMi tag: "latest", }), schema: ApiResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchTransactionHistory(params: TxHistoryParams): Effect.Effect { - return httpFetch({ + private fetchTransactionHistory(params: TxHistoryParams, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: this.buildUrl({ module: "account", action: "txlist", @@ -269,6 +406,7 @@ export class EtherscanV1ProviderEffect extends EvmIdentityMixin(EvmTransactionMi sort: "desc", }), schema: ApiResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } } diff --git a/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts b/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts index b4eea4d15..188461e02 100644 --- a/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts +++ b/src/services/chain-adapter/providers/etherscan-v2-provider.effect.ts @@ -9,17 +9,16 @@ import { Effect, Duration } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, - HttpError, + HttpError, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Direction, BalanceOutput, TransactionsOutput, AddressParams, TxHistoryParams, Transaction } from "./types" import type { ParsedApiEntry } from "@/services/chain-config" import { chainConfigService } from "@/services/chain-config" @@ -27,6 +26,7 @@ import { Amount } from "@/types/amount" import { EvmIdentityMixin } from "../evm/identity-mixin" import { EvmTransactionMixin } from "../evm/transaction-mixin" import { getApiKey } from "./api-key-picker" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" // ==================== Effect Schema 定义 ==================== @@ -110,6 +110,24 @@ export class EtherscanV2ProviderEffect extends EvmIdentityMixin(EvmTransactionMi private readonly pollingInterval: number = 30000 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>>() readonly nativeBalance: StreamInstance readonly transactionHistory: StreamInstance @@ -161,104 +179,222 @@ export class EtherscanV2ProviderEffect extends EvmIdentityMixin(EvmTransactionMi private createTransactionHistorySource( params: TxHistoryParams ): Effect.Effect> { + return this.getSharedTxHistorySource(params.address) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address) + } + + private getSharedTxHistorySource(address: string): Effect.Effect> { const provider = this - const address = params.address.toLowerCase() + const normalizedAddress = address.toLowerCase() + const cacheKey = normalizedAddress const symbol = this.symbol const decimals = this.decimals - const chainId = this.chainId - return Effect.gen(function* () { - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus() - } - const eventBus = provider._eventBus + 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) + }) + } - 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 Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus() } - 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 eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionHistory({ address, limit: 50 }, true).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, normalizedAddress), + assets: [{ + assetType: "native" as const, + value: tx.value, + symbol, + decimals, + }], + })) + }) + ) + + const source = yield* createPollingSource({ + name: `etherscan-v2.${provider.chainId}.txHistory.${cacheKey}`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId: provider.chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + const stopAll = source.stop + provider._txHistorySources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source }) ) - 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"], - }, - }) + provider._txHistoryCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._txHistoryCreations.delete(cacheKey) + } }) } - private createBalanceSource( - params: AddressParams - ): Effect.Effect> { + 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 getSharedBalanceSource(address: string): Effect.Effect> { + const provider = this + const cacheKey = address.toLowerCase() const symbol = this.symbol const decimals = this.decimals - return Effect.gen(function* () { - const txHistorySource = yield* provider.createTransactionHistorySource({ - address: params.address, - limit: 1, + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) + + 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) }) + } - 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, - } + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address) + + const source = yield* createDependentSource({ + name: `etherscan-v2.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (_dep, forceRefresh) => + provider.fetchBalance(address, forceRefresh).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 stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid) + + provider._balanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source }) ) - const source = yield* createDependentSource({ - name: `etherscan-v2.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, - }) + provider._balanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._balanceCreations.delete(cacheKey) + } + }) + } + + 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) + } }) } // ==================== HTTP Fetch Effects ==================== - private fetchBalance(address: string): Effect.Effect { - return httpFetch({ + private fetchBalance(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: this.buildUrl({ module: "account", action: "balance", @@ -266,11 +402,12 @@ export class EtherscanV2ProviderEffect extends EvmIdentityMixin(EvmTransactionMi tag: "latest", }), schema: ApiResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchTransactionHistory(params: TxHistoryParams): Effect.Effect { - return httpFetch({ + private fetchTransactionHistory(params: TxHistoryParams, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: this.buildUrl({ module: "account", action: "txlist", @@ -280,6 +417,7 @@ export class EtherscanV2ProviderEffect extends EvmIdentityMixin(EvmTransactionMi sort: "desc", }), schema: ApiResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } } diff --git a/src/services/chain-adapter/providers/ethwallet-provider.effect.ts b/src/services/chain-adapter/providers/ethwallet-provider.effect.ts index 4afabce16..0423f7c7b 100644 --- a/src/services/chain-adapter/providers/ethwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/ethwallet-provider.effect.ts @@ -4,21 +4,25 @@ * 使用 Effect 原生 Source API 实现响应式数据获取 * - transactionHistory: 定时轮询 + 事件触发 * - balance: 依赖 transactionHistory 变化 + * + * Docs: + * - docs/white-book/02-Driver-Ref/02-EVM/03-Wallet-Provider.md + * - docs/white-book/99-Appendix/05-API-Providers.md */ -import { Effect, Duration } from "effect" +import { Effect, Duration, Stream, SubscriptionRef, Fiber } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, + HttpError, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Transaction, @@ -28,44 +32,118 @@ import type { TransactionsOutput, AddressParams, TxHistoryParams, + TokenBalancesOutput, + TokenBalance, } 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 { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" // ==================== Effect Schema 定义 ==================== const BalanceResponseSchema = S.Struct({ success: S.Boolean, - result: S.Union(S.String, S.Number), + result: S.optional(S.Union(S.String, S.Number)), }) -type BalanceResponse = S.Schema.Type +const BalanceRawSchema = S.Union(BalanceResponseSchema, S.String, S.Number) +type BalanceRaw = S.Schema.Type const NativeTxSchema = S.Struct({ - blockNumber: S.String, + blockNumber: S.optional(S.String), timeStamp: S.String, hash: S.String, from: S.String, to: S.String, value: S.String, isError: S.optional(S.String), + txreceipt_status: 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 TokenTxSchema = S.Struct({ + blockNumber: S.optional(S.String), + timeStamp: S.String, + hash: S.String, + from: S.String, + to: S.String, + value: S.String, + tokenSymbol: S.optional(S.String), + tokenName: S.optional(S.String), + tokenDecimal: S.optional(S.String), + contractAddress: S.optional(S.String), +}) + +const TxHistoryResultSchema = S.Struct({ + status: S.optional(S.String), + message: S.optional(S.String), + result: S.Array(NativeTxSchema), +}) + +const TokenHistoryResultSchema = S.Struct({ + status: S.optional(S.String), + message: S.optional(S.String), + result: S.Array(TokenTxSchema), +}) + const TxHistoryResponseSchema = S.Struct({ success: S.Boolean, - result: S.Struct({ - status: S.optional(S.String), - result: S.Array(NativeTxSchema), - }), + result: S.optional(TxHistoryResultSchema), }) type TxHistoryResponse = S.Schema.Type +const TokenHistoryResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(TokenHistoryResultSchema), +}) +type TokenHistoryResponse = S.Schema.Type + +const BalanceV2ItemSchema = S.Struct({ + amount: S.Union(S.String, S.Number), + contractAddress: S.optional(S.String), + decimals: S.optional(S.Number), + icon: S.optional(S.String), + symbol: S.optional(S.String), + name: S.optional(S.String), +}) + +const BalanceV2ResponseSchema = S.Array(BalanceV2ItemSchema) +const BalanceV2WrappedSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.Array(BalanceV2ItemSchema)), +}) +const BalanceV2RawSchema = S.Union(BalanceV2ResponseSchema, BalanceV2WrappedSchema) +type BalanceV2Raw = S.Schema.Type +type BalanceV2Response = S.Schema.Type + +const TokenListItemSchema = S.Struct({ + chain: S.optional(S.String), + address: S.String, + name: S.String, + icon: S.optional(S.String), + symbol: S.String, + decimals: S.Number, +}) + +const TokenListResultSchema = S.Struct({ + data: S.Array(TokenListItemSchema), + page: S.Number, + pageSize: S.Number, + total: S.Number, + pages: S.optional(S.Number), +}) + +const TokenListResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(TokenListResultSchema), +}) +type TokenListResponse = S.Schema.Type + // ==================== 工具函数 ==================== function getDirection(from: string, to: string, address: string): Direction { @@ -81,6 +159,73 @@ function detectAction(tx: NativeTx): Action { return "contract" } +function toRawString(value: string | number | undefined | null): string { + if (value === undefined || value === null) return "0" + return String(value) +} + +function normalizeBalance(raw: BalanceRaw): { success: boolean; amount: string } { + if (typeof raw === "string" || typeof raw === "number") { + return { success: true, amount: toRawString(raw) } + } + return { success: raw.success, amount: toRawString(raw.result) } +} + +function canCacheSuccess(result: { success: boolean }): Promise { + return Promise.resolve(result.success) +} + +function isWalletApiOk(result?: { status?: string; message?: string }): boolean { + if (!result) return false + const status = result.status?.toUpperCase() + const message = result.message?.toUpperCase() + if (message === "NO TRANSACTIONS FOUND") return true + if (status === "0") return false + if (message === "NOTOK") return false + return true +} + +function canCacheHistory(result: { success: boolean; result?: { status?: string; message?: string } }): Promise { + if (!result.success) return Promise.resolve(false) + return Promise.resolve(isWalletApiOk(result.result)) +} + +function canCacheBalance(result: BalanceRaw): Promise { + if (typeof result === "string" || typeof result === "number") return Promise.resolve(true) + return Promise.resolve(result.success) +} + +function canCacheBalanceV2(result: BalanceV2Raw): Promise { + if (Array.isArray(result)) return Promise.resolve(true) + return Promise.resolve(result.success) +} + +function normalizeBalanceV2(raw: BalanceV2Raw): BalanceV2Response { + if (Array.isArray(raw)) return raw + return raw.result ?? [] +} + +function isHistoryHealthy(raw: TxHistoryResponse | null): boolean { + if (!raw || !raw.success) return false + return isWalletApiOk(raw.result) +} + +function logApiFailure(name: string, payload: { success: boolean; error?: unknown }): void { + if (payload.success) return + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[ethwallet] ${name} success=false`, payload.error ?? payload) + } +} + +function parseTokenDecimals(value: string | number | undefined): number { + if (typeof value === "number") return value + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? 0 : parsed + } + return 0 +} + // ==================== 判断交易列表是否变化 ==================== function hasTransactionListChanged( @@ -93,6 +238,83 @@ function hasTransactionListChanged( return prev[0]?.hash !== next[0]?.hash } +function mergeTransactions(nativeTxs: Transaction[], tokenTxs: Transaction[]): Transaction[] { + const map = new Map() + for (const tx of nativeTxs) { + map.set(tx.hash, tx) + } + for (const tx of tokenTxs) { + const existing = map.get(tx.hash) + if (existing) { + existing.assets = [...existing.assets, ...tx.assets] + map.set(tx.hash, existing) + continue + } + map.set(tx.hash, tx) + } + return Array.from(map.values()).sort((a, b) => b.timestamp - a.timestamp) +} + +function getContractAddressesFromHistory(txs: TransactionsOutput): string[] { + const contracts = new Set() + for (const tx of txs) { + for (const asset of tx.assets) { + if (asset.assetType !== "token") continue + if (!asset.contractAddress) continue + contracts.add(asset.contractAddress) + } + } + return [...contracts].sort() +} + +function normalizeTokenContracts(result: TokenListResponse): string[] { + if (!result.success || !result.result) return [] + return result.result.data + .map((item) => item.address) + .filter((address) => address.length > 0) + .map((address) => address.toLowerCase()) + .sort() +} + +function ensureTokenListSuccess(raw: TokenListResponse): TokenListResponse { + if (raw.success && raw.result) return raw + return { + success: true, + result: { + data: [], + page: 1, + pageSize: 0, + total: 0, + }, + } +} + +function assertHistoryHealthy( + name: string, + raw: TxHistoryResponse +): Effect.Effect { + if (raw.success && isWalletApiOk(raw.result)) { + return Effect.succeed(raw) + } + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[ethwallet] ${name} NOTOK`, raw) + } + return Effect.fail(new HttpError(`[ethwallet] ${name} NOTOK`)) +} + +function assertTokenHistoryHealthy( + name: string, + raw: TokenHistoryResponse +): Effect.Effect { + if (raw.success && isWalletApiOk(raw.result)) { + return Effect.succeed(raw) + } + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[ethwallet] ${name} NOTOK`, raw) + } + return Effect.fail(new HttpError(`[ethwallet] ${name} NOTOK`)) +} + // ==================== Base Class ==================== class EthWalletBase { @@ -117,11 +339,47 @@ export class EthWalletProviderEffect extends EvmIdentityMixin(EvmTransactionMixi private readonly baseUrl: string private readonly pollingInterval: number = 30000 + private readonly balanceCacheTtlMs: number = 5000 + private readonly tokenListCacheTtl: number = 10 * 60 * 1000 // 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>>() + private _tokenListSource: { + source: DataSource + refCount: number + stopAll: Effect.Effect + } | null = null + private _tokenListCreation: Promise> | null = null readonly nativeBalance: StreamInstance + readonly tokenBalances: StreamInstance readonly transactionHistory: StreamInstance constructor(entry: ParsedApiEntry, chainId: string) { @@ -143,6 +401,11 @@ export class EthWalletProviderEffect extends EvmIdentityMixin(EvmTransactionMixi `ethwallet.${chainId}.nativeBalance`, (params) => provider.createBalanceSource(params) ) + + this.tokenBalances = createStreamInstanceFromSource( + `ethwallet.${chainId}.tokenBalances`, + (params) => provider.createTokenBalancesSource(params) + ) } // ==================== Source 创建方法 ==================== @@ -150,106 +413,948 @@ export class EthWalletProviderEffect extends EvmIdentityMixin(EvmTransactionMixi private createTransactionHistorySource( params: TxHistoryParams ): Effect.Effect> { + return this.getSharedTxHistorySource(params.address) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address).pipe( + Effect.catchAllCause((error) => { + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn("[ethwallet] balance fallback", error) + } + return this.createBalanceFallbackSource(params.address) + }) + ) + } + + private createTokenBalancesSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedTokenBalanceSource(params.address).pipe( + Effect.catchAllCause((error) => { + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn("[ethwallet] tokenBalances fallback", error) + } + return this.createTokenBalancesFallbackSource(params.address) + }) + ) + } + + private createBalanceFallbackSource(address: string): Effect.Effect> { + const symbol = this.symbol + const decimals = this.decimals + return createPollingSource({ + name: `ethwallet.${this.chainId}.balance.fallback`, + interval: Duration.millis(this.pollingInterval), + immediate: true, + fetch: this.fetchBalance(address, true).pipe( + Effect.map((raw): BalanceOutput => { + const balance = normalizeBalance(raw) + return { + amount: Amount.fromRaw(balance.amount, decimals, symbol), + symbol, + } + }) + ), + }) + } + + private createTokenBalancesFallbackSource(address: string): Effect.Effect> { + const symbol = this.symbol + const decimals = this.decimals + return createPollingSource({ + name: `ethwallet.${this.chainId}.tokenBalances.fallback`, + interval: Duration.millis(this.pollingInterval), + immediate: true, + fetch: this.fetchTokenBalances(address, [], true).pipe( + Effect.map((balances): TokenBalancesOutput => { + const list: TokenBalance[] = [] + const balance = normalizeBalance(balances.native) + list.push({ + symbol, + name: symbol, + amount: Amount.fromRaw(balance.amount, decimals, symbol), + isNative: true, + decimals, + }) + + for (const item of balances.tokens) { + const tokenSymbol = item.symbol ?? "ERC20" + const tokenDecimals = item.decimals ?? 0 + list.push({ + symbol: tokenSymbol, + name: item.name ?? tokenSymbol, + amount: Amount.fromRaw(toRawString(item.amount), tokenDecimals, tokenSymbol), + isNative: false, + decimals: tokenDecimals, + icon: item.icon, + contractAddress: item.contractAddress, + }) + } + + return list + }) + ), + }) + } + + private getSharedTxHistorySource(address: string): Effect.Effect> { const provider = this - const address = params.address.toLowerCase() + const normalizedAddress = address.toLowerCase() + const cacheKey = normalizedAddress const symbol = this.symbol const decimals = this.decimals - const chainId = this.chainId + 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* () { + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus() + } + const eventBus = provider._eventBus + + const nativeSource = yield* createPollingSource({ + name: `ethwallet.${provider.chainId}.txHistory.native.${cacheKey}`, + fetch: provider.fetchNativeHistory({ address, limit: 50 }, true), + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId: provider.chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + const initialNative = yield* nativeSource.get + if (!isHistoryHealthy(initialNative)) { + yield* nativeSource.stop + return yield* Effect.fail(new HttpError("[ethwallet] trans/normal/history NOTOK")) + } + + const tokenListSource = yield* provider.getSharedTokenListSource() + + const mergeSignal = yield* SubscriptionRef.make(0) + const bump = SubscriptionRef.update(mergeSignal, (value) => value + 1).pipe(Effect.asVoid) + const tokenHistoryCache = yield* SubscriptionRef.make>( + new Map() + ) + + const changeFibers: Fiber.RuntimeFiber[] = [] + const registerChanges = (stream: Stream.Stream) => + Effect.forkDaemon( + stream.pipe( + Stream.tap(() => bump), + Stream.runDrain + ) + ) + + changeFibers.push(yield* registerChanges(nativeSource.changes)) + + const tokenSources = new Map>() + const tokenFibers = new Map>() + + const attachContract = (contract: string) => + Effect.gen(function* () { + if (tokenSources.has(contract)) return + const source = yield* createPollingSource({ + name: `ethwallet.${provider.chainId}.txHistory.erc20.${cacheKey}.${contract}`, + fetch: provider.fetchTokenHistory({ address, limit: 50, contractAddress: contract }, true), + interval: Duration.millis(provider.pollingInterval), + immediate: false, + walletEvents: { + eventBus, + chainId: provider.chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + tokenSources.set(contract, source) + const fiber = yield* Effect.forkDaemon( + source.changes.pipe( + Stream.runForEach((value) => + SubscriptionRef.update(tokenHistoryCache, (map) => { + const next = new Map(map) + next.set(contract, value) + return next + }).pipe( + Effect.zipRight(bump) + ) + ) + ) + ) + tokenFibers.set(contract, fiber) + yield* Effect.forkDaemon(source.refresh.pipe(Effect.asVoid)) + }) + + const detachContract = (contract: string) => + Effect.gen(function* () { + const source = tokenSources.get(contract) + if (source) { + yield* source.stop + tokenSources.delete(contract) + } + const fiber = tokenFibers.get(contract) + if (fiber) { + yield* Fiber.interrupt(fiber) + tokenFibers.delete(contract) + } + yield* SubscriptionRef.update(tokenHistoryCache, (map) => { + const next = new Map(map) + next.delete(contract) + return next + }).pipe(Effect.zipRight(bump)) + }) + + const syncContracts = (tokenList: TokenListResponse | null) => + Effect.gen(function* () { + const nextContracts = tokenList ? normalizeTokenContracts(tokenList) : [] + const nextSet = new Set(nextContracts) + for (const contract of tokenSources.keys()) { + if (!nextSet.has(contract)) { + yield* detachContract(contract) + } + } + for (const contract of nextSet) { + if (!tokenSources.has(contract)) { + yield* attachContract(contract) + } + } + }) + + yield* syncContracts(yield* tokenListSource.get) + + changeFibers.push(yield* Effect.forkDaemon( + tokenListSource.changes.pipe( + Stream.runForEach((next) => + syncContracts(next).pipe( + Effect.zipRight(bump) + ) + ) + ) + )) + + const mergeSource = yield* createDependentSource({ + name: `ethwallet.${provider.chainId}.txHistory.${cacheKey}`, + dependsOn: mergeSignal, + hasChanged: (prev, next) => prev !== next, + fetch: () => + Effect.gen(function* () { + const native = yield* nativeSource.get + const nativeTxs = (native?.result?.result ?? []).map((tx): Transaction => { + const status = + tx.isError === "1" || tx.txreceipt_status === "0" ? "failed" : "confirmed" + return { + hash: tx.hash, + from: tx.from, + to: tx.to, + timestamp: parseInt(tx.timeStamp, 10) * 1000, + status, + blockNumber: tx.blockNumber ? BigInt(tx.blockNumber) : undefined, + action: detectAction(tx), + direction: getDirection(tx.from, tx.to, normalizedAddress), + assets: [{ + assetType: "native" as const, + value: tx.value, + symbol, + decimals, + }], + } + }) + + const tokenValues = [...(yield* SubscriptionRef.get(tokenHistoryCache)).values()] + const tokenTxs = tokenValues.flatMap((raw) => + (raw?.result?.result ?? []).map((tx): Transaction => { + const tokenSymbol = tx.tokenSymbol ?? "ERC20" + const tokenDecimals = parseTokenDecimals(tx.tokenDecimal) + const contractAddress = tx.contractAddress?.toLowerCase() + return { + hash: tx.hash, + from: tx.from, + to: tx.to, + timestamp: parseInt(tx.timeStamp, 10) * 1000, + status: "confirmed", + blockNumber: tx.blockNumber ? BigInt(tx.blockNumber) : undefined, + action: "transfer" as const, + direction: getDirection(tx.from, tx.to, normalizedAddress), + assets: [{ + assetType: "token" as const, + value: tx.value, + symbol: tokenSymbol, + decimals: tokenDecimals, + contractAddress, + name: tx.tokenName, + }], + } + }) + ) + + return mergeTransactions(nativeTxs, tokenTxs) + }), + }) + + const stopAll = Effect.gen(function* () { + for (const fiber of changeFibers) { + yield* Fiber.interrupt(fiber) + } + for (const source of tokenSources.values()) { + yield* source.stop + } + for (const fiber of tokenFibers.values()) { + yield* Fiber.interrupt(fiber) + } + yield* mergeSource.stop + yield* nativeSource.stop + yield* provider.releaseSharedTokenListSource() + }) + + provider._txHistorySources.set(cacheKey, { + source: mergeSource, + refCount: 1, + stopAll, + }) + + return mergeSource + }) + ) + + provider._txHistoryCreations.set(cacheKey, createPromise) + + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._txHistoryCreations.delete(cacheKey) + } + }) + } + + private releaseSharedTxHistorySource(key: string): Effect.Effect { + const provider = this return Effect.gen(function* () { - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus() + const entry = provider._txHistorySources.get(key) + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + yield* entry.stopAll + provider._txHistorySources.delete(key) } - 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, + }) + } + + private getSharedBalanceSource(address: string): Effect.Effect> { + const provider = this + const cacheKey = address.toLowerCase() + const symbol = this.symbol + const decimals = this.decimals + + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) + + 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 Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* Effect.catchAll( + provider.getSharedTxHistorySource(address), + (error) => + Effect.gen(function* () { + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn("[ethwallet] txHistory unavailable for balance", error) + } + return null + }) + ) + + if (txHistorySource === null) { + const source = yield* createPollingSource({ + name: `ethwallet.${provider.chainId}.balance.poll`, + interval: Duration.millis(provider.pollingInterval), + immediate: true, + fetch: provider.fetchBalance(address, true).pipe( + Effect.map((raw): BalanceOutput => { + const balance = normalizeBalance(raw) + return { + amount: Amount.fromRaw(balance.amount, decimals, symbol), + symbol, + } + }) + ), + }) + + provider._balanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll: source.stop, + }) + + return source + } + const balanceCache = yield* SubscriptionRef.make(null) + const mergeSignal = yield* SubscriptionRef.make(0) + const bump = SubscriptionRef.update(mergeSignal, (value) => value + 1).pipe(Effect.asVoid) + + const buildBalance = (raw: BalanceRaw): BalanceOutput => { + const balance = normalizeBalance(raw) + return { + amount: Amount.fromRaw(balance.amount, decimals, symbol), symbol, - decimals, - }], - })) + } + } + + const dependentSource = yield* createDependentSource({ + name: `ethwallet.${provider.chainId}.balance.dep`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (_dep, forceRefresh) => + provider.fetchBalance(address, forceRefresh).pipe( + Effect.map(buildBalance), + Effect.tap((next) => SubscriptionRef.set(balanceCache, next)) + ), + }) + + const shouldImmediatePoll = (yield* txHistorySource.get) === null + const pollingSource = yield* createPollingSource({ + name: `ethwallet.${provider.chainId}.balance.poll`, + interval: Duration.millis(provider.pollingInterval), + immediate: shouldImmediatePoll, + fetch: Effect.gen(function* () { + const history = yield* txHistorySource.get + const cached = yield* SubscriptionRef.get(balanceCache) + if (history !== null && cached !== null) return cached + const raw = yield* provider.fetchBalance(address, true) + const next = buildBalance(raw) + yield* SubscriptionRef.set(balanceCache, next) + return next + }), + }) + + const changeFibers: Fiber.RuntimeFiber[] = [] + const registerChanges = (stream: Stream.Stream) => + Effect.forkDaemon( + stream.pipe( + Stream.tap(() => bump), + Stream.runDrain + ) + ) + + changeFibers.push(yield* registerChanges(dependentSource.changes)) + changeFibers.push(yield* registerChanges(pollingSource.changes)) + + const mergeSource = yield* createDependentSource({ + name: `ethwallet.${provider.chainId}.balance`, + dependsOn: mergeSignal, + hasChanged: (prev, next) => prev !== next, + fetch: () => + Effect.gen(function* () { + const cached = yield* SubscriptionRef.get(balanceCache) + if (!cached) { + return yield* Effect.fail(new HttpError("[ethwallet] balance cache empty")) + } + return cached + }), + }) + + const stopAll = Effect.gen(function* () { + for (const fiber of changeFibers) { + yield* Fiber.interrupt(fiber) + } + yield* dependentSource.stop + yield* pollingSource.stop + yield* mergeSource.stop + yield* txHistorySource.stop + }) + + provider._balanceSources.set(cacheKey, { + source: mergeSource, + refCount: 1, + stopAll, + }) + + return mergeSource }) ) - 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"], - }, - }) + provider._balanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._balanceCreations.delete(cacheKey) + } }) } - private createBalanceSource( - params: AddressParams - ): Effect.Effect> { + 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) + } + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private getSharedTokenBalanceSource(address: string): Effect.Effect> { const provider = this + const cacheKey = address.toLowerCase() const symbol = this.symbol const decimals = this.decimals - return Effect.gen(function* () { - const txHistorySource = yield* provider.createTransactionHistorySource({ - address: params.address, - limit: 1, + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTokenBalanceSource(cacheKey), + }) + + const cached = provider._tokenBalanceSources.get(cacheKey) + if (cached) { + cached.refCount += 1 + return Effect.succeed(wrapSharedSource(cached.source)) + } + + 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* Effect.catchAll( + provider.getSharedTxHistorySource(address), + (error) => + Effect.gen(function* () { + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn("[ethwallet] txHistory unavailable for tokenBalances", error) + } + return null + }) + ) + + if (txHistorySource === null) { + const source = yield* createPollingSource({ + name: `ethwallet.${provider.chainId}.tokenBalances.poll`, + interval: Duration.millis(provider.pollingInterval), + immediate: true, + fetch: provider.fetchTokenBalances(address, [], true).pipe( + Effect.map((balances): TokenBalancesOutput => { + const list: TokenBalance[] = [] + const balance = normalizeBalance(balances.native) + list.push({ + symbol, + name: symbol, + amount: Amount.fromRaw(balance.amount, decimals, symbol), + isNative: true, + decimals, + }) + + for (const item of balances.tokens) { + const tokenSymbol = item.symbol ?? "ERC20" + const tokenDecimals = item.decimals ?? 0 + list.push({ + symbol: tokenSymbol, + name: item.name ?? tokenSymbol, + amount: Amount.fromRaw(toRawString(item.amount), tokenDecimals, tokenSymbol), + isNative: false, + decimals: tokenDecimals, + icon: item.icon, + contractAddress: item.contractAddress, + }) + } - const fetchEffect = provider.fetchBalance(params.address).pipe( - Effect.map((raw): BalanceOutput => ({ - amount: Amount.fromRaw(String(raw.result), decimals, symbol), - symbol, - })) + return list + }) + ), + }) + + provider._tokenBalanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll: source.stop, + }) + + return source + } + const balanceCache = yield* SubscriptionRef.make(null) + const mergeSignal = yield* SubscriptionRef.make(0) + const bump = SubscriptionRef.update(mergeSignal, (value) => value + 1).pipe(Effect.asVoid) + + const buildTokenBalances = (balances: { native: BalanceRaw; tokens: BalanceV2Response }): TokenBalancesOutput => { + const list: TokenBalance[] = [] + const balance = normalizeBalance(balances.native) + list.push({ + symbol, + name: symbol, + amount: Amount.fromRaw(balance.amount, decimals, symbol), + isNative: true, + decimals, + }) + + for (const item of balances.tokens) { + const tokenSymbol = item.symbol ?? "ERC20" + const tokenDecimals = item.decimals ?? 0 + list.push({ + symbol: tokenSymbol, + name: item.name ?? tokenSymbol, + amount: Amount.fromRaw(toRawString(item.amount), tokenDecimals, tokenSymbol), + isNative: false, + decimals: tokenDecimals, + icon: item.icon, + contractAddress: item.contractAddress, + }) + } + + return list + } + + const dependentSource = yield* createDependentSource({ + name: `ethwallet.${provider.chainId}.tokenBalances.dep`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (dep, forceRefresh) => + provider.fetchTokenBalances(address, getContractAddressesFromHistory(dep), forceRefresh).pipe( + Effect.map(buildTokenBalances), + Effect.tap((next) => SubscriptionRef.set(balanceCache, next)) + ), + }) + + const shouldImmediatePoll = (yield* txHistorySource.get) === null + const pollingSource = yield* createPollingSource({ + name: `ethwallet.${provider.chainId}.tokenBalances.poll`, + interval: Duration.millis(provider.pollingInterval), + immediate: shouldImmediatePoll, + fetch: Effect.gen(function* () { + const history = yield* txHistorySource.get + const cached = yield* SubscriptionRef.get(balanceCache) + if (history !== null && cached !== null) return cached + const raw = yield* provider.fetchTokenBalances(address, [], true) + const next = buildTokenBalances(raw) + yield* SubscriptionRef.set(balanceCache, next) + return next + }), + }) + + const changeFibers: Fiber.RuntimeFiber[] = [] + const registerChanges = (stream: Stream.Stream) => + Effect.forkDaemon( + stream.pipe( + Stream.tap(() => bump), + Stream.runDrain + ) + ) + + changeFibers.push(yield* registerChanges(dependentSource.changes)) + changeFibers.push(yield* registerChanges(pollingSource.changes)) + + const mergeSource = yield* createDependentSource({ + name: `ethwallet.${provider.chainId}.tokenBalances`, + dependsOn: mergeSignal, + hasChanged: (prev, next) => prev !== next, + fetch: () => + Effect.gen(function* () { + const cached = yield* SubscriptionRef.get(balanceCache) + if (!cached) { + return yield* Effect.fail(new HttpError("[ethwallet] tokenBalances cache empty")) + } + return cached + }), + }) + + const stopAll = Effect.gen(function* () { + for (const fiber of changeFibers) { + yield* Fiber.interrupt(fiber) + } + yield* dependentSource.stop + yield* pollingSource.stop + yield* mergeSource.stop + yield* txHistorySource.stop + }) + + provider._tokenBalanceSources.set(cacheKey, { + source: mergeSource, + refCount: 1, + stopAll, + }) + + return mergeSource + }) ) - const source = yield* createDependentSource({ - name: `ethwallet.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, - }) + provider._tokenBalanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._tokenBalanceCreations.delete(cacheKey) + } }) } - // ==================== HTTP Fetch Effects ==================== + private releaseSharedTokenBalanceSource(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 fetchBalance(address: string): Effect.Effect { - return httpFetch({ - url: `${this.baseUrl}/balance`, - method: "POST", - body: { address }, - schema: BalanceResponseSchema, + private getSharedTokenListSource(): Effect.Effect> { + const provider = this + if (provider._tokenListSource) { + provider._tokenListSource.refCount += 1 + return Effect.succeed(provider._tokenListSource.source) + } + if (provider._tokenListCreation) { + return Effect.promise(async () => { + const source = await provider._tokenListCreation + if (provider._tokenListSource) { + provider._tokenListSource.refCount += 1 + } + return source + }) + } + + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const source = yield* createPollingSource({ + name: `ethwallet.${provider.chainId}.tokenList`, + fetch: provider.fetchTokenList(false), + interval: Duration.millis(provider.tokenListCacheTtl), + immediate: true, + }) + + provider._tokenListSource = { + source, + refCount: 1, + stopAll: source.stop, + } + + return source + }) + ) + + provider._tokenListCreation = createPromise + try { + return await createPromise + } finally { + provider._tokenListCreation = null + } }) } - private fetchTransactionHistory(params: TxHistoryParams): Effect.Effect { - return httpFetch({ + private releaseSharedTokenListSource(): Effect.Effect { + const provider = this + return Effect.gen(function* () { + const entry = provider._tokenListSource + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + yield* entry.stopAll + provider._tokenListSource = null + } + }) + } + + private fetchBalance(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ + url: `${this.baseUrl}/balance?address=${address}`, + schema: BalanceRawSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: this.balanceCacheTtlMs, + canCache: canCacheBalance, + }).pipe( + Effect.tap((raw) => { + if (typeof raw === "object" && raw !== null && "success" in raw) { + logApiFailure("balance", raw as { success: boolean; error?: unknown }) + } + }) + ) + } + + private fetchNativeHistory( + params: TxHistoryParams, + forceRefresh = false + ): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/trans/normal/history`, method: "POST", - body: { address: params.address, limit: params.limit ?? 20 }, + body: { + address: params.address, + page: 1, + offset: params.limit ?? 50, + }, schema: TxHistoryResponseSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: this.balanceCacheTtlMs, + canCache: canCacheHistory, + }).pipe( + Effect.tap((raw) => logApiFailure("trans/normal/history", raw)), + Effect.flatMap((raw) => assertHistoryHealthy("trans/normal/history", raw)) + ) + } + + private fetchTokenHistory( + params: TxHistoryParams, + forceRefresh = false + ): Effect.Effect { + if (!params.contractAddress) { + return Effect.succeed({ success: true, result: { result: [] } }) + } + + return httpFetchCached({ + url: `${this.baseUrl}/trans/erc20/history`, + method: "POST", + body: { + address: params.address, + contractaddress: params.contractAddress, + page: 1, + offset: params.limit ?? 50, + }, + schema: TokenHistoryResponseSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: this.balanceCacheTtlMs, + canCache: canCacheHistory, + }).pipe( + Effect.tap((raw) => logApiFailure("trans/erc20/history", raw)), + Effect.flatMap((raw) => assertTokenHistoryHealthy("trans/erc20/history", raw)) + ) + } + + private fetchTokenBalances( + address: string, + contracts: string[], + forceRefresh = false + ): Effect.Effect<{ native: BalanceRaw; tokens: BalanceV2Response }, FetchError> { + const sortedContracts = [...contracts].sort() + if (sortedContracts.length === 0) { + const provider = this + return Effect.gen(function* () { + const tokenList = yield* provider.fetchTokenList(false) + const nextContracts = normalizeTokenContracts(tokenList) + const native = yield* provider.fetchBalance(address, forceRefresh) + if (nextContracts.length === 0) { + return { native, tokens: [] } + } + const tokens = yield* httpFetchCached({ + url: `${provider.baseUrl}/account/balance/v2`, + method: "POST", + body: { address, contracts: nextContracts }, + schema: BalanceV2RawSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: provider.balanceCacheTtlMs, + canCache: canCacheBalanceV2, + }).pipe( + Effect.tap((raw) => { + if (!Array.isArray(raw)) logApiFailure("account/balance/v2", raw) + }), + Effect.map(normalizeBalanceV2), + Effect.catchAll(() => Effect.succeed([] as BalanceV2Response)) + ) + return { native, tokens } + }) + } + + return Effect.all({ + native: this.fetchBalance(address, forceRefresh), + tokens: httpFetchCached({ + url: `${this.baseUrl}/account/balance/v2`, + method: "POST", + body: { address, contracts: sortedContracts }, + schema: BalanceV2RawSchema, + cacheStrategy: forceRefresh ? "ttl" : "cache-first", + cacheTtl: this.balanceCacheTtlMs, + canCache: canCacheBalanceV2, + }).pipe( + Effect.tap((raw) => { + if (!Array.isArray(raw)) logApiFailure("account/balance/v2", raw) + }), + Effect.map(normalizeBalanceV2), + Effect.catchAll(() => Effect.succeed([] as BalanceV2Response)) + ), }) } + + private fetchTokenList(forceRefresh = false): Effect.Effect { + return httpFetchCached({ + url: `${this.baseUrl}/contract/tokens`, + method: "POST", + body: { + page: 1, + pageSize: 50, + chain: "ETH", + }, + schema: TokenListResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "ttl", + cacheTtl: this.tokenListCacheTtl, + canCache: canCacheSuccess, + }).pipe( + Effect.tap((raw) => logApiFailure("contract/tokens", raw)), + Effect.map(ensureTokenListSuccess) + ) + } } export function createEthwalletProviderEffect(entry: ParsedApiEntry, chainId: string): ApiProvider | 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 index c745a504b..f75167727 100644 --- a/src/services/chain-adapter/providers/evm-rpc-provider.effect.ts +++ b/src/services/chain-adapter/providers/evm-rpc-provider.effect.ts @@ -10,10 +10,12 @@ import { Effect, Duration } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, + acquireSource, + makeRegistryKey, type FetchError, type DataSource, } from "@biochain/chain-effect" @@ -96,6 +98,24 @@ export class EvmRpcProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(E private readonly symbol: string private readonly decimals: number private readonly pollingInterval: number = 30000 + private _balanceSources = new Map< + string, + { + source: DataSource + refCount: number + stopAll: Effect.Effect + } + >() + private _balanceCreations = new Map>>() + private _transactionSources = new Map< + string, + { + source: DataSource + refCount: number + stopAll: Effect.Effect + } + >() + private _transactionCreations = new Map>>() readonly nativeBalance: StreamInstance readonly blockHeight: StreamInstance @@ -130,126 +150,251 @@ export class EvmRpcProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(E // ==================== Source 创建方法 ==================== private createBlockHeightSource(): Effect.Effect> { - const provider = this - - return Effect.gen(function* () { - const fetchEffect = provider.fetchBlockNumber().pipe( - Effect.map((res): BlockHeightOutput => BigInt(res.result)) - ) + return this.getSharedBlockHeightSource() + } - const source = yield* createPollingSource({ - name: `evm-rpc.${provider.chainId}.blockHeight`, - fetch: fetchEffect, - interval: Duration.millis(provider.pollingInterval), - }) + private getSharedBlockHeightSource(): Effect.Effect> { + const provider = this + const registryKey = makeRegistryKey(this.chainId, "global", "blockHeight") + const fetchEffect = provider.fetchBlockNumber(true).pipe( + Effect.map((res): BlockHeightOutput => BigInt(res.result)) + ) - return source + return acquireSource(registryKey, { + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), }) } private createBalanceSource( params: AddressParams ): Effect.Effect> { + return this.getSharedBalanceSource(params.address) + } + + private createTransactionSource( + params: TransactionParams + ): Effect.Effect> { + return this.getSharedTransactionSource(params) + } + + private getSharedBalanceSource(address: string): Effect.Effect> { const provider = this + const cacheKey = address.toLowerCase() const symbol = this.symbol const decimals = this.decimals - return Effect.gen(function* () { - const blockHeightSource = yield* provider.createBlockHeightSource() + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) - 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 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 Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const blockHeightSource = yield* provider.getSharedBlockHeightSource() + + const source = yield* createDependentSource({ + name: `evm-rpc.${provider.chainId}.balance`, + dependsOn: blockHeightSource.ref, + hasChanged: hasBlockHeightChanged, + fetch: (_dep, forceRefresh) => + provider.fetchBalance(address, forceRefresh).pipe( + Effect.map((res): BalanceOutput => { + const value = BigInt(res.result).toString() + return { amount: Amount.fromRaw(value, decimals, symbol), symbol } + }) + ), + }) + + const stopAll = Effect.all([source.stop, blockHeightSource.stop]).pipe(Effect.asVoid) + + provider._balanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source }) ) - const source = yield* createDependentSource({ - name: `evm-rpc.${provider.chainId}.balance`, - dependsOn: blockHeightSource.ref, - hasChanged: hasBlockHeightChanged, - fetch: () => fetchEffect, - }) + provider._balanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._balanceCreations.delete(cacheKey) + } }) } - private createTransactionSource( + 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 getSharedTransactionSource( params: TransactionParams ): Effect.Effect> { const provider = this + const cacheKey = params.txHash 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 wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTransactionSource(cacheKey), + }) - const value = BigInt(tx.value || "0x0").toString() - - const blockNumber = receipt?.blockNumber ? BigInt(receipt.blockNumber) : undefined - - const base: Transaction = { - hash: tx.hash, - from: tx.from, - to: tx.to ?? "", - timestamp: Date.now(), - status, - action: (tx.to ? "transfer" : "contract") as Action, - direction: "out" as const, - assets: [{ - assetType: "native" as const, - value, - symbol, - decimals, - }], + const cached = provider._transactionSources.get(cacheKey) + if (cached) { + cached.refCount += 1 + return Effect.succeed(wrapSharedSource(cached.source)) + } + + const pending = provider._transactionCreations.get(cacheKey) + if (pending) { + return Effect.promise(async () => { + const source = await pending + const entry = provider._transactionSources.get(cacheKey) + if (entry) { + entry.refCount += 1 } - - return blockNumber === undefined ? base : { ...base, blockNumber } + return wrapSharedSource(source) }) + } + + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const blockHeightSource = yield* provider.getSharedBlockHeightSource() + + const fetchEffect = Effect.gen(function* () { + const [txRes, receiptRes] = yield* Effect.all([ + provider.fetchTransaction(params.txHash, true), + provider.fetchTransactionReceipt(params.txHash, true), + ]) + + 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() + + const blockNumber = receipt?.blockNumber ? BigInt(receipt.blockNumber) : undefined + + const base: Transaction = { + hash: tx.hash, + from: tx.from, + to: tx.to ?? "", + timestamp: Date.now(), + status, + action: (tx.to ? "transfer" : "contract") as Action, + direction: "out" as const, + assets: [{ + assetType: "native" as const, + value, + symbol, + decimals, + }], + } + + return blockNumber === undefined ? base : { ...base, blockNumber } + }) + + const source = yield* createDependentSource({ + name: `evm-rpc.${provider.chainId}.transaction`, + dependsOn: blockHeightSource.ref, + hasChanged: hasBlockHeightChanged, + fetch: () => fetchEffect, + }) + + const stopAll = Effect.all([source.stop, blockHeightSource.stop]).pipe(Effect.asVoid) + + provider._transactionSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source + }) + ) - const source = yield* createDependentSource({ - name: `evm-rpc.${provider.chainId}.transaction`, - dependsOn: blockHeightSource.ref, - hasChanged: hasBlockHeightChanged, - fetch: () => fetchEffect, - }) + provider._transactionCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._transactionCreations.delete(cacheKey) + } + }) + } + + private releaseSharedTransactionSource(key: string): Effect.Effect { + const provider = this + return Effect.gen(function* () { + const entry = provider._transactionSources.get(key) + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + yield* entry.stopAll + provider._transactionSources.delete(key) + } }) } // ==================== HTTP Fetch Effects ==================== - private fetchBlockNumber(): Effect.Effect { - return httpFetch({ + private fetchBlockNumber(forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: this.endpoint, method: "POST", body: { jsonrpc: "2.0", id: 1, method: "eth_blockNumber", params: [] }, schema: RpcResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchBalance(address: string): Effect.Effect { - return httpFetch({ + private fetchBalance(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: this.endpoint, method: "POST", body: { @@ -259,11 +404,12 @@ export class EvmRpcProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(E params: [address, "latest"], }, schema: RpcResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchTransaction(txHash: string): Effect.Effect { - return httpFetch({ + private fetchTransaction(txHash: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: this.endpoint, method: "POST", body: { @@ -273,11 +419,12 @@ export class EvmRpcProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(E params: [txHash], }, schema: EvmTxRpcSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchTransactionReceipt(txHash: string): Effect.Effect { - return httpFetch({ + private fetchTransactionReceipt(txHash: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: this.endpoint, method: "POST", body: { @@ -287,6 +434,7 @@ export class EvmRpcProviderEffect extends EvmIdentityMixin(EvmTransactionMixin(E params: [txHash], }, schema: EvmReceiptRpcSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } } diff --git a/src/services/chain-adapter/providers/mempool-provider.effect.ts b/src/services/chain-adapter/providers/mempool-provider.effect.ts index b0b78c072..c8ebfcc95 100644 --- a/src/services/chain-adapter/providers/mempool-provider.effect.ts +++ b/src/services/chain-adapter/providers/mempool-provider.effect.ts @@ -10,22 +10,24 @@ import { Effect, Duration } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, + acquireSource, + makeRegistryKey, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" 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" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" // ==================== Effect Schema 定义 ==================== @@ -105,6 +107,24 @@ export class MempoolProviderEffect extends BitcoinIdentityMixin(BitcoinTransacti private readonly pollingInterval: number = 60000 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>>() readonly nativeBalance: StreamInstance readonly transactionHistory: StreamInstance @@ -140,124 +160,243 @@ export class MempoolProviderEffect extends BitcoinIdentityMixin(BitcoinTransacti // ==================== Source 创建方法 ==================== private createBlockHeightSource(): Effect.Effect> { - const provider = this - - return Effect.gen(function* () { - const fetchEffect = provider.fetchBlockHeight().pipe( - Effect.map((height): BlockHeightOutput => BigInt(height)) - ) + return this.getSharedBlockHeightSource() + } - const source = yield* createPollingSource({ - name: `mempool.${provider.chainId}.blockHeight`, - fetch: fetchEffect, - interval: Duration.millis(provider.pollingInterval), - }) + private getSharedBlockHeightSource(): Effect.Effect> { + const provider = this + const registryKey = makeRegistryKey(this.chainId, "global", "blockHeight") + const fetchEffect = provider.fetchBlockHeight(true).pipe( + Effect.map((height): BlockHeightOutput => BigInt(height)) + ) - return source + return acquireSource(registryKey, { + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), }) } private createTransactionHistorySource( params: TxHistoryParams ): Effect.Effect> { + return this.getSharedTxHistorySource(params.address) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address) + } + + private getSharedTxHistorySource(address: string): Effect.Effect> { const provider = this - const address = params.address + const cacheKey = address const symbol = this.symbol const decimals = this.decimals - const chainId = this.chainId - return Effect.gen(function* () { - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus() - } - 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 wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTxHistorySource(cacheKey), + }) - 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"], - }, + 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* () { + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus() + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionHistory(address, true).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.${cacheKey}`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId: provider.chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + const stopAll = source.stop + provider._txHistorySources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source + }) + ) - return source + provider._txHistoryCreations.set(cacheKey, createPromise) + + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._txHistoryCreations.delete(cacheKey) + } }) } - private createBalanceSource( - params: AddressParams - ): Effect.Effect> { + 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 getSharedBalanceSource(address: string): Effect.Effect> { const provider = this + const cacheKey = address const symbol = this.symbol const decimals = this.decimals - return Effect.gen(function* () { - const txHistorySource = yield* provider.createTransactionHistorySource({ - address: params.address, - limit: 1, - }) + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) - 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 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 Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address) + + const source = yield* createDependentSource({ + name: `mempool.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (_dep, forceRefresh) => + provider.fetchAddressInfo(address, forceRefresh).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 stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid) + + provider._balanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source }) ) - const source = yield* createDependentSource({ - name: `mempool.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, - }) + provider._balanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._balanceCreations.delete(cacheKey) + } + }) + } + + 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) + } }) } // ==================== HTTP Fetch Effects ==================== - private fetchBlockHeight(): Effect.Effect { - return httpFetch({ + private fetchBlockHeight(forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/blocks/tip/height`, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchAddressInfo(address: string): Effect.Effect { - return httpFetch({ + private fetchAddressInfo(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/address/${address}`, schema: AddressInfoSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchTransactionHistory(address: string): Effect.Effect { - return httpFetch({ + private fetchTransactionHistory(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/address/${address}/txs`, schema: TxListSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } } diff --git a/src/services/chain-adapter/providers/moralis-provider.effect.ts b/src/services/chain-adapter/providers/moralis-provider.effect.ts index 5e36035aa..c42245c2e 100644 --- a/src/services/chain-adapter/providers/moralis-provider.effect.ts +++ b/src/services/chain-adapter/providers/moralis-provider.effect.ts @@ -9,17 +9,16 @@ import { Effect, Schedule, Duration } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, - txConfirmedEvent, + txConfirmedEvent, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, TokenBalance, @@ -40,6 +39,7 @@ import { Amount } from "@/types/amount" import { EvmIdentityMixin } from "../evm/identity-mixin" import { EvmTransactionMixin } from "../evm/transaction-mixin" import { getApiKey } from "./api-key-picker" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" // ==================== 链 ID 映射 ==================== @@ -95,21 +95,21 @@ const NativeTransferSchema = S.Struct({ from_address: S.String, to_address: S.String, value: S.String, - value_formatted: S.optional(S.String), + value_formatted: S.optional(S.NullOr(S.String)), direction: S.optional(S.Literal("send", "receive")), - token_symbol: S.optional(S.String), - token_logo: S.optional(S.String), + token_symbol: S.optional(S.NullOr(S.String)), + token_logo: S.optional(S.NullOr(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), + value_formatted: S.optional(S.NullOr(S.String)), + token_name: S.optional(S.NullOr(S.String)), + token_symbol: S.optional(S.NullOr(S.String)), + token_decimals: S.optional(S.NullOr(S.String)), + token_logo: S.optional(S.NullOr(S.String)), address: S.String, }) @@ -224,6 +224,33 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( // 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>>() readonly nativeBalance: StreamInstance readonly tokenBalances: StreamInstance @@ -285,195 +312,370 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( private createTransactionHistorySource( params: TxHistoryParams ): Effect.Effect> { + return this.getSharedTxHistorySource(params.address) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address) + } + + private createTokenBalancesSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedTokenBalanceSource(params.address) + } + + private getSharedTxHistorySource(address: string): Effect.Effect> { const provider = this - const address = params.address.toLowerCase() + const normalizedAddress = address.toLowerCase() + const cacheKey = normalizedAddress const symbol = this.symbol const decimals = this.decimals - const chainId = this.chainId - return Effect.gen(function* () { - // 获取或创建 Provider 级别共享的 EventBus - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus() - } - const eventBus = provider._eventBus + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTxHistorySource(cacheKey), + }) - 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, - }) - } - } + const cached = provider._txHistorySources.get(cacheKey) + if (cached) { + cached.refCount += 1 + return Effect.succeed(wrapSharedSource(cached.source)) + } - 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, - }) - } + 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 { - 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, - } + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus() + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchWalletHistory({ address, limit: 50 }, true).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 ?? "", normalizedAddress) + 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.${cacheKey}`, + fetch: fetchEffect, + interval: Duration.millis(provider.erc20Interval), + walletEvents: { + eventBus, + chainId: provider.chainId, + address, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + const stopAll = source.stop + provider._txHistorySources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source }) ) - 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"], - }, - }) + provider._txHistoryCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._txHistoryCreations.delete(cacheKey) + } }) } - private createBalanceSource( - params: AddressParams - ): Effect.Effect> { + 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 getSharedBalanceSource(address: string): Effect.Effect> { + const provider = this + const cacheKey = address.toLowerCase() const symbol = this.symbol const decimals = this.decimals - return Effect.gen(function* () { - const txHistorySource = yield* provider.createTransactionHistorySource({ - address: params.address, - limit: 1, + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) + + 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) }) + } - const fetchEffect = provider.fetchNativeBalance(params.address).pipe( - Effect.retry(rateLimitRetrySchedule), - Effect.map((raw): BalanceOutput => ({ - amount: Amount.fromRaw(raw.balance, decimals, symbol), - symbol, - })) + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address) + + const source = yield* createDependentSource({ + name: `moralis.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (_dep, forceRefresh) => + provider.fetchNativeBalance(address, forceRefresh).pipe( + Effect.retry(rateLimitRetrySchedule), + Effect.map((raw): BalanceOutput => ({ + amount: Amount.fromRaw(raw.balance, decimals, symbol), + symbol, + })) + ), + }) + + const stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid) + + provider._balanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source + }) ) - const source = yield* createDependentSource({ - name: `moralis.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, - }) + provider._balanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._balanceCreations.delete(cacheKey) + } }) } - private createTokenBalancesSource( - params: AddressParams - ): Effect.Effect> { + 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 getSharedTokenBalanceSource(address: string): Effect.Effect> { const provider = this + const cacheKey = address.toLowerCase() 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 wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTokenBalanceSource(cacheKey), + }) + + const cached = provider._tokenBalanceSources.get(cacheKey) + if (cached) { + cached.refCount += 1 + return Effect.succeed(wrapSharedSource(cached.source)) + } + + 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) }) + } - 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, - }) + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address) + + const fetchEffect = Effect.all({ + native: provider.fetchNativeBalance(address), + tokens: provider.fetchTokenBalances(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, + }, + }) + } - 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, + }) + + const stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid) + + provider._tokenBalanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) - return result + return source }) ) - const source = yield* createDependentSource({ - name: `moralis.${provider.chainId}.tokenBalances`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, - }) + provider._tokenBalanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._tokenBalanceCreations.delete(cacheKey) + } + }) + } + + private releaseSharedTokenBalanceSource(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) + } }) } @@ -531,8 +733,8 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( // ==================== HTTP Fetch Effects ==================== - private fetchNativeBalance(address: string): Effect.Effect { - return httpFetch({ + private fetchNativeBalance(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/${address}/balance`, searchParams: { chain: this.moralisChain }, headers: { @@ -540,11 +742,12 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( accept: "application/json", }, schema: NativeBalanceResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchTokenBalances(address: string): Effect.Effect { - return httpFetch({ + private fetchTokenBalances(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/${address}/erc20`, searchParams: { chain: this.moralisChain }, headers: { @@ -552,13 +755,14 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( accept: "application/json", }, schema: TokenBalancesResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }).pipe( Effect.map((tokens) => tokens.slice()) ) } - private fetchWalletHistory(params: TxHistoryParams): Effect.Effect { - return httpFetch({ + private fetchWalletHistory(params: TxHistoryParams, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/wallets/${params.address}/history`, searchParams: { chain: this.moralisChain, @@ -569,11 +773,12 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( accept: "application/json", }, schema: WalletHistoryResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchTransactionReceipt(txHash: string): Effect.Effect { - return httpFetch({ + private fetchTransactionReceipt(txHash: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: this.rpcUrl, method: "POST", body: { @@ -583,6 +788,7 @@ export class MoralisProviderEffect extends EvmIdentityMixin(EvmTransactionMixin( params: [txHash], }, schema: TxReceiptRpcResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } } diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts b/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts index ea890bef8..15550dd62 100644 --- a/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts +++ b/src/services/chain-adapter/providers/tron-rpc-provider.effect.ts @@ -9,17 +9,17 @@ import { Effect, Duration } from "effect" import { Schema as S } from "effect" import { - httpFetch, + httpFetchCached, createStreamInstanceFromSource, createPollingSource, createDependentSource, - + acquireSource, + makeRegistryKey, type FetchError, type DataSource, type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Direction, @@ -37,6 +37,8 @@ import { chainConfigService } from "@/services/chain-config" import { Amount } from "@/types/amount" import { TronIdentityMixin } from "../tron/identity-mixin" import { TronTransactionMixin } from "../tron/transaction-mixin" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" +import { normalizeTronAddress, normalizeTronHex, tronAddressToHex } from "../tron/address" import { getApiKey } from "./api-key-picker" // ==================== Effect Schema 定义 ==================== @@ -90,45 +92,20 @@ 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) +function safeNormalizeHex(value: string): string { + try { + return normalizeTronHex(value) + } catch { + return value.toLowerCase() } - 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" + const fromHex = safeNormalizeHex(from) + const toHex = safeNormalizeHex(to) + const addrHex = safeNormalizeHex(address) + if (fromHex === addrHex && toHex === addrHex) return "self" + if (fromHex === addrHex) return "out" return "in" } @@ -172,6 +149,24 @@ export class TronRpcProviderEffect extends TronIdentityMixin(TronTransactionMixi // 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>>() readonly nativeBalance: StreamInstance readonly transactionHistory: StreamInstance @@ -218,118 +213,235 @@ export class TronRpcProviderEffect extends TronIdentityMixin(TronTransactionMixi // ==================== Source 创建方法 ==================== private createBlockHeightSource(): Effect.Effect> { - const provider = this + return this.getSharedBlockHeightSource() + } - return Effect.gen(function* () { - const fetchEffect = provider.fetchNowBlock().pipe( - Effect.map((raw): BlockHeightOutput => - BigInt(raw.block_header?.raw_data?.number ?? 0) - ) + private getSharedBlockHeightSource(): Effect.Effect> { + const provider = this + const registryKey = makeRegistryKey(this.chainId, "global", "blockHeight") + const fetchEffect = provider.fetchNowBlock(true).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 + return acquireSource(registryKey, { + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), }) } private createTransactionHistorySource( params: TxHistoryParams ): Effect.Effect> { + return this.getSharedTxHistorySource(params.address) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address) + } + + private getSharedTxHistorySource(address: string): Effect.Effect> { const provider = this - const address = params.address.toLowerCase() + const eventAddress = normalizeTronAddress(address) + const cacheKey = normalizeTronHex(address) const symbol = this.symbol const decimals = this.decimals - const chainId = this.chainId - return Effect.gen(function* () { - // 获取或创建 Provider 级别共享的 EventBus - if (!provider._eventBus) { - provider._eventBus = yield* getWalletEventBus() - } - 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 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* () { + if (!provider._eventBus) { + provider._eventBus = yield* getWalletEventBus() + } + const eventBus = provider._eventBus + + const fetchEffect = provider.fetchTransactionList(eventAddress, true).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 fromRaw = value?.owner_address ?? "" + const toRaw = value?.to_address ?? "" + const from = normalizeTronAddress(fromRaw) + const to = normalizeTronAddress(toRaw) + 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(fromRaw, toRaw, eventAddress), + assets: [{ + assetType: "native" as const, + value: (value?.amount ?? 0).toString(), + symbol, + decimals, + }], + } + }) + }) + ) + + const source = yield* createPollingSource({ + name: `tron-rpc.${provider.chainId}.txHistory.${cacheKey}`, + fetch: fetchEffect, + interval: Duration.millis(provider.pollingInterval), + walletEvents: { + eventBus, + chainId: provider.chainId, + address: eventAddress, + types: ["tx:confirmed", "tx:sent"], + }, + }) + + const stopAll = source.stop + provider._txHistorySources.set(cacheKey, { + source, + refCount: 1, + stopAll, }) + + return source }) ) - 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"], - }, - }) + provider._txHistoryCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._txHistoryCreations.delete(cacheKey) + } }) } - private createBalanceSource( - params: AddressParams - ): Effect.Effect> { + 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 getSharedBalanceSource(address: string): Effect.Effect> { + const provider = this + const cacheKey = normalizeTronHex(address) 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 wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) + + 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 Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address) + + const source = yield* createDependentSource({ + name: `tron-rpc.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (_dep, forceRefresh) => + provider.fetchAccount(address, forceRefresh).pipe( + Effect.map((raw): BalanceOutput => ({ + amount: Amount.fromRaw((raw.balance ?? 0).toString(), decimals, symbol), + symbol, + })) + ), + }) + + const stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid) - const fetchEffect = provider.fetchAccount(params.address).pipe( - Effect.map((raw): BalanceOutput => ({ - amount: Amount.fromRaw((raw.balance ?? 0).toString(), decimals, symbol), - symbol, - })) + provider._balanceSources.set(cacheKey, { + source, + refCount: 1, + stopAll, + }) + + return source + }) ) - // 依赖 transactionHistory 变化 - const source = yield* createDependentSource({ - name: `tron-rpc.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: () => fetchEffect, - }) + provider._balanceCreations.set(cacheKey, createPromise) - return source + try { + const source = await createPromise + return wrapSharedSource(source) + } finally { + provider._balanceCreations.delete(cacheKey) + } + }) + } + + 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) + } }) } @@ -347,8 +459,10 @@ export class TronRpcProviderEffect extends TronIdentityMixin(TronTransactionMixi const contract = tx.raw_data?.contract?.[0] const value = contract?.parameter?.value - const from = value?.owner_address ?? "" - const to = value?.to_address ?? "" + const fromRaw = value?.owner_address ?? "" + const toRaw = value?.to_address ?? "" + const from = normalizeTronAddress(fromRaw) + const to = normalizeTronAddress(toRaw) let status: "pending" | "confirmed" | "failed" if (!tx.ret || tx.ret.length === 0) { @@ -357,6 +471,8 @@ export class TronRpcProviderEffect extends TronIdentityMixin(TronTransactionMixi status = tx.ret[0]?.contractRet === "SUCCESS" ? "confirmed" : "failed" } + const directionAddress = params.senderId ?? from + return { hash: tx.txID, from, @@ -364,7 +480,7 @@ export class TronRpcProviderEffect extends TronIdentityMixin(TronTransactionMixi timestamp: tx.block_timestamp ?? tx.raw_data?.timestamp ?? 0, status, action: "transfer" as const, - direction: "out", + direction: getDirection(fromRaw, toRaw, directionAddress), assets: [{ assetType: "native" as const, value: (value?.amount ?? 0).toString(), @@ -388,40 +504,47 @@ export class TronRpcProviderEffect extends TronIdentityMixin(TronTransactionMixi // ==================== HTTP Fetch Effects ==================== - private fetchNowBlock(): Effect.Effect { - return httpFetch({ + private fetchNowBlock(forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/wallet/getnowblock`, method: "POST", headers: this.headers, schema: TronNowBlockSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", + cache: "no-store", }) } - private fetchAccount(address: string): Effect.Effect { - return httpFetch({ + private fetchAccount(address: string, forceRefresh = false): Effect.Effect { + return httpFetchCached({ url: `${this.baseUrl}/wallet/getaccount`, method: "POST", headers: this.headers, body: { address: tronAddressToHex(address) }, schema: TronAccountSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } - private fetchTransactionList(address: string): Effect.Effect { - return httpFetch({ - url: `${this.baseUrl}/v1/accounts/${address}/transactions`, + private fetchTransactionList(address: string, forceRefresh = false): Effect.Effect { + const targetAddress = normalizeTronAddress(address) + return httpFetchCached({ + url: `${this.baseUrl}/v1/accounts/${targetAddress}/transactions`, headers: this.headers, schema: TronTxListSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", }) } private fetchTransactionById(txHash: string): Effect.Effect { - return httpFetch({ + return httpFetchCached({ url: `${this.baseUrl}/wallet/gettransactionbyid`, method: "POST", headers: this.headers, body: { value: txHash }, schema: TronTxSchema, + cacheStrategy: "ttl", + cacheTtl: Math.max(1000, Math.floor(this.pollingInterval / 4)), }) } } diff --git a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts index 31ccb582c..46035e72a 100644 --- a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts @@ -1,12 +1,21 @@ /** - * TronWallet API Provider - Effect TS Version (深度重构) - * + * TronWallet API Provider (Effect TS - 深度重构) + * * 使用 Effect 原生 Source API 实现响应式数据获取 + * - transactionHistory: 定时轮询 + 事件触发(共享 source) + * - nativeBalance/tokenBalances: 依赖 transactionHistory 变化 + * + * Legacy Reference: + * - /Users/kzf/Dev/bioforestChain/legacy-apps/libs/wallet-base/services/wallet/tron/tron.service.ts + * Whitebook: + * - docs/white-book/02-Driver-Ref/04-TVM/01-Tron-Provider.md + * - docs/white-book/99-Appendix/05-API-Providers.md */ -import { Effect, Duration } from "effect" +import { Effect, Duration, Stream, SubscriptionRef, Fiber } from "effect" import { Schema as S } from "effect" import { + httpFetch, httpFetchCached, createStreamInstanceFromSource, createPollingSource, @@ -16,152 +25,283 @@ import { type EventBusService, } from "@biochain/chain-effect" import type { StreamInstance } from "@biochain/chain-effect" -import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" import type { ApiProvider, Transaction, Direction, BalanceOutput, + TokenBalancesOutput, TransactionsOutput, + TransactionOutput, AddressParams, TxHistoryParams, + TransactionParams, + TokenBalance, } from "./types" import type { ParsedApiEntry } from "@/services/chain-config" import { chainConfigService } from "@/services/chain-config" import { Amount } from "@/types/amount" +import { ChainServiceError, ChainErrorCodes } from "../types" import { TronIdentityMixin } from "../tron/identity-mixin" import { TronTransactionMixin } from "../tron/transaction-mixin" +import { getWalletEventBus } from "@/services/chain-adapter/wallet-event-bus" +import { normalizeTronAddress, normalizeTronHex, tronAddressToHex } from "../tron/address" +import type { SignedTransaction, SignOptions, TransactionHash, TransactionIntent, TransferIntent, UnsignedTransaction } from "../types" +import type { TronRawTransaction, TronSignedTransaction } from "../tron/types" +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js" +import { secp256k1 } from "@noble/curves/secp256k1.js" // ==================== Effect Schema 定义 ==================== -const BalanceResponseSchema = S.Union( - S.Struct({ - success: S.Boolean, - result: S.Union(S.String, S.Number), - }), - S.Struct({ - success: S.Boolean, - data: S.Union(S.String, S.Number), - }), - S.Struct({ - balance: S.Union(S.String, S.Number), - }), - S.String, - S.Number, -) -type BalanceResponse = S.Schema.Type +const BalanceResponseSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.Union(S.String, S.Number)), +}) +const BalanceRawSchema = S.Union(BalanceResponseSchema, S.String, S.Number) +type BalanceRaw = S.Schema.Type const TronNativeTxSchema = S.Struct({ txID: S.String, from: S.String, to: S.String, - amount: S.Union(S.Number, S.String), + amount: S.Union(S.String, S.Number), timestamp: S.Number, contractRet: S.optional(S.String), - blockNumber: S.optional(S.Number), - fee: S.optional(S.Number), - net_usage: S.optional(S.Number), - net_fee: S.optional(S.Number), - energy_usage: S.optional(S.Number), - energy_fee: S.optional(S.Number), - expiration: S.optional(S.Number), + blockNumber: S.optional(S.Union(S.String, S.Number)), + fee: S.optional(S.Union(S.String, S.Number)), +}) + +const TronTrc20TxSchema = S.Struct({ + txID: S.String, + from: S.String, + to: S.String, + value: S.Union(S.String, S.Number), + timestamp: S.Number, + tokenSymbol: S.optional(S.String), + token_symbol: S.optional(S.String), + tokenName: S.optional(S.String), + token_name: S.optional(S.String), + tokenDecimal: S.optional(S.Number), + token_decimals: S.optional(S.Number), + contractAddress: S.optional(S.String), + token_address: S.optional(S.String), + contractRet: S.optional(S.String), }) const TxHistoryResponseSchema = S.Struct({ success: S.Boolean, - data: S.Array(TronNativeTxSchema), - pageSize: S.optional(S.Number), + data: S.optional(S.Array(TronNativeTxSchema)), fingerprint: S.optional(S.String), + pageSize: S.optional(S.Number), }) type TxHistoryResponse = S.Schema.Type +const Trc20HistoryResponseSchema = S.Struct({ + success: S.Boolean, + data: S.optional(S.Array(TronTrc20TxSchema)), + fingerprint: S.optional(S.String), + pageSize: S.optional(S.Number), +}) +type Trc20HistoryResponse = S.Schema.Type + +const BalanceV2ItemSchema = S.Struct({ + amount: S.Union(S.String, S.Number), + contractAddress: S.optional(S.String), + decimals: S.optional(S.Number), + icon: S.optional(S.String), + symbol: S.optional(S.String), + name: S.optional(S.String), +}) + +const BalanceV2ResponseSchema = S.Array(BalanceV2ItemSchema) +const BalanceV2WrappedSchema = S.Struct({ + success: S.Boolean, + result: S.optional(S.Array(BalanceV2ItemSchema)), +}) +const BalanceV2RawSchema = S.Union(BalanceV2ResponseSchema, BalanceV2WrappedSchema) +type BalanceV2Raw = S.Schema.Type +type BalanceV2Response = S.Schema.Type + +const TokenListItemSchema = S.Struct({ + chain: S.optional(S.String), + address: S.String, + name: S.String, + icon: S.optional(S.String), + symbol: S.String, + decimals: S.Number, +}) + +const TokenListResultSchema = S.Struct({ + data: S.Array(TokenListItemSchema), + page: S.Number, + pageSize: S.Number, + total: S.Number, + pages: S.optional(S.Number), +}) + +const TokenListResponseSchema = S.Struct({ + success: S.Boolean, + result: TokenListResultSchema, +}) +type TokenListResponse = S.Schema.Type + +const PendingTxItemSchema = S.Struct({ + from: S.String, + to: S.String, + state: S.optional(S.Union(S.String, S.Number)), + createdTime: S.Union(S.String, S.Number), + txHash: S.String, + value: S.Union(S.String, S.Number), + assetSymbol: S.optional(S.String), + fee: S.optional(S.Union(S.String, S.Number)), + extra: S.optional(S.Unknown), +}) +type PendingTxItem = S.Schema.Type + +const PendingTxResponseSchema = S.Array(PendingTxItemSchema) +type PendingTxResponse = S.Schema.Type + +const ReceiptResponseSchema = S.Unknown +type ReceiptResponse = 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 - } +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null +} + +function toRawString(value: string | number | undefined | null): string { + if (value === undefined || value === null) return "0" + return String(value) +} + +function safeNormalizeHex(value: string): string { + try { + return normalizeTronHex(value) + } catch { + return value.toLowerCase() + } +} + +function getDirection(from: string, to: string, address: string): Direction { + const fromHex = safeNormalizeHex(from) + const toHex = safeNormalizeHex(to) + const addrHex = safeNormalizeHex(address) + if (fromHex === addrHex && toHex === addrHex) return "self" + if (fromHex === addrHex) return "out" + return "in" +} + +function mapPendingStatus(state: PendingTxItem["state"]): "pending" | "confirmed" | "failed" { + if (typeof state === "string") { + const lowered = state.toLowerCase() + if (lowered.includes("success") || lowered.includes("confirm")) return "confirmed" + if (lowered.includes("fail")) return "failed" + return "pending" } - for (const char of input) { - if (char !== "1") break - bytes.push(0) + if (typeof state === "number") { + if (state >= 2) return "confirmed" + return "pending" } - return new Uint8Array(bytes.reverse()) + return "pending" } -function base58Encode(bytes: Uint8Array): string { - let num = 0n - for (const byte of bytes) { - num = num * 256n + BigInt(byte) - } - let result = "" - while (num > 0n) { - const rem = Number(num % 58n) - num = num / 58n - result = BASE58_ALPHABET[rem] + result - } - for (const byte of bytes) { - if (byte === 0) { - result = BASE58_ALPHABET[0] + result - } else { - break +function mergeTransactions(nativeTxs: Transaction[], tokenTxs: Transaction[]): Transaction[] { + const map = new Map() + for (const tx of nativeTxs) { + map.set(tx.hash, tx) + } + for (const tx of tokenTxs) { + const existing = map.get(tx.hash) + if (existing) { + existing.assets = [...existing.assets, ...tx.assets] + map.set(tx.hash, existing) + continue } + map.set(tx.hash, tx) } - return result + return Array.from(map.values()).sort((a, b) => b.timestamp - a.timestamp) } -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 isConfirmedReceipt(raw: ReceiptResponse): boolean { + if (!isRecord(raw)) return false + return Object.keys(raw).length > 0 } -function tronAddressFromHex(address: string): string { - if (address.startsWith("T")) return address - const normalized = address.startsWith("0x") ? address.slice(2) : address - const hex = normalized.startsWith("41") ? normalized : `41${normalized}` - if (hex.length !== 42) throw new Error(`Invalid Tron hex address: ${address}`) - const bytes = new Uint8Array(hex.match(/.{1,2}/g)!.map((b) => Number.parseInt(b, 16))) - return base58Encode(bytes) +function canCacheSuccess(result: { success: boolean }): Promise { + return Promise.resolve(result.success) } -function getDirection(from: string, to: string, address: string): Direction { - if (from === address && to === address) return "self" - if (from === address) return "out" - return "in" +function canCacheBalance(result: BalanceRaw): Promise { + if (typeof result === "string" || typeof result === "number") return Promise.resolve(true) + return Promise.resolve(result.success) } -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 +function normalizeBalance(raw: BalanceRaw): { success: boolean; amount: string } { + if (typeof raw === "string" || typeof raw === "number") { + return { success: true, amount: toRawString(raw) } + } + return { success: raw.success, amount: toRawString(raw.result) } } -function normalizeBalanceResponse(raw: BalanceResponse): string { - if (typeof raw === "string" || typeof raw === "number") return String(raw) - if ("balance" in raw) return String(raw.balance) - if ("result" in raw) return String(raw.result) - if ("data" in raw) return String(raw.data) - return "0" +function canCacheBalanceV2(result: BalanceV2Raw): Promise { + if (Array.isArray(result)) return Promise.resolve(true) + return Promise.resolve(result.success) +} + +function normalizeBalanceV2(raw: BalanceV2Raw): BalanceV2Response { + if (Array.isArray(raw)) return raw + return raw.result ?? [] +} + +function logApiFailure(name: string, payload: { success: boolean; error?: unknown }): void { + if (payload.success) return + if (typeof import.meta !== "undefined" && import.meta.env?.DEV) { + console.warn(`[tronwallet] ${name} success=false`, payload.error ?? payload) + } +} + +function getContractAddressesFromHistory(txs: TransactionsOutput): string[] { + const contracts = new Set() + for (const tx of txs) { + for (const asset of tx.assets) { + if (asset.assetType !== "token") continue + if (!asset.contractAddress) continue + contracts.add(asset.contractAddress) + } + } + return [...contracts].sort() +} + +function normalizeTokenContracts(result: TokenListResponse): string[] { + if (!result.success) return [] + return result.result.data + .map((item) => item.address) + .filter((address) => address.length > 0) + .map((address) => normalizeTronAddress(address)) + .sort() +} + +type TronWalletBroadcastDetail = { + from: string + to: string + amount: string + fee: string + assetSymbol: string +} + +type TronWalletUnsignedPayload = { + rawTx: TronRawTransaction + detail: TronWalletBroadcastDetail + isToken: boolean +} + +type TronWalletSignedPayload = { + signedTx: TronSignedTransaction + detail: TronWalletBroadcastDetail + isToken: boolean } // ==================== Base Class ==================== @@ -187,13 +327,52 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM private readonly decimals: number private readonly baseUrl: string private readonly pollingInterval: number = 30000 + private readonly tokenListCacheTtl: number = 10 * 60 * 1000 private _eventBus: EventBusService | null = null - private _txHistorySources = new Map; refCount: number }>() + 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>>() + private _tokenListSource: { + source: DataSource + refCount: number + stopAll: Effect.Effect + } | null = null + private _tokenListCreation: Promise> | null = null + + // pendingTx 轮询去重 TTL = pollingInterval / 4 + private get pendingTxCacheTtl(): number { + return Math.max(1000, Math.floor(this.pollingInterval / 4)) + } readonly nativeBalance: StreamInstance + readonly tokenBalances: StreamInstance readonly transactionHistory: StreamInstance + readonly transaction: StreamInstance constructor(entry: ParsedApiEntry, chainId: string) { super(entry, chainId) @@ -212,30 +391,256 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM `tronwallet.${chainId}.nativeBalance`, (params) => provider.createBalanceSource(params) ) + + this.tokenBalances = createStreamInstanceFromSource( + `tronwallet.${chainId}.tokenBalances`, + (params) => provider.createTokenBalancesSource(params) + ) + + this.transaction = createStreamInstanceFromSource( + `tronwallet.${chainId}.transaction`, + (params) => provider.createTransactionSource(params) + ) } - private releaseSharedTxHistorySource(cacheKey: string) { + // ==================== Source 创建方法 ==================== + + private createTransactionHistorySource( + params: TxHistoryParams + ): Effect.Effect> { + return this.getSharedTxHistorySource(params.address) + } + + private createBalanceSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedBalanceSource(params.address) + } + + private createTokenBalancesSource( + params: AddressParams + ): Effect.Effect> { + return this.getSharedTokenBalanceSource(params.address) + } + + private createTransactionSource( + params: TransactionParams + ): Effect.Effect> { const provider = this + const symbol = this.symbol + const decimals = this.decimals + return Effect.gen(function* () { - const entry = provider._txHistorySources.get(cacheKey) - if (!entry) return - entry.refCount -= 1 - if (entry.refCount <= 0) { - provider._txHistorySources.delete(cacheKey) - yield* entry.source.stop - } + const fetchEffect = Effect.gen(function* () { + const [receipt, pending] = yield* Effect.all([ + provider.fetchTransactionReceipt(params.txHash), + params.senderId ? provider.fetchPendingTransactions(params.senderId) : Effect.succeed([] as PendingTxResponse), + ]) + + const pendingTx = pending.find((item) => item.txHash === params.txHash) + const confirmed = isConfirmedReceipt(receipt) + + if (pendingTx) { + const from = normalizeTronAddress(pendingTx.from) + const to = normalizeTronAddress(pendingTx.to) + return { + hash: pendingTx.txHash, + from, + to, + timestamp: Number(pendingTx.createdTime) || Date.now(), + status: confirmed ? "confirmed" : mapPendingStatus(pendingTx.state), + action: "transfer" as const, + direction: getDirection(pendingTx.from, pendingTx.to, params.senderId ?? from), + assets: [{ + assetType: "native" as const, + value: toRawString(pendingTx.value), + symbol, + decimals, + }], + } + } + + if (confirmed) { + const from = params.senderId ? normalizeTronAddress(params.senderId) : "" + return { + hash: params.txHash, + from, + to: "", + timestamp: Date.now(), + status: "confirmed", + action: "transfer" as const, + direction: from ? "out" : "in", + assets: [{ + assetType: "native" as const, + value: "0", + symbol, + decimals, + }], + } + } + + return null + }) + + const source = yield* createPollingSource({ + name: `tronwallet.${provider.chainId}.transaction`, + fetch: fetchEffect, + interval: Duration.millis(3000), + }) + + return source }) } - private getSharedTxHistorySource( - params: TxHistoryParams - ): Effect.Effect> { + // ==================== Transaction Service (override) ==================== + + async buildTransaction(intent: TransactionIntent): Promise { + if (intent.type !== "transfer") { + throw new ChainServiceError( + ChainErrorCodes.NOT_SUPPORTED, + `Transaction type not supported: ${intent.type}` + ) + } + + const transferIntent = intent as TransferIntent + const fromHex = tronAddressToHex(transferIntent.from) + const toHex = tronAddressToHex(transferIntent.to) + const feeRaw = transferIntent.fee?.raw ?? "0" + const isToken = Boolean(transferIntent.tokenAddress) + const assetSymbol = transferIntent.tokenAddress + ? await this.resolveTokenSymbol(transferIntent.tokenAddress) + : this.symbol + + if (isToken && transferIntent.tokenAddress) { + const contractHex = tronAddressToHex(transferIntent.tokenAddress) + const raw = await Effect.runPromise( + httpFetch({ + url: `${this.baseUrl}/trans/contract`, + method: "POST", + body: { + owner_address: fromHex, + contract_address: contractHex, + function_selector: "transfer(address,uint256)", + input: [ + { type: "address", value: toHex }, + { type: "uint256", value: transferIntent.amount.raw }, + ], + fee_limit: 100_000_000, + call_value: 0, + }, + }) + ) + + const rawTx = this.extractTrc20Transaction(raw) + const detail: TronWalletBroadcastDetail = { + from: fromHex, + to: toHex, + amount: transferIntent.amount.raw, + fee: feeRaw, + assetSymbol, + } + + return { + chainId: this.chainId, + intentType: "transfer", + data: { + rawTx, + detail, + isToken: true, + } satisfies TronWalletUnsignedPayload, + } + } + + const rawTx = await Effect.runPromise( + httpFetch({ + url: `${this.baseUrl}/trans/create`, + method: "POST", + body: { + owner_address: fromHex, + to_address: toHex, + amount: Number(transferIntent.amount.raw), + extra_data: transferIntent.memo, + }, + }) + ) + + const detail: TronWalletBroadcastDetail = { + from: fromHex, + to: toHex, + amount: transferIntent.amount.raw, + fee: feeRaw, + assetSymbol, + } + + return { + chainId: this.chainId, + intentType: "transfer", + data: { + rawTx: rawTx as TronRawTransaction, + detail, + isToken: false, + } satisfies TronWalletUnsignedPayload, + } + } + + async signTransaction(unsignedTx: UnsignedTransaction, options: SignOptions): Promise { + if (!options.privateKey) { + throw new ChainServiceError( + ChainErrorCodes.SIGNATURE_FAILED, + "privateKey is required for Tron signing" + ) + } + + const payload = unsignedTx.data as TronWalletUnsignedPayload + const txIdBytes = hexToBytes(payload.rawTx.txID) + const sigBytes = secp256k1.sign(txIdBytes, options.privateKey, { prehash: false, format: "recovered" }) + const signature = bytesToHex(sigBytes) + const signedTx: TronSignedTransaction = { + ...payload.rawTx, + signature: [signature], + } + + return { + chainId: unsignedTx.chainId, + data: { + signedTx, + detail: payload.detail, + isToken: payload.isToken, + } satisfies TronWalletSignedPayload, + signature, + } + } + + async broadcastTransaction(signedTx: SignedTransaction): Promise { + const payload = signedTx.data as TronWalletSignedPayload + const url = payload.isToken ? `${this.baseUrl}/trans/trc20/broadcast` : `${this.baseUrl}/trans/broadcast` + + const result = await Effect.runPromise( + httpFetch({ + url, + method: "POST", + body: { + ...payload.signedTx, + detail: payload.detail, + }, + }) + ) + + const response = result as { result?: boolean; txid?: string } + if (!response.result) { + throw new ChainServiceError(ChainErrorCodes.TX_BROADCAST_FAILED, "Broadcast failed") + } + return payload.signedTx.txID + } + + // ==================== 共享 Source 管理 ==================== + + private getSharedTxHistorySource(address: string): Effect.Effect> { const provider = this - const address = params.address + const eventAddress = normalizeTronAddress(address) + const cacheKey = normalizeTronHex(address) const symbol = this.symbol const decimals = this.decimals - const chainId = this.chainId - const cacheKey = address const wrapSharedSource = (source: DataSource): DataSource => ({ ...source, @@ -268,51 +673,221 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM } const eventBus = provider._eventBus - const fetchEffect = provider.fetchTransactions({ address, limit: 50 }, true).pipe( - Effect.map((raw): TransactionsOutput => { - if (typeof raw === "object" && "success" in raw && raw.success === false) return [] - return raw.data.map((tx): Transaction => { - const from = tronAddressFromHex(tx.from) - const to = tronAddressFromHex(tx.to) - return { - hash: tx.txID, - from, - to, - timestamp: tx.timestamp, - status: tx.contractRet ? (tx.contractRet === "SUCCESS" ? "confirmed" : "failed") : "confirmed", - action: "transfer" as const, - direction: getDirection(from, to, address), - assets: [{ - assetType: "native" as const, - value: String(tx.amount), - symbol, - decimals, - }], - } - }) - }) - ) - - const source = yield* createPollingSource({ - name: `tronwallet.${provider.chainId}.txHistory.${address}`, - fetch: fetchEffect, + const nativeSource = yield* createPollingSource({ + name: `tronwallet.${provider.chainId}.txHistory.native.${cacheKey}`, + fetch: provider.fetchNativeHistory({ address: eventAddress, limit: 50 }, true), interval: Duration.millis(provider.pollingInterval), walletEvents: { eventBus, - chainId, - address, + chainId: provider.chainId, + address: eventAddress, types: ["tx:confirmed", "tx:sent"], }, }) - return source + const tokenListSource = yield* provider.getSharedTokenListSource() + + const mergeSignal = yield* SubscriptionRef.make(0) + + const bump = SubscriptionRef.update(mergeSignal, (value) => value + 1).pipe(Effect.asVoid) + const trc20HistoryCache = yield* SubscriptionRef.make>( + new Map() + ) + + const changeFibers: Fiber.RuntimeFiber[] = [] + + const registerChanges = (stream: Stream.Stream) => + Effect.forkDaemon( + stream.pipe( + Stream.tap(() => bump), + Stream.runDrain + ) + ) + + changeFibers.push(yield* registerChanges(nativeSource.changes)) + + const trc20Sources = new Map>() + const trc20Fibers = new Map>() + + const attachContract = (contract: string) => + Effect.gen(function* () { + if (trc20Sources.has(contract)) return + const source = yield* createPollingSource({ + name: `tronwallet.${provider.chainId}.txHistory.trc20.${cacheKey}.${contract}`, + fetch: provider.fetchTrc20History({ address: eventAddress, limit: 50, contractAddress: contract }, true), + interval: Duration.millis(provider.pollingInterval), + immediate: false, + walletEvents: { + eventBus, + chainId: provider.chainId, + address: eventAddress, + types: ["tx:confirmed", "tx:sent"], + }, + }) + trc20Sources.set(contract, source) + const fiber = yield* Effect.forkDaemon( + source.changes.pipe( + Stream.runForEach((value) => + SubscriptionRef.update(trc20HistoryCache, (map) => { + const next = new Map(map) + next.set(contract, value) + return next + }).pipe( + Effect.zipRight(bump) + ) + ) + ) + ) + trc20Fibers.set(contract, fiber) + yield* Effect.forkDaemon(source.refresh.pipe(Effect.asVoid)) + }) + + const detachContract = (contract: string) => + Effect.gen(function* () { + const source = trc20Sources.get(contract) + if (source) { + yield* source.stop + trc20Sources.delete(contract) + } + const fiber = trc20Fibers.get(contract) + if (fiber) { + yield* Fiber.interrupt(fiber) + trc20Fibers.delete(contract) + } + yield* SubscriptionRef.update(trc20HistoryCache, (map) => { + const next = new Map(map) + next.delete(contract) + return next + }).pipe(Effect.zipRight(bump)) + }) + + const syncContracts = (tokenList: TokenListResponse | null) => + Effect.gen(function* () { + const nextContracts = tokenList ? normalizeTokenContracts(tokenList) : [] + const nextSet = new Set(nextContracts) + for (const contract of trc20Sources.keys()) { + if (!nextSet.has(contract)) { + yield* detachContract(contract) + } + } + for (const contract of nextSet) { + if (!trc20Sources.has(contract)) { + yield* attachContract(contract) + } + } + }) + + yield* syncContracts(yield* tokenListSource.get) + + changeFibers.push(yield* Effect.forkDaemon( + tokenListSource.changes.pipe( + Stream.runForEach((next) => + syncContracts(next).pipe( + Effect.zipRight(bump) + ) + ) + ) + )) + + const mergeSource = yield* createDependentSource({ + name: `tronwallet.${provider.chainId}.txHistory.${cacheKey}`, + dependsOn: mergeSignal, + hasChanged: (prev, next) => prev !== next, + fetch: () => + Effect.gen(function* () { + const native = yield* nativeSource.get + const nativeTxs = (native?.data ?? []).map((tx): Transaction => { + const from = normalizeTronAddress(tx.from) + const to = normalizeTronAddress(tx.to) + const status = tx.contractRet === "SUCCESS" ? "confirmed" : "failed" + return { + hash: tx.txID, + from, + to, + timestamp: tx.timestamp, + status, + blockNumber: tx.blockNumber ? BigInt(String(tx.blockNumber)) : undefined, + action: "transfer" as const, + direction: getDirection(tx.from, tx.to, eventAddress), + assets: [{ + assetType: "native" as const, + value: toRawString(tx.amount), + symbol, + decimals, + }], + fee: tx.fee !== undefined + ? { value: toRawString(tx.fee), symbol, decimals } + : undefined, + } + }) + + const trc20Values = [...(yield* SubscriptionRef.get(trc20HistoryCache)).values()] + const tokenTxs = trc20Values.flatMap((raw) => + (raw?.data ?? []).map((tx): Transaction => { + const from = normalizeTronAddress(tx.from) + const to = normalizeTronAddress(tx.to) + const status = tx.contractRet + ? (tx.contractRet === "SUCCESS" ? "confirmed" : "failed") + : "confirmed" + const tokenSymbol = tx.tokenSymbol ?? tx.token_symbol ?? "TRC20" + const tokenDecimals = tx.tokenDecimal ?? tx.token_decimals ?? 0 + const contractAddressRaw = tx.contractAddress ?? tx.token_address + const contractAddress = contractAddressRaw + ? normalizeTronAddress(contractAddressRaw) + : "" + return { + hash: tx.txID, + from, + to, + timestamp: tx.timestamp, + status, + action: "transfer" as const, + direction: getDirection(tx.from, tx.to, eventAddress), + assets: [{ + assetType: "token" as const, + value: toRawString(tx.value), + symbol: tokenSymbol, + decimals: tokenDecimals, + contractAddress, + name: tx.tokenName ?? tx.token_name, + }], + } + }) + ) + + return mergeTransactions(nativeTxs, tokenTxs) + }), + }) + + const stopAll = Effect.gen(function* () { + for (const fiber of changeFibers) { + yield* Fiber.interrupt(fiber) + } + for (const source of trc20Sources.values()) { + yield* source.stop + } + for (const fiber of trc20Fibers.values()) { + yield* Fiber.interrupt(fiber) + } + yield* mergeSource.stop + yield* nativeSource.stop + yield* provider.releaseSharedTokenListSource() + }) + + provider._txHistorySources.set(cacheKey, { + source: mergeSource, + refCount: 1, + stopAll, + }) + + return mergeSource }) ) provider._txHistoryCreations.set(cacheKey, createPromise) + try { const source = await createPromise - provider._txHistorySources.set(cacheKey, { source, refCount: 1 }) return wrapSharedSource(source) } finally { provider._txHistoryCreations.delete(cacheKey) @@ -320,57 +895,292 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM }) } - private createTransactionHistorySource( - params: TxHistoryParams - ): Effect.Effect> { - return this.getSharedTxHistorySource(params) + 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 - ): Effect.Effect> { + private getSharedBalanceSource(address: string): Effect.Effect> { const provider = this + const cacheKey = normalizeTronHex(address) const symbol = this.symbol const decimals = this.decimals + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedBalanceSource(cacheKey), + }) + + 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 Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address) + + const source = yield* createDependentSource({ + name: `tronwallet.${provider.chainId}.balance`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (_dep, forceRefresh) => + provider.fetchBalance(address, forceRefresh).pipe( + Effect.map((raw): BalanceOutput => { + const balance = normalizeBalance(raw) + return { + amount: Amount.fromRaw(balance.amount, 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 releaseSharedBalanceSource(key: string): Effect.Effect { + const provider = this return Effect.gen(function* () { - const txHistorySource = yield* provider.getSharedTxHistorySource({ - address: params.address, - limit: 50, + const entry = provider._balanceSources.get(key) + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + yield* entry.stopAll + provider._balanceSources.delete(key) + } + }) + } + + private getSharedTokenBalanceSource(address: string): Effect.Effect> { + const provider = this + const cacheKey = normalizeTronHex(address) + const symbol = this.symbol + const decimals = this.decimals + + const wrapSharedSource = (source: DataSource): DataSource => ({ + ...source, + stop: provider.releaseSharedTokenBalanceSource(cacheKey), + }) + + const cached = provider._tokenBalanceSources.get(cacheKey) + if (cached) { + cached.refCount += 1 + return Effect.succeed(wrapSharedSource(cached.source)) + } + + 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) }) + } - const fetchBalance = (forceRefresh?: boolean) => - provider.fetchBalance(params.address, forceRefresh).pipe( - Effect.map((raw): BalanceOutput => ({ - amount: Amount.fromRaw(normalizeBalanceResponse(raw), decimals, symbol), - symbol, - })) - ) + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const txHistorySource = yield* provider.getSharedTxHistorySource(address) + + const source = yield* createDependentSource({ + name: `tronwallet.${provider.chainId}.tokenBalances`, + dependsOn: txHistorySource.ref, + hasChanged: hasTransactionListChanged, + fetch: (dep, forceRefresh) => + provider.fetchTokenBalances(address, getContractAddressesFromHistory(dep), forceRefresh).pipe( + Effect.map((balances): TokenBalancesOutput => { + const list: TokenBalance[] = [] + const balance = normalizeBalance(balances.native) + list.push({ + symbol, + name: symbol, + amount: Amount.fromRaw(balance.amount, decimals, symbol), + isNative: true, + decimals, + }) + + for (const item of balances.tokens) { + const tokenSymbol = item.symbol ?? "TRC20" + const tokenDecimals = item.decimals ?? 0 + const contractAddress = item.contractAddress + ? normalizeTronAddress(item.contractAddress) + : undefined + list.push({ + symbol: tokenSymbol, + name: item.name ?? tokenSymbol, + amount: Amount.fromRaw(toRawString(item.amount), tokenDecimals, tokenSymbol), + isNative: false, + decimals: tokenDecimals, + icon: item.icon, + contractAddress, + }) + } + + return list + }) + ), + }) + + const stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid) + + 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 releaseSharedTokenBalanceSource(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) + } + }) + } - const source = yield* createDependentSource({ - name: `tronwallet.${provider.chainId}.balance`, - dependsOn: txHistorySource.ref, - hasChanged: hasTransactionListChanged, - fetch: (_dep, forceRefresh) => fetchBalance(forceRefresh), + private getSharedTokenListSource(): Effect.Effect> { + const provider = this + if (provider._tokenListSource) { + provider._tokenListSource.refCount += 1 + return Effect.succeed(provider._tokenListSource.source) + } + if (provider._tokenListCreation) { + return Effect.promise(async () => { + const source = await provider._tokenListCreation + if (provider._tokenListSource) { + provider._tokenListSource.refCount += 1 + } + return source }) + } - const stopAll = Effect.all([source.stop, txHistorySource.stop]).pipe(Effect.asVoid) - return { - ...source, - stop: stopAll, + return Effect.promise(async () => { + const createPromise = Effect.runPromise( + Effect.gen(function* () { + const source = yield* createPollingSource({ + name: `tronwallet.${provider.chainId}.tokenList`, + fetch: provider.fetchTokenList(false), + interval: Duration.millis(provider.tokenListCacheTtl), + immediate: true, + }) + + provider._tokenListSource = { + source, + refCount: 1, + stopAll: source.stop, + } + + return source + }) + ) + + provider._tokenListCreation = createPromise + try { + return await createPromise + } finally { + provider._tokenListCreation = null } }) } - private fetchBalance(address: string, forceRefresh = false): Effect.Effect { + private releaseSharedTokenListSource(): Effect.Effect { + const provider = this + return Effect.gen(function* () { + const entry = provider._tokenListSource + if (!entry) return + entry.refCount -= 1 + if (entry.refCount <= 0) { + yield* entry.stopAll + provider._tokenListSource = null + } + }) + } + + // ==================== HTTP Fetch Effects ==================== + + private fetchBalance(address: string, forceRefresh = false): Effect.Effect { return httpFetchCached({ - url: `${this.baseUrl}/balance?address=${tronAddressToHex(address)}`, - schema: BalanceResponseSchema, + url: `${this.baseUrl}/balance`, + method: "GET", + searchParams: { address: tronAddressToHex(address) }, + schema: BalanceRawSchema, cacheStrategy: forceRefresh ? "network-first" : "cache-first", - }) + canCache: canCacheBalance, + }).pipe( + Effect.tap((raw) => { + if (typeof raw === "object" && raw !== null && "success" in raw) { + logApiFailure("balance", raw as { success: boolean; error?: unknown }) + } + }) + ) } - private fetchTransactions( + private fetchNativeHistory( params: TxHistoryParams, forceRefresh = false ): Effect.Effect { @@ -383,6 +1193,174 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM }, schema: TxHistoryResponseSchema, cacheStrategy: forceRefresh ? "network-first" : "cache-first", + canCache: canCacheSuccess, + }).pipe( + Effect.tap((raw) => logApiFailure("trans/common/history", raw)) + ) + } + + private fetchTrc20History( + params: TxHistoryParams, + forceRefresh = false + ): Effect.Effect { + if (!params.contractAddress) { + return Effect.succeed({ success: true, data: [], fingerprint: "", pageSize: 0 }) + } + + return httpFetchCached({ + url: `${this.baseUrl}/trans/trc20/history`, + method: "POST", + body: { + address: tronAddressToHex(params.address), + limit: params.limit ?? 50, + contract_address: normalizeTronAddress(params.contractAddress), + }, + schema: Trc20HistoryResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", + canCache: canCacheSuccess, + }).pipe( + Effect.tap((raw) => logApiFailure("trans/trc20/history", raw)) + ) + } + + private fetchTransactions( + params: TxHistoryParams, + forceRefresh = false + ): Effect.Effect<{ native: TxHistoryResponse; trc20: Trc20HistoryResponse }, FetchError> { + return Effect.all({ + native: this.fetchNativeHistory(params, forceRefresh), + trc20: this.fetchTrc20History(params, forceRefresh).pipe( + Effect.catchAll(() => + Effect.succeed({ + success: false, + data: [], + } as Trc20HistoryResponse) + ) + ), + }) + } + + private fetchTokenBalances( + address: string, + contracts: string[], + forceRefresh = false + ): Effect.Effect<{ native: BalanceRaw; tokens: BalanceV2Response }, FetchError> { + const normalizedAddress = normalizeTronAddress(address) + const sortedContracts = [...contracts].sort() + + if (sortedContracts.length === 0) { + const provider = this + return Effect.gen(function* () { + const tokenList = yield* provider.fetchTokenList(false) + const nextContracts = normalizeTokenContracts(tokenList) + const native = yield* provider.fetchBalance(address, forceRefresh) + if (nextContracts.length === 0) { + return { + native, + tokens: [], + } + } + const tokens = yield* httpFetchCached({ + url: `${provider.baseUrl}/account/balance/v2`, + method: "POST", + body: { address: normalizedAddress, contracts: nextContracts }, + schema: BalanceV2RawSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", + canCache: canCacheBalanceV2, + }).pipe( + Effect.tap((raw) => { + if (!Array.isArray(raw)) logApiFailure("account/balance/v2", raw) + }), + Effect.map(normalizeBalanceV2), + Effect.catchAll(() => Effect.succeed([] as BalanceV2Response)) + ) + return { + native, + tokens, + } + }) + } + + return Effect.all({ + native: this.fetchBalance(address, forceRefresh), + tokens: httpFetchCached({ + url: `${this.baseUrl}/account/balance/v2`, + method: "POST", + body: { address: normalizedAddress, contracts: sortedContracts }, + schema: BalanceV2RawSchema, + cacheStrategy: forceRefresh ? "network-first" : "cache-first", + canCache: canCacheBalanceV2, + }).pipe( + Effect.tap((raw) => { + if (!Array.isArray(raw)) logApiFailure("account/balance/v2", raw) + }), + Effect.map(normalizeBalanceV2), + Effect.catchAll(() => Effect.succeed([] as BalanceV2Response)) + ), + }) + } + + private fetchTokenList(forceRefresh = false): Effect.Effect { + return httpFetchCached({ + url: `${this.baseUrl}/contract/tokens`, + method: "POST", + body: { + page: 1, + pageSize: 50, + chain: "TRON", + }, + schema: TokenListResponseSchema, + cacheStrategy: forceRefresh ? "network-first" : "ttl", + cacheTtl: this.tokenListCacheTtl, + canCache: canCacheSuccess, + }).pipe( + Effect.tap((raw) => logApiFailure("contract/tokens", raw)) + ) + } + + private async resolveTokenSymbol(contractAddress: string): Promise { + const hex = normalizeTronHex(contractAddress) + const response = await Effect.runPromise(this.fetchTokenList(false)) + const match = response.result.data.find((item) => normalizeTronHex(item.address) === hex) + return match?.symbol ?? contractAddress + } + + private extractTrc20Transaction(raw: unknown): TronRawTransaction { + if (raw && typeof raw === "object") { + const record = raw as Record + const transaction = record["transaction"] + if (transaction && typeof transaction === "object" && "txID" in transaction) { + return transaction as TronRawTransaction + } + if ("txID" in record) { + return record as TronRawTransaction + } + } + throw new ChainServiceError(ChainErrorCodes.TX_BUILD_FAILED, "Invalid TRC20 transaction response") + } + + private fetchPendingTransactions(address: string): Effect.Effect { + return httpFetchCached({ + url: `${this.baseUrl}/trans/pending`, + method: "POST", + body: { + address: tronAddressToHex(address), + assetSymbol: this.symbol, + }, + schema: PendingTxResponseSchema, + cacheStrategy: "ttl", + cacheTtl: this.pendingTxCacheTtl, + }) + } + + private fetchTransactionReceipt(txHash: string): Effect.Effect { + return httpFetchCached({ + url: `${this.baseUrl}/trans/receipt`, + method: "POST", + body: { txId: txHash }, + schema: ReceiptResponseSchema, + cacheStrategy: "ttl", + cacheTtl: this.pendingTxCacheTtl, }) } } diff --git a/src/services/chain-adapter/providers/types.ts b/src/services/chain-adapter/providers/types.ts index 5cca37692..35f6a18e4 100644 --- a/src/services/chain-adapter/providers/types.ts +++ b/src/services/chain-adapter/providers/types.ts @@ -63,6 +63,7 @@ export const TxHistoryParamsSchema = z.object({ address: z.string(), limit: z.number().optional().default(20), page: z.number().optional(), + contractAddress: z.string().optional(), }) export type TxHistoryParams = z.infer diff --git a/src/services/chain-adapter/tron/address.ts b/src/services/chain-adapter/tron/address.ts new file mode 100644 index 000000000..d63705809 --- /dev/null +++ b/src/services/chain-adapter/tron/address.ts @@ -0,0 +1,80 @@ +/** + * Tron address helpers + * + * - Base58Check <-> Hex (41-prefixed) conversion + * - Normalization helpers for comparisons + */ + +import { base58check } from '@scure/base' +import { sha256 } from '@noble/hashes/sha2.js' + +const tronBase58 = base58check(sha256) + +function stripHexPrefix(value: string): string { + return value.startsWith('0x') || value.startsWith('0X') ? value.slice(2) : value +} + +function isHex(value: string): boolean { + return /^[0-9a-fA-F]+$/.test(value) +} + +function hexToBytes(hex: string): Uint8Array { + const normalized = stripHexPrefix(hex) + if (normalized.length % 2 !== 0 || !isHex(normalized)) { + throw new Error(`Invalid hex: ${hex}`) + } + const bytes = new Uint8Array(normalized.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(normalized.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +} + +export function tronAddressToHex(address: string): string { + const trimmed = address.trim() + const noPrefix = stripHexPrefix(trimmed) + + if (noPrefix.length === 42 && isHex(noPrefix) && noPrefix.startsWith('41')) { + return noPrefix + } + + if (!trimmed.startsWith('T')) { + throw new Error(`Invalid Tron address: ${address}`) + } + + const decoded = tronBase58.decode(trimmed) + // base58check decode returns payload without checksum; Tron payload is 21 bytes (41 + 20) + if (decoded.length !== 21) { + throw new Error(`Invalid Tron address length: ${address}`) + } + return bytesToHex(decoded) +} + +export function tronHexToAddress(hex: string): string { + const normalized = stripHexPrefix(hex).toLowerCase() + if (!isHex(normalized)) { + throw new Error(`Invalid Tron hex address: ${hex}`) + } + const withPrefix = normalized.length === 40 ? `41${normalized}` : normalized + if (withPrefix.length !== 42) { + throw new Error(`Invalid Tron hex length: ${hex}`) + } + const bytes = hexToBytes(withPrefix) + return tronBase58.encode(bytes) +} + +export function normalizeTronAddress(address: string): string { + const trimmed = address.trim() + if (trimmed.startsWith('T')) { + return trimmed + } + return tronHexToAddress(trimmed) +} + +export function normalizeTronHex(address: string): string { + return tronAddressToHex(address).toLowerCase() +} diff --git a/src/services/transaction/pending-tx.ts b/src/services/transaction/pending-tx.ts index a9b71ea4f..539293420 100644 --- a/src/services/transaction/pending-tx.ts +++ b/src/services/transaction/pending-tx.ts @@ -15,17 +15,13 @@ 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'; +import { isChainDebugEnabled } from '@/services/chain-adapter/debug'; // ==================== 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; + const message = `[chain-effect] pending-tx ${args.join(' ')}`; + if (!isChainDebugEnabled(message)) return; console.log('[chain-effect]', 'pending-tx', ...args); }