diff --git "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/03-Playwright\351\205\215\347\275\256/storybook-visual-testing.md" "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/03-Playwright\351\205\215\347\275\256/storybook-visual-testing.md" new file mode 100644 index 000000000..924ec07c9 --- /dev/null +++ "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/03-Playwright\351\205\215\347\275\256/storybook-visual-testing.md" @@ -0,0 +1,349 @@ +# Storybook 视觉测试最佳实践 + +> 基于 Storybook 官方文档和项目实践总结 + +--- + +## 1. 测试架构概述 + +项目采用**双轨制**测试策略: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Storybook 测试生态 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────────────┐ │ +│ │ pnpm test:storybook │ │ npx playwright test │ │ +│ │ (vitest + browser) │ │ --config=playwright. │ │ +│ │ │ │ storybook.config.ts │ │ +│ ├─────────────────────┤ ├─────────────────────────────┤ │ +│ │ ✓ 交互测试 │ │ ✓ 视觉回归测试 │ │ +│ │ ✓ play 函数验证 │ │ ✓ 截图对比 │ │ +│ │ ✓ 状态断言 │ │ ✓ 跨浏览器验证 │ │ +│ └─────────────────────┘ └─────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 1.1 Vitest Storybook 测试 + +用于验证组件**交互逻辑**: + +```bash +pnpm test:storybook +``` + +- 执行所有 stories 中的 `play` 函数 +- 验证用户交互行为 +- 断言组件状态变化 + +### 1.2 Playwright Storybook E2E + +用于**视觉回归**截图测试: + +```bash +npx playwright test --config=playwright.storybook.config.ts +``` + +- 截图对比检测 UI 变化 +- 移动端视口验证 +- 基线管理 + +--- + +## 2. 配置规范 + +### 2.1 Playwright Storybook 配置 + +```typescript +// playwright.storybook.config.ts +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './storybook-e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [['list']], + + // 截图输出目录 + snapshotDir: './storybook-e2e/__screenshots__', + snapshotPathTemplate: '{snapshotDir}/{projectName}/{testFileName}-{arg}{ext}', + + use: { + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + expect: { + toHaveScreenshot: { + // 允许 1% 像素差异(处理抗锯齿等微小差异) + maxDiffPixelRatio: 0.01, + }, + }, + + projects: [ + { + // 移动端优先 + name: 'Mobile-Chrome', + use: { + ...devices['Pixel 5'], + viewport: { width: 393, height: 851 }, + }, + }, + ], +}) +``` + +### 2.2 截图目录结构 + +``` +storybook-e2e/ +├── __screenshots__/ +│ └── Mobile-Chrome/ +│ ├── provider-fallback-warning.spec.ts-bfmeta-normal-data.png +│ ├── provider-fallback-warning.spec.ts-bfmeta-fallback-warning.png +│ ├── provider-fallback-warning.spec.ts-ethereum-normal-data.png +│ └── ... +├── provider-fallback-warning.spec.ts +├── currency-exchange.spec.ts +└── ... +``` + +--- + +## 3. 编写规范 + +### 3.1 测试文件结构 + +```typescript +// storybook-e2e/xxx.spec.ts +import { expect, test } from '@playwright/test' +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const storybookStaticDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../storybook-static' +) + +// 启动静态服务器(复用模式) +async function startStorybookServer() { /* ... */ } + +// 生成 story iframe URL +function storyIframeUrl(baseUrl: string, storyId: string): string { + return `${baseUrl}/iframe.html?viewMode=story&id=${storyId}` +} + +test.describe('Feature Screenshots', () => { + let server: Server + let baseUrl: string + + test.beforeAll(async () => { + const started = await startStorybookServer() + server = started.server + baseUrl = started.baseUrl + }) + + test.afterAll(async () => { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + }) + + test('Component - State A', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'category-component--state-a')) + await page.waitForSelector('[data-testid="component"]', { timeout: 10000 }) + await expect(page).toHaveScreenshot('component-state-a.png') + }) +}) +``` + +### 3.2 Story ID 命名规则 + +Story ID 由 Storybook 自动生成,规则如下: + +``` +{category}-{component}--{story-name} +``` + +示例: +- `wallet-walletaddressportfolio--bf-meta-normal-data` +- `token-tokenitem--multi-currency` + +可通过 Storybook URL 查看实际 ID: +``` +http://localhost:6006/?path=/story/wallet-walletaddressportfolio--bf-meta-normal-data + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 这就是 Story ID +``` + +--- + +## 4. Stories 设计原则 + +### 4.1 为截图测试设计 Stories + +```tsx +// xxx.stories.tsx +export const NormalData: Story = { + args: { + // 使用固定的 mock 数据,确保截图一致性 + balance: '23.68', + symbol: 'ETH', + }, +} + +export const FallbackWarning: Story = { + args: { + // 模拟降级场景 + supported: false, + fallbackReason: 'Provider timeout after 10s', + balance: '0', + }, +} + +export const EmptyState: Story = { + args: { + // 空数据状态 + balance: '0', + transactions: [], + }, +} +``` + +### 4.2 Mock 数据稳定性 + +**关键原则:截图测试的 mock 数据必须稳定** + +```tsx +// ❌ 不稳定 - 使用随机数据 +const mockBalance = Math.random() * 100 + +// ✅ 稳定 - 使用固定数据 +const mockBalance = '23.68' + +// ❌ 不稳定 - 使用当前时间 +const mockDate = new Date() + +// ✅ 稳定 - 使用固定时间 +const mockDate = new Date('2024-01-15T10:30:00Z') +``` + +### 4.3 添加 data-testid + +```tsx +// 组件中添加 data-testid 便于等待和验证 +
+ {/* ... */} +
+ +
+ {/* ... */} +
+``` + +--- + +## 5. 运行和维护 + +### 5.1 运行截图测试 + +```bash +# 1. 先构建 Storybook 静态文件 +pnpm build-storybook + +# 2. 运行截图测试 +npx playwright test --config=playwright.storybook.config.ts + +# 3. 更新基线截图 +npx playwright test --config=playwright.storybook.config.ts --update-snapshots +``` + +### 5.2 CI 集成 + +```yaml +# .github/workflows/test.yml +storybook-visual: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v2 + - run: pnpm install + - run: pnpm build-storybook + - run: npx playwright test --config=playwright.storybook.config.ts +``` + +### 5.3 截图更新流程 + +1. 本地运行测试,发现差异 +2. 检查差异是否为**预期变更** +3. 如果是预期变更,运行 `--update-snapshots` +4. 提交新的基线截图到 Git + +--- + +## 6. 官方推荐方案对比 + +| 方案 | 优点 | 缺点 | 适用场景 | +|------|------|------|----------| +| **Chromatic** (官方推荐) | 云端托管、跨浏览器、团队协作 | 付费服务、需网络 | 大型团队、CI/CD | +| **Playwright + Storybook** | 免费、本地运行、灵活 | 需手动管理基线 | 本项目采用 | +| **Percy** | 云端、集成好 | 付费 | 企业项目 | + +本项目采用 **Playwright + Storybook** 方案,原因: +- 开源免费 +- 本地可控 +- 与现有 E2E 测试工具链统一 +- 移动端优先的截图策略 + +--- + +## 7. 常见问题 + +### Q1: 截图不稳定(flaky)? + +检查以下几点: +1. Mock 数据是否固定 +2. 字体是否已加载完成 +3. 动画是否已完成 +4. 异步数据是否已渲染 + +### Q2: 如何等待组件渲染完成? + +```typescript +// 等待特定元素出现 +await page.waitForSelector('[data-testid="component"]', { timeout: 10000 }) + +// 等待网络空闲 +await page.waitForLoadState('networkidle') + +// 等待固定时间(最后手段) +await page.waitForTimeout(500) +``` + +### Q3: 如何处理动态内容? + +```typescript +await expect(page).toHaveScreenshot('component.png', { + // 遮盖动态区域 + mask: [ + page.locator('[data-testid="timestamp"]'), + page.locator('[data-testid="random-avatar"]'), + ], +}) +``` + +--- + +## 本章小结 + +- **双轨制**:Vitest 测交互,Playwright 测视觉 +- **移动优先**:截图使用移动端视口 (393x851) +- **稳定数据**:Mock 数据必须固定 +- **统一目录**:截图存放于 `storybook-e2e/__screenshots__/Mobile-Chrome/` diff --git "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/index.md" "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/index.md" index 8f96eceba..f2d826ca7 100644 --- "a/docs/white-book/08-\346\265\213\350\257\225\347\257\207/index.md" +++ "b/docs/white-book/08-\346\265\213\350\257\225\347\257\207/index.md" @@ -10,6 +10,7 @@ - [第二十四章:Vitest 配置](./02-Vitest配置/) - 单元测试、组件测试 - [第二十五章:Playwright 配置](./03-Playwright配置/) - E2E、视觉回归 - [E2E 测试最佳实践](./03-Playwright配置/e2e-best-practices.md) - **必读:国际化项目的测试规范** +- [Storybook 视觉测试](./03-Playwright配置/storybook-visual-testing.md) - **必读:Storybook 截图测试最佳实践** - [第二十六章:测试网络](./06-测试网络/) - **新增:接入公开测试网络进行真实交易测试** --- diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-bitcoin-with-data.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-bitcoin-with-data.png new file mode 100644 index 000000000..a7412aca7 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-bitcoin-with-data.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum-with-data.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum-with-data.png new file mode 100644 index 000000000..a7412aca7 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum-with-data.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum.png new file mode 100644 index 000000000..e2b2cc86f Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions-bitcoin.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions-bitcoin.png new file mode 100644 index 000000000..7e477d090 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions-bitcoin.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions.png new file mode 100644 index 000000000..6f8eaa512 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/transaction-history.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/transaction-history.png new file mode 100644 index 000000000..60e4e7e4c Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/transaction-history.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-bitcoin.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-bitcoin.png new file mode 100644 index 000000000..49a4338a4 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-bitcoin.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-ethereum.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-ethereum.png new file mode 100644 index 000000000..61b2676b9 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-ethereum.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-normal.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-normal.png new file mode 100644 index 000000000..cd7cf5e13 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-normal.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-tron.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-tron.png new file mode 100644 index 000000000..b4e099838 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-tron.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-token-list.png b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-token-list.png new file mode 100644 index 000000000..96b8315b5 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/provider-fallback-warning.mock.spec.ts/wallet-token-list.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-bitcoin-with-data.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-bitcoin-with-data.png new file mode 100644 index 000000000..1950cd2dc Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-bitcoin-with-data.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum-with-data.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum-with-data.png new file mode 100644 index 000000000..75cb3f73c Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum-with-data.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum.png new file mode 100644 index 000000000..92e4ce08a Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-balance-ethereum.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions-bitcoin.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions-bitcoin.png new file mode 100644 index 000000000..be4303639 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions-bitcoin.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions.png new file mode 100644 index 000000000..1265a245e Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/address-transactions.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/transaction-history.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/transaction-history.png new file mode 100644 index 000000000..a050edc3b Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/transaction-history.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-bitcoin.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-bitcoin.png new file mode 100644 index 000000000..22e245f00 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-bitcoin.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-ethereum.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-ethereum.png new file mode 100644 index 000000000..48463eb8c Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-ethereum.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-normal.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-normal.png new file mode 100644 index 000000000..d146bb667 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-normal.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-tron.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-tron.png new file mode 100644 index 000000000..7762eac28 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-home-tron.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-token-list.png b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-token-list.png new file mode 100644 index 000000000..d146bb667 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.mock.spec.ts/wallet-token-list.png differ diff --git a/e2e/fixtures/provider-data/real-provider-data.v1.json b/e2e/fixtures/provider-data/real-provider-data.v1.json new file mode 100644 index 000000000..fbb67c3ae --- /dev/null +++ b/e2e/fixtures/provider-data/real-provider-data.v1.json @@ -0,0 +1,1211 @@ +{ + "version": 1, + "collectedAt": "2026-01-06T09:58:21.467Z", + "addresses": { + "ethereum": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "tron": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", + "bitcoin": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "binance": "0x8894E0a0c962CB723c1976a4421c95949bE2D4E3" + }, + "balances": [ + { + "provider": "ethereum-rpc", + "chain": "ethereum", + "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "balance": "0x148ab79176748cb81", + "balanceDecimal": 23.683156206881918, + "symbol": "ETH", + "decimals": 18 + }, + { + "provider": "ethereumwallet-v1", + "chain": "ethereum", + "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "success": false, + "error": { + "code": 500, + "message": "Cannot GET /wallet/eth/balance/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "info": { + "message": "Cannot GET /wallet/eth/balance/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "error": "Not Found", + "statusCode": 404 + } + }, + "timeStamp": 1767693523750, + "path": "/wallet/eth/balance/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "version": "1.5.0" + }, + { + "provider": "mempool-v1", + "chain": "bitcoin", + "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "balance": "5446576393", + "balanceDecimal": 54.46576393, + "symbol": "BTC", + "decimals": 8 + }, + { + "provider": "bitcoinwallet-v1", + "chain": "bitcoin", + "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "success": false, + "error": { + "code": 500, + "message": "Cannot GET /wallet/btc/balance/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "info": { + "message": "Cannot GET /wallet/btc/balance/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "error": "Not Found", + "statusCode": 404 + } + }, + "timeStamp": 1767693540594, + "path": "/wallet/btc/balance/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "version": "1.5.0" + }, + { + "provider": "tron-rpc", + "chain": "tron", + "address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", + "balance": "163377648279", + "balanceDecimal": 163377.648279, + "symbol": "TRX", + "decimals": 6 + }, + { + "provider": "tronwallet-v1", + "chain": "tron", + "address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", + "success": false, + "error": { + "code": 500, + "message": "Cannot GET /wallet/tron/balance/TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", + "info": { + "message": "Cannot GET /wallet/tron/balance/TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", + "error": "Not Found", + "statusCode": 404 + } + }, + "timeStamp": 1767693543044, + "path": "/wallet/tron/balance/TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", + "version": "1.5.0" + }, + { + "provider": "binancewallet-v1", + "chain": "binance", + "address": "0x8894E0a0c962CB723c1976a4421c95949bE2D4E3", + "success": false, + "error": { + "code": 500, + "message": "Cannot GET /wallet/bsc/balance/0x8894E0a0c962CB723c1976a4421c95949bE2D4E3", + "info": { + "message": "Cannot GET /wallet/bsc/balance/0x8894E0a0c962CB723c1976a4421c95949bE2D4E3", + "error": "Not Found", + "statusCode": 404 + } + }, + "timeStamp": 1767693555550, + "path": "/wallet/bsc/balance/0x8894E0a0c962CB723c1976a4421c95949bE2D4E3", + "version": "1.5.0" + } + ], + "transactions": [ + { + "provider": "ethereumwallet-v1", + "chain": "ethereum", + "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "transactions": [] + }, + { + "provider": "etherscan-v2", + "chain": "ethereum", + "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "error": "fetch failed", + "transactions": [] + }, + { + "provider": "mempool-v1", + "chain": "bitcoin", + "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "transactions": [ + { + "txid": "78a44e6e62bf7638065bf58327c8486217dbf84bba617def8f8a2816a23e14c9", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "449d2f77a493a625b9d229314ef7ced47b4f379388977863a5cd28357a535939", + "vout": 6, + "prevout": { + "scriptpubkey": "5120fd40112abcf093a14fdfc59b8b23f383d4645c77ac9d1066ea747c68ef56d53c", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 fd40112abcf093a14fdfc59b8b23f383d4645c77ac9d1066ea747c68ef56d53c", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pl4qpz24u7zf6zn7lckdckglns02xghrh4jw3qeh2w37x3m6k657qv6pqw7", + "value": 566 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "a6f7c40e7565db0da1d3efca35b6ad9e7a049253af0835a40858ea495da16c0ff6dd06c7f3b26565f4da80973b4248f080080ce90fa53efabadcbe4309fb97cb", + "20d399a94327ee1b06c080184a3f763e5947dc631493e9605996ca9c1e6115d834ac0063036f7264010118746578742f706c61696e3b636861727365743d7574662d3800347b2270223a226272632d3230222c226f70223a226d696e74222c227469636b223a22e2b5a320222c22616d74223a22373530227d68", + "c1d399a94327ee1b06c080184a3f763e5947dc631493e9605996ca9c1e6115d834" + ], + "is_coinbase": false, + "sequence": 4294967295, + "inner_witnessscript_asm": "OP_PUSHBYTES_32 d399a94327ee1b06c080184a3f763e5947dc631493e9605996ca9c1e6115d834 OP_CHECKSIG OP_0 OP_IF OP_PUSHBYTES_3 6f7264 OP_PUSHBYTES_1 01 OP_PUSHBYTES_24 746578742f706c61696e3b636861727365743d7574662d38 OP_0 OP_PUSHBYTES_52 7b2270223a226272632d3230222c226f70223a226d696e74222c227469636b223a22e2b5a320222c22616d74223a22373530227d OP_ENDIF" + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 546 + } + ], + "size": 310, + "weight": 565, + "sigops": 4, + "fee": 20, + "status": { + "confirmed": false + } + }, + { + "txid": "d274d00350d384f443cb1e42defdd7e12f350aee37813d305b6bb9468270de19", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "61fdee826ec85cec267be0ded4a01e43676136f0424f49158e634b912a804485", + "vout": 0, + "prevout": { + "scriptpubkey": "5120256fef6318793bfb8d8a71ef4fd2eb7296ae07d3be368a2ce59b51695c26cda2", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 256fef6318793bfb8d8a71ef4fd2eb7296ae07d3be368a2ce59b51695c26cda2", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1py4h77ccc0yalhrv2w8h5l5htw2t2up7nhcmg5t89ndgkjhpxek3qz3dsgc", + "value": 546 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "f61691837ff9232b6f5d4c902c84294469f37f07abb3de2d3dc9eb7cda248438fe6b7b8b1317d1c979095f7dcdbbf4d05370d6f1d8e1ce2033a6456dd7ca8562" + ], + "is_coinbase": false, + "sequence": 2147483649 + }, + { + "txid": "20b7579c4d338021930b06c5252560e366b02caee26860519a4f4eec667fb314", + "vout": 1, + "prevout": { + "scriptpubkey": "5120256fef6318793bfb8d8a71ef4fd2eb7296ae07d3be368a2ce59b51695c26cda2", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 256fef6318793bfb8d8a71ef4fd2eb7296ae07d3be368a2ce59b51695c26cda2", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1py4h77ccc0yalhrv2w8h5l5htw2t2up7nhcmg5t89ndgkjhpxek3qz3dsgc", + "value": 100751 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "7054cc0030fdd7b868b9962952c9bcf3549ed410e244c5f2038bd2d2f9aa0b759581121192f6689d11e152a50dba6db4425a2bbb0ae1b5c866b82645bbcbe85e" + ], + "is_coinbase": false, + "sequence": 2147483649 + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 546 + }, + { + "scriptpubkey": "5120256fef6318793bfb8d8a71ef4fd2eb7296ae07d3be368a2ce59b51695c26cda2", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 256fef6318793bfb8d8a71ef4fd2eb7296ae07d3be368a2ce59b51695c26cda2", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1py4h77ccc0yalhrv2w8h5l5htw2t2up7nhcmg5t89ndgkjhpxek3qz3dsgc", + "value": 100548 + } + ], + "size": 303, + "weight": 810, + "sigops": 4, + "fee": 203, + "status": { + "confirmed": true, + "block_height": 931117, + "block_hash": "000000000000000000008ead2e8c1ce0e0dba3f804147f9026fb1c928db1fa3c", + "block_time": 1767688327 + } + }, + { + "txid": "c11dfe6e1033eb354c6bf7b3428f9290d635cefbe16876b86a8ce84be8c5637d", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "855358bbb245807d65a1dc2f2b3cdce715387f8839b18b08126e22c33bf60b9d", + "vout": 0, + "prevout": { + "scriptpubkey": "5120f52c97273a864fc12d7af4fabf272ca3c8df90bfde06d9690befc5a16e12ee2e", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 f52c97273a864fc12d7af4fabf272ca3c8df90bfde06d9690befc5a16e12ee2e", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1p75kfwfe6se8uztt67nat7fev50ydly9lmcrdj6gtalz6zmsjachqtryrtc", + "value": 546 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "93b5aac19f30351b10372b0ef1be26c4c2764f5b21919412e804f8687f318b043ba74a461a70e4c474edd208f12029d8032fc70115c7e3ea38805d8524d6afe1" + ], + "is_coinbase": false, + "sequence": 2147483649 + }, + { + "txid": "c881e427809b726b4f591624deed73eb27b2df8298f3eb2aaf275b627cd93bab", + "vout": 1, + "prevout": { + "scriptpubkey": "5120f52c97273a864fc12d7af4fabf272ca3c8df90bfde06d9690befc5a16e12ee2e", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 f52c97273a864fc12d7af4fabf272ca3c8df90bfde06d9690befc5a16e12ee2e", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1p75kfwfe6se8uztt67nat7fev50ydly9lmcrdj6gtalz6zmsjachqtryrtc", + "value": 7139 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "cf76256a76e3e60e7bf5bc438f3eb05bed27fd91581a7b7df872e49a98b5beeb34df2b1fca0d6d75d369f5887db73722f05bf251ef20f21d16c26fb8c18f3e93" + ], + "is_coinbase": false, + "sequence": 2147483649 + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 546 + }, + { + "scriptpubkey": "5120f52c97273a864fc12d7af4fabf272ca3c8df90bfde06d9690befc5a16e12ee2e", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 f52c97273a864fc12d7af4fabf272ca3c8df90bfde06d9690befc5a16e12ee2e", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1p75kfwfe6se8uztt67nat7fev50ydly9lmcrdj6gtalz6zmsjachqtryrtc", + "value": 6936 + } + ], + "size": 303, + "weight": 810, + "sigops": 4, + "fee": 203, + "status": { + "confirmed": true, + "block_height": 931108, + "block_hash": "000000000000000000006942b360bf236b97a21d3a8e4c9bb914eb8bf43603ff", + "block_time": 1767682433 + } + }, + { + "txid": "d8773c23582a0bf0fe7f640fd1053c14c5ebf3782fed994bd9cd2aed7d19dedd", + "version": 2, + "locktime": 931106, + "vin": [ + { + "txid": "3e759d27f744ea623506709990833a9f41d9c556e0d05404cb57324fb32e5570", + "vout": 0, + "prevout": { + "scriptpubkey": "51200e3a81257cf3b41ffa1d032159b1617046a47a5d2e96d27d71f8bdea1256b620", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 0e3a81257cf3b41ffa1d032159b1617046a47a5d2e96d27d71f8bdea1256b620", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1ppcagzftu7w6pl7saqvs4nvtpwpr2g7ja96tdylt3lz775yjkkcsqlu7exa", + "value": 12474 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "49354c01dd6c197698be11f08ef7097dae7eba5c1431cc10f1ba6ace8596ae8607dc3808997beffeb4e775ef94f9b362f5e2ea53ea5c15f1f024fe62933ff79b" + ], + "is_coinbase": false, + "sequence": 0 + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 12168 + } + ], + "size": 153, + "weight": 408, + "sigops": 4, + "fee": 306, + "status": { + "confirmed": true, + "block_height": 931107, + "block_hash": "00000000000000000000bee97dc1b76fcffdbf8fae4656d9c55329c2ca9a1a40", + "block_time": 1767681823 + } + }, + { + "txid": "71fe03be2ea78dc7b426d96e739b675d0664ef0378683444d654bcd7e06fe29c", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "c2d9c69bfbeccebcfa5684e83b99b70bdd2bfe26a45dc832b724d3e267fac68a", + "vout": 0, + "prevout": { + "scriptpubkey": "5120dd2be97fea12ef7931184c38ca06c246342b76b7ed72300b9c2d6ab9d06d6d20", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 dd2be97fea12ef7931184c38ca06c246342b76b7ed72300b9c2d6ab9d06d6d20", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pm547jll2zthhjvgcfsuv5pkzgc6zka4ha4erqzuu944tn5rdd5sq4lp7zp", + "value": 546 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "8490bfa3a1cd48aaef03adb3b5bc4e08b68d497eec67824148a53ad49e47fdaec2a173635dfb156cb0f2d13262adb7d47601fb2d6cb7db03a202a053f04aec23" + ], + "is_coinbase": false, + "sequence": 2147483649 + }, + { + "txid": "c9037981b60620c7f1d1623e106326a193dc163423d1c6f9f695307bd252e7fb", + "vout": 1, + "prevout": { + "scriptpubkey": "5120dd2be97fea12ef7931184c38ca06c246342b76b7ed72300b9c2d6ab9d06d6d20", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 dd2be97fea12ef7931184c38ca06c246342b76b7ed72300b9c2d6ab9d06d6d20", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pm547jll2zthhjvgcfsuv5pkzgc6zka4ha4erqzuu944tn5rdd5sq4lp7zp", + "value": 112942 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "dc986fc796691f42b665559efdca7c470893343c278454d6d30e551d7b8b2a55c21b8f9029b90ec6f17449dd51453252a8d6c2e6ba9c853961b5239e88324a26" + ], + "is_coinbase": false, + "sequence": 2147483649 + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 546 + }, + { + "scriptpubkey": "5120dd2be97fea12ef7931184c38ca06c246342b76b7ed72300b9c2d6ab9d06d6d20", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 dd2be97fea12ef7931184c38ca06c246342b76b7ed72300b9c2d6ab9d06d6d20", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pm547jll2zthhjvgcfsuv5pkzgc6zka4ha4erqzuu944tn5rdd5sq4lp7zp", + "value": 112538 + } + ], + "size": 303, + "weight": 810, + "sigops": 4, + "fee": 404, + "status": { + "confirmed": true, + "block_height": 931089, + "block_hash": "0000000000000000000060287c197b7fa17e04a452893433da5fe93f75685d63", + "block_time": 1767668628 + } + }, + { + "txid": "95ed5362b2e374ac45784d012203d4f9744285bf110c37687698585f5c5a0e22", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "256c765cf885b9942e3903bcff83b93870672ec9874c40a117f1fcc35bea0fe0", + "vout": 0, + "prevout": { + "scriptpubkey": "51202818e280caae5408fafe1822fa8d062e4a69ab3e211384b3c34a35940fbff705", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 2818e280caae5408fafe1822fa8d062e4a69ab3e211384b3c34a35940fbff705", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1p9qvw9qx24e2q37h7rq304rgx9e9xn2e7yyfcfv7rfg6egral7uzsnwxq46", + "value": 546 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "ecdfc1aea2e54de630f284c3ee02c15f44f05c1e6c1fb0c187f0e6fe22294f6f19ab443bb84d13e6150536272e48afd9cd9d8b283a7118192205243106b8d97f" + ], + "is_coinbase": false, + "sequence": 2147483649 + }, + { + "txid": "c9b2dc97356cb7f2c638b5e548e2e3bccc5f260324fc5a786277282cdebe55e7", + "vout": 1, + "prevout": { + "scriptpubkey": "51202818e280caae5408fafe1822fa8d062e4a69ab3e211384b3c34a35940fbff705", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 2818e280caae5408fafe1822fa8d062e4a69ab3e211384b3c34a35940fbff705", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1p9qvw9qx24e2q37h7rq304rgx9e9xn2e7yyfcfv7rfg6egral7uzsnwxq46", + "value": 10148 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "5ada12205dc3e811e17027bfb14c8d4aeb870a558449feea93b90ffdcbde0b2b227491f91548ec72e91b4f312f55c34ce1158eff46e9e1c5a3b1fb9d34b813c5" + ], + "is_coinbase": false, + "sequence": 2147483649 + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 546 + }, + { + "scriptpubkey": "51202818e280caae5408fafe1822fa8d062e4a69ab3e211384b3c34a35940fbff705", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 2818e280caae5408fafe1822fa8d062e4a69ab3e211384b3c34a35940fbff705", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1p9qvw9qx24e2q37h7rq304rgx9e9xn2e7yyfcfv7rfg6egral7uzsnwxq46", + "value": 9945 + } + ], + "size": 303, + "weight": 810, + "sigops": 4, + "fee": 203, + "status": { + "confirmed": true, + "block_height": 931081, + "block_hash": "000000000000000000016e7cbd4cea7ec4e662a27670f63bebdd83fcf6362e0c", + "block_time": 1767663690 + } + }, + { + "txid": "1b56fcf11a94b038862e8f0013aa9a43bfce16b77aa2ff6ddd94b59804c0d51b", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "bf01bb7339220c6e4b96c5c15eebfd7f07bd46a44d26e03540d5112d83b414fb", + "vout": 0, + "prevout": { + "scriptpubkey": "51204ed31631ff0a083d9f6e52e96d382118d9bb2b62efe8b92a8063469b5b3c6178", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 4ed31631ff0a083d9f6e52e96d382118d9bb2b62efe8b92a8063469b5b3c6178", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pfmf3vv0lpgyrm8mw2t5k6wpprrvmk2mzal5tj25qvdrfkkeuv9uq62u43t", + "value": 546 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "31ed90bcecd7a5e78c9d7277d8a52fb18da8b8f6e8feda9e34845d71eac545acd0c854a4fe9b606e0d67cabc3b86657021a872bc46b479c02661e7ba623db06a" + ], + "is_coinbase": false, + "sequence": 4294967293 + }, + { + "txid": "2394fd95c2358afb40920144285ce99f6fb3e24e4e62c4bebb06a8d4b36ca777", + "vout": 1, + "prevout": { + "scriptpubkey": "51204ed31631ff0a083d9f6e52e96d382118d9bb2b62efe8b92a8063469b5b3c6178", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 4ed31631ff0a083d9f6e52e96d382118d9bb2b62efe8b92a8063469b5b3c6178", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pfmf3vv0lpgyrm8mw2t5k6wpprrvmk2mzal5tj25qvdrfkkeuv9uq62u43t", + "value": 18746 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "e83f5faff9b94274eab294bbfa879bfcc7e43ce0b7bed3a34cf4ed347734b93423bc7f40ed842008fe8b114cf11b6a33d23a925472982de02cdc8a8cf720269d" + ], + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 546 + }, + { + "scriptpubkey": "51204ed31631ff0a083d9f6e52e96d382118d9bb2b62efe8b92a8063469b5b3c6178", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 4ed31631ff0a083d9f6e52e96d382118d9bb2b62efe8b92a8063469b5b3c6178", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pfmf3vv0lpgyrm8mw2t5k6wpprrvmk2mzal5tj25qvdrfkkeuv9uq62u43t", + "value": 18322 + } + ], + "size": 303, + "weight": 810, + "sigops": 4, + "fee": 424, + "status": { + "confirmed": true, + "block_height": 931068, + "block_hash": "000000000000000000010790dc8c8c022065380300f037d07dc12e94493dc758", + "block_time": 1767657685 + } + }, + { + "txid": "05b2d30b12783e014db1a8ef8cfc72a661d283d0e39923f0f47042431bab544c", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "131422428df62e48a7651cfa8c33f85919951441adcf13d47ab4c3abb8bd2b15", + "vout": 0, + "prevout": { + "scriptpubkey": "51204d90ce019274f6894e591b12acbc98ab36fae03abf5175d28a8659a7cdd8382c", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 4d90ce019274f6894e591b12acbc98ab36fae03abf5175d28a8659a7cdd8382c", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pfkgvuqvjwnmgjnjervf2e0yc4vm04cp6haght552sev60nwc8qkqhvayey", + "value": 546 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "989c26f97202a6173d57121e24529e38fcb30ae4248aee66758f02b1fda18f01350eefc252874e6ca304a0a0d2a9ad9bac7ebec3afe3dbf4a9561e869cf29080" + ], + "is_coinbase": false, + "sequence": 4294967293 + }, + { + "txid": "96c6728d2c62991c26a7e3283dab0f9c95613ff1ef874a6a936c2160e567b068", + "vout": 1, + "prevout": { + "scriptpubkey": "51204d90ce019274f6894e591b12acbc98ab36fae03abf5175d28a8659a7cdd8382c", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 4d90ce019274f6894e591b12acbc98ab36fae03abf5175d28a8659a7cdd8382c", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pfkgvuqvjwnmgjnjervf2e0yc4vm04cp6haght552sev60nwc8qkqhvayey", + "value": 28746 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "73966e2ea19680a3a0d1a5f4301011c97e4f4642e100fdae2df54a770858c58c6ab81608d7f758f4b9f29d2f1cdf0be0e59c40c9be078774a89dbbb241616be4" + ], + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 546 + }, + { + "scriptpubkey": "51204d90ce019274f6894e591b12acbc98ab36fae03abf5175d28a8659a7cdd8382c", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 4d90ce019274f6894e591b12acbc98ab36fae03abf5175d28a8659a7cdd8382c", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pfkgvuqvjwnmgjnjervf2e0yc4vm04cp6haght552sev60nwc8qkqhvayey", + "value": 28322 + } + ], + "size": 303, + "weight": 810, + "sigops": 4, + "fee": 424, + "status": { + "confirmed": true, + "block_height": 931009, + "block_hash": "00000000000000000001d5a6b7a0e933d38e429286a30fd907ff6d3c04677975", + "block_time": 1767620512 + } + }, + { + "txid": "5b585e4ca31586108687d7f56119bf898adccfa44c095d33df6157aa9e3ef3b4", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "9737cd42a4a6c4afad7ce980c6b2b50d688d1a24970bb863ffa837db0186928c", + "vout": 0, + "prevout": { + "scriptpubkey": "0014339af0895ffeb7fac7f32908161b13f291950a95", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 339af0895ffeb7fac7f32908161b13f291950a95", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qxwd0pz2ll6ml43ln9yypvxcn72ge2z54wz5k8x", + "value": 546 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "304402205a4003ed02acf1188e2a236ed474ce71d32366e2629c33e4e5322a73a47455050220639c671fc4d546e42a7ecdb4bd310c22175032d7dcd2220f45c8b592deb3f42001", + "02f18358f49a192b6eea6e7f2beca925a94ac50378b7da257751a06e54713a1a70" + ], + "is_coinbase": false, + "sequence": 4294967293 + }, + { + "txid": "5495988049b22bfd68cbdcbf7bf11740fbb87338249d47394870ae61261e0291", + "vout": 1, + "prevout": { + "scriptpubkey": "0014339af0895ffeb7fac7f32908161b13f291950a95", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 339af0895ffeb7fac7f32908161b13f291950a95", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qxwd0pz2ll6ml43ln9yypvxcn72ge2z54wz5k8x", + "value": 1289541 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "304402202e7e79395ef00ba15a8074bb7770cb5ccbc999a168d182bf138f45fda3981e3402203fbab062d0fecc1885497842e0efb9839d1dbb211ce590c39c9eb3f62c7ba15a01", + "02f18358f49a192b6eea6e7f2beca925a94ac50378b7da257751a06e54713a1a70" + ], + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 546 + }, + { + "scriptpubkey": "0014339af0895ffeb7fac7f32908161b13f291950a95", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 339af0895ffeb7fac7f32908161b13f291950a95", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "bc1qxwd0pz2ll6ml43ln9yypvxcn72ge2z54wz5k8x", + "value": 1289117 + } + ], + "size": 373, + "weight": 844, + "sigops": 6, + "fee": 424, + "status": { + "confirmed": true, + "block_height": 931004, + "block_hash": "00000000000000000001a731ee995757f8b61341c025bce746fc7e1845374049", + "block_time": 1767618125 + } + }, + { + "txid": "45a5e1abf2c1da645bbf4a6fc7040d049e521d2b76c601fb6f5b41c3303b7d6a", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "bd75b91aa2b7d2c17c09399293f3f6c30a7294e525b67fbdbd40b5d028381afe", + "vout": 0, + "prevout": { + "scriptpubkey": "51204d04f31e4a096a8ddfcb998f625ea569f497e74ca1c00977b55d65b450d17db8", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 4d04f31e4a096a8ddfcb998f625ea569f497e74ca1c00977b55d65b450d17db8", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pf5z0x8j2p94gmh7tnx8kyh49d86f0e6v58qqjaa4t4jmg5x30kuqudqwtq", + "value": 546 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "b95c375ccb50b2ba20a890327ca65514c0b137ca4debbcd08d6771cef16a56438c17577d228504db2f1f9b2b1e0a06a00ce09f4ace8384d65e1dc722ddd591b6" + ], + "is_coinbase": false, + "sequence": 4294967293 + }, + { + "txid": "6f66396209bd8e11055fae391227a23e0be697aa2f44e6711c24fe9c888e3857", + "vout": 3, + "prevout": { + "scriptpubkey": "51204d04f31e4a096a8ddfcb998f625ea569f497e74ca1c00977b55d65b450d17db8", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 4d04f31e4a096a8ddfcb998f625ea569f497e74ca1c00977b55d65b450d17db8", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pf5z0x8j2p94gmh7tnx8kyh49d86f0e6v58qqjaa4t4jmg5x30kuqudqwtq", + "value": 51447 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "89df59c30adaf64bab136aba45e2c54f6e69601cc97040e90e0adfde928195301b20286a87d52123575c794b88f70805abcd600aadf40be2bab2220d641cbd30" + ], + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 62e907b15cbf27d5425399ebf6f0fb50ebb88f18 OP_EQUALVERIFY OP_CHECKSIG", + "scriptpubkey_type": "p2pkh", + "scriptpubkey_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "value": 546 + }, + { + "scriptpubkey": "51204d04f31e4a096a8ddfcb998f625ea569f497e74ca1c00977b55d65b450d17db8", + "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 4d04f31e4a096a8ddfcb998f625ea569f497e74ca1c00977b55d65b450d17db8", + "scriptpubkey_type": "v1_p2tr", + "scriptpubkey_address": "bc1pf5z0x8j2p94gmh7tnx8kyh49d86f0e6v58qqjaa4t4jmg5x30kuqudqwtq", + "value": 51023 + } + ], + "size": 303, + "weight": 810, + "sigops": 4, + "fee": 424, + "status": { + "confirmed": true, + "block_height": 931004, + "block_hash": "00000000000000000001a731ee995757f8b61341c025bce746fc7e1845374049", + "block_time": 1767618125 + } + } + ] + }, + { + "provider": "tronwallet-v1", + "chain": "tron", + "address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", + "transactions": [] + }, + { + "provider": "tron-rpc", + "chain": "tron", + "address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", + "transactions": [ + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 0 + } + ], + "signature": [ + "6ea0aad2ec14976bcfe1f6fe0eebfff41617e04e941ca6dbcba0b79e3436c1f23d71b5a5e99835e594d9b42f13b43df2b29b61ef61ffd9e8e092844286c41f0600" + ], + "txID": "caacd034fd600a9a66cb9c841f39b81101e97e23068c8d73152f8741654139e1", + "net_usage": 345, + "raw_data_hex": "0a02963c22083dd1580175f3087740b8b6fdbdb9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541e744bdca961a15d129faeea25e59fb2f581618f412154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb0000000000000000000000003c32c7224525e60e43d91c3a87244d414fb175110000000000000000000000000000000000000000000000000000000008e0cbf0708da7cc90b9339001a8fd8201", + "net_fee": 0, + "energy_usage": 14383, + "blockNumber": 79009367, + "block_timestamp": 1767691140000, + "energy_fee": 0, + "energy_usage_total": 14383, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb0000000000000000000000003c32c7224525e60e43d91c3a87244d414fb175110000000000000000000000000000000000000000000000000000000008e0cbf0", + "owner_address": "41e744bdca961a15d129faeea25e59fb2f581618f4", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "963c", + "ref_block_hash": "3dd1580175f30877", + "expiration": 1767777459000, + "fee_limit": 2145960, + "timestamp": 1767682282381 + }, + "internal_transactions": [] + }, + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 3283300 + } + ], + "signature": [ + "1e04d1a8a910620ce30a201bf9cd9efcf6f3a19f0469d8616900795229238d7f425fbb6ad39e3ff066e87a757ef1fa219f80e48da78557b44c94261dd27b02b700" + ], + "txID": "423e05f94d86b027bd73c2b29f6dc93b15ab4ce90523ef9f7e09ebf7525d13d2", + "net_usage": 0, + "raw_data_hex": "0a0295f42208d81e6453c428bd5b40c8e1dd94b9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541e62410ca69fa855b81f856a08672ad4d4eaf9cdf12154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb000000000000000000000000e744bdca961a15d129faeea25e59fb2f581618f40000000000000000000000000000000000000000000000000000000008e0cbf0708fa6da94b933900180e1eb17", + "net_fee": 345000, + "energy_usage": 0, + "blockNumber": 79009288, + "block_timestamp": 1767690903000, + "energy_fee": 2938300, + "energy_usage_total": 29383, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb000000000000000000000000e744bdca961a15d129faeea25e59fb2f581618f40000000000000000000000000000000000000000000000000000000008e0cbf0", + "owner_address": "41e62410ca69fa855b81f856a08672ad4d4eaf9cdf", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "95f4", + "ref_block_hash": "d81e6453c428bd5b", + "expiration": 1767690957000, + "fee_limit": 50000000, + "timestamp": 1767690900239 + }, + "internal_transactions": [] + }, + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 345000 + } + ], + "signature": [ + "280da87e1700e1cf723d3aedb2b09bf442c55049096e8a6dc904fe225d236e6e467beff142984db12e4ab2defb74788be3b1147d78694ef360733ec0816d43ae00" + ], + "txID": "32150eeb416fefcd8b3e0fa9df93d89dd6fde5711a37945ec18607331224f9b6", + "net_usage": 0, + "raw_data_hex": "0a0292fb220873107486a011eff140c0b7e193b9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541b1bf43fe293344a471612c111cf28df55783f9a212154184716914c0fdf7110a44030d04d0c4923504d9cc2244095ea7b3000000000000000000000000e95812d8d5b5412d2b9f3a4d5a87ca15c5c51f33ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff70dcd0cb93b933900180c2d72f", + "net_fee": 345000, + "energy_usage": 22594, + "blockNumber": 79008517, + "block_timestamp": 1767688590000, + "energy_fee": 0, + "energy_usage_total": 22594, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "095ea7b3000000000000000000000000e95812d8d5b5412d2b9f3a4d5a87ca15c5c51f33ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "owner_address": "41b1bf43fe293344a471612c111cf28df55783f9a2", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "92fb", + "ref_block_hash": "73107486a011eff1", + "expiration": 1767688920000, + "fee_limit": 100000000, + "timestamp": 1767688562780 + }, + "internal_transactions": [] + }, + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 345000 + } + ], + "signature": [ + "a4437651544643aeae5610b5c6f740553739c5e4b8f3ee6baa774579a775eff0aa7d1b84c6d2c916653b060a8a83a4bbd67802b9727edb834e1810c9dd024c3b01" + ], + "txID": "43529f236cfa266f6fb15592b35fa909810d8d4788e46621a96831c2cbf21019", + "net_usage": 0, + "raw_data_hex": "0a0292db2208df148bee7908749440e0a1c993b9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541ea5408890e9b7d485eda7efb32da60ab3944da3912154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb0000000000000000000000004a873887bd67e10104a02bf11d949860795719ab000000000000000000000000000000000000000000000000000000000000ea607096d3c593b933900180c2d72f", + "net_fee": 345000, + "energy_usage": 29383, + "blockNumber": 79008476, + "block_timestamp": 1767688467000, + "energy_fee": 0, + "energy_usage_total": 29383, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb0000000000000000000000004a873887bd67e10104a02bf11d949860795719ab000000000000000000000000000000000000000000000000000000000000ea60", + "owner_address": "41ea5408890e9b7d485eda7efb32da60ab3944da39", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "92db", + "ref_block_hash": "df148bee79087494", + "expiration": 1767688524000, + "fee_limit": 100000000, + "timestamp": 1767688464790 + }, + "internal_transactions": [] + }, + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 0 + } + ], + "signature": [ + "efaf15982da2291ff6431fc9caf6197500edbea7d707a7d215eb8c4a4a6d453a6ab28c13b842fb51a3109748c01c6bd71131785f8cc2446737e41ff5ff3bdfc800" + ], + "txID": "7acddac2240f755157a1e60f0405cfdaf8e39c4fb550e2a3253ebecc8f53b94a", + "net_usage": 345, + "raw_data_hex": "0a0291872208cd02aa092bd1cf1b40a0e4a0bcb9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541e744bdca961a15d129faeea25e59fb2f581618f412154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb0000000000000000000000003c32c7224525e60e43d91c3a87244d414fb175110000000000000000000000000000000000000000000000000000000005f5b9f070c198f08eb9339001a8fd8201", + "net_fee": 0, + "energy_usage": 14383, + "blockNumber": 79008163, + "block_timestamp": 1767687528000, + "energy_fee": 0, + "energy_usage_total": 14383, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb0000000000000000000000003c32c7224525e60e43d91c3a87244d414fb175110000000000000000000000000000000000000000000000000000000005f5b9f0", + "owner_address": "41e744bdca961a15d129faeea25e59fb2f581618f4", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "9187", + "ref_block_hash": "cd02aa092bd1cf1b", + "expiration": 1767773844000, + "fee_limit": 2145960, + "timestamp": 1767678676033 + }, + "internal_transactions": [] + }, + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 3283300 + } + ], + "signature": [ + "a827738b9f7c585fd3f0f9a6a146f13e6454f5695b946018bda0de54e55458324274be97a9c477aae42253e05289a6cd2be609fa639a02b2ede027aced64b0c400" + ], + "txID": "fdb3232eb52aade54370f544eb152328f2ed01a3060ba02676bcec323fd62d1c", + "net_usage": 0, + "raw_data_hex": "0a029144220894f27856df40bf9240c8848293b9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541e62410ca69fa855b81f856a08672ad4d4eaf9cdf12154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb000000000000000000000000e744bdca961a15d129faeea25e59fb2f581618f40000000000000000000000000000000000000000000000000000000005f5b9f070b0c9fe92b933900180e1eb17", + "net_fee": 345000, + "energy_usage": 0, + "blockNumber": 79008088, + "block_timestamp": 1767687303000, + "energy_fee": 2938300, + "energy_usage_total": 29383, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb000000000000000000000000e744bdca961a15d129faeea25e59fb2f581618f40000000000000000000000000000000000000000000000000000000005f5b9f0", + "owner_address": "41e62410ca69fa855b81f856a08672ad4d4eaf9cdf", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "9144", + "ref_block_hash": "94f27856df40bf92", + "expiration": 1767687357000, + "fee_limit": 50000000, + "timestamp": 1767687300272 + }, + "internal_transactions": [] + }, + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 0 + } + ], + "signature": [ + "817a9475a01bb74780817bed457cc5b485d05df978d01a06cebf0b1b942374606f163e21fe08830a04a3aac7ee9e495f263fe19fdc15f5791da56c99033cb88901" + ], + "txID": "6a1e5cfb4687d787e21470ce24a06834a0a1ff6be2fcb63d1b8c3fef47b9d358", + "net_usage": 345, + "raw_data_hex": "0a028ecd2208dc6e1798d047a6cc40f0fca0bbb9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541e744bdca961a15d129faeea25e59fb2f581618f412154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb0000000000000000000000003c32c7224525e60e43d91c3a87244d414fb175110000000000000000000000000000000000000000000000000000000005f5b9f07080e2f08db9339001a8fd8201", + "net_fee": 0, + "energy_usage": 14383, + "blockNumber": 79007473, + "block_timestamp": 1767685458000, + "energy_fee": 0, + "energy_usage_total": 14383, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb0000000000000000000000003c32c7224525e60e43d91c3a87244d414fb175110000000000000000000000000000000000000000000000000000000005f5b9f0", + "owner_address": "41e744bdca961a15d129faeea25e59fb2f581618f4", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "8ecd", + "ref_block_hash": "dc6e1798d047a6cc", + "expiration": 1767771750000, + "fee_limit": 2145960, + "timestamp": 1767676588288 + }, + "internal_transactions": [] + }, + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 3283300 + } + ], + "signature": [ + "5f3f3d3129feaae229bd8ded0ff838fb172c8e0c34fa491187473c4365cc44c72bfc1c0f10b0b854735aff7e4ea0232b2458a8b2556e31a93b63d200ea34703900" + ], + "txID": "99e01fac743e70fcbe1a2cf1c6ff966e15177df4ed42206f5406219487121493", + "net_usage": 0, + "raw_data_hex": "0a028e8822080504d2c034b2566640a8ee8192b9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541e62410ca69fa855b81f856a08672ad4d4eaf9cdf12154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb000000000000000000000000e744bdca961a15d129faeea25e59fb2f581618f40000000000000000000000000000000000000000000000000000000005f5b9f070f7b2fe91b933900180e1eb17", + "net_fee": 345000, + "energy_usage": 0, + "blockNumber": 79007388, + "block_timestamp": 1767685203000, + "energy_fee": 2938300, + "energy_usage_total": 29383, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb000000000000000000000000e744bdca961a15d129faeea25e59fb2f581618f40000000000000000000000000000000000000000000000000000000005f5b9f0", + "owner_address": "41e62410ca69fa855b81f856a08672ad4d4eaf9cdf", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "8e88", + "ref_block_hash": "0504d2c034b25666", + "expiration": 1767685257000, + "fee_limit": 50000000, + "timestamp": 1767685200247 + }, + "internal_transactions": [] + }, + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 0 + } + ], + "signature": [ + "ee40aeb8aea708a6b7640a2253995e2bc056825e03ccd03a42001269aed2660d48e68457a24a44e3451a2ddfa79e22e5f15864df018d0bbc962ed24af64ded6701" + ], + "txID": "bd634a342c95921a1da6e3fb7300845cc70c43448f64eae64ce90230ce35c858", + "net_usage": 345, + "raw_data_hex": "0a028d9f220893c2d9e03f1567b240e0d6e9bab9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541e744bdca961a15d129faeea25e59fb2f581618f412154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb0000000000000000000000003c32c7224525e60e43d91c3a87244d414fb175110000000000000000000000000000000000000000000000000000000005f5b9f070a7f4b88db9339001a8fd8201", + "net_fee": 0, + "energy_usage": 14383, + "blockNumber": 79007165, + "block_timestamp": 1767684534000, + "energy_fee": 0, + "energy_usage_total": 14383, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb0000000000000000000000003c32c7224525e60e43d91c3a87244d414fb175110000000000000000000000000000000000000000000000000000000005f5b9f0", + "owner_address": "41e744bdca961a15d129faeea25e59fb2f581618f4", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "8d9f", + "ref_block_hash": "93c2d9e03f1567b2", + "expiration": 1767770844000, + "fee_limit": 2145960, + "timestamp": 1767675673127 + }, + "internal_transactions": [] + }, + { + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 3283300 + } + ], + "signature": [ + "b97b4f972e35c8f4bedd62ed439e46b764125bcdb7f124398f260984ddda29256667131dbe39e6ce6ebaf1273ab317a8a7f47465d3a6e69b9f38f0ca3b75130700" + ], + "txID": "1f58123df63c25654325eb16c8e85e253d8f6d5f2517068ee661ecff22221cde", + "net_usage": 0, + "raw_data_hex": "0a028d5c2208967ab4263b20d4904088f7ca91b9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541e62410ca69fa855b81f856a08672ad4d4eaf9cdf12154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb000000000000000000000000e744bdca961a15d129faeea25e59fb2f581618f40000000000000000000000000000000000000000000000000000000005f5b9f070e1bbc791b933900180e1eb17", + "net_fee": 345000, + "energy_usage": 0, + "blockNumber": 79007088, + "block_timestamp": 1767684303000, + "energy_fee": 2938300, + "energy_usage_total": 29383, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb000000000000000000000000e744bdca961a15d129faeea25e59fb2f581618f40000000000000000000000000000000000000000000000000000000005f5b9f0", + "owner_address": "41e62410ca69fa855b81f856a08672ad4d4eaf9cdf", + "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "8d5c", + "ref_block_hash": "967ab4263b20d490", + "expiration": 1767684357000, + "fee_limit": 50000000, + "timestamp": 1767684300257 + }, + "internal_transactions": [] + } + ] + }, + { + "provider": "binancewallet-v1", + "chain": "binance", + "address": "0x8894E0a0c962CB723c1976a4421c95949bE2D4E3", + "transactions": [] + }, + { + "provider": "bscscan-v2", + "chain": "binance", + "address": "0x8894E0a0c962CB723c1976a4421c95949bE2D4E3", + "error": "fetch failed", + "transactions": [] + } + ] +} \ No newline at end of file diff --git a/playwright.storybook.config.ts b/playwright.storybook.config.ts index f088aed8a..5bd5d29cf 100644 --- a/playwright.storybook.config.ts +++ b/playwright.storybook.config.ts @@ -7,16 +7,23 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [['list']], + snapshotDir: './storybook-e2e/__screenshots__', + snapshotPathTemplate: '{snapshotDir}/{projectName}/{testFileName}-{arg}{ext}', use: { trace: 'on-first-retry', screenshot: 'only-on-failure', }, + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.01, + }, + }, projects: [ { - name: 'Desktop Chrome', + name: 'Mobile-Chrome', use: { - ...devices['Desktop Chrome'], - viewport: { width: 1280, height: 720 }, + ...devices['Pixel 5'], + viewport: { width: 393, height: 851 }, }, }, ], diff --git a/scripts/collect-provider-fixtures.mjs b/scripts/collect-provider-fixtures.mjs new file mode 100644 index 000000000..4d4f48db6 --- /dev/null +++ b/scripts/collect-provider-fixtures.mjs @@ -0,0 +1,439 @@ +#!/usr/bin/env node +/** + * 从真实 Provider API 收集数据用于 E2E 测试 fixtures + * + * 使用 Storybook 中配置的真实地址拉取: + * - 余额数据 + * - 交易历史 + * + * 运行: node scripts/collect-provider-fixtures.mjs + */ + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +// Storybook 中的真实地址 +const REAL_ADDRESSES = { + ethereum: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', // Vitalik + tron: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9', + bitcoin: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', // Satoshi + binance: '0x8894E0a0c962CB723c1976a4421c95949bE2D4E3', +} + +// Provider API endpoints +const PROVIDERS = { + ethereum: { + rpc: 'https://ethereum-rpc.publicnode.com', + blockscout: 'https://eth.blockscout.com/api', + ethwallet: 'https://walletapi.bfmeta.info/wallet/eth', + }, + tron: { + rpc: 'https://api.trongrid.io', + tronwallet: 'https://walletapi.bfmeta.info/wallet/tron', + }, + bitcoin: { + mempool: 'https://mempool.space/api', + btcwallet: 'https://walletapi.bfmeta.info/wallet/btc', + }, + binance: { + rpc: 'https://bsc-dataseed.binance.org', + bscwallet: 'https://walletapi.bfmeta.info/wallet/bsc', + }, +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return response.json() +} + +// Ethereum RPC balance +async function fetchEthereumRpcBalance(address) { + const result = await fetchJson(PROVIDERS.ethereum.rpc, { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_getBalance', + params: [address, 'latest'], + id: 1, + }), + }) + return { + provider: 'ethereum-rpc', + chain: 'ethereum', + address, + balance: result.result, + balanceDecimal: parseInt(result.result, 16) / 1e18, + symbol: 'ETH', + decimals: 18, + } +} + +// Blockscout balance +async function fetchBlockscoutBalance(address) { + const url = `${PROVIDERS.ethereum.blockscout}?module=account&action=balance&address=${address}` + const result = await fetchJson(url) + return { + provider: 'blockscout-v1', + chain: 'ethereum', + address, + balance: result.result, + balanceDecimal: parseInt(result.result) / 1e18, + symbol: 'ETH', + decimals: 18, + } +} + +// Blockscout transactions +async function fetchBlockscoutTransactions(address) { + const url = `${PROVIDERS.ethereum.blockscout}?module=account&action=txlist&address=${address}&page=1&offset=10&sort=desc` + const result = await fetchJson(url) + return { + provider: 'blockscout-v1', + chain: 'ethereum', + address, + transactions: result.result?.slice(0, 10) || [], + } +} + +// WalletAPI balance +async function fetchWalletApiBalance(chain, address) { + const endpoints = { + ethereum: PROVIDERS.ethereum.ethwallet, + tron: PROVIDERS.tron.tronwallet, + bitcoin: PROVIDERS.bitcoin.btcwallet, + binance: PROVIDERS.binance.bscwallet, + } + const url = `${endpoints[chain]}/balance/${address}` + try { + const result = await fetchJson(url) + return { + provider: `${chain}wallet-v1`, + chain, + address, + ...result, + } + } catch (e) { + return { provider: `${chain}wallet-v1`, chain, address, error: e.message } + } +} + +// WalletAPI transactions +async function fetchWalletApiTransactions(chain, address) { + const endpoints = { + ethereum: PROVIDERS.ethereum.ethwallet, + tron: PROVIDERS.tron.tronwallet, + bitcoin: PROVIDERS.bitcoin.btcwallet, + binance: PROVIDERS.binance.bscwallet, + } + const url = `${endpoints[chain]}/transactions/${address}?limit=10` + try { + const result = await fetchJson(url) + return { + provider: `${chain}wallet-v1`, + chain, + address, + transactions: Array.isArray(result) ? result : result.transactions || [], + } + } catch (e) { + return { provider: `${chain}wallet-v1`, chain, address, error: e.message, transactions: [] } + } +} + +// Mempool.space Bitcoin +async function fetchMempoolBalance(address) { + const url = `${PROVIDERS.bitcoin.mempool}/address/${address}` + const result = await fetchJson(url) + const balance = (result.chain_stats?.funded_txo_sum || 0) - (result.chain_stats?.spent_txo_sum || 0) + return { + provider: 'mempool-v1', + chain: 'bitcoin', + address, + balance: balance.toString(), + balanceDecimal: balance / 1e8, + symbol: 'BTC', + decimals: 8, + } +} + +async function fetchMempoolTransactions(address) { + const url = `${PROVIDERS.bitcoin.mempool}/address/${address}/txs` + const result = await fetchJson(url) + return { + provider: 'mempool-v1', + chain: 'bitcoin', + address, + transactions: result.slice(0, 10), + } +} + +// Tron RPC balance +async function fetchTronBalance(address) { + const url = `${PROVIDERS.tron.rpc}/v1/accounts/${address}` + try { + const result = await fetchJson(url) + const balance = result.data?.[0]?.balance || 0 + return { + provider: 'tron-rpc', + chain: 'tron', + address, + balance: balance.toString(), + balanceDecimal: balance / 1e6, + symbol: 'TRX', + decimals: 6, + } + } catch (e) { + return { provider: 'tron-rpc', chain: 'tron', address, error: e.message } + } +} + +// Tron RPC transactions +async function fetchTronTransactions(address) { + const url = `${PROVIDERS.tron.rpc}/v1/accounts/${address}/transactions?limit=10` + try { + const result = await fetchJson(url) + return { + provider: 'tron-rpc', + chain: 'tron', + address, + transactions: result.data || [], + } + } catch (e) { + return { provider: 'tron-rpc', chain: 'tron', address, error: e.message, transactions: [] } + } +} + +// Etherscan transactions (using V2 API) +async function fetchEtherscanTransactions(address) { + const apiKey = 'UN6TIKGANUFRYYPNSD1SCHRAZ3NABF52XS' + // V2 API endpoint + const url = `https://api.etherscan.io/v2/api?chainid=1&module=account&action=txlist&address=${address}&startblock=0&endblock=99999999&page=1&offset=10&sort=desc&apikey=${apiKey}` + try { + const result = await fetchJson(url) + return { + provider: 'etherscan-v2', + chain: 'ethereum', + address, + transactions: result.result?.slice(0, 10) || [], + } + } catch (e) { + return { provider: 'etherscan-v2', chain: 'ethereum', address, error: e.message, transactions: [] } + } +} + +// BSCScan transactions (using V2 API via Etherscan) +async function fetchBscScanTransactions(address) { + const apiKey = 'UN6TIKGANUFRYYPNSD1SCHRAZ3NABF52XS' + // V2 API endpoint with chainid=56 for BSC + const url = `https://api.etherscan.io/v2/api?chainid=56&module=account&action=txlist&address=${address}&startblock=0&endblock=99999999&page=1&offset=10&sort=desc&apikey=${apiKey}` + try { + const result = await fetchJson(url) + return { + provider: 'bscscan-v2', + chain: 'binance', + address, + transactions: result.result?.slice(0, 10) || [], + } + } catch (e) { + return { provider: 'bscscan-v2', chain: 'binance', address, error: e.message, transactions: [] } + } +} + +// BSC RPC balance +async function fetchBscBalance(address) { + const result = await fetchJson(PROVIDERS.binance.rpc, { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_getBalance', + params: [address, 'latest'], + id: 1, + }), + }) + return { + provider: 'bsc-rpc', + chain: 'binance', + address, + balance: result.result, + balanceDecimal: parseInt(result.result, 16) / 1e18, + symbol: 'BNB', + decimals: 18, + } +} + +async function collectAllFixtures() { + console.log('Collecting provider fixtures from real APIs...\n') + + const fixtures = { + version: 1, + collectedAt: new Date().toISOString(), + addresses: REAL_ADDRESSES, + balances: [], + transactions: [], + } + + // Ethereum + console.log('📦 Ethereum...') + try { + const ethRpc = await fetchEthereumRpcBalance(REAL_ADDRESSES.ethereum) + console.log(` ✓ ethereum-rpc: ${ethRpc.balanceDecimal} ETH`) + fixtures.balances.push(ethRpc) + } catch (e) { + console.log(` ✗ ethereum-rpc: ${e.message}`) + } + + try { + const blockscout = await fetchBlockscoutBalance(REAL_ADDRESSES.ethereum) + console.log(` ✓ blockscout: ${blockscout.balanceDecimal} ETH`) + fixtures.balances.push(blockscout) + } catch (e) { + console.log(` ✗ blockscout: ${e.message}`) + } + + try { + const blockscoutTx = await fetchBlockscoutTransactions(REAL_ADDRESSES.ethereum) + console.log(` ✓ blockscout txs: ${blockscoutTx.transactions.length} transactions`) + fixtures.transactions.push(blockscoutTx) + } catch (e) { + console.log(` ✗ blockscout txs: ${e.message}`) + } + + try { + const ethwallet = await fetchWalletApiBalance('ethereum', REAL_ADDRESSES.ethereum) + console.log(` ✓ ethwallet: ${JSON.stringify(ethwallet).slice(0, 100)}...`) + fixtures.balances.push(ethwallet) + } catch (e) { + console.log(` ✗ ethwallet: ${e.message}`) + } + + try { + const ethwalletTx = await fetchWalletApiTransactions('ethereum', REAL_ADDRESSES.ethereum) + console.log(` ✓ ethwallet txs: ${ethwalletTx.transactions?.length || 0} transactions`) + fixtures.transactions.push(ethwalletTx) + } catch (e) { + console.log(` ✗ ethwallet txs: ${e.message}`) + } + + try { + const etherscanTx = await fetchEtherscanTransactions(REAL_ADDRESSES.ethereum) + console.log(` ✓ etherscan txs: ${etherscanTx.transactions?.length || 0} transactions`) + fixtures.transactions.push(etherscanTx) + } catch (e) { + console.log(` ✗ etherscan txs: ${e.message}`) + } + + // Bitcoin + console.log('\n📦 Bitcoin...') + try { + const mempool = await fetchMempoolBalance(REAL_ADDRESSES.bitcoin) + console.log(` ✓ mempool: ${mempool.balanceDecimal} BTC`) + fixtures.balances.push(mempool) + } catch (e) { + console.log(` ✗ mempool: ${e.message}`) + } + + try { + const mempoolTx = await fetchMempoolTransactions(REAL_ADDRESSES.bitcoin) + console.log(` ✓ mempool txs: ${mempoolTx.transactions.length} transactions`) + fixtures.transactions.push(mempoolTx) + } catch (e) { + console.log(` ✗ mempool txs: ${e.message}`) + } + + try { + const btcwallet = await fetchWalletApiBalance('bitcoin', REAL_ADDRESSES.bitcoin) + console.log(` ✓ btcwallet: ${JSON.stringify(btcwallet).slice(0, 100)}...`) + fixtures.balances.push(btcwallet) + } catch (e) { + console.log(` ✗ btcwallet: ${e.message}`) + } + + // Tron + console.log('\n📦 Tron...') + try { + const tron = await fetchTronBalance(REAL_ADDRESSES.tron) + console.log(` ✓ tron-rpc: ${tron.balanceDecimal} TRX`) + fixtures.balances.push(tron) + } catch (e) { + console.log(` ✗ tron-rpc: ${e.message}`) + } + + try { + const tronwallet = await fetchWalletApiBalance('tron', REAL_ADDRESSES.tron) + console.log(` ✓ tronwallet: ${JSON.stringify(tronwallet).slice(0, 100)}...`) + fixtures.balances.push(tronwallet) + } catch (e) { + console.log(` ✗ tronwallet: ${e.message}`) + } + + try { + const tronwalletTx = await fetchWalletApiTransactions('tron', REAL_ADDRESSES.tron) + console.log(` ✓ tronwallet txs: ${tronwalletTx.transactions?.length || 0} transactions`) + fixtures.transactions.push(tronwalletTx) + } catch (e) { + console.log(` ✗ tronwallet txs: ${e.message}`) + } + + try { + const tronRpcTx = await fetchTronTransactions(REAL_ADDRESSES.tron) + console.log(` ✓ tron-rpc txs: ${tronRpcTx.transactions?.length || 0} transactions`) + fixtures.transactions.push(tronRpcTx) + } catch (e) { + console.log(` ✗ tron-rpc txs: ${e.message}`) + } + + // Binance + console.log('\n📦 Binance Smart Chain...') + try { + const bsc = await fetchBscBalance(REAL_ADDRESSES.binance) + console.log(` ✓ bsc-rpc: ${bsc.balanceDecimal} BNB`) + fixtures.balances.push(bsc) + } catch (e) { + console.log(` ✗ bsc-rpc: ${e.message}`) + } + + try { + const bscwallet = await fetchWalletApiBalance('binance', REAL_ADDRESSES.binance) + console.log(` ✓ bscwallet: ${JSON.stringify(bscwallet).slice(0, 100)}...`) + fixtures.balances.push(bscwallet) + } catch (e) { + console.log(` ✗ bscwallet: ${e.message}`) + } + + try { + const bscwalletTx = await fetchWalletApiTransactions('binance', REAL_ADDRESSES.binance) + console.log(` ✓ bscwallet txs: ${bscwalletTx.transactions?.length || 0} transactions`) + fixtures.transactions.push(bscwalletTx) + } catch (e) { + console.log(` ✗ bscwallet txs: ${e.message}`) + } + + try { + const bscscanTx = await fetchBscScanTransactions(REAL_ADDRESSES.binance) + console.log(` ✓ bscscan txs: ${bscscanTx.transactions?.length || 0} transactions`) + fixtures.transactions.push(bscscanTx) + } catch (e) { + console.log(` ✗ bscscan txs: ${e.message}`) + } + + // Save fixtures + const outputPath = path.join(__dirname, '../e2e/fixtures/provider-data/real-provider-data.v1.json') + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + fs.writeFileSync(outputPath, JSON.stringify(fixtures, null, 2)) + console.log(`\n✅ Saved fixtures to ${outputPath}`) + console.log(` - ${fixtures.balances.length} balance records`) + console.log(` - ${fixtures.transactions.length} transaction records`) +} + +collectAllFixtures().catch(console.error) diff --git a/src/components/common/provider-fallback-warning.tsx b/src/components/common/provider-fallback-warning.tsx new file mode 100644 index 000000000..d6744e5a3 --- /dev/null +++ b/src/components/common/provider-fallback-warning.tsx @@ -0,0 +1,42 @@ +import { IconAlertTriangle } from '@tabler/icons-react' +import { cn } from '@/lib/utils' + +interface ProviderFallbackWarningProps { + feature: string + reason?: string + className?: string +} + +export function ProviderFallbackWarning({ feature, reason, className }: ProviderFallbackWarningProps) { + return ( +
+
+ +
+

