diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bfchainv2.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bfchainv2.png new file mode 100644 index 000000000..c1e550f33 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bfchainv2.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-binance.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-binance.png new file mode 100644 index 000000000..0bc99fba0 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-binance.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bitcoin.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bitcoin.png new file mode 100644 index 000000000..05f8ebd7b Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bitcoin.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-ethereum.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-ethereum.png new file mode 100644 index 000000000..0d4836cde Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-ethereum.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-real-fixtures.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-real-fixtures.png new file mode 100644 index 000000000..4468716eb Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-real-fixtures.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-tron.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-tron.png new file mode 100644 index 000000000..d0dae0a1f Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-tron.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-approve.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-approve.png new file mode 100644 index 000000000..d450bf412 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-approve.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bfchainv2.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bfchainv2.png new file mode 100644 index 000000000..dc11eef1c Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bfchainv2.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-binance.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-binance.png new file mode 100644 index 000000000..dc11eef1c Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-binance.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bitcoin.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bitcoin.png new file mode 100644 index 000000000..dc11eef1c Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bitcoin.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-ethereum.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-ethereum.png new file mode 100644 index 000000000..dc11eef1c Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-ethereum.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-interaction.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-interaction.png new file mode 100644 index 000000000..aef110198 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-interaction.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-swap.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-swap.png new file mode 100644 index 000000000..3b1f7e1bb Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-swap.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-tron.png b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-tron.png new file mode 100644 index 000000000..dc11eef1c Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-tron.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bfchainv2.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bfchainv2.png new file mode 100644 index 000000000..75370600d Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bfchainv2.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-binance.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-binance.png new file mode 100644 index 000000000..2754907e3 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-binance.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bitcoin.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bitcoin.png new file mode 100644 index 000000000..864adb097 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-bitcoin.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-ethereum.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-ethereum.png new file mode 100644 index 000000000..86a49a7df Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-ethereum.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-real-fixtures.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-real-fixtures.png new file mode 100644 index 000000000..13fe918cb Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-real-fixtures.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-tron.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-tron.png new file mode 100644 index 000000000..3fcbf04ce Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/history-tron.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-approve.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-approve.png new file mode 100644 index 000000000..79d800887 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-approve.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bfchainv2.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bfchainv2.png new file mode 100644 index 000000000..e5d13f6d7 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bfchainv2.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-binance.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-binance.png new file mode 100644 index 000000000..e5d13f6d7 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-binance.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bitcoin.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bitcoin.png new file mode 100644 index 000000000..e5d13f6d7 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-bitcoin.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-ethereum.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-ethereum.png new file mode 100644 index 000000000..e5d13f6d7 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-ethereum.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-interaction.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-interaction.png new file mode 100644 index 000000000..27a1056bc Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-interaction.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-swap.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-swap.png new file mode 100644 index 000000000..40fdbd25b Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-swap.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-tron.png b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-tron.png new file mode 100644 index 000000000..e5d13f6d7 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/transaction-history.real-fixtures.mock.spec.ts/transaction-detail-tron.png differ diff --git a/e2e/fixtures/transactions/real-transactions.v1.json b/e2e/fixtures/transactions/real-transactions.v1.json new file mode 100644 index 000000000..f79fadae3 --- /dev/null +++ b/e2e/fixtures/transactions/real-transactions.v1.json @@ -0,0 +1,226 @@ +{ + "version": 1, + "transactions": [ + { + "id": "ethereum--0x1111111111111111111111111111111111111111111111111111111111111111", + "type": "swap", + "status": "confirmed", + "amount": { "value": "100000000", "decimals": 6, "symbol": "USDT" }, + "symbol": "USDT", + "decimals": 6, + "address": "0x1111111254eeb25477b68fb85ed929f73a960582", + "timestamp": 1700000000000, + "hash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "chain": "ethereum", + "fee": { "value": "2000000000000000", "decimals": 18, "symbol": "ETH" }, + "blockNumber": 19000000, + "confirmations": 12, + "from": "0xc00eb08fef86e5f74b692813f31bb5957eaa045c", + "to": "0x1111111254eeb25477b68fb85ed929f73a960582", + "action": "swap", + "direction": "out", + "assets": [ + { + "assetType": "token", + "value": "100000000", + "symbol": "USDT", + "decimals": 6, + "contractAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "name": "Tether USD" + }, + { + "assetType": "native", + "value": "50000000000000000", + "symbol": "ETH", + "decimals": 18 + } + ], + "contract": { + "address": "0x1111111254eeb25477b68fb85ed929f73a960582", + "method": "swapExactTokensForETH", + "methodId": "0x38ed1739" + } + }, + { + "id": "ethereum--0x2222222222222222222222222222222222222222222222222222222222222222", + "type": "approve", + "status": "confirmed", + "amount": { "value": "1000000000", "decimals": 6, "symbol": "USDT" }, + "symbol": "USDT", + "decimals": 6, + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "timestamp": 1700000500000, + "hash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "chain": "ethereum", + "fee": { "value": "1500000000000000", "decimals": 18, "symbol": "ETH" }, + "blockNumber": 19000001, + "confirmations": 12, + "from": "0xc00eb08fef86e5f74b692813f31bb5957eaa045c", + "to": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "action": "approve", + "direction": "out", + "assets": [ + { + "assetType": "token", + "value": "1000000000", + "symbol": "USDT", + "decimals": 6, + "contractAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "name": "Tether USD" + } + ], + "contract": { + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "method": "approve", + "methodId": "0x095ea7b3" + } + }, + { + "id": "ethereum--0x3333333333333333333333333333333333333333333333333333333333333333", + "type": "interaction", + "status": "confirmed", + "amount": { "value": "0", "decimals": 18, "symbol": "ETH" }, + "symbol": "ETH", + "decimals": 18, + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "timestamp": 1700001000000, + "hash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "chain": "ethereum", + "fee": { "value": "1200000000000000", "decimals": 18, "symbol": "ETH" }, + "blockNumber": 19000002, + "confirmations": 12, + "from": "0xc00eb08fef86e5f74b692813f31bb5957eaa045c", + "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "action": "contract", + "direction": "out", + "contract": { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "method": "transfer", + "methodId": "0xa9059cbb" + } + }, + { + "id": "ethereum--0x4444444444444444444444444444444444444444444444444444444444444444", + "type": "send", + "status": "confirmed", + "amount": { "value": "100000000000000000", "decimals": 18, "symbol": "ETH" }, + "symbol": "ETH", + "decimals": 18, + "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + "timestamp": 1700001500000, + "hash": "0x4444444444444444444444444444444444444444444444444444444444444444", + "chain": "ethereum", + "fee": { "value": "2100000000000000", "decimals": 18, "symbol": "ETH" }, + "blockNumber": 19000003, + "confirmations": 12, + "from": "0xc00eb08fef86e5f74b692813f31bb5957eaa045c", + "to": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + "action": "transfer", + "direction": "out", + "assets": [ + { + "assetType": "native", + "value": "100000000000000000", + "symbol": "ETH", + "decimals": 18 + } + ] + }, + { + "id": "binance--0x5555555555555555555555555555555555555555555555555555555555555555", + "type": "approve", + "status": "confirmed", + "amount": { "value": "250000000000000000000", "decimals": 18, "symbol": "BUSD" }, + "symbol": "BUSD", + "decimals": 18, + "address": "0xe9e7cea3dedca5984780bafc599bd69add087d56", + "timestamp": 1700002000000, + "hash": "0x5555555555555555555555555555555555555555555555555555555555555555", + "chain": "binance", + "fee": { "value": "3000000000000000", "decimals": 18, "symbol": "BNB" }, + "blockNumber": 32000000, + "confirmations": 18, + "from": "0x71c7656ec7ab88b098defb751b7401b5f6d8976f", + "to": "0xe9e7cea3dedca5984780bafc599bd69add087d56", + "action": "approve", + "direction": "out", + "assets": [ + { + "assetType": "token", + "value": "250000000000000000000", + "symbol": "BUSD", + "decimals": 18, + "contractAddress": "0xe9e7cea3dedca5984780bafc599bd69add087d56", + "name": "Binance USD" + } + ], + "contract": { + "address": "0xe9e7cea3dedca5984780bafc599bd69add087d56", + "method": "approve", + "methodId": "0x095ea7b3" + } + }, + { + "id": "tron--7f6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a", + "type": "interaction", + "status": "confirmed", + "amount": { "value": "0", "decimals": 6, "symbol": "TRX" }, + "symbol": "TRX", + "decimals": 6, + "address": "TX9dQz9cJrG2JYc4bG4gqkz8oF6sVw1P2X", + "timestamp": 1700002500000, + "hash": "7f6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a", + "chain": "tron", + "fee": { "value": "10000", "decimals": 6, "symbol": "TRX" }, + "blockNumber": 56000000, + "confirmations": 25, + "from": "TQf2Gf5gX5dP8qP3bG9r2qkY1mZt8rKJ6u", + "to": "TX9dQz9cJrG2JYc4bG4gqkz8oF6sVw1P2X", + "action": "contract", + "direction": "out", + "contract": { + "address": "TX9dQz9cJrG2JYc4bG4gqkz8oF6sVw1P2X", + "method": "transfer", + "methodId": "0xa9059cbb" + } + }, + { + "id": "bitcoin--9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a", + "type": "send", + "status": "confirmed", + "amount": { "value": "250000", "decimals": 8, "symbol": "BTC" }, + "symbol": "BTC", + "decimals": 8, + "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "timestamp": 1700003000000, + "hash": "9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a", + "chain": "bitcoin", + "fee": { "value": "1500", "decimals": 8, "symbol": "BTC" }, + "blockNumber": 820000, + "confirmations": 6, + "from": "bc1qf7n7v0m3p7j9y2t5k0x8m7g0v9c5u7zv5l4q8a", + "to": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "action": "transfer", + "direction": "out" + }, + { + "id": "bfchainv2--0x6666666666666666666666666666666666666666666666666666666666666666", + "type": "send", + "status": "confirmed", + "amount": { "value": "123450000", "decimals": 8, "symbol": "BFC" }, + "symbol": "BFC", + "decimals": 8, + "address": "BF2uQh3nC7pW8c9tX1yZ2aB3cD4eF5gH6iJ7kL8mN", + "timestamp": 1700003500000, + "hash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "chain": "bfchainv2", + "fee": { "value": "100000", "decimals": 8, "symbol": "BFC" }, + "blockNumber": 1200000, + "confirmations": 10, + "from": "BF2aB3cD4eF5gH6iJ7kL8mN9pQ1rS2tU3vW4xY5z", + "to": "BF2uQh3nC7pW8c9tX1yZ2aB3cD4eF5gH6iJ7kL8mN", + "action": "transfer", + "direction": "out" + } + ] +} diff --git a/e2e/transaction-history.real-fixtures.mock.spec.ts b/e2e/transaction-history.real-fixtures.mock.spec.ts new file mode 100644 index 000000000..63f8273fe --- /dev/null +++ b/e2e/transaction-history.real-fixtures.mock.spec.ts @@ -0,0 +1,186 @@ +import { test, expect, type Page } from '@playwright/test' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const E2E_FIXTURE_STORAGE_KEY = 'bfm_e2e_transaction_fixtures_v1' + +type FixturePayload = + | { version?: number; transactions: Array<{ id: string; type: string; chain?: string }> } + | Array<{ id: string; type: string; chain?: string }> + +function loadFixturePayload(): { raw: string; transactions: Array<{ id: string; type: string; chain?: string }> } { + const e2eDir = path.dirname(fileURLToPath(import.meta.url)) + const defaultPath = path.join(e2eDir, 'fixtures/transactions/real-transactions.v1.json') + const fixturePath = process.env.TX_FIXTURE_PATH ? path.resolve(process.env.TX_FIXTURE_PATH) : defaultPath + + const raw = fs.readFileSync(fixturePath, 'utf8') + const parsed = JSON.parse(raw) as FixturePayload + const transactions = Array.isArray(parsed) ? parsed : parsed.transactions + return { raw, transactions } +} + +const FIXTURE = loadFixturePayload() + +const CHAIN_LABELS: Record = { + bfchainv2: 'BFChain V2', + ethereum: 'Ethereum', + bitcoin: 'Bitcoin', + tron: 'Tron', + binance: 'BNB Smart Chain', +} + +// 测试钱包数据(带交易历史) +const TEST_WALLET_DATA = { + wallets: [ + { + id: 'test-wallet-1', + name: '测试钱包', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + chain: 'ethereum', + chainAddresses: [ + { + chain: 'ethereum', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + tokens: [ + { symbol: 'ETH', balance: '1.5', decimals: 18 }, + ], + }, + { + chain: 'binance', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + tokens: [ + { symbol: 'BNB', balance: '2', decimals: 18 }, + ], + }, + { + chain: 'tron', + address: 'TQf2Gf5gX5dP8qP3bG9r2qkY1mZt8rKJ6u', + tokens: [ + { symbol: 'TRX', balance: '100', decimals: 6 }, + ], + }, + { + chain: 'bitcoin', + address: 'bc1qf7n7v0m3p7j9y2t5k0x8m7g0v9c5u7zv5l4q8a', + tokens: [ + { symbol: 'BTC', balance: '0.5', decimals: 8 }, + ], + }, + { + chain: 'bfchainv2', + address: 'BF2aB3cD4eF5gH6iJ7kL8mN9pQ1rS2tU3vW4xY5z', + tokens: [ + { symbol: 'BFC', balance: '1234.5', decimals: 8 }, + ], + }, + ], + encryptedMnemonic: { ciphertext: 'test', iv: 'test', salt: 'test' }, + createdAt: Date.now(), + tokens: [], + }, + ], + currentWalletId: 'test-wallet-1', + selectedChain: 'ethereum', +} + +async function setupTestWalletWithFixtures(page: Page, targetUrl: string = '/', language: string = 'en') { + await page.addInitScript((data) => { + localStorage.setItem('bfm_wallets', JSON.stringify(data.wallet)) + localStorage.setItem('bfm_preferences', JSON.stringify({ language: data.lang, currency: 'USD' })) + localStorage.setItem(data.fixtureKey, data.fixtures) + }, { wallet: TEST_WALLET_DATA, lang: language, fixtureKey: E2E_FIXTURE_STORAGE_KEY, fixtures: FIXTURE.raw }) + + const hashUrl = targetUrl === '/' ? '/' : `/#${targetUrl}` + await page.goto(hashUrl) + await page.waitForLoadState('networkidle') +} + +async function waitForAppReady(page: Page) { + await page.locator('svg[aria-label="加载中"]').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}) +} + +function pickTxId(options: { chain: string; type?: string }): string | null { + const hit = FIXTURE.transactions.find((t) => t.chain === options.chain && (!options.type || t.type === options.type)) + return hit?.id ?? null +} + +async function setHistoryChainFilter(page: Page, chainId: string) { + const label = CHAIN_LABELS[chainId] ?? chainId + const chainCombobox = page.getByRole('combobox').first() + await chainCombobox.scrollIntoViewIfNeeded().catch(() => {}) + await chainCombobox.click() + await page.locator('[data-slot="select-item"]').filter({ hasText: label }).first().click() +} + +test.describe('交易历史 - real fixtures screenshots', () => { + test('交易列表截图 - real fixtures', async ({ page }) => { + await setupTestWalletWithFixtures(page, '/history') + await waitForAppReady(page) + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot('history-real-fixtures.png') + }) + + test('交易列表截图 - per chain', async ({ page }) => { + await setupTestWalletWithFixtures(page, '/history') + await waitForAppReady(page) + + const chains = ['bfchainv2', 'ethereum', 'bitcoin', 'tron', 'binance'] + for (const chainId of chains) { + await setHistoryChainFilter(page, chainId) + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot(`history-${chainId}.png`) + } + }) + + test('交易详情截图 - per chain', async ({ page }) => { + const targets = [ + { chain: 'bfchainv2', type: 'send' }, + { chain: 'ethereum', type: 'approve' }, + { chain: 'bitcoin', type: 'send' }, + { chain: 'tron', type: 'interaction' }, + { chain: 'binance', type: 'approve' }, + ] + + for (const target of targets) { + const txId = pickTxId({ chain: target.chain, type: target.type }) + if (!txId) { + throw new Error(`${target.chain}:${target.type} transaction missing in fixture`) + } + await setupTestWalletWithFixtures(page, `/transaction/${txId}`) + await waitForAppReady(page) + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot(`transaction-detail-${target.chain}.png`) + } + }) + + test('交易详情截图 - swap', async ({ page }) => { + const txId = FIXTURE.transactions.find((t) => t.type === 'swap')?.id ?? null + test.skip(!txId, 'swap transaction missing in fixture') + if (!txId) return + await setupTestWalletWithFixtures(page, `/transaction/${txId}`) + await waitForAppReady(page) + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot('transaction-detail-swap.png') + }) + + test('交易详情截图 - approve', async ({ page }) => { + const txId = FIXTURE.transactions.find((t) => t.type === 'approve')?.id ?? null + test.skip(!txId, 'approve transaction missing in fixture') + if (!txId) return + await setupTestWalletWithFixtures(page, `/transaction/${txId}`) + await waitForAppReady(page) + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot('transaction-detail-approve.png') + }) + + test('交易详情截图 - interaction', async ({ page }) => { + const txId = FIXTURE.transactions.find((t) => t.type === 'interaction')?.id ?? null + test.skip(!txId, 'interaction transaction missing in fixture') + if (!txId) return + await setupTestWalletWithFixtures(page, `/transaction/${txId}`) + await waitForAppReady(page) + await page.waitForTimeout(500) + await expect(page).toHaveScreenshot('transaction-detail-interaction.png') + }) +}) diff --git a/src/components/transaction/transaction-item.tsx b/src/components/transaction/transaction-item.tsx index 2a32fa9f0..78d638bdd 100644 --- a/src/components/transaction/transaction-item.tsx +++ b/src/components/transaction/transaction-item.tsx @@ -1,62 +1,12 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; -import type { Amount } from '@/types/amount'; -import type { ChainType } from '@/stores'; import { AddressDisplay } from '../wallet/address-display'; import { ChainIcon } from '../wallet/chain-icon'; import { AmountDisplay, TimeDisplay } from '../common'; -import { - IconArrowUp, - IconArrowDown, - IconArrowsExchange, - IconLock, - IconLockOpen, - IconShieldLock, - IconShieldCheck, - IconFlame, - IconGift, - IconHandGrab, - IconUserShare, - IconSignature, - IconLogout, - IconLogin, - IconSparkles, - IconCoins, - IconDiamond, - IconTrash, - IconMapPin, - IconApps, - IconCertificate, - IconFileText, - IconDots, - IconClick, -} from '@tabler/icons-react'; -import type { Icon } from '@tabler/icons-react'; +import type { TransactionInfo } from './types'; +import { getTransactionStatusColor, getTransactionVisualMeta } from './transaction-meta'; -export type TransactionType = - | 'send' | 'receive' | 'signature' - | 'stake' | 'unstake' | 'destroy' - | 'gift' | 'grab' | 'trust' | 'signFor' - | 'emigrate' | 'immigrate' | 'exchange' - | 'issueAsset' | 'increaseAsset' - | 'issueEntity' | 'destroyEntity' - | 'locationName' | 'dapp' | 'certificate' | 'mark' - | 'approve' | 'interaction' | 'other'; - -export type TransactionStatus = 'pending' | 'confirmed' | 'failed'; - -export interface TransactionInfo { - id: string; - type: TransactionType; - status: TransactionStatus; - amount: Amount; - symbol: string; - address: string; - timestamp: Date | string; - hash?: string | undefined; - /** 链类型,用于显示链图标 */ - chain?: ChainType | undefined; -} +export type { TransactionInfo, TransactionStatus, TransactionType } from './types'; interface TransactionItemProps { transaction: TransactionInfo; @@ -66,54 +16,12 @@ interface TransactionItemProps { showChainIcon?: boolean | undefined; } -// 颜色按类别归类,图标各不相同 -const typeIcons: Record = { - // 资产流出 - 红橙色 - send: { Icon: IconArrowUp, color: 'text-tx-out', bg: 'bg-tx-out/10' }, - destroy: { Icon: IconFlame, color: 'text-tx-out', bg: 'bg-tx-out/10' }, - emigrate: { Icon: IconLogout, color: 'text-tx-out', bg: 'bg-tx-out/10' }, - destroyEntity: { Icon: IconTrash, color: 'text-tx-out', bg: 'bg-tx-out/10' }, - // 资产流入 - 绿色 - receive: { Icon: IconArrowDown, color: 'text-tx-in', bg: 'bg-tx-in/10' }, - grab: { Icon: IconHandGrab, color: 'text-tx-in', bg: 'bg-tx-in/10' }, - immigrate: { Icon: IconLogin, color: 'text-tx-in', bg: 'bg-tx-in/10' }, - signFor: { Icon: IconSignature, color: 'text-tx-in', bg: 'bg-tx-in/10' }, - // 交换 - 蓝色 - exchange: { Icon: IconArrowsExchange, color: 'text-tx-exchange', bg: 'bg-tx-exchange/10' }, - // 质押/委托 - 紫色 - stake: { Icon: IconLock, color: 'text-tx-lock', bg: 'bg-tx-lock/10' }, - unstake: { Icon: IconLockOpen, color: 'text-tx-lock', bg: 'bg-tx-lock/10' }, - trust: { Icon: IconUserShare, color: 'text-tx-lock', bg: 'bg-tx-lock/10' }, - // 安全 - 青色 - signature: { Icon: IconShieldLock, color: 'text-tx-security', bg: 'bg-tx-security/10' }, - // 创建/发行 - 橙色 - gift: { Icon: IconGift, color: 'text-tx-create', bg: 'bg-tx-create/10' }, - issueAsset: { Icon: IconSparkles, color: 'text-tx-create', bg: 'bg-tx-create/10' }, - increaseAsset: { Icon: IconCoins, color: 'text-tx-create', bg: 'bg-tx-create/10' }, - issueEntity: { Icon: IconDiamond, color: 'text-tx-create', bg: 'bg-tx-create/10' }, - // 系统操作 - 灰蓝色 - locationName: { Icon: IconMapPin, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - dapp: { Icon: IconApps, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - certificate: { Icon: IconCertificate, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - mark: { Icon: IconFileText, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - // 合约交互 - 灰蓝色 - approve: { Icon: IconShieldCheck, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - interaction: { Icon: IconClick, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - other: { Icon: IconDots, color: 'text-tx-system', bg: 'bg-tx-system/10' }, -}; - -const statusColors: Record = { - pending: 'text-yellow-500', - confirmed: 'text-green-500', - failed: 'text-destructive', -}; - export function TransactionItem({ transaction, onClick, className, showChainIcon }: TransactionItemProps) { const { t } = useTranslation('transaction'); - const typeIcon = typeIcons[transaction.type]; - const statusColor = statusColors[transaction.status]; + const typeMeta = getTransactionVisualMeta(transaction.type); + const statusColor = getTransactionStatusColor(transaction.status); const isClickable = !!onClick; - const Icon = typeIcon.Icon; + const Icon = typeMeta.Icon; // Get the amount value for display const amountValue = transaction.type === 'send' @@ -137,10 +45,10 @@ export function TransactionItem({ transaction, onClick, className, showChainIcon
- +
{showChainIcon && transaction.chain && ( diff --git a/src/components/transaction/transaction-meta.ts b/src/components/transaction/transaction-meta.ts new file mode 100644 index 000000000..0623bcd08 --- /dev/null +++ b/src/components/transaction/transaction-meta.ts @@ -0,0 +1,163 @@ +import { + IconArrowUp, + IconArrowDown, + IconArrowsExchange, + IconRepeat, + IconLock, + IconLockOpen, + IconShieldLock, + IconShieldCheck, + IconFlame, + IconGift, + IconHandGrab, + IconUserShare, + IconSignature, + IconLogout, + IconLogin, + IconSparkles, + IconCoins, + IconDiamond, + IconTrash, + IconMapPin, + IconApps, + IconCertificate, + IconFileText, + IconDots, + IconClick, +} from '@tabler/icons-react'; +import type { Icon } from '@tabler/icons-react'; +import type { TransactionStatus, TransactionType } from './types'; +import type { TransactionStatusType } from './transaction-status'; +import type { Action, Direction } from '@/services/chain-adapter/providers/types'; + +export type AmountSign = 'auto' | 'always' | 'never'; + +export interface TransactionVisualMeta { + Icon: Icon; + color: string; + bg: string; + titleKey: string; + amountSign: AmountSign; +} + +const OUT_META: Pick = { amountSign: 'always' }; +const IN_META: Pick = { amountSign: 'always' }; +const NEUTRAL_META: Pick = { amountSign: 'always' }; + +const TYPE_META: Record = { + // 资产流出 - 红橙色 + send: { Icon: IconArrowUp, color: 'text-tx-out', bg: 'bg-tx-out/10', titleKey: 'type.send', ...OUT_META }, + destroy: { Icon: IconFlame, color: 'text-tx-out', bg: 'bg-tx-out/10', titleKey: 'type.destroy', ...OUT_META }, + emigrate: { Icon: IconLogout, color: 'text-tx-out', bg: 'bg-tx-out/10', titleKey: 'type.emigrate', ...OUT_META }, + destroyEntity: { Icon: IconTrash, color: 'text-tx-out', bg: 'bg-tx-out/10', titleKey: 'type.destroyEntity', ...OUT_META }, + + // 资产流入 - 绿色 + receive: { Icon: IconArrowDown, color: 'text-tx-in', bg: 'bg-tx-in/10', titleKey: 'type.receive', ...IN_META }, + grab: { Icon: IconHandGrab, color: 'text-tx-in', bg: 'bg-tx-in/10', titleKey: 'type.grab', ...IN_META }, + immigrate: { Icon: IconLogin, color: 'text-tx-in', bg: 'bg-tx-in/10', titleKey: 'type.immigrate', ...IN_META }, + signFor: { Icon: IconSignature, color: 'text-tx-in', bg: 'bg-tx-in/10', titleKey: 'type.signFor', ...IN_META }, + + // 交换 / Swap + exchange: { Icon: IconArrowsExchange, color: 'text-tx-exchange', bg: 'bg-tx-exchange/10', titleKey: 'type.exchange', ...NEUTRAL_META }, + swap: { Icon: IconRepeat, color: 'text-tx-exchange', bg: 'bg-tx-exchange/10', titleKey: 'type.swap', amountSign: 'never' }, + + // 质押/委托 - 紫色 + stake: { Icon: IconLock, color: 'text-tx-lock', bg: 'bg-tx-lock/10', titleKey: 'type.stake', ...NEUTRAL_META }, + unstake: { Icon: IconLockOpen, color: 'text-tx-lock', bg: 'bg-tx-lock/10', titleKey: 'type.unstake', ...NEUTRAL_META }, + trust: { Icon: IconUserShare, color: 'text-tx-lock', bg: 'bg-tx-lock/10', titleKey: 'type.trust', ...NEUTRAL_META }, + + // 安全 - 青色 + signature: { Icon: IconShieldLock, color: 'text-tx-security', bg: 'bg-tx-security/10', titleKey: 'type.signature', ...NEUTRAL_META }, + + // 创建/发行 - 橙色 + gift: { Icon: IconGift, color: 'text-tx-create', bg: 'bg-tx-create/10', titleKey: 'type.gift', ...NEUTRAL_META }, + issueAsset: { Icon: IconSparkles, color: 'text-tx-create', bg: 'bg-tx-create/10', titleKey: 'type.issueAsset', ...NEUTRAL_META }, + increaseAsset: { Icon: IconCoins, color: 'text-tx-create', bg: 'bg-tx-create/10', titleKey: 'type.increaseAsset', ...NEUTRAL_META }, + mint: { Icon: IconCoins, color: 'text-tx-create', bg: 'bg-tx-create/10', titleKey: 'type.mint', ...NEUTRAL_META }, + issueEntity: { Icon: IconDiamond, color: 'text-tx-create', bg: 'bg-tx-create/10', titleKey: 'type.issueEntity', ...NEUTRAL_META }, + + // 系统操作 - 灰蓝色 + locationName: { Icon: IconMapPin, color: 'text-tx-system', bg: 'bg-tx-system/10', titleKey: 'type.locationName', ...NEUTRAL_META }, + dapp: { Icon: IconApps, color: 'text-tx-system', bg: 'bg-tx-system/10', titleKey: 'type.dapp', ...NEUTRAL_META }, + certificate: { Icon: IconCertificate, color: 'text-tx-system', bg: 'bg-tx-system/10', titleKey: 'type.certificate', ...NEUTRAL_META }, + mark: { Icon: IconFileText, color: 'text-tx-system', bg: 'bg-tx-system/10', titleKey: 'type.mark', ...NEUTRAL_META }, + + // 合约交互 - 灰蓝色 + approve: { Icon: IconShieldCheck, color: 'text-tx-system', bg: 'bg-tx-system/10', titleKey: 'type.approve', ...NEUTRAL_META }, + interaction: { Icon: IconClick, color: 'text-tx-system', bg: 'bg-tx-system/10', titleKey: 'type.interaction', ...NEUTRAL_META }, + + other: { Icon: IconDots, color: 'text-tx-system', bg: 'bg-tx-system/10', titleKey: 'type.other', ...NEUTRAL_META }, +}; + +const STATUS_COLOR: Record = { + pending: 'text-yellow-500', + confirmed: 'text-green-500', + failed: 'text-destructive', +}; + +export function getTransactionVisualMeta(type: TransactionType): TransactionVisualMeta { + return TYPE_META[type] ?? TYPE_META.other; +} + +export function getTransactionStatusColor(status: TransactionStatus): string { + return STATUS_COLOR[status]; +} + +export interface TransactionStatusMeta { + badgeStatus: TransactionStatusType; +} + +export function getTransactionStatusMeta(status: TransactionStatus): TransactionStatusMeta { + if (status === 'confirmed') return { badgeStatus: 'success' }; + if (status === 'failed') return { badgeStatus: 'failed' }; + return { badgeStatus: 'pending' }; +} + +// ==================== Provider Action → UI Type Mapping ==================== +// This is the single source of truth for mapping chain-adapter Action to UI TransactionType + +const ACTION_TO_UI_TYPE: Partial> = { + gift: 'gift', + grab: 'grab', + trust: 'trust', + signFor: 'signFor', + signature: 'signature', + emigrate: 'emigrate', + immigrate: 'immigrate', + swap: 'swap', + exchange: 'exchange', + stake: 'stake', + unstake: 'unstake', + issueAsset: 'issueAsset', + increaseAsset: 'increaseAsset', + mint: 'mint', + destroyAsset: 'destroy', + issueEntity: 'issueEntity', + destroyEntity: 'destroyEntity', + locationName: 'locationName', + dapp: 'dapp', + certificate: 'certificate', + mark: 'mark', + approve: 'approve', + burn: 'destroy', + claim: 'receive', + contract: 'interaction', +}; + +/** + * Maps provider Action + Direction to UI TransactionType. + * This is the canonical mapping - all consumers should use this function. + */ +export function mapActionToTransactionType(action: Action, direction: Direction): TransactionType { + const mapped = ACTION_TO_UI_TYPE[action]; + if (mapped) return mapped; + + // For transfer/unknown, use direction to determine type + if (action === 'transfer' || action === 'unknown') { + if (direction === 'out') return 'send'; + if (direction === 'in') return 'receive'; + return 'other'; // self transfer + } + + return 'other'; +} diff --git a/src/components/transaction/types.ts b/src/components/transaction/types.ts new file mode 100644 index 000000000..e78f24aac --- /dev/null +++ b/src/components/transaction/types.ts @@ -0,0 +1,45 @@ +import type { Amount } from '@/types/amount'; +import type { ChainType } from '@/stores'; + +export type TransactionType = + | 'send' + | 'receive' + | 'signature' + | 'stake' + | 'unstake' + | 'destroy' + | 'gift' + | 'grab' + | 'trust' + | 'signFor' + | 'emigrate' + | 'immigrate' + | 'exchange' + | 'swap' + | 'issueAsset' + | 'increaseAsset' + | 'mint' + | 'issueEntity' + | 'destroyEntity' + | 'locationName' + | 'dapp' + | 'certificate' + | 'mark' + | 'approve' + | 'interaction' + | 'other'; + +export type TransactionStatus = 'pending' | 'confirmed' | 'failed'; + +export interface TransactionInfo { + id: string; + type: TransactionType; + status: TransactionStatus; + amount: Amount; + symbol: string; + address: string; + timestamp: Date | string; + hash?: string | undefined; + /** 链类型,用于显示链图标 */ + chain?: ChainType | undefined; +} diff --git a/src/components/wallet/wallet-address-portfolio-from-provider.tsx b/src/components/wallet/wallet-address-portfolio-from-provider.tsx index b116e0478..5109dbdc5 100644 --- a/src/components/wallet/wallet-address-portfolio-from-provider.tsx +++ b/src/components/wallet/wallet-address-portfolio-from-provider.tsx @@ -5,10 +5,11 @@ import { chainConfigService } from '@/services/chain-config' import { useChainConfigState } from '@/stores/chain-config' import { WalletAddressPortfolioView, type WalletAddressPortfolioViewProps } from './wallet-address-portfolio-view' import type { TokenInfo } from '@/components/token/token-item' -import type { TransactionInfo, TransactionType } from '@/components/transaction/transaction-item' +import type { TransactionInfo } from '@/components/transaction/transaction-item' import type { ChainType } from '@/stores' -import type { Transaction, Action } from '@/services/chain-adapter/providers/types' +import type { Transaction } from '@/services/chain-adapter/providers/types' import { Amount } from '@/types/amount' +import { mapActionToTransactionType } from '@/components/transaction/transaction-meta' export interface WalletAddressPortfolioFromProviderProps { chainId: ChainType @@ -81,7 +82,7 @@ export function WalletAddressPortfolioFromProvider({ const currentProvider = chainConfigState.snapshot ? createChainProvider(chainId) : null if (!currentProvider?.getTransactionHistory) return [] const txs = await currentProvider.getTransactionHistory(address, 20) - return txs.map(tx => convertToTransactionInfo(tx, address, chainId, decimals)) + return txs.map(tx => convertToTransactionInfo(tx, chainId, decimals)) }, enabled: transactionsEnabled, staleTime: 30_000, @@ -111,21 +112,20 @@ export function WalletAddressPortfolioFromProvider({ /** 将 provider Transaction 转换为 UI TransactionInfo */ function convertToTransactionInfo( tx: Transaction, - address: string, chainId: ChainType, fallbackDecimals: number ): TransactionInfo { // 使用 direction 判断对方地址 const counterpartyAddress = tx.direction === 'out' ? tx.to : tx.from - // 获取主要资产信息 (第一个资产) - const primaryAsset = tx.assets[0] - const value = primaryAsset?.value ?? '0' - const symbol = primaryAsset?.symbol ?? '' - const decimals = primaryAsset?.decimals ?? fallbackDecimals + // 获取主要资产信息 (优先 fungible: native/token) + const primaryAsset = tx.assets.find((a) => a.assetType === 'native' || a.assetType === 'token') + const value = primaryAsset ? primaryAsset.value : '0' + const symbol = primaryAsset ? primaryAsset.symbol : '' + const decimals = primaryAsset ? primaryAsset.decimals : fallbackDecimals - // 将 action + direction 映射到 UI TransactionType - const uiType = mapToUIType(tx.action, tx.direction) + // 将 action + direction 映射到 UI TransactionType (single source of truth) + const uiType = mapActionToTransactionType(tx.action, tx.direction) return { id: tx.hash, @@ -140,42 +140,4 @@ function convertToTransactionInfo( } } -/** 将 action + direction 映射到 UI TransactionType */ -function mapToUIType(action: Action, direction: 'in' | 'out' | 'self'): TransactionType { - // 基于 action 的直接映射 - const actionMap: Partial> = { - gift: 'gift', - grab: 'grab', - trust: 'trust', - signFor: 'signFor', - signature: 'signature', - emigrate: 'emigrate', - immigrate: 'immigrate', - swap: 'exchange', - stake: 'stake', - unstake: 'unstake', - issueAsset: 'issueAsset', - increaseAsset: 'increaseAsset', - destroyAsset: 'destroy', - issueEntity: 'issueEntity', - destroyEntity: 'destroyEntity', - locationName: 'locationName', - dapp: 'dapp', - certificate: 'certificate', - mark: 'mark', - approve: 'approve', - mint: 'issueAsset', - burn: 'destroy', - claim: 'receive', - contract: 'interaction', - } - - if (actionMap[action]) { - return actionMap[action]! - } - - // 对于 transfer/unknown,使用 direction 判断 - if (direction === 'out') return 'send' - if (direction === 'in') return 'receive' - return 'other' // self transfer -} +// mapToUIType removed - use mapActionToTransactionType from transaction-meta.ts (single source of truth) diff --git a/src/i18n/locales/ar/transaction.json b/src/i18n/locales/ar/transaction.json index a4ab6fe93..5f68e2c37 100644 --- a/src/i18n/locales/ar/transaction.json +++ b/src/i18n/locales/ar/transaction.json @@ -70,18 +70,42 @@ "confirmations": "التأكيدات", "copied": "تم النسخ", "copyHash": "نسخ الهاش", + "contractAddress": "Contract Address", + "spender": "Spender", + "allowance": "Allowance", "fee": "الرسوم", "fromAddress": "عنوان المرسل", "hash": "هاش المعاملة", "info": "معلومات المعاملة", + "method": "Method", "network": "الشبكة", + "invalidData": "فشل تحليل بيانات المعاملة", "notFound": "المعاملة غير موجودة أو منتهية الصلاحية", "share": "Share", + "swapFrom": "From", + "swapTo": "To", "time": "الوقت", "title": "تفاصيل المعاملة", "toAddress": "عنوان المستلم", "viewExplorer": "عرض في المستكشف" }, + "method": { + "approve": "Approve", + "transfer": "Transfer", + "swap": "Swap", + "swapExactTokensForETH": "Swap Tokens for ETH", + "swapExactTokensForTokens": "Swap Tokens", + "swapExactETHForTokens": "Swap ETH for Tokens", + "multicall": "Multi-call", + "execute": "Execute", + "deposit": "Deposit", + "withdraw": "Withdraw", + "stake": "Stake", + "unstake": "Unstake", + "claim": "Claim", + "mint": "Mint", + "burn": "Burn" + }, "dpTransactionDetails": "DP Details", "easyTransfer": "Easy Transfer", "enterAmount": "Enter Amount", @@ -256,10 +280,12 @@ "destroyEntity": "Destroy Entity", "emigrate": "Emigrate", "exchange": "Exchange", + "swap": "Swap", "gift": "Gift", "grab": "Accept Gift", "immigrate": "Immigrate", "increaseAsset": "Increase Asset", + "mint": "Mint", "issueAsset": "Issue Asset", "issueEntity": "Issue Entity", "locationName": "Location Name", diff --git a/src/i18n/locales/en/transaction.json b/src/i18n/locales/en/transaction.json index 1f0567bdd..bb13d2d69 100644 --- a/src/i18n/locales/en/transaction.json +++ b/src/i18n/locales/en/transaction.json @@ -121,18 +121,42 @@ "confirmations": "Confirmations", "copied": "Copied", "copyHash": "Copy Hash", + "contractAddress": "Contract Address", + "spender": "Spender", + "allowance": "Allowance", "fee": "Fee", "fromAddress": "From Address", "hash": "Transaction Hash", "info": "Transaction Info", + "method": "Method", "network": "Network", + "invalidData": "Failed to parse transaction data", "notFound": "Transaction not found or expired", + "swapFrom": "From", + "swapTo": "To", "time": "Time", "title": "Transaction Details", "toAddress": "To Address", "viewExplorer": "View in Explorer", "share": "Share" }, + "method": { + "approve": "Approve", + "transfer": "Transfer", + "swap": "Swap", + "swapExactTokensForETH": "Swap Tokens for ETH", + "swapExactTokensForTokens": "Swap Tokens", + "swapExactETHForTokens": "Swap ETH for Tokens", + "multicall": "Multi-call", + "execute": "Execute", + "deposit": "Deposit", + "withdraw": "Withdraw", + "stake": "Stake", + "unstake": "Unstake", + "claim": "Claim", + "mint": "Mint", + "burn": "Burn" + }, "dpTransactionDetails": "DP Details", "easyTransfer": "Easy Transfer", "enterAmount": "Enter Amount", @@ -228,8 +252,10 @@ "emigrate": "Emigrate", "immigrate": "Immigrate", "exchange": "Exchange", + "swap": "Swap", "issueAsset": "Issue Asset", "increaseAsset": "Increase Asset", + "mint": "Mint", "issueEntity": "Issue Entity", "destroyEntity": "Destroy Entity", "locationName": "Location Name", diff --git a/src/i18n/locales/zh-CN/transaction.json b/src/i18n/locales/zh-CN/transaction.json index 53fb7a965..54d97bc6f 100644 --- a/src/i18n/locales/zh-CN/transaction.json +++ b/src/i18n/locales/zh-CN/transaction.json @@ -80,6 +80,9 @@ }, "detail": { "title": "交易详情", + "invalidData": "交易数据解析失败", + "spender": "授权对象", + "allowance": "授权额度", "notFound": "交易不存在或已过期", "info": "交易信息", "toAddress": "收款地址", @@ -90,11 +93,32 @@ "blockHeight": "区块高度", "confirmations": "确认数", "hash": "交易哈希", + "contractAddress": "合约地址", + "method": "方法", + "swapFrom": "换出", + "swapTo": "换入", "copied": "已复制", "copyHash": "复制哈希", "viewExplorer": "区块浏览器", "share": "分享" }, + "method": { + "approve": "授权", + "transfer": "转账", + "swap": "兑换", + "swapExactTokensForETH": "代币兑换 ETH", + "swapExactTokensForTokens": "代币兑换", + "swapExactETHForTokens": "ETH 兑换代币", + "multicall": "批量调用", + "execute": "执行", + "deposit": "存入", + "withdraw": "提取", + "stake": "质押", + "unstake": "解押", + "claim": "领取", + "mint": "铸造", + "burn": "销毁" + }, "type": { "send": "发送", "receive": "接收", @@ -109,8 +133,10 @@ "emigrate": "权益迁出", "immigrate": "权益迁入", "exchange": "权益交换", + "swap": "Swap", "issueAsset": "创建权益", "increaseAsset": "增发权益", + "mint": "Mint", "issueEntity": "创建资产", "destroyEntity": "销毁资产", "locationName": "位名操作", diff --git a/src/i18n/locales/zh-TW/transaction.json b/src/i18n/locales/zh-TW/transaction.json index 588a40ebe..7836497f7 100644 --- a/src/i18n/locales/zh-TW/transaction.json +++ b/src/i18n/locales/zh-TW/transaction.json @@ -70,18 +70,42 @@ "confirmations": "確認數", "copied": "已複製", "copyHash": "複製哈希", + "contractAddress": "合約地址", + "spender": "授權對象", + "allowance": "授權額度", "fee": "手續費", "fromAddress": "發送地址", "hash": "交易哈希", "info": "交易資訊", + "method": "方法", "network": "網路", + "invalidData": "交易資料解析失敗", "notFound": "交易不存在或已過期", "share": "分享", + "swapFrom": "換出", + "swapTo": "換入", "time": "交易時間", "title": "交易詳情", "toAddress": "收款地址", "viewExplorer": "區塊瀏覽器" }, + "method": { + "approve": "授權", + "transfer": "轉帳", + "swap": "兌換", + "swapExactTokensForETH": "代幣兌換 ETH", + "swapExactTokensForTokens": "代幣兌換", + "swapExactETHForTokens": "ETH 兌換代幣", + "multicall": "批量調用", + "execute": "執行", + "deposit": "存入", + "withdraw": "提取", + "stake": "質押", + "unstake": "解押", + "claim": "領取", + "mint": "鑄造", + "burn": "銷毀" + }, "dpTransactionDetails": "DP詳情", "easyTransfer": "輕鬆轉賬", "enterAmount": "輸入數量", @@ -193,7 +217,7 @@ "failed": "失敗", "pending": "處理中" }, - "swap": "[MISSING:zh-TW] 兑换", + "swap": "兌換", "theCurrentAddressDoesNotHave_{assettype}AssetsAndCannotBeTransferred": "當前地址未擁有 資產,無法進行轉賬.", "theFrozenAddressCannotBeTransferred": "該地址已凍結,無法發起交易", "theMinimumMinerFeeIs_{fee}": "本次交易最低曠工費為:", @@ -256,11 +280,13 @@ "destroyEntity": "销毁资产", "emigrate": "权益迁出", "exchange": "权益交换", + "swap": "Swap", "gift": "发起赠送", "grab": "接受赠送", "immigrate": "权益迁入", "increaseAsset": "增发权益", "issueAsset": "创建权益", + "mint": "Mint", "issueEntity": "创建资产", "locationName": "位名操作", "mark": "数据存证", diff --git a/src/pages/address-transactions/index.test.tsx b/src/pages/address-transactions/index.test.tsx index 6199770d3..7899b9013 100644 --- a/src/pages/address-transactions/index.test.tsx +++ b/src/pages/address-transactions/index.test.tsx @@ -126,10 +126,18 @@ describe('AddressTransactionsPage', () => { hash: '0xabc123', from: '0x1111111111111111111111111111111111111111', to: '0x1234567890abcdef1234567890abcdef12345678', - value: '1000000000000000000', - symbol: 'ETH', timestamp: Date.now(), status: 'confirmed', + action: 'transfer', + direction: 'in', + assets: [ + { + assetType: 'native', + value: '1000000000000000000', + symbol: 'ETH', + decimals: 18, + }, + ], }, ] mockGetTransactionHistory.mockResolvedValue(mockTxs) diff --git a/src/pages/address-transactions/index.tsx b/src/pages/address-transactions/index.tsx index 9f19caad5..545a5e3cc 100644 --- a/src/pages/address-transactions/index.tsx +++ b/src/pages/address-transactions/index.tsx @@ -23,7 +23,11 @@ function formatDate(timestamp: number): string { } function TransactionItem({ tx, address }: { tx: Transaction; address: string }) { - const isOutgoing = tx.from.toLowerCase() === address.toLowerCase() + const isOutgoing = tx.direction === 'out' || tx.from.toLowerCase() === address.toLowerCase() + const primaryAsset = tx.assets.find((a) => a.assetType === 'native' || a.assetType === 'token') + const value = primaryAsset ? primaryAsset.value : '0' + const symbol = primaryAsset ? primaryAsset.symbol : '' + const decimals = primaryAsset ? primaryAsset.decimals : 0 return (
@@ -39,7 +43,7 @@ function TransactionItem({ tx, address }: { tx: Transaction; address: string })
- {isOutgoing ? '-' : '+'}{formatAmount(tx.value, 8)} {tx.symbol} + {isOutgoing ? '-' : '+'}{formatAmount(value, decimals)} {symbol}
) diff --git a/src/pages/history/detail.test.tsx b/src/pages/history/detail.test.tsx index 11167a417..b7b8c30ea 100644 --- a/src/pages/history/detail.test.tsx +++ b/src/pages/history/detail.test.tsx @@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Amount } from '@/types/amount' import { TransactionDetailPage } from './detail' import { TestI18nProvider, testI18n } from '@/test/i18n-mock' +import { transactionService } from '@/services/transaction' +import { InvalidDataError } from '@/services/chain-adapter/providers' // Mock stackflow const mockNavigate = vi.fn() @@ -51,9 +53,49 @@ const createMockTransaction = () => ({ confirmations: 50, }) +const createMockSwapTransaction = (overrides?: Partial>) => ({ + ...createMockTransaction(), + id: 'ethereum--0xswaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + type: 'swap' as const, + symbol: 'ETH', + amount: Amount.fromFormatted('0', 18, 'ETH'), + assets: [ + { assetType: 'token', symbol: 'ETH', decimals: 18, value: '1000000000000000000', contractAddress: '0xeth' }, + ], + ...overrides, +}) + +const createMockSwapTransactionEnhanced = () => + createMockSwapTransaction({ + assets: [ + { assetType: 'token', symbol: 'ETH', decimals: 18, value: '1000000000000000000', contractAddress: '0xeth' }, + { assetType: 'token', symbol: 'USDC', decimals: 6, value: '1000000', contractAddress: '0xusdc' }, + ], + }) + +const createMockApproveTransaction = () => ({ + ...createMockTransaction(), + id: 'ethereum--0xapppppppppppppppppppppppppppppppppppppppppppppppppppppppp', + type: 'approve' as const, + amount: Amount.fromFormatted('0', 18, 'ETH'), + to: '0xtoken', + contract: { address: '0xtoken' }, +}) + +const createMockInteractionTransaction = () => ({ + ...createMockTransaction(), + id: 'ethereum--0xintttttttttttttttttttttttttttttttttttttttttttttttttttttttt', + type: 'interaction' as const, + amount: Amount.fromFormatted('0', 18, 'ETH'), + to: '0xcontract', + contract: { address: '0xcontract' }, +}) + +let mockTransactions = [createMockTransaction()] + vi.mock('@/queries', () => ({ useTransactionHistoryQuery: () => ({ - transactions: [createMockTransaction()], + transactions: mockTransactions, isLoading: false, isFetching: false, error: undefined, @@ -75,6 +117,12 @@ vi.mock('@/services/clipboard', () => ({ }, })) +vi.mock('@/services/transaction', () => ({ + transactionService: { + getTransaction: vi.fn().mockResolvedValue(null), + }, +})) + function renderWithProviders(ui: React.ReactElement) { const queryClient = new QueryClient({ defaultOptions: { @@ -95,6 +143,7 @@ describe('TransactionDetailPage', () => { vi.clearAllMocks() mockCurrentWallet = mockWallet mockTxId = 'tx-1' + mockTransactions = [createMockTransaction()] mockClipboardWrite.mockResolvedValue(undefined) }) @@ -148,10 +197,71 @@ describe('TransactionDetailPage', () => { }) describe('Transaction Not Found', () => { - it('shows message when transaction is not found', () => { + it('shows message when transaction is not found', async () => { mockTxId = 'non-existent-tx' renderWithProviders() - expect(screen.getByText(t('transaction:detail.notFound'))).toBeInTheDocument() + expect(await screen.findByText(t('transaction:detail.notFound'))).toBeInTheDocument() + }) + }) + + describe('Invalid Data', () => { + it('shows message when transaction data cannot be parsed', async () => { + mockTransactions = [] + mockTxId = 'ethereum--0xdeadbeef' + vi.mocked(transactionService.getTransaction).mockRejectedValue( + new InvalidDataError({ source: 'provider', chainId: 'ethereum', method: 'getTransaction' }), + ) + + renderWithProviders() + expect(await screen.findByText(t('transaction:detail.invalidData'))).toBeInTheDocument() + + const copyButton = screen.getByText(t('transaction:detail.copyHash')) + await userEvent.click(copyButton) + expect(mockClipboardWrite).toHaveBeenCalledWith({ text: 'ethereum--0xdeadbeef'.split('--')[1] }) + }) + }) + + describe('Provider Enhancement', () => { + it('enhances swap transaction and renders From/To + header summary', async () => { + mockTransactions = [createMockSwapTransaction()] + mockTxId = mockTransactions[0]!.id + vi.mocked(transactionService.getTransaction).mockResolvedValue(createMockSwapTransactionEnhanced() as any) + + renderWithProviders() + + expect(await screen.findByText(t('transaction:detail.swapFrom'))).toBeInTheDocument() + expect(screen.getByText(t('transaction:detail.swapTo'))).toBeInTheDocument() + expect(screen.getByText('→')).toBeInTheDocument() + expect(screen.getAllByText('ETH').length).toBeGreaterThan(0) + expect(screen.getAllByText('USDC').length).toBeGreaterThan(0) + }) + }) + + describe('Approve/Interaction Placeholders', () => { + it('renders approve with method/spender/allowance placeholders', async () => { + mockTransactions = [createMockApproveTransaction()] + mockTxId = mockTransactions[0]!.id + vi.mocked(transactionService.getTransaction).mockResolvedValue(null) + + renderWithProviders() + + expect(await screen.findByText(t('transaction:detail.contractAddress'))).toBeInTheDocument() + expect(screen.getByText(t('transaction:detail.method'))).toBeInTheDocument() + expect(screen.getByText(t('transaction:detail.spender'))).toBeInTheDocument() + expect(screen.getByText(t('transaction:detail.allowance'))).toBeInTheDocument() + expect(screen.getAllByText(t('common:unknown')).length).toBeGreaterThan(0) + }) + + it('renders interaction with method placeholder', async () => { + mockTransactions = [createMockInteractionTransaction()] + mockTxId = mockTransactions[0]!.id + vi.mocked(transactionService.getTransaction).mockResolvedValue(null) + + renderWithProviders() + + expect(await screen.findByText(t('transaction:detail.contractAddress'))).toBeInTheDocument() + expect(screen.getByText(t('transaction:detail.method'))).toBeInTheDocument() + expect(screen.getByText(t('common:unknown'))).toBeInTheDocument() }) }) diff --git a/src/pages/history/detail.tsx b/src/pages/history/detail.tsx index c5d6f6c9e..8982d054b 100644 --- a/src/pages/history/detail.tsx +++ b/src/pages/history/detail.tsx @@ -1,34 +1,12 @@ import { useCallback, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigation, useActivityParams } from '@/stackflow'; import { IconCopy as Copy, IconCheck as Check, IconShare as Share, - IconArrowUp, - IconArrowDown, - IconArrowsExchange, - IconLock, - IconLockOpen, - IconShieldLock, - IconFlame, - IconGift, - IconHandGrab, - IconUserShare, - IconSignature, - IconLogout, - IconLogin, - IconSparkles, - IconCoins, - IconDiamond, - IconTrash, - IconMapPin, - IconApps, - IconCertificate, - IconFileText, - IconDots, } from '@tabler/icons-react'; -import type { Icon } from '@tabler/icons-react'; import { PageHeader } from '@/components/layout/page-header'; import { AddressDisplay } from '@/components/wallet/address-display'; import { AmountDisplay, TimeDisplay } from '@/components/common'; @@ -40,49 +18,37 @@ import { useCurrentWallet, useChainConfigState, chainConfigSelectors } from '@/s import { cn } from '@/lib/utils'; import { clipboardService } from '@/services/clipboard'; import type { TransactionType } from '@/components/transaction/transaction-item'; +import { getTransactionStatusMeta, getTransactionVisualMeta } from '@/components/transaction/transaction-meta'; +import { Amount } from '@/types/amount'; +import { transactionService } from '@/services/transaction'; +import { InvalidDataError } from '@/services/chain-adapter/providers'; -// 交易类型图标和颜色配置 -const typeConfig: Record = { - // 资产流出 - 红橙色 - send: { Icon: IconArrowUp, color: 'text-tx-out', bg: 'bg-tx-out/10' }, - destroy: { Icon: IconFlame, color: 'text-tx-out', bg: 'bg-tx-out/10' }, - emigrate: { Icon: IconLogout, color: 'text-tx-out', bg: 'bg-tx-out/10' }, - destroyEntity: { Icon: IconTrash, color: 'text-tx-out', bg: 'bg-tx-out/10' }, - // 资产流入 - 绿色 - receive: { Icon: IconArrowDown, color: 'text-tx-in', bg: 'bg-tx-in/10' }, - grab: { Icon: IconHandGrab, color: 'text-tx-in', bg: 'bg-tx-in/10' }, - immigrate: { Icon: IconLogin, color: 'text-tx-in', bg: 'bg-tx-in/10' }, - signFor: { Icon: IconSignature, color: 'text-tx-in', bg: 'bg-tx-in/10' }, - // 交换 - 蓝色 - exchange: { Icon: IconArrowsExchange, color: 'text-tx-exchange', bg: 'bg-tx-exchange/10' }, - // 质押/委托 - 紫色 - stake: { Icon: IconLock, color: 'text-tx-lock', bg: 'bg-tx-lock/10' }, - unstake: { Icon: IconLockOpen, color: 'text-tx-lock', bg: 'bg-tx-lock/10' }, - trust: { Icon: IconUserShare, color: 'text-tx-lock', bg: 'bg-tx-lock/10' }, - // 安全 - 青色 - signature: { Icon: IconShieldLock, color: 'text-tx-security', bg: 'bg-tx-security/10' }, - // 创建/发行 - 橙色 - gift: { Icon: IconGift, color: 'text-tx-create', bg: 'bg-tx-create/10' }, - issueAsset: { Icon: IconSparkles, color: 'text-tx-create', bg: 'bg-tx-create/10' }, - increaseAsset: { Icon: IconCoins, color: 'text-tx-create', bg: 'bg-tx-create/10' }, - issueEntity: { Icon: IconDiamond, color: 'text-tx-create', bg: 'bg-tx-create/10' }, - // 系统操作 - 灰蓝色 - locationName: { Icon: IconMapPin, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - dapp: { Icon: IconApps, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - certificate: { Icon: IconCertificate, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - mark: { Icon: IconFileText, color: 'text-tx-system', bg: 'bg-tx-system/10' }, - other: { Icon: IconDots, color: 'text-tx-system', bg: 'bg-tx-system/10' }, -}; - -/** 状态映射 (TransactionInfo.status -> TransactionStatusType) */ -const STATUS_MAP: Record = { - confirmed: 'success', - pending: 'pending', - failed: 'failed', -}; +function parseTxId(id: string | undefined): { chainId: string; hash: string } | null { + if (!id) return null; + const [chainId, hash] = id.split('--'); + if (!chainId || !hash) return null; + return { chainId, hash }; +} + +function needsEnhancement(record: TransactionRecord | undefined): boolean { + if (!record) return false; + + if (record.type === 'swap') { + const assets = record.assets ?? []; + return assets.length < 2; + } + + if (record.type === 'approve' || record.type === 'interaction') { + const method = record.contract?.method; + const methodId = record.contract?.methodId; + return !method && !methodId; + } + + return false; +} export function TransactionDetailPage() { - const { t } = useTranslation('transaction'); + const { t } = useTranslation(['transaction', 'common']); const { goBack } = useNavigation(); const { txId } = useActivityParams<{ txId: string }>(); const currentWallet = useCurrentWallet(); @@ -91,23 +57,40 @@ export function TransactionDetailPage() { const [copied, setCopied] = useState(false); - // 查找交易 - const transaction = useMemo(() => { + const txFromHistory = useMemo(() => { return transactions.find((tx) => tx.id === txId); }, [transactions, txId]); + const parsedTxId = useMemo(() => parseTxId(txId), [txId]); + + const txDetailQuery = useQuery({ + queryKey: ['transaction-detail', txId], + queryFn: async () => { + return transactionService.getTransaction({ id: txId }); + }, + enabled: !!currentWallet?.id && !!txId && (!txFromHistory || needsEnhancement(txFromHistory)), + staleTime: 30_000, + }); + + const enhancedTransaction = txDetailQuery.data ?? undefined; + const transaction = enhancedTransaction ?? txFromHistory; + + const isPageLoading = isLoading || txDetailQuery.isLoading; + // 获取链配置(用于构建浏览器 URL) const chainConfig = useMemo(() => { - if (!transaction?.chain) return null; - return chainConfigSelectors.getChainById(chainConfigState, transaction.chain); - }, [chainConfigState, transaction?.chain]); + const chainId = transaction?.chain ?? parsedTxId?.chainId; + if (!chainId) return null; + return chainConfigSelectors.getChainById(chainConfigState, chainId); + }, [chainConfigState, parsedTxId?.chainId, transaction?.chain]); // 构建交易浏览器 URL(没有配置则返回 null) const explorerTxUrl = useMemo(() => { const queryTx = chainConfig?.explorer?.queryTx; - if (!queryTx || !transaction?.hash) return null; - return queryTx.replace(':signature', transaction.hash); - }, [transaction?.hash, chainConfig?.explorer?.queryTx]); + const hash = transaction?.hash ?? parsedTxId?.hash; + if (!queryTx || !hash) return null; + return queryTx.replace(':signature', hash); + }, [chainConfig?.explorer?.queryTx, parsedTxId?.hash, transaction?.hash]); // 复制交易哈希 const handleCopyHash = useCallback(async () => { @@ -118,6 +101,13 @@ export function TransactionDetailPage() { } }, [transaction?.hash]); + const handleCopyFallbackHash = useCallback(async () => { + if (!parsedTxId?.hash) return; + await clipboardService.write({ text: parsedTxId.hash }); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [parsedTxId?.hash]); + // 分享(复制浏览器链接) const [shared, setShared] = useState(false); const handleShare = useCallback(async () => { @@ -145,7 +135,7 @@ export function TransactionDetailPage() { } // 加载中 - if (isLoading) { + if (isPageLoading) { return (
@@ -158,6 +148,74 @@ export function TransactionDetailPage() { ); } + if (txDetailQuery.error instanceof InvalidDataError) { + const fallbackHash = parsedTxId?.hash; + + return ( +
+ +
+
+

{t('detail.invalidData')}

+
+ + {fallbackHash && ( +
+

{t('detail.hash')}

+

{fallbackHash}

+ +
+ + + +
+
+ )} +
+
+ ); + } + // 交易未找到 if (!transaction) { return ( @@ -172,8 +230,23 @@ export function TransactionDetailPage() { // 获取交易类型配置 const txType = transaction.type as TransactionType; - const config = typeConfig[txType] || typeConfig.other; - const TxIcon = config.Icon; + const typeMeta = getTransactionVisualMeta(txType); + const TxIcon = typeMeta.Icon; + + const swapFromAsset = transaction.type === 'swap' ? transaction.assets?.[0] : undefined; + const swapToAsset = transaction.type === 'swap' ? transaction.assets?.[1] : undefined; + const contractAddress = transaction.contract?.address || transaction.to; + const contractMethod = transaction.contract?.method; + const contractMethodId = transaction.contract?.methodId; + + const contractMethodLabel = contractMethod ? t(`method.${contractMethod}`, { defaultValue: contractMethod }) : null; + + const swapHeaderFrom = swapFromAsset + ? Amount.fromRaw(swapFromAsset.value, swapFromAsset.decimals, swapFromAsset.symbol) + : null; + const swapHeaderTo = swapToAsset + ? Amount.fromRaw(swapToAsset.value, swapToAsset.decimals, swapToAsset.symbol) + : null; return (
@@ -182,26 +255,50 @@ export function TransactionDetailPage() {
{/* 状态头 */}
-
- +
+

{t(`type.${transaction.type}`, { defaultValue: transaction.type })}

- + {transaction.type === 'swap' && swapHeaderFrom && swapHeaderTo ? ( +
+ + → + +
+ ) : ( + + )}
@@ -210,15 +307,116 @@ export function TransactionDetailPage() {

{t('detail.info')}

{/* 地址 */} -
- - {transaction.type === 'send' ? t('detail.toAddress') : t('detail.fromAddress')} - - -
+ {transaction.from && transaction.to ? ( + <> +
+ {t('detail.fromAddress')} + +
+ +
+ +
+ {t('detail.toAddress')} + +
+ + ) : ( +
+ + {transaction.type === 'send' ? t('detail.toAddress') : t('detail.fromAddress')} + + +
+ )}
+ {/* Swap */} + {transaction.type === 'swap' && (swapFromAsset || swapToAsset) && ( + <> +
+ {t('detail.swapFrom')} + + {swapFromAsset ? ( + + ) : ( + '-' + )} + +
+ +
+ +
+ {t('detail.swapTo')} + + {swapToAsset ? ( + + ) : ( + '-' + )} + +
+ +
+ + )} + + {/* 合约信息 */} + {(transaction.type === 'approve' || transaction.type === 'interaction' || transaction.type === 'swap') && contractAddress && ( + <> +
+ {t('detail.contractAddress')} + +
+ + {(transaction.type === 'approve' || transaction.type === 'interaction') && ( + <> +
+
+ {t('detail.method')} + + {contractMethodLabel || contractMethodId || t('common:unknown')} + +
+ + {transaction.type === 'approve' && ( + <> +
+
+ {t('detail.spender')} + {t('common:unknown')} +
+ +
+
+ {t('detail.allowance')} + {t('common:unknown')} +
+ + )} + + )} + +
+ + )} + {/* 时间 */}
{t('detail.time')} diff --git a/src/queries/use-transaction-history-query.ts b/src/queries/use-transaction-history-query.ts index f2645e6a0..ed104eb34 100644 --- a/src/queries/use-transaction-history-query.ts +++ b/src/queries/use-transaction-history-query.ts @@ -20,8 +20,15 @@ export interface TransactionRecord extends TransactionInfo { chain: ChainType fee: Amount | undefined feeSymbol: string | undefined + feeDecimals: number | undefined blockNumber: number | undefined confirmations: number | undefined + from: ServiceTransactionRecord['from'] + to: ServiceTransactionRecord['to'] + action: ServiceTransactionRecord['action'] + direction: ServiceTransactionRecord['direction'] + assets: ServiceTransactionRecord['assets'] + contract: ServiceTransactionRecord['contract'] } /** @@ -48,8 +55,15 @@ function convertToComponentFormat(record: ServiceTransactionRecord): Transaction chain: record.chain, fee: record.fee, feeSymbol: record.feeSymbol, + feeDecimals: record.feeDecimals, blockNumber: record.blockNumber, confirmations: record.confirmations, + from: record.from, + to: record.to, + action: record.action, + direction: record.direction, + assets: record.assets, + contract: record.contract, } } 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 78a280afc..651d383e5 100644 --- a/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts +++ b/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ChainProvider } from '../chain-provider' import type { ApiProvider, Balance, Transaction } from '../types' import { Amount } from '@/types/amount' +import { InvalidDataError } from '../errors' vi.mock('@/services/chain-config', () => ({ chainConfigService: { @@ -73,21 +74,25 @@ describe('ChainProvider', () => { }) it('delegates getTransactionHistory to the implementing provider', async () => { - const mockTxs: Transaction[] = [{ - hash: '0xabc', - from: '0x1', - to: '0x2', - timestamp: Date.now(), - status: 'confirmed', - action: 'transfer', - direction: 'out', - assets: [{ - assetType: 'native', - value: '1000', - symbol: 'TEST', - decimals: 8, - }], - }] + const mockTxs: Transaction[] = [ + { + hash: '0xabc', + from: '0x1', + to: '0x2', + timestamp: Date.now(), + status: 'confirmed', + action: 'transfer', + direction: 'out', + assets: [ + { + assetType: 'native', + value: '1000', + symbol: 'TEST', + decimals: 8, + }, + ], + }, + ] const provider = createMockProvider({ getTransactionHistory: vi.fn().mockResolvedValue(mockTxs), }) @@ -162,6 +167,47 @@ describe('ChainProvider', () => { }) }) + describe('guard-at-the-gate validation', () => { + it('filters invalid transactions from getTransactionHistory and warns', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const valid: Transaction = { + hash: '0xvalid', + from: '0x1', + to: '0x2', + timestamp: Date.now(), + status: 'confirmed', + action: 'transfer', + direction: 'out', + assets: [ + { + assetType: 'native', + value: '1', + symbol: 'TEST', + decimals: 18, + }, + ], + } + + const provider = createMockProvider({ + getTransactionHistory: vi.fn().mockResolvedValue([valid, { bad: true }]), + }) + const chainProvider = new ChainProvider('test', [provider]) + + const result = await chainProvider.getTransactionHistory!('0x123', 10) + expect(result).toEqual([valid]) + expect(warnSpy).toHaveBeenCalled() + }) + + it('throws InvalidDataError 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) + }) + }) + describe('convenience properties', () => { it('supportsNativeBalance reflects provider capabilities', () => { const provider = createMockProvider({ diff --git a/src/services/chain-adapter/providers/__tests__/wrapped-transaction-provider.test.ts b/src/services/chain-adapter/providers/__tests__/wrapped-transaction-provider.test.ts index 66ada700f..7cd9e4f60 100644 --- a/src/services/chain-adapter/providers/__tests__/wrapped-transaction-provider.test.ts +++ b/src/services/chain-adapter/providers/__tests__/wrapped-transaction-provider.test.ts @@ -64,19 +64,23 @@ describe('WrappedTransactionProvider', () => { hash: '0xabc', from: '0x1', to: '0x2', - value: '1000', - symbol: 'TEST', + amount: Amount.fromRaw('1000', 8, 'TEST'), + fee: Amount.fromRaw('10', 8, 'TEST'), timestamp: 1234567890, - status: 'confirmed', + status: { status: 'confirmed', confirmations: 1, requiredConfirmations: 1 }, blockNumber: 100n, + type: 'transfer', }] vi.mocked(transactionService.getTransactionHistory).mockResolvedValue(mockTxs) - const result = await provider.getTransactionHistory('0x123', 10) + const result = await provider.getTransactionHistory('0x1', 10) - expect(transactionService.getTransactionHistory).toHaveBeenCalledWith('0x123', 10) + expect(transactionService.getTransactionHistory).toHaveBeenCalledWith('0x1', 10) expect(result).toHaveLength(1) expect(result[0].hash).toBe('0xabc') + expect(result[0].action).toBe('transfer') + expect(result[0].direction).toBe('out') + expect(result[0].assets[0]?.value).toBe('1000') }) }) @@ -86,10 +90,11 @@ describe('WrappedTransactionProvider', () => { hash: '0xabc', from: '0x1', to: '0x2', - value: '1000', - symbol: 'TEST', + amount: Amount.fromRaw('1000', 8, 'TEST'), + fee: Amount.fromRaw('10', 8, 'TEST'), timestamp: 1234567890, - status: 'confirmed', + status: { status: 'confirmed', confirmations: 1, requiredConfirmations: 1 }, + type: 'transfer', } vi.mocked(transactionService.getTransaction).mockResolvedValue(mockTx) @@ -97,6 +102,7 @@ describe('WrappedTransactionProvider', () => { expect(transactionService.getTransaction).toHaveBeenCalledWith('0xabc') expect(result?.hash).toBe('0xabc') + expect(result?.action).toBe('transfer') }) it('returns null when transaction not found', async () => { diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts index 8b0d95daa..2db251e26 100644 --- a/src/services/chain-adapter/providers/chain-provider.ts +++ b/src/services/chain-adapter/providers/chain-provider.ts @@ -30,6 +30,9 @@ const READ_METHODS = new Set([ 'getBlockHeight', ]) +import { TransactionSchema } from './types' +import { InvalidDataError } from './errors' + export class ChainProvider { readonly chainId: string private readonly providers: ApiProvider[] @@ -182,11 +185,71 @@ export class ChainProvider { } get getTransactionHistory(): ((address: string, limit?: number) => Promise) | undefined { - return this.getMethod('getTransactionHistory') + const getHistory = this.getMethod('getTransactionHistory') + if (!getHistory) return undefined + + return async (address: string, limit?: number) => { + const raw: unknown = await (getHistory as unknown as (address: string, limit?: number) => Promise)( + address, + limit, + ) + + if (!Array.isArray(raw)) { + console.warn('[ChainProvider] Invalid transaction history payload (not array)', { + chainId: this.chainId, + method: 'getTransactionHistory', + }) + return [] + } + + 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 + } } get getTransaction(): ((hash: string) => Promise) | undefined { - return this.getMethod('getTransaction') + const getTransaction = this.getMethod('getTransaction') + if (!getTransaction) return undefined + + 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, + }) + } + + return parsed.data + } } get getTransactionStatus(): ((hash: string) => Promise) | undefined { diff --git a/src/services/chain-adapter/providers/errors.ts b/src/services/chain-adapter/providers/errors.ts new file mode 100644 index 000000000..e501c9cc7 --- /dev/null +++ b/src/services/chain-adapter/providers/errors.ts @@ -0,0 +1,29 @@ +import type { ZodIssue } from 'zod' + +export type InvalidDataSource = 'fetch' | 'indexeddb' | 'provider' + +export interface InvalidDataErrorParams { + source: InvalidDataSource + chainId: string + method: string + issues?: ZodIssue[] + cause?: unknown +} + +export class InvalidDataError extends Error { + readonly source: InvalidDataSource + readonly chainId: string + readonly method: string + readonly issues: ZodIssue[] + + constructor(params: InvalidDataErrorParams) { + super(`[InvalidData] ${params.source}:${params.chainId}:${params.method}`, { + cause: params.cause instanceof Error ? params.cause : undefined, + }) + this.name = 'InvalidDataError' + this.source = params.source + this.chainId = params.chainId + this.method = params.method + this.issues = params.issues ?? [] + } +} diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts index 5e171c5e3..2cd38e137 100644 --- a/src/services/chain-adapter/providers/index.ts +++ b/src/services/chain-adapter/providers/index.ts @@ -6,6 +6,7 @@ export * from './types' export { ChainProvider } from './chain-provider' +export { InvalidDataError } from './errors' // API Provider 实现 export { EtherscanProvider, createEtherscanProvider } from './etherscan-provider' diff --git a/src/services/chain-adapter/providers/transaction-schema.ts b/src/services/chain-adapter/providers/transaction-schema.ts index bf7efe60e..3dbef0fde 100644 --- a/src/services/chain-adapter/providers/transaction-schema.ts +++ b/src/services/chain-adapter/providers/transaction-schema.ts @@ -21,6 +21,7 @@ export const ActionSchema = z.enum([ // 通用操作 'transfer', // 转账 'swap', // 兑换 + 'exchange', // 交换 (BioForest legacy) 'approve', // 授权 'revoke', // 撤销授权 'signature', // 签名/签章 diff --git a/src/services/chain-adapter/providers/wrapped-transaction-provider.ts b/src/services/chain-adapter/providers/wrapped-transaction-provider.ts index 692b01552..b6f1d9687 100644 --- a/src/services/chain-adapter/providers/wrapped-transaction-provider.ts +++ b/src/services/chain-adapter/providers/wrapped-transaction-provider.ts @@ -41,7 +41,7 @@ export class WrappedTransactionProvider implements ApiProvider { async getTransactionHistory(address: string, limit?: number): Promise { const txs = await this.transactionService.getTransactionHistory(address, limit) - return txs.map(tx => this.convertTransaction(tx)) + return txs.map(tx => this.convertTransaction(tx, address)) } async getTransaction(hash: string): Promise { @@ -76,17 +76,97 @@ export class WrappedTransactionProvider implements ApiProvider { return this.transactionService.broadcastTransaction(signedTx) } - private convertTransaction(tx: AdapterTransaction): Transaction { + private convertTransaction(tx: AdapterTransaction, addressForDirection?: string): Transaction { + const direction: Transaction['direction'] = addressForDirection + ? (tx.from.toLowerCase() === addressForDirection.toLowerCase() ? 'out' : tx.to.toLowerCase() === addressForDirection.toLowerCase() ? 'in' : 'self') + : 'self' + + const action: Transaction['action'] = this.deriveAction(tx) + return { hash: tx.hash, from: tx.from, - to: tx.to ?? '', - value: tx.value, - symbol: tx.symbol, + to: tx.to, timestamp: tx.timestamp, - status: tx.status, + status: tx.status.status === 'confirmed' ? 'confirmed' : tx.status.status === 'failed' ? 'failed' : 'pending', blockNumber: tx.blockNumber, + action, + direction, + assets: [ + { + assetType: 'native', + value: tx.amount.toRawString(), + symbol: tx.amount.symbol ?? '', + decimals: tx.amount.decimals, + }, + ], + contract: tx.type === 'contract-call' || action === 'contract' ? { address: tx.to } : undefined, + fee: { + value: tx.fee.toRawString(), + symbol: tx.fee.symbol ?? '', + decimals: tx.fee.decimals, + }, + } + } + + private deriveAction(tx: AdapterTransaction): Transaction['action'] { + const rawType = tx.rawType + if (rawType) { + // 安全 + if (rawType.includes('BSE-01')) return 'signature' + + // 权益操作 + if (rawType.includes('AST-02')) return 'transfer' + if (rawType.includes('AST-03')) return 'destroyAsset' + if (rawType.includes('AST-04')) return 'gift' + if (rawType.includes('AST-05')) return 'grab' + if (rawType.includes('AST-06')) return 'trust' + if (rawType.includes('AST-07')) return 'signFor' + if (rawType.includes('AST-08')) return 'emigrate' + if (rawType.includes('AST-09')) return 'immigrate' + if (rawType.includes('AST-10') || rawType.includes('AST-11')) return 'exchange' + if (rawType.includes('AST-12')) return 'stake' + if (rawType.includes('AST-13')) return 'unstake' + if (rawType.includes('AST-00')) return 'issueAsset' + if (rawType.includes('AST-01')) return 'increaseAsset' + + // NFT + if (rawType.includes('ETY-02') || rawType.includes('ETY-04')) return 'issueEntity' + if (rawType.includes('ETY-03')) return 'destroyEntity' + if (rawType.includes('ETY-00') || rawType.includes('ETY-01')) return 'issueAsset' + + // 任意资产 + if (rawType.includes('ANY-00')) return 'transfer' + if (rawType.includes('ANY-01')) return 'gift' + if (rawType.includes('ANY-02')) return 'grab' + if ( + rawType.includes('ANY-03') || + rawType.includes('ANY-04') || + rawType.includes('ANY-05') || + rawType.includes('ANY-06') || + rawType.includes('ANY-07') || + rawType.includes('ANY-08') + ) { + return 'exchange' + } + + // 位名 + if (rawType.includes('LNS-')) return 'locationName' + + // DApp + if (rawType.includes('WOD-')) return 'dapp' + + // 凭证 + if (rawType.includes('CRT-')) return 'certificate' + + // 数据存证 + if (rawType.includes('EXT-00')) return 'mark' } + + if (tx.type === 'stake') return 'stake' + if (tx.type === 'unstake') return 'unstake' + if (tx.type === 'contract-call') return 'contract' + return 'transfer' } private convertTransactionStatus(status: AdapterTransactionStatus): TransactionStatus { diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index d52f53837..47582092c 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -99,11 +99,15 @@ async function loadDefaultChainConfigs(): Promise { } const json: unknown = await response.json() - const parsed = VersionedChainConfigFileSchema.parse(json) + const parsedResult = VersionedChainConfigFileSchema.safeParse(json) + if (!parsedResult.success) { + const firstIssue = parsedResult.error.issues[0] + throw new Error(firstIssue?.message ?? 'Invalid default chain config file') + } + + const parsed = parsedResult.data - const configs = parsed.chains.map((chain) => { - // Strict validation: unknown chainKind will fail schema validation directly - const config = ChainConfigSchema.parse(chain) + const configs = parsed.chains.map((config) => { const resolvedPaths = resolveIconPaths(config, jsonUrl) return { ...config, @@ -172,9 +176,16 @@ function resolveIconPaths( function parseConfigs(input: unknown, source: ChainConfigSource, jsonFileUrl?: string): ChainConfig[] { // Strict validation: unknown chainKind will fail schema validation directly - const parsed = Array.isArray(input) - ? ChainConfigListSchema.parse(input) - : [ChainConfigSchema.parse(input)] + const parsedResult = Array.isArray(input) + ? ChainConfigListSchema.safeParse(input) + : ChainConfigSchema.safeParse(input) + + if (!parsedResult.success) { + const firstIssue = parsedResult.error.issues[0] + throw new Error(firstIssue?.message ?? 'Invalid chain config') + } + + const parsed = Array.isArray(input) ? parsedResult.data : [parsedResult.data] return parsed.map((config) => { const resolvedPaths = jsonFileUrl ? resolveIconPaths(config, jsonFileUrl) : {} diff --git a/src/services/chain-config/storage.ts b/src/services/chain-config/storage.ts index df3925f8d..c018b52fc 100644 --- a/src/services/chain-config/storage.ts +++ b/src/services/chain-config/storage.ts @@ -1,4 +1,5 @@ -import { ChainConfigSubscriptionSchema } from './schema' +import { z } from 'zod' +import { ChainConfigSchema, ChainConfigSourceSchema, ChainConfigSubscriptionSchema } from './schema' import type { ChainConfig, ChainConfigSource, ChainConfigSubscription } from './types' const DB_NAME = 'bfm_chain_config' @@ -24,6 +25,16 @@ interface PreferenceRecord { let dbInstance: IDBDatabase | null = null let dbOpening: Promise | null = null +const BaseChainConfigSchema = ChainConfigSchema.omit({ enabled: true, source: true }) +const ChainConfigRecordSchema = z.object({ + key: z.string(), + id: z.string(), + source: ChainConfigSourceSchema, + config: BaseChainConfigSchema, +}) + +const EnabledMapRawSchema = z.record(z.string(), z.any()) + function assertIndexedDbAvailable(): void { if (typeof indexedDB === 'undefined') { throw new Error('indexedDB is not available in this environment') @@ -151,29 +162,32 @@ export async function loadChainConfigs(): Promise { const records = await requestToPromise(store.getAll()) await transactionDone(tx) - return records - .filter((r): r is ChainConfigRecord => { - return ( - typeof r === 'object' && - r !== null && - 'source' in r && - 'config' in r && - 'id' in r - ) - }) - .map((record) => ({ - ...record.config, - source: record.source, - enabled: true, - })) -} + const configs: ChainConfig[] = [] -function isRecordOfBoolean(value: unknown): value is Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false - for (const entry of Object.values(value as Record)) { - if (typeof entry !== 'boolean') return false + for (const raw of records as unknown[]) { + const parsed = ChainConfigRecordSchema.safeParse(raw) + if (!parsed.success) { + console.warn('[ChainConfigStorage] Invalid chain config record:', parsed.error.issues[0]) + continue + } + + if (parsed.data.id !== parsed.data.config.id) { + console.warn('[ChainConfigStorage] Invalid chain config record (id mismatch):', { + key: parsed.data.key, + id: parsed.data.id, + configId: parsed.data.config.id, + }) + continue + } + + configs.push({ + ...parsed.data.config, + source: parsed.data.source, + enabled: true, + }) } - return true + + return configs } export async function saveUserPreferences(preferences: Record): Promise { @@ -197,7 +211,31 @@ export async function loadUserPreferences(): Promise> { if (!record || typeof record !== 'object') return {} const value = (record as PreferenceRecord).value - return isRecordOfBoolean(value) ? value : {} + const parsed = EnabledMapRawSchema.safeParse(value) + if (!parsed.success) { + console.warn('[ChainConfigStorage] Invalid enabledMap:', parsed.error.issues[0]) + return {} + } + + const result: Record = {} + for (const [key, rawFlag] of Object.entries(parsed.data)) { + if (typeof rawFlag === 'boolean') { + result[key] = rawFlag + continue + } + + if (typeof rawFlag === 'string') { + const normalized = rawFlag.trim().toLowerCase() + if (normalized === 'true' || normalized === '1') result[key] = true + else if (normalized === 'false' || normalized === '0') result[key] = false + continue + } + + if (rawFlag === 1) result[key] = true + else if (rawFlag === 0) result[key] = false + } + + return result } export async function saveSubscriptionMeta(meta: ChainConfigSubscription): Promise { @@ -263,5 +301,10 @@ export async function loadDefaultVersion(): Promise { if (!record || typeof record !== 'object') return null const value = (record as PreferenceRecord).value - return typeof value === 'string' ? value : null + const parsed = z.string().safeParse(value) + if (!parsed.success) { + console.warn('[ChainConfigStorage] Invalid defaultVersion:', parsed.error.issues[0]) + return null + } + return parsed.data } diff --git a/src/services/chain-config/subscription.ts b/src/services/chain-config/subscription.ts index 7c8120d5f..92945e230 100644 --- a/src/services/chain-config/subscription.ts +++ b/src/services/chain-config/subscription.ts @@ -31,10 +31,17 @@ const REQUEST_TIMEOUT = 10_000 export function parseAndValidate(json: unknown): ChainConfig[] { // Strict validation: unknown chainKind will fail schema validation directly const parsed = Array.isArray(json) - ? ChainConfigListSchema.parse(json) - : [ChainConfigSchema.parse(json)] + ? ChainConfigListSchema.safeParse(json) + : ChainConfigSchema.safeParse(json) - return parsed.map((c) => ({ + if (!parsed.success) { + const firstIssue = parsed.error.issues[0] + throw new Error(firstIssue?.message ?? 'Invalid subscription chain config') + } + + const list = Array.isArray(json) ? parsed.data : [parsed.data] + + return list.map((c) => ({ ...c, source: 'subscription', enabled: true, @@ -106,11 +113,16 @@ export async function fetchSubscription( const merged = mergeWithExisting(fetchedConfigs, cachedConfigs) const responseEtag = response.headers.get('ETag') ?? undefined - const nextMeta: ChainConfigSubscription = ChainConfigSubscriptionSchema.parse({ + const nextMetaParsed = ChainConfigSubscriptionSchema.safeParse({ url, ...(responseEtag !== undefined ? { etag: responseEtag } : {}), lastUpdated: new Date().toISOString(), }) + if (!nextMetaParsed.success) { + return { status: 'error', error: 'Invalid subscription meta', configs: cachedConfigs, meta: existingMeta ?? null } + } + + const nextMeta: ChainConfigSubscription = nextMetaParsed.data await saveChainConfigs({ source: 'subscription', configs: merged }) await saveSubscriptionMeta(nextMeta) diff --git a/src/services/ecosystem/storage.ts b/src/services/ecosystem/storage.ts index 97085e358..e0cfe8873 100644 --- a/src/services/ecosystem/storage.ts +++ b/src/services/ecosystem/storage.ts @@ -1,4 +1,6 @@ +import { z } from 'zod' import type { EcosystemSource } from './types' +import { EcosystemSourceSchema } from './schema' const DB_NAME = 'bfm_ecosystem' const DB_VERSION = 1 @@ -12,6 +14,13 @@ export interface SourcePayloadRecord { payload: EcosystemSource } +const SourcePayloadRecordSchema = z.object({ + url: z.string(), + etag: z.string().optional(), + lastUpdated: z.string(), + payload: EcosystemSourceSchema, +}) + let dbInstance: IDBDatabase | null = null let dbOpening: Promise | null = null @@ -81,14 +90,12 @@ export async function loadSourcePayload(url: string): Promise - - if (typeof parsed.url !== 'string') return null - if (typeof parsed.lastUpdated !== 'string') return null - if (typeof parsed.payload !== 'object' || parsed.payload === null) return null - - return parsed as SourcePayloadRecord + const parsed = SourcePayloadRecordSchema.safeParse(record) + if (!parsed.success) { + console.warn('[EcosystemStorage] Invalid source payload record:', parsed.error.issues[0]) + return null + } + return parsed.data } export async function deleteSourcePayload(url: string): Promise { diff --git a/src/services/migration/mpay-reader.ts b/src/services/migration/mpay-reader.ts index 16b2bf0bf..81edd59d8 100644 --- a/src/services/migration/mpay-reader.ts +++ b/src/services/migration/mpay-reader.ts @@ -12,6 +12,76 @@ import type { MpayAddressBookEntry, } from './types' +import { z } from 'zod' + +const MpayAddressAssetSchema = z.looseObject({ + assetType: z.string(), + decimals: z.number(), + amount: z.string().optional(), + useDefaultDecimals: z.boolean().optional(), + logoUrl: z.string().optional(), + contractAddress: z.string().optional(), +}) + +const MpayChainAddressInfoSchema: z.ZodType = z.looseObject({ + address: z.string(), + symbol: z.string(), + addressKey: z.string(), + mainWalletId: z.string(), + chain: z.string(), + mnemonic: z.string().optional(), + privateKey: z.string(), + publicKey: z.string().optional(), + isCustomAssets: z.boolean().optional(), + isFreezed: z.boolean().optional(), + assets: z.array(MpayAddressAssetSchema), + name: z.string(), + prohibitChangeName: z.boolean().optional(), + index: z.number().optional(), + purpose: z.union([z.literal(44), z.literal(49), z.literal(84), z.literal(86)]).optional(), +}) + +const MpayMainWalletAddressInfoSchema = z.looseObject({ + chainName: z.string(), + addressKey: z.string(), + address: z.string(), + symbol: z.string(), + mainWalletId: z.string(), + index: z.number().optional(), + purpose: z.union([z.literal(44), z.literal(49), z.literal(84), z.literal(86)]).optional(), +}) + +const MpayMainWalletSchema: z.ZodType = z.looseObject({ + name: z.string(), + skipBackup: z.boolean().optional(), + importPhrase: z.string(), + importType: z.string(), + addressKeyList: z.array(MpayMainWalletAddressInfoSchema), + mainWalletId: z.string(), + headSculpture: z.string(), + createTimestamp: z.number(), + lastBitcoinAddressKey: z.string().optional(), +}) + +const MpayAddressBookEntrySchema: z.ZodType = z.looseObject({ + addressBookId: z.string(), + name: z.string(), + address: z.string(), + chainList: z.array(z.string()).optional(), + remarks: z.string().optional(), + iconName: z.string().optional(), + symbol: z.string().optional(), +}) + +const MpayWalletAppSettingsSchema: z.ZodType = z.looseObject({ + password: z.string(), + passwordTips: z.string().optional(), + lastWalletActivate: MpayChainAddressInfoSchema.optional(), + walletLock: z.boolean().optional(), + fingerprintLock: z.boolean().optional(), + fingerprintPay: z.boolean().optional(), +}) + const MPAY_IDB_NAME = 'walletv2-idb' const MPAY_ADDRESS_BOOK_IDB = 'chainAddressBook-idb' const MPAY_SETTINGS_KEY = '👨‍👩‍👧‍👦walletAppSetting' @@ -37,7 +107,7 @@ async function checkIndexedDBExists(): Promise { /** * 获取 IndexedDB store 中的所有数据 */ -async function getAllFromStore(storeName: string): Promise { +async function getAllFromStore(storeName: string): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(MPAY_IDB_NAME) request.onsuccess = () => { @@ -66,7 +136,7 @@ async function getAllFromStore(storeName: string): Promise { /** * 获取指定 IndexedDB 数据库中的所有数据 */ -async function getAllFromDatabase(dbName: string): Promise { +async function getAllFromDatabase(dbName: string): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName) request.onsuccess = () => { @@ -107,7 +177,9 @@ function getMpaySettings(): MpayWalletAppSettings | null { try { const json = localStorage.getItem(MPAY_SETTINGS_KEY) if (!json) return null - return JSON.parse(json) as MpayWalletAppSettings + const raw: unknown = JSON.parse(json) + const parsed = MpayWalletAppSettingsSchema.safeParse(raw) + return parsed.success ? parsed.data : null } catch { return null } @@ -129,8 +201,8 @@ export async function detectMpayData(): Promise { } const [wallets, addresses, addressBook] = await Promise.all([ - getAllFromStore('mainWallet'), - getAllFromStore('chainAddress'), + readMpayWallets(), + readMpayAddresses(), readMpayAddressBook(), ]) @@ -149,14 +221,34 @@ export async function detectMpayData(): Promise { * 读取所有 mpay 钱包数据 */ export async function readMpayWallets(): Promise { - return getAllFromStore('mainWallet') + const raw = await getAllFromStore('mainWallet') + const wallets: MpayMainWallet[] = [] + for (const item of raw) { + const parsed = MpayMainWalletSchema.safeParse(item) + if (!parsed.success) { + console.warn('[mpay-reader] Invalid mainWallet record:', parsed.error.issues[0]) + continue + } + wallets.push(parsed.data) + } + return wallets } /** * 读取所有 mpay 链地址数据 */ export async function readMpayAddresses(): Promise { - return getAllFromStore('chainAddress') + const raw = await getAllFromStore('chainAddress') + const addresses: MpayChainAddressInfo[] = [] + for (const item of raw) { + const parsed = MpayChainAddressInfoSchema.safeParse(item) + if (!parsed.success) { + console.warn('[mpay-reader] Invalid chainAddress record:', parsed.error.issues[0]) + continue + } + addresses.push(parsed.data) + } + return addresses } /** @@ -171,7 +263,17 @@ export function readMpaySettings(): MpayWalletAppSettings | null { */ export async function readMpayAddressBook(): Promise { try { - return await getAllFromDatabase(MPAY_ADDRESS_BOOK_IDB) + const raw = await getAllFromDatabase(MPAY_ADDRESS_BOOK_IDB) + const entries: MpayAddressBookEntry[] = [] + for (const item of raw) { + const parsed = MpayAddressBookEntrySchema.safeParse(item) + if (!parsed.success) { + console.warn('[mpay-reader] Invalid address book record:', parsed.error.issues[0]) + continue + } + entries.push(parsed.data) + } + return entries } catch { // 地址簿数据库可能不存在 return [] diff --git a/src/services/transaction/mock.ts b/src/services/transaction/mock.ts index dae1bbf16..850270639 100644 --- a/src/services/transaction/mock.ts +++ b/src/services/transaction/mock.ts @@ -2,9 +2,14 @@ * Transaction 服务 - Mock 实现 */ +import { z } from 'zod' import { Amount } from '@/types/amount' import { transactionServiceMeta, type ITransactionMockController, type TransactionRecord, type TransactionFilter, type TransactionType, type TransactionStatus } from './types' import type { ChainType } from '@/stores' +import { ActionSchema, AssetSchema, ContractInfoSchema, DirectionSchema } from '@/services/chain-adapter/providers/transaction-schema' + +const E2E_FIXTURE_STORAGE_KEY = 'bfm_e2e_transaction_fixtures_v1' +const E2E_FIXTURE_GLOBAL_KEY = '__BFM_E2E_TRANSACTION_FIXTURES_V1__' // ==================== 工具函数 ==================== @@ -28,6 +33,113 @@ const TOKEN_DECIMALS: Record = { BFC: 8, CC: 8, PM: 8, BTG: 8, BIW: 8, ETHM: 18, MAL: 8, TOKEN: 18, } +const TransactionTypeSchema = z.enum([ + 'send', 'receive', 'signature', 'stake', 'unstake', 'destroy', + 'gift', 'grab', 'trust', 'signFor', 'emigrate', 'immigrate', 'exchange', 'swap', + 'issueAsset', 'increaseAsset', 'mint', 'issueEntity', 'destroyEntity', + 'locationName', 'dapp', 'certificate', 'approve', 'interaction', 'mark', 'other', +]) + +const TransactionStatusSchema = z.enum(['pending', 'confirmed', 'failed']) + +const FixtureAmountSchema = z.object({ + value: z.string(), + decimals: z.number(), + symbol: z.string(), +}) + +const TransactionFixtureSchema = z.object({ + id: z.string(), + type: TransactionTypeSchema, + status: TransactionStatusSchema, + amount: FixtureAmountSchema, + symbol: z.string(), + decimals: z.number(), + address: z.string(), + timestamp: z.union([z.number(), z.string()]), + hash: z.string().optional(), + chain: z.string(), + fee: FixtureAmountSchema.optional(), + feeSymbol: z.string().optional(), + feeDecimals: z.number().optional(), + blockNumber: z.number().optional(), + confirmations: z.number().optional(), + from: z.string().optional(), + to: z.string().optional(), + action: ActionSchema.optional(), + direction: DirectionSchema.optional(), + assets: z.array(AssetSchema).optional(), + contract: ContractInfoSchema.optional(), +}) + +const TransactionFixturePayloadSchema = z.union([ + z.array(TransactionFixtureSchema), + z.object({ + version: z.literal(1).optional(), + transactions: z.array(TransactionFixtureSchema), + }), +]) + +function tryLoadFixturePayload(): unknown | null { + try { + const globalValue = (globalThis as any)[E2E_FIXTURE_GLOBAL_KEY] + if (globalValue !== undefined && globalValue !== null) return globalValue + + if (typeof localStorage === 'undefined') return null + const raw = localStorage.getItem(E2E_FIXTURE_STORAGE_KEY) + if (!raw) return null + return JSON.parse(raw) as unknown + } catch { + return null + } +} + +function tryLoadFixtureTransactions(): TransactionRecord[] | null { + const payload = tryLoadFixturePayload() + if (!payload) return null + + const parsed = TransactionFixturePayloadSchema.safeParse(payload) + if (!parsed.success) { + // eslint-disable-next-line no-console + console.warn('[transaction/mock] invalid e2e fixture payload, fallback to generated transactions', parsed.error) + return null + } + + const items = Array.isArray(parsed.data) ? parsed.data : parsed.data.transactions + const records: TransactionRecord[] = [] + + for (const item of items) { + const date = new Date(item.timestamp) + if (Number.isNaN(date.getTime())) continue + + records.push({ + id: item.id, + type: item.type, + status: item.status, + amount: Amount.fromRaw(item.amount.value, item.amount.decimals, item.amount.symbol), + symbol: item.symbol, + decimals: item.decimals, + address: item.address, + timestamp: date, + hash: item.hash, + chain: item.chain as ChainType, + fee: item.fee ? Amount.fromRaw(item.fee.value, item.fee.decimals, item.fee.symbol) : undefined, + feeSymbol: item.fee?.symbol ?? item.feeSymbol, + feeDecimals: item.fee?.decimals ?? item.feeDecimals, + blockNumber: item.blockNumber, + confirmations: item.confirmations, + from: item.from, + to: item.to, + action: item.action, + direction: item.direction, + assets: item.assets, + contract: item.contract, + }) + } + + return records.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) +} + function generateInitialTransactions(count: number = 30): TransactionRecord[] { const types: TransactionType[] = ['send', 'receive', 'exchange', 'stake', 'unstake'] const statuses: TransactionStatus[] = ['confirmed', 'confirmed', 'confirmed', 'pending', 'failed'] @@ -102,7 +214,7 @@ function filterByChain(transactions: TransactionRecord[], chain: ChainType | 'al // ==================== Mock 状态 ==================== -let transactions = generateInitialTransactions(30) +let transactions = tryLoadFixtureTransactions() ?? generateInitialTransactions(30) let delayMs = 200 let simulatedError: Error | null = null diff --git a/src/services/transaction/types.ts b/src/services/transaction/types.ts index f3db1fb90..fdf546b28 100644 --- a/src/services/transaction/types.ts +++ b/src/services/transaction/types.ts @@ -6,6 +6,8 @@ import { z } from 'zod' import { defineServiceMeta } from '@/lib/service-meta' import { Amount } from '@/types/amount' import type { ChainType } from '@/stores' +import type { Action, Asset, ContractInfo, Direction } from '@/services/chain-adapter/providers/types' +import { ActionSchema, AssetSchema, ContractInfoSchema, DirectionSchema } from '@/services/chain-adapter/providers/transaction-schema' // Custom Amount schema for Zod validation const AmountSchema = z.custom((val) => val instanceof Amount) @@ -28,9 +30,11 @@ export type TransactionType = | 'emigrate' // AST-08 权益迁出 | 'immigrate' // AST-09 权益迁入 | 'exchange' // AST-10/11 权益交换 + | 'swap' // Swap // 资产发行 | 'issueAsset' // AST-00 创建权益 | 'increaseAsset' // AST-01 增发权益 + | 'mint' // Mint // NFT | 'issueEntity' // ETY-02 创建非同质资产 | 'destroyEntity' // ETY-03 销毁非同质资产 @@ -67,6 +71,12 @@ export interface TransactionRecord { feeDecimals?: number | undefined blockNumber?: number | undefined confirmations?: number | undefined + from?: string | undefined + to?: string | undefined + action?: Action | undefined + direction?: Direction | undefined + assets?: Asset[] | undefined + contract?: ContractInfo | undefined } /** 交易过滤器 */ @@ -82,8 +92,8 @@ export interface TransactionFilter { // All transaction types for validation const TransactionTypeEnum = z.enum([ 'send', 'receive', 'signature', 'stake', 'unstake', 'destroy', - 'gift', 'grab', 'trust', 'signFor', 'emigrate', 'immigrate', 'exchange', - 'issueAsset', 'increaseAsset', 'issueEntity', 'destroyEntity', + 'gift', 'grab', 'trust', 'signFor', 'emigrate', 'immigrate', 'exchange', 'swap', + 'issueAsset', 'increaseAsset', 'mint', 'issueEntity', 'destroyEntity', 'locationName', 'dapp', 'certificate', 'approve', 'interaction', 'mark', 'other', ]) @@ -103,6 +113,12 @@ const TransactionRecordSchema = z.object({ feeDecimals: z.number().optional(), blockNumber: z.number().optional(), confirmations: z.number().optional(), + from: z.string().optional(), + to: z.string().optional(), + action: ActionSchema.optional(), + direction: DirectionSchema.optional(), + assets: z.array(AssetSchema).optional(), + contract: ContractInfoSchema.optional(), }) const TransactionFilterSchema = z.object({ diff --git a/src/services/transaction/web.test.ts b/src/services/transaction/web.test.ts index c64ecd2f7..86f9f0342 100644 --- a/src/services/transaction/web.test.ts +++ b/src/services/transaction/web.test.ts @@ -70,11 +70,19 @@ describe('transactionService(web)', () => { hash: '0xdeadbeef', from: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa', to: '0xBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBb', - value: '1000000000000000000', - symbol: 'ETH', timestamp: 1_700_000_000_000, status: 'confirmed', blockNumber: 123n, + action: 'transfer', + direction: 'out', + assets: [ + { + assetType: 'native', + value: '1000000000000000000', + symbol: 'ETH', + decimals: 18, + }, + ], }, ]) diff --git a/src/services/transaction/web.ts b/src/services/transaction/web.ts index b326dcad1..5c91066b4 100644 --- a/src/services/transaction/web.ts +++ b/src/services/transaction/web.ts @@ -5,13 +5,19 @@ 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 { createBioforestAdapter, type Transaction as ChainTransaction } from '@/services/chain-adapter' -import { getChainProvider, type Transaction as ProviderTransaction } from '@/services/chain-adapter/providers' +import { getChainProvider, type Transaction as ProviderTransaction, InvalidDataError } from '@/services/chain-adapter/providers' import { Amount } from '@/types/amount' +import { mapActionToTransactionType } from '@/components/transaction/transaction-meta' const recordCache = new Map() type TransactionFilterInput = Partial | undefined +function parseRecordId(id: string): { chainId: string; hash: string } | null { + const [chainId, hash] = id.split('--') + if (!chainId || !hash) return null + return { chainId, hash } +} + async function fetchHistory(walletId: string, filter?: TransactionFilterInput): Promise { await walletStorageService.initialize() @@ -28,19 +34,12 @@ async function fetchHistory(walletId: string, filter?: TransactionFilterInput): const config = enabledMap.get(addressInfo.chain) ?? getChainById(snapshot, addressInfo.chain) if (!config || !config.enabled) return [] - if (config.chainKind === 'bioforest') { - const adapter = createBioforestAdapter(config.id) - return adapter.transaction.getTransactionHistory(addressInfo.address, 50).then((list) => - list.map((tx) => mapChainTransaction(tx, config, addressInfo.address)) - ) - } - 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, addressInfo.address)) + list.map((tx) => mapProviderTransaction(tx, config)) ) } catch { return [] @@ -67,107 +66,49 @@ async function fetchHistory(walletId: string, filter?: TransactionFilterInput): return records } -function mapStatus(status: ChainTransaction['status']): TransactionStatus { - if (status.status === 'confirmed') return 'confirmed' - if (status.status === 'failed') return 'failed' - return 'pending' -} +// mapProviderType removed - use mapActionToTransactionType from transaction-meta.ts -function mapType(tx: ChainTransaction, address: string, rawType?: string): TransactionType { - // Handle BioForest chain transaction types - if (rawType) { - // 安全 - if (rawType.includes('BSE-01')) return 'signature' - // 权益操作 - if (rawType.includes('AST-02')) return tx.from === address ? 'send' : 'receive' - if (rawType.includes('AST-03')) return 'destroy' - if (rawType.includes('AST-04')) return 'gift' - if (rawType.includes('AST-05')) return 'grab' - if (rawType.includes('AST-06')) return 'trust' - if (rawType.includes('AST-07')) return 'signFor' - if (rawType.includes('AST-08')) return 'emigrate' - if (rawType.includes('AST-09')) return 'immigrate' - if (rawType.includes('AST-10') || rawType.includes('AST-11')) return 'exchange' - if (rawType.includes('AST-12')) return 'stake' - if (rawType.includes('AST-13')) return 'unstake' - if (rawType.includes('AST-00')) return 'issueAsset' - if (rawType.includes('AST-01')) return 'increaseAsset' - // NFT - if (rawType.includes('ETY-02') || rawType.includes('ETY-04')) return 'issueEntity' - if (rawType.includes('ETY-03')) return 'destroyEntity' - if (rawType.includes('ETY-00') || rawType.includes('ETY-01')) return 'issueAsset' - // 任意资产 - if (rawType.includes('ANY-00')) return tx.from === address ? 'send' : 'receive' - if (rawType.includes('ANY-01')) return 'gift' - if (rawType.includes('ANY-02')) return 'grab' - if (rawType.includes('ANY-03') || rawType.includes('ANY-04') || - rawType.includes('ANY-05') || rawType.includes('ANY-06') || - rawType.includes('ANY-07') || rawType.includes('ANY-08')) return 'exchange' - // 位名 - if (rawType.includes('LNS-')) return 'locationName' - // DApp - if (rawType.includes('WOD-')) return 'dapp' - // 凭证 - if (rawType.includes('CRT-')) return 'certificate' - // 数据存证 - if (rawType.includes('EXT-00')) return 'mark' - // 其他 (MTP, PMS, MAC) - if (rawType.includes('MTP-') || rawType.includes('PMS-') || rawType.includes('MAC-')) return 'other' - } - return tx.from === address ? 'send' : 'receive' +function getPrimaryFungibleAsset( + tx: ProviderTransaction, + fallback: { symbol: string; decimals: number }, +): { value: string; symbol: string; decimals: number } { + const primary = tx.assets.find((a) => a.assetType === 'native' || a.assetType === 'token') + if (!primary) return { value: '0', symbol: fallback.symbol, decimals: fallback.decimals } + return { value: primary.value, symbol: primary.symbol, decimals: primary.decimals } } -function mapChainTransaction(tx: ChainTransaction, config: ChainConfig, address: string): TransactionRecord { - const type = mapType(tx, address, tx.rawType) +function mapProviderTransaction(tx: ProviderTransaction, config: ChainConfig): TransactionRecord { + const type = mapActionToTransactionType(tx.action, tx.direction) + const primaryAsset = getPrimaryFungibleAsset(tx, { symbol: config.symbol, decimals: config.decimals }) - return { - // Use '--' as separator to avoid URL routing conflicts with ':' - id: `${config.id}--${tx.hash}`, - type, - status: mapStatus(tx.status), - amount: tx.amount, - symbol: config.symbol, - decimals: config.decimals, - address: type === 'send' ? tx.to : tx.from, - timestamp: new Date(tx.timestamp), - hash: tx.hash, - chain: config.id, - fee: tx.fee, - feeSymbol: config.symbol, - feeDecimals: config.decimals, - blockNumber: tx.blockNumber ? Number(tx.blockNumber) : undefined, - confirmations: tx.status.confirmations, - } -} - -function isSameAddress(left: string, right: string): boolean { - const a = left.trim() - const b = right.trim() - if (a.startsWith('0x') && b.startsWith('0x')) return a.toLowerCase() === b.toLowerCase() - return a === b -} - -function mapProviderTransaction(tx: ProviderTransaction, config: ChainConfig, address: string): TransactionRecord { - const isOutgoing = isSameAddress(tx.from, address) - const type: TransactionType = isOutgoing ? 'send' : 'receive' + const isTransfer = tx.action === 'transfer' + const counterpartyAddress = isTransfer + ? (tx.direction === 'out' ? tx.to : tx.from) + : (tx.contract?.address ?? tx.to ?? tx.from) return { // Use '--' as separator to avoid URL routing conflicts with ':' id: `${config.id}--${tx.hash}`, type, status: tx.status, - amount: Amount.fromRaw(tx.value, config.decimals, config.symbol), - symbol: config.symbol, - decimals: config.decimals, - address: type === 'send' ? tx.to : tx.from, + amount: Amount.fromRaw(primaryAsset.value, primaryAsset.decimals, primaryAsset.symbol), + symbol: primaryAsset.symbol, + decimals: primaryAsset.decimals, + address: counterpartyAddress, timestamp: new Date(tx.timestamp), hash: tx.hash, chain: config.id, blockNumber: tx.blockNumber ? Number(tx.blockNumber) : undefined, confirmations: undefined, - fee: undefined, - feeSymbol: undefined, - feeDecimals: undefined, + fee: tx.fee ? Amount.fromRaw(tx.fee.value, tx.fee.decimals, tx.fee.symbol) : undefined, + feeSymbol: tx.fee?.symbol, + feeDecimals: tx.fee?.decimals, + from: tx.from, + to: tx.to, + action: tx.action, + direction: tx.direction, + assets: tx.assets, + contract: tx.contract, } } @@ -199,7 +140,30 @@ export const transactionService = transactionServiceMeta.impl({ }, async getTransaction({ id }) { - return recordCache.get(id) ?? null + const cached = recordCache.get(id) + if (cached) return cached + + const parsed = parseRecordId(id) + if (!parsed) return null + + const snapshot = await initializeChainConfigs() + const config = getChainById(snapshot, parsed.chainId) + if (!config || !config.enabled) return null + + try { + const provider = getChainProvider(config.id) + if (!provider.getTransaction) return null + + const tx = await provider.getTransaction(parsed.hash) + if (!tx) return null + + const record = mapProviderTransaction(tx, config) + recordCache.set(record.id, record) + return record + } catch (error) { + if (error instanceof InvalidDataError) throw error + return null + } }, async refresh({ walletId }) { diff --git a/src/services/wallet-storage/schema.ts b/src/services/wallet-storage/schema.ts index 95eefc75a..2ca4d5e69 100644 --- a/src/services/wallet-storage/schema.ts +++ b/src/services/wallet-storage/schema.ts @@ -70,3 +70,14 @@ export const StorageMetadataSchema = z.object({ createdAt: z.number(), lastMigratedAt: z.number().optional(), }).passthrough() + +/** 地址簿条目 schema */ +export const AddressBookEntrySchema = z.object({ + id: z.string(), + chain: z.string(), + address: z.string(), + name: z.string(), + note: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), +}).passthrough() diff --git a/src/services/wallet-storage/service.ts b/src/services/wallet-storage/service.ts index a75fb06b9..a153a75c4 100644 --- a/src/services/wallet-storage/service.ts +++ b/src/services/wallet-storage/service.ts @@ -20,7 +20,7 @@ import { WalletStorageErrorCode, WalletStorageMigrationError, } from './types'; -import { WalletInfoSchema, ChainAddressInfoSchema, WalleterInfoSchema } from './schema'; +import { WalletInfoSchema, ChainAddressInfoSchema, WalleterInfoSchema, AddressBookEntrySchema } from './schema'; const DB_NAME = 'bfm-wallet-db'; const DB_VERSION = 1; @@ -546,19 +546,28 @@ export class WalletStorageService { /** 获取地址簿条目 */ async getAddressBookEntry(id: string): Promise { this.ensureInitialized(); - return (await this.db!.get('addressBook', id)) ?? null; + const raw = await this.db!.get('addressBook', id); + if (!raw) return null; + const result = AddressBookEntrySchema.safeParse(raw); + if (!result.success) { + console.warn('[WalletStorage] Invalid address book entry:', result.error.issues[0]); + return null; + } + return result.data as AddressBookEntry; } /** 获取所有地址簿条目 */ async getAllAddressBookEntries(): Promise { this.ensureInitialized(); - return this.db!.getAll('addressBook'); + const raw = await this.db!.getAll('addressBook'); + return safeParseArray(AddressBookEntrySchema, raw, 'indexeddb:addressBook') as AddressBookEntry[]; } /** 获取链的地址簿条目 */ async getChainAddressBookEntries(chain: string): Promise { this.ensureInitialized(); - return this.db!.getAllFromIndex('addressBook', 'by-chain', chain); + const raw = await this.db!.getAllFromIndex('addressBook', 'by-chain', chain); + return safeParseArray(AddressBookEntrySchema, raw, 'indexeddb:addressBook') as AddressBookEntry[]; } /** 删除地址簿条目 */