+ {feature} query failed +

+

+ All configured providers failed. Showing default value. +

+ {reason && ( +
+ + Technical details + +

+ {reason} +

+
+ )} +
+
+
+ ) +} diff --git a/src/components/wallet/wallet-address-portfolio-from-provider.tsx b/src/components/wallet/wallet-address-portfolio-from-provider.tsx index 5109dbdc5..427f187dd 100644 --- a/src/components/wallet/wallet-address-portfolio-from-provider.tsx +++ b/src/components/wallet/wallet-address-portfolio-from-provider.tsx @@ -8,9 +8,17 @@ import type { TokenInfo } from '@/components/token/token-item' import type { TransactionInfo } from '@/components/transaction/transaction-item' import type { ChainType } from '@/stores' import type { Transaction } from '@/services/chain-adapter/providers/types' +import { isSupported } from '@/services/chain-adapter/providers/types' import { Amount } from '@/types/amount' import { mapActionToTransactionType } from '@/components/transaction/transaction-meta' +/** 查询结果(带 supported 状态) */ +interface QueryResultWithSupport { + data: T + supported: boolean + fallbackReason?: string +} + export interface WalletAddressPortfolioFromProviderProps { chainId: ChainType address: string @@ -46,31 +54,55 @@ export function WalletAddressPortfolioFromProvider({ const tokensQuery = useQuery({ queryKey: ['address-token-balances', chainId, address, !!provider], - queryFn: async (): Promise => { + queryFn: async (): Promise> => { // Re-create provider inside queryFn to ensure we have latest const currentProvider = chainConfigState.snapshot ? createChainProvider(chainId) : null + if (!currentProvider) { + return { data: [], supported: false, fallbackReason: 'Provider not ready' } + } + + // 先尝试获取 token 列表 + const tokensResult = await currentProvider.getTokenBalances(address) + if (isSupported(tokensResult) && tokensResult.data.length > 0) { + return { + data: tokensResult.data.map(t => ({ + symbol: t.symbol, + name: t.name, + balance: t.amount.toFormatted(), + decimals: t.amount.decimals, + chain: chainId, + })), + supported: true, + } + } - if (!currentProvider?.getTokenBalances) { - if (currentProvider?.getNativeBalance) { - const balance = await currentProvider.getNativeBalance(address) - return [{ - symbol: balance.symbol, - name: balance.symbol, - balance: balance.amount.toFormatted(), - decimals: balance.amount.decimals, + // fallback 到原生代币余额 + const balanceResult = await currentProvider.getNativeBalance(address) + if (isSupported(balanceResult)) { + return { + data: [{ + symbol: balanceResult.data.symbol, + name: balanceResult.data.symbol, + balance: balanceResult.data.amount.toFormatted(), + decimals: balanceResult.data.amount.decimals, chain: chainId, - }] + }], + supported: true, } - return [] } - const tokens = await currentProvider.getTokenBalances(address) - return tokens.map(t => ({ - symbol: t.symbol, - name: t.name, - balance: t.amount.toFormatted(), - decimals: t.amount.decimals, - chain: chainId, - })) + + // 两者都失败,返回 fallback 数据 + return { + data: [{ + symbol: balanceResult.data.symbol, + name: balanceResult.data.symbol, + balance: balanceResult.data.amount.toFormatted(), + decimals: balanceResult.data.amount.decimals, + chain: chainId, + }], + supported: false, + fallbackReason: balanceResult.reason, + } }, enabled: tokensEnabled, staleTime: 30_000, @@ -78,11 +110,26 @@ export function WalletAddressPortfolioFromProvider({ const transactionsQuery = useQuery({ queryKey: ['address-transactions', chainId, address, !!provider], - queryFn: async (): Promise => { + queryFn: async (): Promise> => { const currentProvider = chainConfigState.snapshot ? createChainProvider(chainId) : null - if (!currentProvider?.getTransactionHistory) return [] - const txs = await currentProvider.getTransactionHistory(address, 20) - return txs.map(tx => convertToTransactionInfo(tx, chainId, decimals)) + if (!currentProvider) { + return { data: [], supported: false, fallbackReason: 'Provider not ready' } + } + + const result = await currentProvider.getTransactionHistory(address, 20) + + if (isSupported(result)) { + return { + data: result.data.map(tx => convertToTransactionInfo(tx, chainId, decimals)), + supported: true, + } + } + + return { + data: [], + supported: false, + fallbackReason: result.reason, + } }, enabled: transactionsEnabled, staleTime: 30_000, @@ -96,11 +143,15 @@ export function WalletAddressPortfolioFromProvider({ void onTransactionClick?: (tx: TransactionInfo) => void className?: string @@ -29,6 +36,10 @@ export function WalletAddressPortfolioView({ tokensLoading = false, transactionsLoading = false, tokensRefreshing = false, + tokensSupported = true, + tokensFallbackReason, + transactionsSupported = true, + transactionsFallbackReason, onTokenClick, onTransactionClick, className, @@ -49,6 +60,13 @@ export function WalletAddressPortfolioView({ {(tab) => tab === 'assets' ? (
+ {!tokensSupported && !tokensLoading && ( + + )} ) : (
+ {!transactionsSupported && !transactionsLoading && ( + + )} {children}; } +const REAL_ADDRESSES = { + ethereum: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + tron: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9', + bitcoin: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + binance: '0x8894E0a0c962CB723c1976a4421c95949bE2D4E3', +} as const; + +const COMPARE_CHAIN_CONFIGS: ChainConfig[] = [ + { + id: 'ethereum-blockscout-only', + version: '1.0', + chainKind: 'evm', + name: 'Ethereum (blockscout only)', + symbol: 'ETH', + icon: '../icons/ethereum/chain.svg', + decimals: 18, + enabled: true, + source: 'manual', + apis: [{ type: 'blockscout-v1', endpoint: 'https://eth.blockscout.com/api' }], + }, + { + id: 'ethereum-rpc-only', + version: '1.0', + chainKind: 'evm', + name: 'Ethereum (rpc only)', + symbol: 'ETH', + icon: '../icons/ethereum/chain.svg', + decimals: 18, + enabled: true, + source: 'manual', + apis: [{ type: 'ethereum-rpc', endpoint: 'https://ethereum-rpc.publicnode.com' }], + }, + { + id: 'ethereum-ethwallet-only', + version: '1.0', + chainKind: 'evm', + name: 'Ethereum (ethwallet only)', + symbol: 'ETH', + icon: '../icons/ethereum/chain.svg', + decimals: 18, + enabled: true, + source: 'manual', + apis: [{ type: 'ethwallet-v1', endpoint: 'https://walletapi.bfmeta.info/wallet/eth' }], + }, + + { + id: 'tron-rpc-only', + version: '1.0', + chainKind: 'tron', + name: 'Tron (rpc only)', + symbol: 'TRX', + icon: '../icons/tron/chain.svg', + decimals: 6, + enabled: true, + source: 'manual', + apis: [{ type: 'tron-rpc', endpoint: 'https://api.trongrid.io' }], + }, + { + id: 'tron-rpc-pro-only', + version: '1.0', + chainKind: 'tron', + name: 'Tron (rpc-pro only)', + symbol: 'TRX', + icon: '../icons/tron/chain.svg', + decimals: 6, + enabled: true, + source: 'manual', + apis: [{ type: 'tron-rpc-pro', endpoint: 'https://api.trongrid.io', config: { apiKeyEnv: 'VITE_TRONGRID_API_KEY' } }], + }, + { + id: 'tron-tronwallet-only', + version: '1.0', + chainKind: 'tron', + name: 'Tron (tronwallet only)', + symbol: 'TRX', + icon: '../icons/tron/chain.svg', + decimals: 6, + enabled: true, + source: 'manual', + apis: [{ type: 'tronwallet-v1', endpoint: 'https://walletapi.bfmeta.info/wallet/tron' }], + }, + + { + id: 'bitcoin-mempool-only', + version: '1.0', + chainKind: 'bitcoin', + name: 'Bitcoin (mempool only)', + symbol: 'BTC', + icon: '../icons/bitcoin/chain.svg', + decimals: 8, + enabled: true, + source: 'manual', + apis: [{ type: 'mempool-v1', endpoint: 'https://mempool.space/api' }], + }, + { + id: 'bitcoin-btcwallet-only', + version: '1.0', + chainKind: 'bitcoin', + name: 'Bitcoin (btcwallet only)', + symbol: 'BTC', + icon: '../icons/bitcoin/chain.svg', + decimals: 8, + enabled: true, + source: 'manual', + apis: [{ type: 'btcwallet-v1', endpoint: 'https://walletapi.bfmeta.info/wallet/btc/blockbook' }], + }, + + { + id: 'binance-bsc-rpc-only', + version: '1.0', + chainKind: 'evm', + name: 'BNB Smart Chain (rpc only)', + symbol: 'BNB', + icon: '../icons/binance/chain.svg', + decimals: 18, + enabled: true, + source: 'manual', + apis: [{ type: 'bsc-rpc', endpoint: 'https://bsc-rpc.publicnode.com' }], + }, + { + id: 'binance-bscwallet-only', + version: '1.0', + chainKind: 'evm', + name: 'BNB Smart Chain (bscwallet only)', + symbol: 'BNB', + icon: '../icons/binance/chain.svg', + decimals: 18, + enabled: true, + source: 'manual', + apis: [{ type: 'bscwallet-v1', endpoint: 'https://walletapi.bfmeta.info/wallet/bsc' }], + }, +]; + +function mergeConfigsById(existing: ChainConfig[], injected: ChainConfig[]): ChainConfig[] { + const byId = new Map(); + for (const config of existing) { + byId.set(config.id, config); + } + for (const config of injected) { + byId.set(config.id, config); + } + return [...byId.values()]; +} + +function CompareConfigInjector() { + const state = useChainConfigState(); + const [injected, setInjected] = useState(false); + + useEffect(() => { + if (!state.snapshot) return; + if (injected) return; + const alreadyInjected = state.snapshot.configs.some((c) => c.id === 'ethereum-blockscout-only'); + if (alreadyInjected) { + setInjected(true); + return; + } + + chainConfigStore.setState((prev) => { + if (!prev.snapshot) return prev; + return { + ...prev, + snapshot: { + ...prev.snapshot, + configs: mergeConfigsById(prev.snapshot.configs, COMPARE_CHAIN_CONFIGS), + }, + }; + }); + + clearProviderCache(); + setInjected(true); + }, [state.snapshot, injected]); + + return null; +} + +function formatApiLabel(api: ChainConfig['apis'][number]): string { + const apiKeyEnv = api.config && 'apiKeyEnv' in api.config ? (api.config.apiKeyEnv as string | undefined) : undefined; + if (apiKeyEnv) return `${api.type} (apiKeyEnv=${apiKeyEnv})`; + return api.type; +} + +function ProviderPanel({ + title, + chainId, + address, + chainName, + testId, +}: { + title: string; + chainId: ChainConfig['id']; + address: string; + chainName: string; + testId: string; +}) { + const config = COMPARE_CHAIN_CONFIGS.find((c) => c.id === chainId); + const apiTypes = config?.apis?.map(formatApiLabel) ?? []; + const primary = apiTypes[0] ?? 'unknown'; + const apiSummary = apiTypes.length ? apiTypes.join(' → ') : 'unknown'; + + return ( +
+
+
+
+

{title}

+

Primary: {primary}

+
+

{chainId}

+
+

apis[]: {apiSummary}

+
+ +
+ ); +} + const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -97,6 +458,84 @@ const withChainConfig: DecoratorFunction = (Story) => ( ); +const withCompareChainConfig: DecoratorFunction = (Story) => ( + + + +
+ +
+
+
+); + +function hasPositiveNumber(text: string): boolean { + const matches = text.match(/\d+(?:\.\d+)?/g); + if (!matches) return false; + for (const value of matches) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) return true; + } + return false; +} + +async function verifyNonEmptyAssetsAndHistory(canvas: ReturnType, testId: string): Promise { + await waitFor( + () => { + const portfolio = canvas.getByTestId(testId); + expect(portfolio).toBeVisible(); + }, + { timeout: 25_000 }, + ); + + await waitFor( + () => { + const tokenList = canvas.queryByTestId(`${testId}-token-list`); + expect(tokenList).not.toBeNull(); + const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]') ?? []; + expect(tokenItems.length).toBeGreaterThan(0); + const tokenText = tokenItems[0]?.textContent ?? ''; + expect(hasPositiveNumber(tokenText)).toBe(true); + + const tokenEmpty = canvas.queryByTestId(`${testId}-token-list-empty`); + expect(tokenEmpty).toBeNull(); + }, + { timeout: 25_000 }, + ); + + const historyTab = canvas.getByTestId(`${testId}-tabs-tab-history`); + historyTab.click(); + + await waitFor( + () => { + const txList = canvas.queryByTestId(`${testId}-transaction-list`); + expect(txList).not.toBeNull(); + + const txEmpty = canvas.queryByTestId(`${testId}-transaction-list-empty`); + expect(txEmpty).toBeNull(); + }, + { timeout: 25_000 }, + ); +} + +async function expectAnyProviderPanelOk(options: { + canvas: ReturnType; + label: string; + testIds: string[]; +}): Promise { + const errors: string[] = []; + for (const testId of options.testIds) { + try { + await verifyNonEmptyAssetsAndHistory(options.canvas, testId); + return; + } catch (error) { + errors.push(`${testId}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + throw new Error(`No provider panel returned non-empty data for ${options.label}. Details: ${errors.join(' | ')}`); +} + const meta = { title: 'Wallet/WalletAddressPortfolio', component: WalletAddressPortfolioView, @@ -360,3 +799,348 @@ export const RealDataBinance: Story = { ); }, }; + +export const CompareProviders: Story = { + name: 'Compare Providers (Real Data)', + decorators: [withCompareChainConfig], + parameters: { + chromatic: { delay: 8000 }, + docs: { + description: { + story: 'Side-by-side comparison: each panel uses a single provider (single-item apis[]), so you can compare results provider-by-provider.', + }, + }, + }, + render: () => ( +
+
+

Ethereum

+
+ + + +
+
+ +
+

Tron

+
+ + + +
+
+ +
+

Bitcoin

+
+ + +
+
+ +
+

BNB Smart Chain

+
+ + +
+
+
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Storybook's Vitest runner has a default 15s timeout and runs in MODE=test. + // Keep this story lightweight in automated Storybook tests; enforce real-network assertions in Storybook UI. + if (import.meta.env.MODE === 'test') { + await waitFor( + () => { + expect(canvas.getByTestId('cmp-ethereum-blockscout')).toBeVisible(); + expect(canvas.getByTestId('cmp-ethereum-rpc')).toBeVisible(); + expect(canvas.getByTestId('cmp-ethereum-ethwallet')).toBeVisible(); + expect(canvas.getByTestId('cmp-tron-rpc')).toBeVisible(); + expect(canvas.getByTestId('cmp-tron-rpc-pro')).toBeVisible(); + expect(canvas.getByTestId('cmp-tron-tronwallet')).toBeVisible(); + expect(canvas.getByTestId('cmp-bitcoin-mempool')).toBeVisible(); + expect(canvas.getByTestId('cmp-bitcoin-btcwallet')).toBeVisible(); + expect(canvas.getByTestId('cmp-binance-rpc')).toBeVisible(); + expect(canvas.getByTestId('cmp-binance-bscwallet')).toBeVisible(); + }, + { timeout: 10_000 }, + ); + return; + } + + await expectAnyProviderPanelOk({ + canvas, + label: 'ethereum', + testIds: ['cmp-ethereum-blockscout', 'cmp-ethereum-ethwallet'], + }); + + await expectAnyProviderPanelOk({ + canvas, + label: 'tron', + testIds: ['cmp-tron-rpc', 'cmp-tron-rpc-pro', 'cmp-tron-tronwallet'], + }); + + await expectAnyProviderPanelOk({ + canvas, + label: 'bitcoin', + testIds: ['cmp-bitcoin-mempool', 'cmp-bitcoin-btcwallet'], + }); + + await expectAnyProviderPanelOk({ + canvas, + label: 'binance', + testIds: ['cmp-binance-rpc', 'cmp-binance-bscwallet'], + }); + }, +}; + +/** + * ProviderFallbackWarning 截图验证 Stories + * + * 覆盖四种主要链的各种场景: + * 1. 正常数据(supported: true) + * 2. Fallback 警告(supported: false) + */ + +// ==================== BFMeta 链 ==================== +export const BFMetaNormalData: Story = { + name: 'BFMeta - Normal Data', + args: { + chainId: 'bfmeta', + chainName: 'BFMeta', + tokens: [ + { symbol: 'BFT', name: 'BFT', balance: '1234.56789012', decimals: 8, chain: 'bfmeta' }, + { symbol: 'USDT', name: 'USDT', balance: '500.00', decimals: 8, chain: 'bfmeta' }, + ], + transactions: mockTransactions, + tokensSupported: true, + transactionsSupported: true, + }, +}; + +export const BFMetaFallbackWarning: Story = { + name: 'BFMeta - Fallback Warning', + args: { + chainId: 'bfmeta', + chainName: 'BFMeta', + tokens: [{ symbol: 'BFT', name: 'BFT', balance: '0', decimals: 8, chain: 'bfmeta' }], + transactions: [], + tokensSupported: false, + tokensFallbackReason: 'All 2 provider(s) failed. Last error: Connection timeout', + transactionsSupported: false, + transactionsFallbackReason: 'No provider implements getTransactionHistory', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getAllByTestId('provider-fallback-warning').length).toBeGreaterThanOrEqual(1); + }); + }, +}; + +// ==================== Ethereum 链 ==================== +export const EthereumNormalData: Story = { + name: 'Ethereum - Normal Data (23.68 ETH)', + args: { + chainId: 'ethereum', + chainName: 'Ethereum', + tokens: [ + { symbol: 'ETH', name: 'Ethereum', balance: '23.683156206881918', decimals: 18, chain: 'ethereum' }, + { symbol: 'USDC', name: 'USD Coin', balance: '1500.00', decimals: 6, chain: 'ethereum' }, + ], + transactions: mockEthereumTransactions, + tokensSupported: true, + transactionsSupported: true, + }, +}; + +export const EthereumFallbackWarning: Story = { + name: 'Ethereum - Fallback Warning', + args: { + chainId: 'ethereum', + chainName: 'Ethereum', + tokens: [{ symbol: 'ETH', name: 'Ethereum', balance: '0', decimals: 18, chain: 'ethereum' }], + transactions: [], + tokensSupported: false, + tokensFallbackReason: 'All providers failed: ethereum-rpc timeout, etherscan rate limited', + transactionsSupported: false, + transactionsFallbackReason: 'Blockscout API returned 503', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getAllByTestId('provider-fallback-warning').length).toBeGreaterThanOrEqual(1); + }); + }, +}; + +// ==================== BSC/Binance 链 ==================== +export const BinanceNormalData: Story = { + name: 'Binance - Normal Data (234.08 BNB)', + args: { + chainId: 'binance', + chainName: 'BNB Smart Chain', + tokens: [ + { symbol: 'BNB', name: 'BNB', balance: '234.084063038409', decimals: 18, chain: 'binance' }, + { symbol: 'BUSD', name: 'BUSD', balance: '2000.00', decimals: 18, chain: 'binance' }, + ], + transactions: mockBinanceTransactions, + tokensSupported: true, + transactionsSupported: true, + }, +}; + +export const BinanceFallbackWarning: Story = { + name: 'Binance - Fallback Warning', + args: { + chainId: 'binance', + chainName: 'BNB Smart Chain', + tokens: [{ symbol: 'BNB', name: 'BNB', balance: '0', decimals: 18, chain: 'binance' }], + transactions: [], + tokensSupported: false, + tokensFallbackReason: 'BSC RPC node unreachable', + transactionsSupported: false, + transactionsFallbackReason: 'BscScan API key exhausted', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getAllByTestId('provider-fallback-warning').length).toBeGreaterThanOrEqual(1); + }); + }, +}; + +// ==================== Tron 链 ==================== +export const TronNormalData: Story = { + name: 'Tron - Normal Data (163,377 TRX)', + args: { + chainId: 'tron', + chainName: 'Tron', + tokens: [ + { symbol: 'TRX', name: 'Tron', balance: '163377.648279', decimals: 6, chain: 'tron' }, + { symbol: 'USDT', name: 'Tether', balance: '10000.00', decimals: 6, chain: 'tron' }, + ], + transactions: mockTronTransactions, + tokensSupported: true, + transactionsSupported: true, + }, +}; + +export const TronFallbackWarning: Story = { + name: 'Tron - Fallback Warning', + args: { + chainId: 'tron', + chainName: 'Tron', + tokens: [{ symbol: 'TRX', name: 'Tron', balance: '0', decimals: 6, chain: 'tron' }], + transactions: [], + tokensSupported: false, + tokensFallbackReason: 'TronGrid API rate limit exceeded', + transactionsSupported: false, + transactionsFallbackReason: 'All Tron providers failed', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getAllByTestId('provider-fallback-warning').length).toBeGreaterThanOrEqual(1); + }); + }, +}; + +// ==================== 混合场景 ==================== +export const PartialFallback: Story = { + name: 'Partial Fallback - Tokens OK, Transactions Failed', + args: { + chainId: 'ethereum', + chainName: 'Ethereum', + tokens: [ + { symbol: 'ETH', name: 'Ethereum', balance: '5.5', decimals: 18, chain: 'ethereum' }, + ], + transactions: [], + tokensSupported: true, // 余额查询成功 + transactionsSupported: false, // 交易历史查询失败 + transactionsFallbackReason: 'Etherscan API key invalid', + }, +}; + +export const EmptyButSupported: Story = { + name: 'Empty Data - No Warning (Provider OK)', + args: { + chainId: 'ethereum', + chainName: 'Ethereum', + tokens: [], + transactions: [], + tokensSupported: true, // Provider 正常,只是没数据 + transactionsSupported: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + // 应该没有警告 + expect(canvas.queryAllByTestId('provider-fallback-warning')).toHaveLength(0); + }); + }, +}; diff --git a/src/hooks/use-send.web3.ts b/src/hooks/use-send.web3.ts index 70d9dde0e..579fccd23 100644 --- a/src/hooks/use-send.web3.ts +++ b/src/hooks/use-send.web3.ts @@ -8,7 +8,7 @@ import type { AssetInfo } from '@/types/asset' import type { ChainConfig } from '@/services/chain-config' import { Amount } from '@/types/amount' import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage' -import { getChainProvider } from '@/services/chain-adapter/providers' +import { getChainProvider, isSupported } from '@/services/chain-adapter/providers' import { mnemonicToSeedSync } from '@scure/bip39' export interface Web3FeeResult { @@ -37,18 +37,17 @@ export async function fetchWeb3Fee(chainConfig: ChainConfig, fromAddress: string export async function fetchWeb3Balance(chainConfig: ChainConfig, fromAddress: string): Promise { const chainProvider = getChainProvider(chainConfig.id) + const result = await chainProvider.getNativeBalance(fromAddress) - if (!chainProvider.supportsNativeBalance) { - throw new Error(`Chain ${chainConfig.id} does not support balance queries`) + if (!isSupported(result)) { + throw new Error(result.reason || `Chain ${chainConfig.id} balance query failed`) } - const balance = await chainProvider.getNativeBalance!(fromAddress) - return { - assetType: balance.symbol, + assetType: result.data.symbol, name: chainConfig.name, - amount: balance.amount, - decimals: balance.amount.decimals, + amount: result.data.amount, + decimals: result.data.amount.decimals, } } diff --git a/src/pages/address-transactions/index.test.tsx b/src/pages/address-transactions/index.test.tsx index 7899b9013..b127da6c4 100644 --- a/src/pages/address-transactions/index.test.tsx +++ b/src/pages/address-transactions/index.test.tsx @@ -64,17 +64,21 @@ vi.mock('@/stores', () => ({ useEnabledChains: () => mockEnabledChains, })) -// Mock ChainProvider -const mockGetTransactionHistory = vi.fn<(address: string, limit?: number) => Promise>() +// Mock ChainProvider - 现在 getTransactionHistory 返回 ProviderResult +const mockGetTransactionHistory = vi.fn() const mockSupportsTransactionHistory = vi.fn<() => boolean>() -vi.mock('@/services/chain-adapter/providers', () => ({ - getChainProvider: (chainId: string) => ({ - chainId, - supportsTransactionHistory: mockSupportsTransactionHistory(), - getTransactionHistory: mockSupportsTransactionHistory() ? mockGetTransactionHistory : undefined, - }), -})) +vi.mock('@/services/chain-adapter/providers', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getChainProvider: (chainId: string) => ({ + chainId, + supportsTransactionHistory: mockSupportsTransactionHistory(), + getTransactionHistory: mockGetTransactionHistory, + }), + } +}) function renderWithProviders(ui: React.ReactElement) { const queryClient = new QueryClient({ @@ -93,7 +97,8 @@ describe('AddressTransactionsPage', () => { beforeEach(() => { vi.clearAllMocks() mockSupportsTransactionHistory.mockReturnValue(true) - mockGetTransactionHistory.mockResolvedValue([]) + // 返回 ProviderResult 格式 + mockGetTransactionHistory.mockResolvedValue({ supported: true, data: [] }) }) it('renders page with chain selector and address input', () => { @@ -105,7 +110,7 @@ describe('AddressTransactionsPage', () => { }) it('shows empty state when no transactions found', async () => { - mockGetTransactionHistory.mockResolvedValue([]) + mockGetTransactionHistory.mockResolvedValue({ supported: true, data: [] }) renderWithProviders() @@ -140,7 +145,7 @@ describe('AddressTransactionsPage', () => { ], }, ] - mockGetTransactionHistory.mockResolvedValue(mockTxs) + mockGetTransactionHistory.mockResolvedValue({ supported: true, data: mockTxs }) renderWithProviders() @@ -155,8 +160,13 @@ describe('AddressTransactionsPage', () => { }) }) - it('shows explorer hint when chain does not support transaction history', async () => { - mockSupportsTransactionHistory.mockReturnValue(false) + it('shows explorer hint when provider returns not supported', async () => { + // Provider returns fallback result indicating not supported + mockGetTransactionHistory.mockResolvedValue({ + supported: false, + data: [], + fallbackReason: 'No provider implements getTransactionHistory' + }) renderWithProviders() @@ -173,7 +183,7 @@ describe('AddressTransactionsPage', () => { it('calls getTransactionHistory with correct parameters', async () => { const testAddress = '0x75a6F48BF634868b2980c97CcEf467A127597e08' - mockGetTransactionHistory.mockResolvedValue([]) + mockGetTransactionHistory.mockResolvedValue({ supported: true, data: [] }) renderWithProviders() diff --git a/src/pages/address-transactions/index.tsx b/src/pages/address-transactions/index.tsx index 545a5e3cc..42a93beed 100644 --- a/src/pages/address-transactions/index.tsx +++ b/src/pages/address-transactions/index.tsx @@ -9,7 +9,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Card, CardContent } from '@/components/ui/card' import { useEnabledChains } from '@/stores' import { useAddressTransactionsQuery } from '@/queries' -import { getChainProvider } from '@/services/chain-adapter/providers' import { IconSearch, IconExternalLink, IconArrowUpRight, IconArrowDownLeft, IconLoader2 } from '@tabler/icons-react' import type { Transaction } from '@/services/chain-adapter/providers/types' @@ -63,23 +62,16 @@ export function AddressTransactionsPage() { [enabledChains, selectedChain] ) - // 通过 ChainProvider 检查是否支持交易历史查询 - const supportsTransactionHistory = useMemo(() => { - if (!selectedChainConfig) return false - try { - const chainProvider = getChainProvider(selectedChain) - return chainProvider.supportsTransactionHistory - } catch { - return false - } - }, [selectedChain, selectedChainConfig]) - - const { data: transactions, isLoading, isError, refetch } = useAddressTransactionsQuery({ + const { data: txResult, isLoading, isError, refetch } = useAddressTransactionsQuery({ chainId: selectedChain, address: searchAddress, enabled: !!searchAddress, }) + // 从查询结果中提取数据 + const transactions = txResult?.transactions ?? [] + const supportsTransactionHistory = txResult?.supported ?? true + const explorerUrl = useMemo(() => { if (!selectedChainConfig?.explorer || !searchAddress.trim()) return null const { queryAddress, url } = selectedChainConfig.explorer diff --git a/src/queries/use-address-balance-query.ts b/src/queries/use-address-balance-query.ts index 1e27bacb6..10135ed31 100644 --- a/src/queries/use-address-balance-query.ts +++ b/src/queries/use-address-balance-query.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { getChainProvider, type Balance } from '@/services/chain-adapter/providers' +import { getChainProvider, type Balance, isSupported } from '@/services/chain-adapter/providers' export const addressBalanceKeys = { all: ['addressBalance'] as const, @@ -9,6 +9,8 @@ export const addressBalanceKeys = { export interface AddressBalanceResult { balance: Balance | null error: string | null + /** 是否成功查询(false 表示 fallback 到默认值) */ + supported: boolean } /** @@ -19,26 +21,16 @@ export function useAddressBalanceQuery(chainId: string, address: string, enabled queryKey: addressBalanceKeys.query(chainId, address), queryFn: async (): Promise => { if (!chainId || !address) { - return { balance: null, error: 'Missing chain or address' } + return { balance: null, error: 'Missing chain or address', supported: false } } - try { - const chainProvider = getChainProvider(chainId) - - if (!chainProvider.supportsNativeBalance) { - return { balance: null, error: `Chain ${chainId} does not support balance query` } - } - - const getBalance = chainProvider.getNativeBalance - if (!getBalance) { - return { balance: null, error: `No balance provider for chain: ${chainId}` } - } - - const balance = await getBalance(address) - return { balance, error: null } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { balance: null, error: message } + const chainProvider = getChainProvider(chainId) + const result = await chainProvider.getNativeBalance(address) + + if (isSupported(result)) { + return { balance: result.data, error: null, supported: true } + } else { + return { balance: result.data, error: result.reason, supported: false } } }, enabled: enabled && !!chainId && !!address, diff --git a/src/queries/use-address-transactions-query.ts b/src/queries/use-address-transactions-query.ts index 45d547c71..41de6a625 100644 --- a/src/queries/use-address-transactions-query.ts +++ b/src/queries/use-address-transactions-query.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { getChainProvider, type Transaction } from '@/services/chain-adapter/providers' +import { getChainProvider, type Transaction, isSupported } from '@/services/chain-adapter/providers' export const addressTransactionsQueryKeys = { all: ['addressTransactions'] as const, @@ -13,6 +13,13 @@ interface UseAddressTransactionsQueryOptions { enabled?: boolean } +export interface AddressTransactionsResult { + transactions: Transaction[] + /** 是否成功查询(false 表示 fallback 到默认值) */ + supported: boolean + fallbackReason?: string +} + export function useAddressTransactionsQuery({ chainId, address, @@ -21,20 +28,19 @@ export function useAddressTransactionsQuery({ }: UseAddressTransactionsQueryOptions) { return useQuery({ queryKey: addressTransactionsQueryKeys.address(chainId, address), - queryFn: async (): Promise => { - if (!chainId || !address) return [] + queryFn: async (): Promise => { + if (!chainId || !address) { + return { transactions: [], supported: false, fallbackReason: 'Missing chain or address' } + } const chainProvider = getChainProvider(chainId) + const result = await chainProvider.getTransactionHistory(address, limit) - if (!chainProvider.supportsTransactionHistory) { - console.warn(`[useAddressTransactionsQuery] Chain ${chainId} does not support transaction history`) - return [] + if (isSupported(result)) { + return { transactions: result.data, supported: true } + } else { + return { transactions: result.data, supported: false, fallbackReason: result.reason } } - - const getHistory = chainProvider.getTransactionHistory - if (!getHistory) return [] - - return getHistory(address, limit) }, enabled: enabled && !!chainId && !!address.trim(), staleTime: 30 * 1000, diff --git a/src/services/chain-adapter/providers/__tests__/blockscout-balance.test.ts b/src/services/chain-adapter/providers/__tests__/blockscout-balance.test.ts new file mode 100644 index 000000000..21d2e12b8 --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/blockscout-balance.test.ts @@ -0,0 +1,78 @@ +/** + * Blockscout/Etherscan Provider Balance Support Test + * + * 验证 blockscout-v1 等 scan 类 provider 是否支持余额查询 + */ + +import { describe, it, expect } from 'vitest' +import { createChainProvider } from '../index' +import { isSupported } from '../types' +import { chainConfigStore } from '@/stores/chain-config' +import type { ChainConfig } from '@/services/chain-config' + +const BLOCKSCOUT_TEST_CONFIG: ChainConfig = { + id: 'ethereum-blockscout-test', + version: '1.0', + chainKind: 'evm', + name: 'Ethereum (blockscout test)', + symbol: 'ETH', + icon: '', + decimals: 18, + enabled: true, + source: 'manual', + apis: [{ type: 'blockscout-v1', endpoint: 'https://eth.blockscout.com/api' }], +} + +describe('Blockscout/Etherscan Provider Balance Support', () => { + it('blockscout-v1 should support getNativeBalance or getTokenBalances', () => { + // 注入测试配置 + chainConfigStore.setState((prev) => ({ + ...prev, + snapshot: { + ...prev.snapshot!, + configs: [BLOCKSCOUT_TEST_CONFIG], + }, + })) + + const provider = createChainProvider('ethereum-blockscout-test') + + // 至少要支持其中之一 + const hasBalanceSupport = provider.supportsNativeBalance || provider.supportsTokenBalances + + expect(hasBalanceSupport).toBe(true) + }) + + it('blockscout-v1 should return balance data (with working getNativeBalance call)', async () => { + chainConfigStore.setState((prev) => ({ + ...prev, + snapshot: { + ...prev.snapshot!, + configs: [BLOCKSCOUT_TEST_CONFIG], + }, + })) + + const provider = createChainProvider('ethereum-blockscout-test') + // Test address + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + + // 查询余额 + const balanceResult = await provider.getNativeBalance(address) + + // 应该返回 supported 结果(即使网络请求在测试环境下可能失败) + expect(balanceResult).toBeDefined() + expect(balanceResult.data).toBeDefined() + expect(balanceResult.data.symbol).toBe('ETH') + expect(balanceResult.data.amount).toBeDefined() + expect(balanceResult.data.amount.decimals).toBe(18) + + // 余额应该 >= 0 + const value = balanceResult.data.amount.toNumber() + expect(typeof value).toBe('number') + expect(value).toBeGreaterThanOrEqual(0) + + // 查询 token 列表 + const tokensResult = await provider.getTokenBalances(address) + expect(tokensResult).toBeDefined() + expect(Array.isArray(tokensResult.data)).toBe(true) + }, 30_000) +}) diff --git a/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts b/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts index 651d383e5..5db8a339b 100644 --- a/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts +++ b/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ChainProvider } from '../chain-provider' import type { ApiProvider, Balance, Transaction } from '../types' +import { isSupported } from '../types' import { Amount } from '@/types/amount' import { InvalidDataError } from '../errors' @@ -65,11 +66,10 @@ describe('ChainProvider', () => { }) const chainProvider = new ChainProvider('test', [provider]) - const getBalance = chainProvider.getNativeBalance - expect(getBalance).toBeDefined() + const result = await chainProvider.getNativeBalance('0x123') - const result = await getBalance!('0x123') - expect(result).toEqual(mockBalance) + expect(isSupported(result)).toBe(true) + expect(result.data).toEqual(mockBalance) expect(provider.getNativeBalance).toHaveBeenCalledWith('0x123') }) @@ -98,11 +98,10 @@ describe('ChainProvider', () => { }) const chainProvider = new ChainProvider('test', [provider]) - const getHistory = chainProvider.getTransactionHistory - expect(getHistory).toBeDefined() + const result = await chainProvider.getTransactionHistory('0x123', 10) - const result = await getHistory!('0x123', 10) - expect(result).toEqual(mockTxs) + expect(isSupported(result)).toBe(true) + expect(result.data).toEqual(mockTxs) expect(provider.getTransactionHistory).toHaveBeenCalledWith('0x123', 10) }) @@ -121,14 +120,15 @@ describe('ChainProvider', () => { }) const chainProvider = new ChainProvider('test', [p1, p2]) - const result = await chainProvider.getNativeBalance!('0x123') + const result = await chainProvider.getNativeBalance('0x123') - expect(result).toEqual(mockBalance) + expect(isSupported(result)).toBe(true) + expect(result.data).toEqual(mockBalance) expect(p1.getNativeBalance).toHaveBeenCalledTimes(1) expect(p2.getNativeBalance).toHaveBeenCalledTimes(1) }) - it('returns safe defaults for read methods when all providers fail', async () => { + it('returns fallback result when all providers fail', async () => { const p1 = createMockProvider({ type: 'p1', getTransactionHistory: vi.fn().mockRejectedValue(new Error('nope')), @@ -139,9 +139,14 @@ describe('ChainProvider', () => { }) const chainProvider = new ChainProvider('test', [p1, p2]) - const txs = await chainProvider.getTransactionHistory!('0xabc', 10) + const result = await chainProvider.getTransactionHistory('0xabc', 10) - expect(txs).toEqual([]) + expect(isSupported(result)).toBe(false) + expect(result.data).toEqual([]) + if (!isSupported(result)) { + expect(result.reason).toContain('All 2 provider(s) failed') + expect(result.reason).toContain('still nope') + } }) it('rethrows for non-read methods when all providers fail', async () => { @@ -158,12 +163,21 @@ describe('ChainProvider', () => { })).rejects.toThrow('fee fail') }) - it('returns undefined for unimplemented methods', () => { + it('returns fallback result when no provider implements the method', async () => { const provider = createMockProvider() const chainProvider = new ChainProvider('test', [provider]) - expect(chainProvider.getNativeBalance).toBeUndefined() - expect(chainProvider.getTransactionHistory).toBeUndefined() + const balanceResult = await chainProvider.getNativeBalance('0x123') + const txResult = await chainProvider.getTransactionHistory('0x123', 10) + + expect(isSupported(balanceResult)).toBe(false) + expect(isSupported(txResult)).toBe(false) + if (!isSupported(balanceResult)) { + expect(balanceResult.reason).toContain('No provider implements') + } + if (!isSupported(txResult)) { + expect(txResult.reason).toContain('No provider implements') + } }) }) @@ -193,18 +207,25 @@ describe('ChainProvider', () => { }) const chainProvider = new ChainProvider('test', [provider]) - const result = await chainProvider.getTransactionHistory!('0x123', 10) - expect(result).toEqual([valid]) + const result = await chainProvider.getTransactionHistory('0x123', 10) + // 现在返回 ProviderResult 格式 + expect(isSupported(result)).toBe(true) + if (isSupported(result)) { + expect(result.data).toEqual([valid]) + } expect(warnSpy).toHaveBeenCalled() }) - it('throws InvalidDataError when getTransaction returns invalid payload', async () => { + it('returns fallback result when getTransaction returns invalid payload', async () => { const provider = createMockProvider({ getTransaction: vi.fn().mockResolvedValue({ bad: true }), }) const chainProvider = new ChainProvider('test', [provider]) - await expect(chainProvider.getTransaction!('0xabc')).rejects.toBeInstanceOf(InvalidDataError) + // 现在 getTransaction 返回 ProviderResult 而不是抛出异常 + const result = await chainProvider.getTransaction('0xabc') + expect(result.supported).toBe(false) + expect(result.data).toBeNull() }) }) diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts index 2db251e26..e8e24a9d9 100644 --- a/src/services/chain-adapter/providers/chain-provider.ts +++ b/src/services/chain-adapter/providers/chain-provider.ts @@ -2,6 +2,8 @@ * Chain Provider * * 聚合多个 ApiProvider,通过能力发现动态代理方法调用。 + * + * 查询方法返回 ProviderResult,区分"查询成功"与"fallback到默认值"。 */ import type { @@ -15,21 +17,14 @@ import type { TransferParams, UnsignedTransaction, SignedTransaction, + ProviderResult, } from './types' +import { createSupportedResult, createFallbackResult } from './types' import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' const SYNC_METHODS = new Set(['isValidAddress', 'normalizeAddress']) -const READ_METHODS = new Set([ - 'getNativeBalance', - 'getTokenBalances', - 'getTransactionHistory', - 'getTransaction', - 'getTransactionStatus', - 'getBlockHeight', -]) - import { TransactionSchema } from './types' import { InvalidDataError } from './errors' @@ -50,7 +45,7 @@ export class ChainProvider { } /** - * 查找实现了某方法的 Provider,返回自动 fallback 的方法 + * 查找实现了某方法的 Provider,返回自动 fallback 的方法(用于非查询方法) */ private getMethod(method: K): ApiProvider[K] | undefined { const candidates = this.providers.filter((p) => typeof p[method] === 'function') @@ -77,45 +72,28 @@ export class ChainProvider { } } - if (READ_METHODS.has(method)) { - return this.getReadDefault(method) - } - throw lastError instanceof Error ? lastError : new Error('All providers failed') }) as ApiProvider[K] return fn } - private getReadDefault(method: ApiProviderMethod): unknown { - switch (method) { - case 'getNativeBalance': { - const decimals = chainConfigService.getDecimals(this.chainId) - const symbol = chainConfigService.getSymbol(this.chainId) - const balance: Balance = { - amount: Amount.zero(decimals, symbol), - symbol, - } - return balance - } - case 'getTokenBalances': - return [] as TokenBalance[] - case 'getTransactionHistory': - return [] as Transaction[] - case 'getTransaction': - return null as Transaction | null - case 'getTransactionStatus': { - const status: TransactionStatus = { - status: 'pending', - confirmations: 0, - requiredConfirmations: 1, - } - return status - } - case 'getBlockHeight': - return 0n - default: - return null + // ===== 查询方法的默认值 ===== + + private getDefaultBalance(): Balance { + const decimals = chainConfigService.getDecimals(this.chainId) + const symbol = chainConfigService.getSymbol(this.chainId) + return { + amount: Amount.zero(decimals, symbol), + symbol, + } + } + + private getDefaultTransactionStatus(): TransactionStatus { + return { + status: 'pending', + confirmations: 0, + requiredConfirmations: 1, } } @@ -174,90 +152,240 @@ export class ChainProvider { return this.supports('isValidAddress') } - // ===== 代理方法:查询 ===== + // ===== 查询方法(返回 ProviderResult) ===== - get getNativeBalance(): ((address: string) => Promise) | undefined { - return this.getMethod('getNativeBalance') - } + /** + * 查询原生代币余额 + * @returns ProviderResult - supported: true 表示查询成功,false 表示 fallback 到默认值 + */ + async getNativeBalance(address: string): Promise> { + const candidates = this.providers.filter(p => typeof p.getNativeBalance === 'function') + + if (candidates.length === 0) { + return createFallbackResult( + this.getDefaultBalance(), + 'No provider implements getNativeBalance' + ) + } - get getTokenBalances(): ((address: string) => Promise) | undefined { - return this.getMethod('getTokenBalances') - } + let lastError: unknown = null + for (const provider of candidates) { + try { + const balance = await provider.getNativeBalance!(address) + return createSupportedResult(balance) + } catch (error) { + lastError = error + } + } - get getTransactionHistory(): ((address: string, limit?: number) => Promise) | undefined { - const getHistory = this.getMethod('getTransactionHistory') - if (!getHistory) return undefined + const errorMsg = lastError instanceof Error ? lastError.message : 'Unknown error' + return createFallbackResult( + this.getDefaultBalance(), + `All ${candidates.length} provider(s) failed. Last error: ${errorMsg}` + ) + } - return async (address: string, limit?: number) => { - const raw: unknown = await (getHistory as unknown as (address: string, limit?: number) => Promise)( - address, - limit, + /** + * 查询所有代币余额 + * @returns ProviderResult - supported: true 表示查询成功,false 表示 fallback 到默认值 + */ + async getTokenBalances(address: string): Promise> { + const candidates = this.providers.filter(p => typeof p.getTokenBalances === 'function') + + if (candidates.length === 0) { + return createFallbackResult( + [], + 'No provider implements getTokenBalances' ) + } - if (!Array.isArray(raw)) { - console.warn('[ChainProvider] Invalid transaction history payload (not array)', { - chainId: this.chainId, - method: 'getTransactionHistory', - }) - return [] + let lastError: unknown = null + for (const provider of candidates) { + try { + const tokens = await provider.getTokenBalances!(address) + return createSupportedResult(tokens) + } catch (error) { + lastError = error } + } + + const errorMsg = lastError instanceof Error ? lastError.message : 'Unknown error' + return createFallbackResult( + [], + `All ${candidates.length} provider(s) failed. Last error: ${errorMsg}` + ) + } + + /** + * 查询交易历史(带数据验证) + * @returns ProviderResult - supported: true 表示查询成功,false 表示 fallback 到默认值 + */ + async getTransactionHistory(address: string, limit = 20): Promise> { + const candidates = this.providers.filter(p => typeof p.getTransactionHistory === 'function') + + if (candidates.length === 0) { + return createFallbackResult( + [], + 'No provider implements getTransactionHistory' + ) + } - const parsed: Transaction[] = [] - let invalidCount = 0 - let firstIssue: unknown = null - - for (const item of raw) { - const result = TransactionSchema.safeParse(item) - if (result.success) { - parsed.push(result.data) - } else { - invalidCount += 1 - if (!firstIssue) firstIssue = result.error.issues[0] + let lastError: unknown = null + for (const provider of candidates) { + try { + const raw: unknown = await provider.getTransactionHistory!(address, limit) + + // 数据验证 + if (!Array.isArray(raw)) { + console.warn('[ChainProvider] Invalid transaction history payload (not array)', { + chainId: this.chainId, + method: 'getTransactionHistory', + }) + continue } - } - if (invalidCount > 0) { - console.warn('[ChainProvider] Dropped invalid transactions from history', { - chainId: this.chainId, - method: 'getTransactionHistory', - invalidCount, - totalCount: raw.length, - firstIssue, - }) - } + const parsed: Transaction[] = [] + let invalidCount = 0 + let firstIssue: unknown = null + + for (const item of raw) { + const result = TransactionSchema.safeParse(item) + if (result.success) { + parsed.push(result.data) + } else { + invalidCount += 1 + if (!firstIssue) firstIssue = result.error.issues[0] + } + } + + if (invalidCount > 0) { + console.warn('[ChainProvider] Dropped invalid transactions from history', { + chainId: this.chainId, + method: 'getTransactionHistory', + invalidCount, + totalCount: raw.length, + firstIssue, + }) + } - return parsed + return createSupportedResult(parsed) + } catch (error) { + lastError = error + } } + + const errorMsg = lastError instanceof Error ? lastError.message : 'Unknown error' + return createFallbackResult( + [], + `All ${candidates.length} provider(s) failed. Last error: ${errorMsg}` + ) } - get getTransaction(): ((hash: string) => Promise) | undefined { - const getTransaction = this.getMethod('getTransaction') - if (!getTransaction) return undefined + /** + * 查询单笔交易(带数据验证) + * @returns ProviderResult - supported: true 表示查询成功,false 表示 fallback 到默认值 + */ + async getTransaction(hash: string): Promise> { + const candidates = this.providers.filter(p => typeof p.getTransaction === 'function') + + if (candidates.length === 0) { + return createFallbackResult( + null, + 'No provider implements getTransaction' + ) + } + + let lastError: unknown = null + for (const provider of candidates) { + try { + const raw: unknown = await provider.getTransaction!(hash) + if (raw === null || raw === undefined) { + return createSupportedResult(null) + } - return async (hash: string) => { - const raw: unknown = await (getTransaction as unknown as (hash: string) => Promise)(hash) - if (raw === null || raw === undefined) return null + const parsed = TransactionSchema.safeParse(raw) + if (!parsed.success) { + throw new InvalidDataError({ + source: 'provider', + chainId: this.chainId, + method: 'getTransaction', + issues: parsed.error.issues, + }) + } - const parsed = TransactionSchema.safeParse(raw) - if (!parsed.success) { - throw new InvalidDataError({ - source: 'provider', - chainId: this.chainId, - method: 'getTransaction', - issues: parsed.error.issues, - }) + return createSupportedResult(parsed.data) + } catch (error) { + lastError = error } - - return parsed.data } + + const errorMsg = lastError instanceof Error ? lastError.message : 'Unknown error' + return createFallbackResult( + null, + `All ${candidates.length} provider(s) failed. Last error: ${errorMsg}` + ) } - get getTransactionStatus(): ((hash: string) => Promise) | undefined { - return this.getMethod('getTransactionStatus') + /** + * 获取交易状态 + * @returns ProviderResult - supported: true 表示查询成功,false 表示 fallback 到默认值 + */ + async getTransactionStatus(hash: string): Promise> { + const candidates = this.providers.filter(p => typeof p.getTransactionStatus === 'function') + + if (candidates.length === 0) { + return createFallbackResult( + this.getDefaultTransactionStatus(), + 'No provider implements getTransactionStatus' + ) + } + + let lastError: unknown = null + for (const provider of candidates) { + try { + const status = await provider.getTransactionStatus!(hash) + return createSupportedResult(status) + } catch (error) { + lastError = error + } + } + + const errorMsg = lastError instanceof Error ? lastError.message : 'Unknown error' + return createFallbackResult( + this.getDefaultTransactionStatus(), + `All ${candidates.length} provider(s) failed. Last error: ${errorMsg}` + ) } - get getBlockHeight(): (() => Promise) | undefined { - return this.getMethod('getBlockHeight') + /** + * 获取当前区块高度 + * @returns ProviderResult - supported: true 表示查询成功,false 表示 fallback 到默认值 + */ + async getBlockHeight(): Promise> { + const candidates = this.providers.filter(p => typeof p.getBlockHeight === 'function') + + if (candidates.length === 0) { + return createFallbackResult( + 0n, + 'No provider implements getBlockHeight' + ) + } + + let lastError: unknown = null + for (const provider of candidates) { + try { + const height = await provider.getBlockHeight!() + return createSupportedResult(height) + } catch (error) { + lastError = error + } + } + + const errorMsg = lastError instanceof Error ? lastError.message : 'Unknown error' + return createFallbackResult( + 0n, + `All ${candidates.length} provider(s) failed. Last error: ${errorMsg}` + ) } // ===== 代理方法:交易 ===== diff --git a/src/services/chain-adapter/providers/etherscan-provider.ts b/src/services/chain-adapter/providers/etherscan-provider.ts index d07caee2a..90b047f7c 100644 --- a/src/services/chain-adapter/providers/etherscan-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-provider.ts @@ -84,6 +84,54 @@ export class EtherscanProvider implements ApiProvider { return true } + get supportsNativeBalance() { + return true + } + + async getNativeBalance(address: string): Promise { + const params = new URLSearchParams({ + module: 'account', + action: 'balance', + address, + }) + + // Etherscan v2 统一 API 需要 chainid + if (this.endpoint.includes('etherscan.io/v2')) { + params.set('chainid', this.evmChainId.toString()) + } + + const apiKey = this.config?.apiKey + if (apiKey && typeof apiKey === 'string') { + params.set('apikey', apiKey) + } + + const url = `${this.endpoint}?${params.toString()}` + const json: unknown = await fetchJson(url, undefined, { + cacheKey: `balance:${this.chainId}:${address}`, + ttlMs: 30_000, // 30s cache + tags: [`balance:${this.chainId}:${address}`], + }) + + const parsed = ApiResponseSchema.safeParse(json) + if (!parsed.success) { + throw new Error('Invalid API response for balance') + } + + if (parsed.data.status !== '1') { + throw new Error(`API error: ${parsed.data.message}`) + } + + const result = parsed.data.result + if (typeof result !== 'string') { + throw new Error('Invalid balance result') + } + + return { + amount: Amount.fromRaw(result, this.decimals, this.symbol), + symbol: this.symbol, + } + } + async getTransactionHistory(address: string, limit = 20): Promise { const fetchLimit = limit * FETCH_MULTIPLIER diff --git a/src/services/chain-adapter/providers/types.ts b/src/services/chain-adapter/providers/types.ts index ba1603f2e..bbab9e1b3 100644 --- a/src/services/chain-adapter/providers/types.ts +++ b/src/services/chain-adapter/providers/types.ts @@ -8,6 +8,15 @@ import type { Amount } from '@/types/amount' import type { ParsedApiEntry } from '@/services/chain-config' +// 导出 ProviderResult 类型 +export { + type ProviderResult, + createSupportedResult, + createFallbackResult, + isSupported, + unwrapResult, +} from '@/types/provider-result' + // 从 transaction-schema 导出 Transaction 相关类型 export { type Transaction, diff --git a/src/services/transaction/web.test.ts b/src/services/transaction/web.test.ts index 86f9f0342..7139c43a3 100644 --- a/src/services/transaction/web.test.ts +++ b/src/services/transaction/web.test.ts @@ -28,9 +28,13 @@ vi.mock('@/services/chain-adapter', () => ({ createBioforestAdapter: mocks.mockCreateBioforestAdapter, })) -vi.mock('@/services/chain-adapter/providers', () => ({ - getChainProvider: mocks.mockGetChainProvider, -})) +vi.mock('@/services/chain-adapter/providers', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getChainProvider: mocks.mockGetChainProvider, + } +}) import { transactionService } from './web' @@ -65,26 +69,30 @@ describe('transactionService(web)', () => { getTransactionHistory: mocks.mockGetTransactionHistory, }) - mocks.mockGetTransactionHistory.mockResolvedValue([ - { - hash: '0xdeadbeef', - from: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa', - to: '0xBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBb', - timestamp: 1_700_000_000_000, - status: 'confirmed', - blockNumber: 123n, - action: 'transfer', - direction: 'out', - assets: [ - { - assetType: 'native', - value: '1000000000000000000', - symbol: 'ETH', - decimals: 18, - }, - ], - }, - ]) + // 返回 ProviderResult 格式(使用新的 Transaction 结构) + mocks.mockGetTransactionHistory.mockResolvedValue({ + supported: true, + data: [ + { + hash: '0xdeadbeef', + from: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa', + to: '0xBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBb', + timestamp: 1_700_000_000_000, + status: 'confirmed', + blockNumber: 123n, + action: 'transfer', + direction: 'out', + assets: [ + { + assetType: 'native', + value: '1000000000000000000', + symbol: 'ETH', + decimals: 18, + }, + ], + }, + ], + }) const records = await transactionService.getHistory({ walletId: 'w1', diff --git a/src/services/transaction/web.ts b/src/services/transaction/web.ts index 5c91066b4..a50888ebc 100644 --- a/src/services/transaction/web.ts +++ b/src/services/transaction/web.ts @@ -5,7 +5,7 @@ import { transactionServiceMeta, type TransactionFilter, type TransactionRecord, type TransactionStatus, type TransactionType } from './types' import { walletStorageService } from '@/services/wallet-storage' import { initialize as initializeChainConfigs, getEnabledChains, getChainById, type ChainConfig } from '@/services/chain-config' -import { getChainProvider, type Transaction as ProviderTransaction, InvalidDataError } from '@/services/chain-adapter/providers' +import { getChainProvider, type Transaction as ProviderTransaction, isSupported, InvalidDataError } from '@/services/chain-adapter/providers' import { Amount } from '@/types/amount' import { mapActionToTransactionType } from '@/components/transaction/transaction-meta' @@ -36,11 +36,11 @@ async function fetchHistory(walletId: string, filter?: TransactionFilterInput): try { const provider = getChainProvider(addressInfo.chain) - if (!provider.supportsTransactionHistory || !provider.getTransactionHistory) return [] - return provider.getTransactionHistory(addressInfo.address, 50).then((list) => - list.map((tx) => mapProviderTransaction(tx, config)) - ) + return provider.getTransactionHistory(addressInfo.address, 50).then((result) => { + if (!isSupported(result)) return [] + return result.data.map((tx) => mapProviderTransaction(tx, config)) + }) } catch { return [] } diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index c1ba87929..645f925e9 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -489,20 +489,22 @@ export const walletActions = { try { // 动态导入避免循环依赖 - const { getChainProvider } = await import('@/services/chain-adapter/providers') + const { getChainProvider, isSupported } = await import('@/services/chain-adapter/providers') const chainProvider = getChainProvider(chain) - if (!chainProvider.supportsNativeBalance) { + // 获取原生代币余额 + const result = await chainProvider.getNativeBalance(chainAddress.address) + + if (!isSupported(result)) { // This is expected during initialization or for unsupported chains // Only log in development to avoid console noise if (import.meta.env.DEV) { - console.debug(`[refreshBalance] Skipping chain without balance support: ${chain}`) + console.debug(`[refreshBalance] Balance query failed for ${chain}: ${result.reason}`) } return } - // 获取原生代币余额 - const balance = await chainProvider.getNativeBalance!(chainAddress.address) + const balance = result.data // 转换为 Token 格式 (目前只支持原生代币) const tokens: Token[] = [{ diff --git a/src/types/provider-result.ts b/src/types/provider-result.ts new file mode 100644 index 000000000..84c57c97d --- /dev/null +++ b/src/types/provider-result.ts @@ -0,0 +1,42 @@ +/** + * Provider 查询结果类型 + * + * 用于区分"查询成功但数据为空"与"所有 providers 都失败(fallback 到默认值)" + */ + +/** + * Provider 查询结果 + * - supported: true → 至少一个 provider 成功返回数据 + * - supported: false → 所有 providers 失败,data 是 fallback 默认值 + */ +export type ProviderResult = + | { supported: true; data: T } + | { supported: false; data: T; reason: string } + +/** + * 创建成功结果 + */ +export function createSupportedResult(data: T): ProviderResult { + return { supported: true, data } +} + +/** + * 创建 fallback 结果 + */ +export function createFallbackResult(data: T, reason: string): ProviderResult { + return { supported: false, data, reason } +} + +/** + * 类型守卫:检查是否查询成功 + */ +export function isSupported(result: ProviderResult): result is { supported: true; data: T } { + return result.supported === true +} + +/** + * 解包 ProviderResult,返回 data(无论是否 supported) + */ +export function unwrapResult(result: ProviderResult): T { + return result.data +} diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-bfmeta-fallback-warning.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-bfmeta-fallback-warning.png new file mode 100644 index 000000000..b04d79097 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-bfmeta-fallback-warning.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-bfmeta-normal-assets.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-bfmeta-normal-assets.png new file mode 100644 index 000000000..9fff4e996 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-bfmeta-normal-assets.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-bfmeta-normal-transactions.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-bfmeta-normal-transactions.png new file mode 100644 index 000000000..ef8a568ee Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-bfmeta-normal-transactions.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-binance-fallback-warning.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-binance-fallback-warning.png new file mode 100644 index 000000000..354e2c416 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-binance-fallback-warning.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-binance-normal-assets.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-binance-normal-assets.png new file mode 100644 index 000000000..3d5b872fe Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-binance-normal-assets.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-binance-normal-transactions.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-binance-normal-transactions.png new file mode 100644 index 000000000..a1177b793 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-binance-normal-transactions.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-empty-no-warning.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-empty-no-warning.png new file mode 100644 index 000000000..5709af386 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-empty-no-warning.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-ethereum-fallback-warning.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-ethereum-fallback-warning.png new file mode 100644 index 000000000..184f4dc73 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-ethereum-fallback-warning.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-ethereum-normal-assets.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-ethereum-normal-assets.png new file mode 100644 index 000000000..b9668924a Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-ethereum-normal-assets.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-ethereum-normal-transactions.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-ethereum-normal-transactions.png new file mode 100644 index 000000000..852a153c1 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-ethereum-normal-transactions.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-partial-fallback-transactions-tab.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-partial-fallback-transactions-tab.png new file mode 100644 index 000000000..df896cc19 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-partial-fallback-transactions-tab.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-tron-fallback-warning.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-tron-fallback-warning.png new file mode 100644 index 000000000..f50fe24a2 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-tron-fallback-warning.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-tron-normal-assets.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-tron-normal-assets.png new file mode 100644 index 000000000..29a80b11f Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-tron-normal-assets.png differ diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-tron-normal-transactions.png b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-tron-normal-transactions.png new file mode 100644 index 000000000..4011ea0a7 Binary files /dev/null and b/storybook-e2e/__screenshots__/Mobile-Chrome/provider-fallback-warning.spec.ts-tron-normal-transactions.png differ diff --git a/storybook-e2e/provider-fallback-warning.spec.ts b/storybook-e2e/provider-fallback-warning.spec.ts new file mode 100644 index 000000000..e3a2471d2 --- /dev/null +++ b/storybook-e2e/provider-fallback-warning.spec.ts @@ -0,0 +1,237 @@ +/** + * ProviderFallbackWarning Storybook E2E 截图测试 + * + * 验证四条链(BFMeta, ETH, BSC, Tron)的场景: + * 1. 资产标签页 - 正常数据(supported: true)- 无警告 + * 2. 资产标签页 - Fallback 警告(supported: false)- 黄色警告 + * 3. 交易标签页 - 正常数据 - 有交易记录 + * 4. 交易标签页 - Fallback 警告 - 显示警告 + * + * 运行: npx playwright test --config=playwright.storybook.config.ts storybook-e2e/provider-fallback-warning.spec.ts + */ +import { expect, test } from '@playwright/test' +import { createReadStream } from 'node:fs' +import { stat, mkdir } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const storybookStaticDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../storybook-static' +) + +function getContentType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase() + switch (ext) { + case '.html': return 'text/html; charset=utf-8' + case '.js': return 'text/javascript; charset=utf-8' + case '.css': return 'text/css; charset=utf-8' + case '.json': return 'application/json; charset=utf-8' + case '.svg': return 'image/svg+xml' + case '.png': return 'image/png' + case '.woff': return 'font/woff' + case '.woff2': return 'font/woff2' + default: return 'application/octet-stream' + } +} + +async function startStorybookServer(): Promise<{ server: Server; baseUrl: string }> { + await stat(path.join(storybookStaticDir, 'index.html')) + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost') + const rawPath = decodeURIComponent(url.pathname) + const relativePath = rawPath === '/' ? '/index.html' : rawPath + const filePath = path.normalize(path.join(storybookStaticDir, relativePath)) + + if (!filePath.startsWith(storybookStaticDir)) { + res.statusCode = 403 + res.end('Forbidden') + return + } + + try { + const stats = await stat(filePath) + if (!stats.isFile()) { + res.statusCode = 404 + res.end('Not found') + return + } + res.statusCode = 200 + res.setHeader('Content-Type', getContentType(filePath)) + createReadStream(filePath).pipe(res) + } catch { + res.statusCode = 404 + res.end('Not found') + } + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Unexpected server address') + + return { server, baseUrl: `http://127.0.0.1:${address.port}` } +} + +function storyIframeUrl(baseUrl: string, storyId: string): string { + return `${baseUrl}/iframe.html?viewMode=story&id=${storyId}` +} + +test.describe('ProviderFallbackWarning Screenshots', () => { + let server: Server + let baseUrl: string + + test.beforeAll(async () => { + const started = await startStorybookServer() + server = started.server + baseUrl = started.baseUrl + }) + + test.afterAll(async () => { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + }) + + // ==================== BFMeta 链 ==================== + test('BFMeta - Normal Data (Assets)', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--bf-meta-normal-data')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + // 验证没有警告 + const warnings = page.locator('[data-testid="provider-fallback-warning"]') + await expect(warnings).toHaveCount(0) + + await expect(page).toHaveScreenshot('bfmeta-normal-assets.png') + }) + + test('BFMeta - Normal Data (Transactions)', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--bf-meta-normal-data')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + // 切换到交易标签页 + const txTab = page.getByTestId('wallet-address-portfolio-tabs-tab-history') + await txTab.click() + await page.waitForTimeout(300) + + await expect(page).toHaveScreenshot('bfmeta-normal-transactions.png') + }) + + test('BFMeta - Fallback Warning', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--bf-meta-fallback-warning')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + // 验证警告出现 + const warnings = page.locator('[data-testid="provider-fallback-warning"]') + await expect(warnings.first()).toBeVisible() + + await expect(page).toHaveScreenshot('bfmeta-fallback-warning.png') + }) + + // ==================== Ethereum 链 ==================== + test('Ethereum - Normal Data (Assets)', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--ethereum-normal-data')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + await expect(page.locator('[data-testid="provider-fallback-warning"]')).toHaveCount(0) + await expect(page).toHaveScreenshot('ethereum-normal-assets.png') + }) + + test('Ethereum - Normal Data (Transactions)', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--ethereum-normal-data')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + const txTab = page.getByTestId('wallet-address-portfolio-tabs-tab-history') + await txTab.click() + await page.waitForTimeout(300) + + await expect(page).toHaveScreenshot('ethereum-normal-transactions.png') + }) + + test('Ethereum - Fallback Warning', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--ethereum-fallback-warning')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + await expect(page.locator('[data-testid="provider-fallback-warning"]').first()).toBeVisible() + await expect(page).toHaveScreenshot('ethereum-fallback-warning.png') + }) + + // ==================== Binance/BSC 链 ==================== + test('Binance - Normal Data (Assets)', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--binance-normal-data')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + await expect(page.locator('[data-testid="provider-fallback-warning"]')).toHaveCount(0) + await expect(page).toHaveScreenshot('binance-normal-assets.png') + }) + + test('Binance - Normal Data (Transactions)', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--binance-normal-data')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + const txTab = page.getByTestId('wallet-address-portfolio-tabs-tab-history') + await txTab.click() + await page.waitForTimeout(300) + + await expect(page).toHaveScreenshot('binance-normal-transactions.png') + }) + + test('Binance - Fallback Warning', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--binance-fallback-warning')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + await expect(page.locator('[data-testid="provider-fallback-warning"]').first()).toBeVisible() + await expect(page).toHaveScreenshot('binance-fallback-warning.png') + }) + + // ==================== Tron 链 ==================== + test('Tron - Normal Data (Assets)', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--tron-normal-data')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + await expect(page.locator('[data-testid="provider-fallback-warning"]')).toHaveCount(0) + await expect(page).toHaveScreenshot('tron-normal-assets.png') + }) + + test('Tron - Normal Data (Transactions)', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--tron-normal-data')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + const txTab = page.getByTestId('wallet-address-portfolio-tabs-tab-history') + await txTab.click() + await page.waitForTimeout(300) + + await expect(page).toHaveScreenshot('tron-normal-transactions.png') + }) + + test('Tron - Fallback Warning', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--tron-fallback-warning')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + await expect(page.locator('[data-testid="provider-fallback-warning"]').first()).toBeVisible() + await expect(page).toHaveScreenshot('tron-fallback-warning.png') + }) + + // ==================== 混合场景 ==================== + test('Partial Fallback - Tokens OK, Transactions Failed', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--partial-fallback')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + // 切换到交易标签页查看警告 + const txTab = page.getByTestId('wallet-address-portfolio-tabs-tab-history') + await txTab.click() + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot('partial-fallback-transactions-tab.png') + }) + + test('Empty Data - No Warning (Provider OK)', async ({ page }) => { + await page.goto(storyIframeUrl(baseUrl, 'wallet-walletaddressportfolio--empty-but-supported')) + await page.waitForSelector('[data-testid="wallet-address-portfolio"]', { timeout: 10000 }) + + // 空数据但 Provider 正常时不应该有警告 + await expect(page.locator('[data-testid="provider-fallback-warning"]')).toHaveCount(0) + await expect(page).toHaveScreenshot('empty-no-warning.png') + }) +})