diff --git a/CHAT.md b/CHAT.md
index 1e9418cc6..78f283282 100644
--- a/CHAT.md
+++ b/CHAT.md
@@ -28,7 +28,7 @@ PDR文件不该包含过多的软件工程技术细节吧,反而应该专注
我们的build.ts脚本所实现的持续集成功能,其实默认生成的是“beta”渠道版本:
1. 这是每次git-push到main分支就会自动触发的。
-2. beta版本的网页版链接应该是 `https://???.github.io/???/webapp-dev/`
+2. beta版本的网页版链接应该是 `https://???.github.io/???/webapp-beta/`
1. 也就是说“stable”版本仍然在`https://???.github.io/???/webapp/`,这个我们可能需要从github-release找最近的一个stable版本
2. 如果要生成“stable”,那么需要本地执行`pnpm gen:stable`,这个脚本默认是在本地执行:
1. 它的目的是更新版本号,生成changelog,这是一个交互式的命令。
@@ -49,7 +49,7 @@ PDR文件不该包含过多的软件工程技术细节吧,反而应该专注
1. 我们需要vitepress作为我们页面的骨架
2. 不论是stable还是beta,默认都应该从github-release下载,如果下载不到,那么就使用本地构建的
-3. download页面可以下载各种渠道的版本,目前有4个:webapp-stable/webapp-dev/dwebapp-stable/dwebapp-dev
+3. download页面可以下载各种渠道的版本,目前有4个:webapp-stable/webapp-beta/dwebapp-stable/dwebapp-beta
---
@@ -1017,6 +1017,7 @@ forge 和 teleport 虽然可以使用我们自己的keyapp的组件库,但是作
新开一个worktree进行工作:
这是之前已经完成的一个pr:
+
```md
现在要开发一套基于iframe的小程序. 入口也是在底部tab中, 就叫做“生态”.
@@ -1039,7 +1040,128 @@ forge 和 teleport 虽然可以使用我们自己的keyapp的组件库,但是作
```
以上pr已经完成,接下来,我们需要开始正式对接这两个小程序的后端.
+
+---
+
具体的信息需要阅读文件: (会话 2025年12月29日.pdf)[/Users/kzf/Dev/bioforestChain/KeyApp/.chat/会话 2025年12月29日.pdf]
我需要你调查会话中的资料,然后生成两篇独立的research文档,也是放在.chat目录下,客观地记录调查结果.
research资料的目的是确保能分别完成这两个小程序的后端功能对接.
+
+另外调查过程中如果遇到什么问题,也将问题记录到research文档中,我会替你去向后端提供商进一步咨询这些问题.
+
+持续调查,直到两篇research文档全部完成.
+
+---
+
+我们会同时开启两个AI在两个独立的worktree中分别工作,
+你是其中一个AI,你的任务是《一键传送》,阅读[接口报告](.chat/research-miniapp-一键传送-backend.md),开始完成 minapps/teleport
+
+---
+
+我们会同时开启两个AI在两个独立的worktree中分别工作,
+你是其中一个AI,你的任务是《锻造》,阅读[接口报告](.chat/research-miniapp-锻造-backend.md),开始完成 minapps/forge
+
+---
+
+worktree new task:
+
+1. 修复小程序的启动屏幕: 现在不是立刻显示
+ 1. 将启动屏幕封装成独立的组件,并进行 单元测试、真实DOM测试、集成测试
+ 2. 将小程序的“桌面”的渲染方案提供给启动屏,因为这种方案更加柔和,不会出现一大片的色块,而是将颜色作为光晕,所以效果更容易令人接受
+2. 优化小程序启动的动画效果: 模拟macos应用启动立刻全屏化的特效
+ 1. 不再使用Stackflow的Activity去展示小程序, 而是使用在我们的“生态页”的“发现|我的”这两个Tab页后,增加一个“应用堆栈”
+ 2. “应用堆栈”如果没有激活的应用,那么是不能滑动过去(Slider滑动),属于禁用状态. 当我们打开小程序,那么它有了应用,就可以使用了.
+ 3. 此时的“启动特效层”是两层:
+ 1. 底层是生态Tab页的Slider滑动
+ 2. 顶层是“特效层”:“我的”页中的应用图标本身是一个popover元素,然后这图标会被“窗口化”,也就是和我们的“小程序窗口”做一个“平滑变换”,参考IOS的动画曲线
+ 3. 注意:这里的关键是,“小程序窗口”其实并不在“应用堆栈”这个DOM中,而始终是一种popover的状态,始终是绝对定位,“应用堆栈”更多只是作为一个背景板
+ 4. “小程序窗口”顶部不再有“应用栏,而是在右上角顶部,会有一个悬浮的“胶囊”,存放两个按钮:“多功能按钮(默认IconDots,授权等动作会切换成其它图标)|关闭(IconCircleDot)”
+ 1. 胶囊需要渲染在安全区域中所以要避开.
+ 2. 所以我们提供的小程序ctx,需要额外再传递safeAreaInsets信息.
+ 5. 点击“关闭(IconCircleDot)”,这时候会“反转启动特效”:
+ 1. 底层是生态Tab页的Slider滑动,回到“我的”
+ 2. 顶层的“特效层”,是“小程序窗口”经过“平滑变换”,变回成“小程序图标”
+ 6. 注意,底部的“生态Tab按钮”要切换图标, 使用 IconAppWindowFilled 这个图标
+ 7. 背景板其实就是“我的”页面的“墙纸”,这里有一个关键的改动:使用swiperjs的Parallax技术,让这个墙纸在三个页面(发现|我的|应用堆栈)中共享
+3. 点击“生态Tab按钮”的效果是:如果未激活,那么跳转到生态Tab页面;如果已激活:
+ 1. 如果在“发现”,那么切换到“我的”
+ 2. 如果在“应用堆栈”,那么切换到“我的”
+ 3. 如果在“我的”,保持不动
+ 4. 这三个图标使用Silder进行封装:可以通过滑动来进行切换, 它本质上是“生态Tab页”的“同步滑动指示器”, 划出的图标透明度要淡化成0,划入则是渐显成1
+ 5. 我要的效果就是当前这种“普通图标”的效果,默认情况下只看到当前Slider对应的图标,生态Page的Slider在滑动的时候,把这个Page滑动进度同步到我们指示器的滑动进度上,
+ 反之,在指示器上滑动,进度同样可以同步给Page滑动进度. 这是一个双向绑定. swiperjs可能没提供这样的内置双向绑定的能力,
+ 如果你调查过确实没内置这个功能,那么注意:你需要监听pointerdown/up来区分是当前的滑动是否是用户的动作,从而决定同步的方向.
+4. 如果在“应用堆栈”,在“生态Tab按钮”向上滑动,可以将目前已经打开的应用全部堆叠展示出来,进入类似IOS那种层叠效果,用户可以通过左右滑动切(Slider),向上滑动关闭应用
+
+---
+
+两种动画路径:
+
+1. icon->window-with-splash
+2. icon->window
+
+这两种动画路径是不一样的:
+
+1. icon->window-with-splash 动画过程中,icon 与 splash.icon 是属于 sharedElement 的关系。而 window.bounding 一开始是 icon.bounding,然后目标是 desktop.bounding
+2. icon->window 动画过程中,icon 和 window 是属于 sharedElement 的关系
+
+---
+
+正确的逻辑和做法是:
+
+1. 应用启动了,那么这时候应该是 stack 这个 slide 页面要先生成。因为它是应用启动后,最后的容器
+2. 确定 stack-slide 到 DOM-tree 中了,那么这时候,需要去获得这个stack-slide的 rect,因为它是我们最终要动画的目的地
+3. icon 从 staic-popover 模式进入 show-popover 模式(popover 的 top-layer 且 position: fixed),window 也准备好,也处于 show-popover 模式
+4. 在开始使用 flip 技术开始计算之前,需要了解一个问题:
+ 1. icon 是一个正方形,window 则是一个长方形
+ 2. 如果我们只是简单地进行 transform,那么虽然效果是平滑的,但是会导致一种拉伸或者压缩的效果
+ 3. 所以我们的解决方案是,不论是 icon 还是 window,它们本身首先是一个 popover,同时也是一个容器,下文我们定义为 popover-container,容器存放着内容使用`object-fit: cover`的方式来充满容器,这样做的目的,是确保避免出现拉伸或者压缩的副作用,下文我们将这个内容定义为 popover-inner
+ 4. 因此我们的动画过程中,不是直接transform,而是直接通过修改 icon 还是 window 的 width/height/borderradius 来做效果
+ 5. 而 popover-inner,则是锁死 flip 计算好的的尺寸,然后通过 transform 来进行缩放。
+ 6. 也就是说 icon-inner 就是最开始的那个小尺寸,window-inner 就是最终的大尺寸,二者通过 transform-scale 来强制进行对齐
+5. 理解了这个 popover-container+inner 的原理之后,我们再来看 flip 动画要如何做:
+ 1. 首先是icon->window-with-splash这个效果(基于 FLIP 的原理来解释):
+ 1. First:icon-container 的层级需要比window-container高,所以先进行,也就是说最开始 window-container 是在 icon-container 下面的
+ 2. First:window-container 的 bg 固定是 theme-color,但是 window 的 rect 和 borderradius 是和 icon-container 一致的。
+ 3. First:window-splash 与 window-inner 同级,所以不受 window-inner 缩放的影响。因此 window-container 结构是:`[window-inner, splashBackground, splashIcon]`
+ 4. First:window-inner 的 rect 和 desktop 的 rect 一致,然后需要基于 `object-fit: cover`的原理,将自己 scale 到 width/height 刚好等于 window-container 的尺寸,居中放置在 window-container 的中心位置。
+ 5. First:splashBackground 和 window-inner 类似,一样的cover算法,将自己 scale 到 width/height 刚好等于 window-container 的尺寸,居中放置在 window-container 的中心位置。
+ 6. First:icon-inner 也要基于`object-fit: cover`的原理,计算出最终 splashIcon 的 rect 尺寸下自己的 width/height 对应的 scale。因为是小放大,所以可能会失真,但是没关系,因为它的透明度也会减低,所以符合预期
+ - Last:TODO,你来推理。
+ 2. 其次是 icon->window 这个效果(基于 FLIP 的原理来解释):
+ - First:TODO ,你来推理。
+ - Last:TODO ,你来推理。
+6. window 也是用原生 Popover(popover="manual" + showPopover())来当 window-container。这里的关键是,在动画结束后,它应该是变成 static-position 的状态
+7. 最最最关键的点在于:需要将 animation 的进度和 swiper 的动画进度绑定在一起。这是什么概念呢:如果我滑动“指示器”来回到“我的”页面,结果就是,动画会跟着手势走。
+ 1. 也就是说 animation 始终不会自动 play,而是全程被 swiper 的状态带着走
+ 2. 也就是说,我可以通过控制“指示器”,来实现完全“跟手”的动画效果,我可以用滑动来做到将 window 缩回到 icon 上。
+ 3. 我说的这些你不用刻意去实现,只要做好单向绑定,就可以实现我说的这些效果。
+ 4. 这个技术的重点在于,在动画进度 =0% 的时候,icon-popover 仍然是 static-popover ,并且 window-popover 处于 display:none,只有动画开始之后的下一帧(无限接近于 0%),才是 fixed-popover
+ 5. 同理,反过来,在动画进度 =100% 的时候,window-popover 会被强制锁定成 static-popover,否则无限接近于 100% 的阶段,它都是 fixed-popover
+ 6. 如果通过指示器回到我的页面,我们会看到的效果是,window-popover会从static-popover进入fixed-popover状态,然后慢慢缩放会 icon-popover,直到 swiper 滑动结束,这时候 icon-popover 从 fixed-popover 进入到 static-popover,然后 window-popover 的 DOM 从页面上被进入 display:none。这里很容易忽视的一点是,如果这时候我点击了另外一个小程序的 icon,那么发生的事情会是:原先的 animation 与 swiper 解除关联,因为它不再是“激活的小程序”,而是被刚刚被点击的小程序的 icon 所关联了。
+ 7. 为了确保你理解了,我问你一个问题:如果我点击了“小程序胶囊的关闭按钮”,那么需要如何实现“关闭”的特效?
+
+---
+
+应该统一依赖于"动画结束"事件,现在不是已经有动画结束时间吗?
+我觉得我们runtime应该定义一些"生命周期",然后由 react来触发这些生命周期。这样 runtime自己基于生命周期来行开发。而不是依赖setTimeout、requestAnimationFrame。不过, requestAnimationFrame会特殊一点,但理论上应该尽可能使用生命周期,只有在特定情况下才需要依赖raf。
+理解源代码,按照我的设想,给出你的计划.
+
+---
+
+永远不要在代码中硬编码DEFAULT_RPC_URLS,但是我允许你将 default-chains.json 从 public 迁移到 src 目录下,然后你可以通过 import 的方式引入这个文件,将 json 通过编译技术硬编码到最终的 bundle 中。
+但意味着原本依赖 fetch-json-url 的相关代码也要更改。但好处是启动更快、体验会更好。
+
+---
+
+请深入调查核心原因,从架构师的角度出发,分析是不是架构出了问题?
+从高级工程师的角度出发,分析是不是残留代码导致的问题?
+从测试工程师的角度出发,分析是不是测试没有覆盖到位导致的问题?
+从产品经理的角度出发,是不是流程设计不合理,简洁导致了架构代码或者工程代码出现了不可靠的问题?
+
+---
+
+将 WalletTab 中的下半部分:关于某个地址某个网络中的资产列表和交易列表,封装成一套组件。我要在 stories 中看到这套组件,并绑定真实的 chainProvider。
+提供两个 stories 测试,一个是 `bfmeta:bCfAynSAKhzgKLi3BXyuh5k22GctLR72j` ;一个是 `eth:bCfAynSAKhzgKLi3BXyuh5k22GctLR72j`
+我要确保能看到真实的数据。
diff --git "a/docs/white-book/00-\345\277\205\350\257\273/best-practices.md" "b/docs/white-book/00-\345\277\205\350\257\273/best-practices.md"
index 0fbfd3331..391a3f5cb 100644
--- "a/docs/white-book/00-\345\277\205\350\257\273/best-practices.md"
+++ "b/docs/white-book/00-\345\277\205\350\257\273/best-practices.md"
@@ -29,3 +29,5 @@
- CSS Modules 适用场景:@keyframes 动画、伪元素(::before/::after)、复杂选择器(:focus-within)、scroll-driven animations
- CSS Modules 与 Tailwind 混用:`className={cn(styles.header, 'sticky top-0 z-10 px-5')}`
- 优先级:CSS Modules > globals.css,组件样式应内聚到组件目录
+- ❌ as TypeAssertion → ✅ z.looseObject().safeParse() 验证外部 API 响应
+- ❌ z.record(z.record(...)) → ✅ z.record(z.string(), z.record(z.string(), schema))(Zod 4 嵌套 record 需显式 key 类型)
diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json
index a037c6dab..bc02f29ea 100644
--- a/public/configs/default-chains.json
+++ b/public/configs/default-chains.json
@@ -175,7 +175,7 @@
"decimals": 18,
"api": {
"bsc-rpc": "https://bsc-rpc.publicnode.com",
- "bscscan-v2": "https://api.bscscan.com/v2/api"
+ "bscwallet-v1": "https://walletapi.bfmeta.info/wallet/bsc"
},
"explorer": {
"url": "https://bscscan.com",
diff --git a/scripts/collect-real-transaction-fixtures.mjs b/scripts/collect-real-transaction-fixtures.mjs
new file mode 100644
index 000000000..097d88295
--- /dev/null
+++ b/scripts/collect-real-transaction-fixtures.mjs
@@ -0,0 +1,213 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { execFileSync } from 'node:child_process'
+
+const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const OUT_DIR = path.join(
+ ROOT_DIR,
+ 'src/services/chain-adapter/providers/__tests__/fixtures/real'
+)
+
+function writeJson(fileName, data) {
+ fs.mkdirSync(OUT_DIR, { recursive: true })
+ fs.writeFileSync(path.join(OUT_DIR, fileName), JSON.stringify(data, null, 2) + '\n', 'utf8')
+ console.log('[fixtures] wrote', fileName)
+}
+
+async function fetchJson(url, init) {
+ const args = ['-sS']
+ const method = init?.method ?? 'GET'
+ if (method && method !== 'GET') {
+ args.push('-X', method)
+ }
+
+ const headers = init?.headers ?? {}
+ for (const [key, value] of Object.entries(headers)) {
+ args.push('-H', `${key}: ${value}`)
+ }
+
+ if (init?.body) {
+ args.push('-d', typeof init.body === 'string' ? init.body : String(init.body))
+ }
+
+ args.push(url)
+ const stdout = execFileSync('curl', args, { encoding: 'utf8' })
+ return JSON.parse(stdout)
+}
+
+async function fetchEvmAccountAction(baseUrl, action, address, { page = 1, offset = 100, sort = 'desc' } = {}) {
+ const params = new URLSearchParams({
+ module: 'account',
+ action,
+ address,
+ page: String(page),
+ offset: String(offset),
+ sort,
+ })
+ const url = `${baseUrl}?${params.toString()}`
+ return { url, json: await fetchJson(url) }
+}
+
+async function findFirstEvmTx({ baseUrl, address, predicate, maxPages = 10, offset = 100 }) {
+ for (let page = 1; page <= maxPages; page++) {
+ const { url, json } = await fetchEvmAccountAction(baseUrl, 'txlist', address, { page, offset })
+ const list = Array.isArray(json?.result) ? json.result : []
+ for (const tx of list) {
+ if (predicate(tx)) {
+ return { url, tx }
+ }
+ }
+ }
+ return null
+}
+
+async function findFirstEvmTokenTx({ baseUrl, address, predicate, maxPages = 10, offset = 100 }) {
+ for (let page = 1; page <= maxPages; page++) {
+ const { url, json } = await fetchEvmAccountAction(baseUrl, 'tokentx', address, { page, offset })
+ const list = Array.isArray(json?.result) ? json.result : []
+ for (const tx of list) {
+ if (predicate(tx)) {
+ return { url, tx }
+ }
+ }
+ }
+ return null
+}
+
+async function main() {
+ const ethBlockscout = 'https://eth.blockscout.com/api'
+
+ // EVM (Ethereum) - try to collect swap + approve + native transfer + token transfer
+ const evmContractCaller = '0xc00eb08fef86e5f74b692813f31bb5957eaa045c'
+ const evmNativeReceiver = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
+
+ const swap = await findFirstEvmTx({
+ baseUrl: ethBlockscout,
+ address: evmContractCaller,
+ predicate: (tx) => typeof tx?.input === 'string' && (
+ tx.input.startsWith('0x38ed1739') || // swapExactTokensForTokens
+ tx.input.startsWith('0x7ff36ab5') || // swapExactETHForTokens
+ tx.input.startsWith('0x18cbafe5') // swapExactTokensForETH
+ ),
+ })
+ if (swap) {
+ writeJson('eth-blockscout-native-swap-tx.json', { sourceUrl: swap.url, tx: swap.tx })
+ }
+
+ const approve = await findFirstEvmTx({
+ baseUrl: ethBlockscout,
+ address: evmContractCaller,
+ predicate: (tx) => typeof tx?.input === 'string' && tx.input.startsWith('0x095ea7b3'),
+ })
+ if (approve) {
+ writeJson('eth-blockscout-native-approve-tx.json', { sourceUrl: approve.url, tx: approve.tx })
+ }
+
+ const nativeTransfer = await findFirstEvmTx({
+ baseUrl: ethBlockscout,
+ address: evmNativeReceiver,
+ predicate: (tx) => typeof tx?.value === 'string' && tx.value !== '0' && (tx.input === '0x' || tx.input === '0x0' || tx.input === '0x00'),
+ })
+ if (nativeTransfer) {
+ writeJson('eth-blockscout-native-transfer-tx.json', { sourceUrl: nativeTransfer.url, tx: nativeTransfer.tx })
+ }
+
+ const tokenTransfer = await findFirstEvmTokenTx({
+ baseUrl: ethBlockscout,
+ address: evmNativeReceiver,
+ predicate: (tx) => typeof tx?.tokenSymbol === 'string' && typeof tx?.value === 'string',
+ })
+ if (tokenTransfer) {
+ writeJson('eth-blockscout-token-transfer-tx.json', { sourceUrl: tokenTransfer.url, tx: tokenTransfer.tx })
+ }
+
+ // BioForest (BFMeta) - collect transferAsset transaction
+ const bfmetaAddress = 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j'
+ const bfmetaBase = 'https://walletapi.bfmeta.info/wallet/bfm'
+
+ const lastblock = await fetchJson(`${bfmetaBase}/lastblock`)
+ writeJson('bfmeta-lastblock.json', lastblock)
+
+ const maxHeight = lastblock?.result?.height ?? 0
+ const bfmetaQuery = await fetchJson(`${bfmetaBase}/transactions/query`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ maxHeight, address: bfmetaAddress, limit: 30 }),
+ })
+ writeJson('bfmeta-transactions-query.json', bfmetaQuery)
+
+ // BIW (BIWMeta) - collect provided real signatures
+ const biwmetaBase = 'https://walletapi.biw-meta.com/wallet/biwmeta'
+ const biwmetaLastblock = await fetchJson(`${biwmetaBase}/lastblock`)
+ writeJson('biwmeta-lastblock.json', biwmetaLastblock)
+
+ const biwmetaMaxHeight = biwmetaLastblock?.result?.height ?? 0
+ const biwmetaSignatures = [
+ {
+ file: 'biwmeta-bse-01-signature.json',
+ signature:
+ 'eb85d4ef1303f900815fd23098c4837b2576f27c776afb599574a6d5b0c10c1fd230cbf8613a76ac48988deb0e70c3ea6b2fcf58745771d0ca16832f24e83e0c',
+ },
+ {
+ file: 'biwmeta-ast-03-destroyAsset.json',
+ signature:
+ '3c96dfa4f6579ed753549df27653e762780c40c0a1f959cfa18655095ca975666ddc2aa151f179d78fd1fe27b0d7de82ba13edcef4bd53d7768399d848c94c07',
+ },
+ {
+ file: 'biwmeta-ety-02-issueEntity.json',
+ signature:
+ '287c40e7e68cddde48cd7158872744ad2dc772153599e61cac0311d5f0bfa0f38f4c9e81023ee21adf8cc243e6daf0fd059525dccf1f78d464f8fe7dd9785905',
+ },
+ {
+ file: 'biwmeta-ety-01-issueEntityFactory.json',
+ signature:
+ '6160e4827b0fb7758e54f74cf906fbc6de5b6c9f803e0bf5dd39260a187fb0623600bc9a44de788cfc476f36eea0513244f550ed123aff0fbe8c84becf458206',
+ },
+ {
+ file: 'biwmeta-ast-02-transferAsset.json',
+ signature:
+ 'ae1f5de56b79822954e3da0090cecc20e5af603cf6737e4f57cdd667e66c170f2fcb566f43053c163b755dd62847c22ee49996664c188f1361ab170924ecf202',
+ },
+ ]
+
+ for (const { file, signature } of biwmetaSignatures) {
+ const result = await fetchJson(`${biwmetaBase}/transactions/query`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ maxHeight: biwmetaMaxHeight, signature, limit: 5 }),
+ })
+ writeJson(file, result)
+ }
+
+ // BSC (walletapi) - tx history sample
+ const bscAddress = '0x8894E0a0c962CB723c1976a4421c95949bE2D4E3'
+ const bscBase = 'https://walletapi.bfmeta.info/wallet/bsc'
+ const bscHistory = await fetchJson(`${bscBase}/trans/normal/history`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ address: bscAddress, page: 1, offset: 20 }),
+ })
+ writeJson('bsc-transactions-history.json', bscHistory)
+
+ // BTC (mempool.space) - tx list sample
+ const btcAddress = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'
+ const btcTxs = await fetchJson(`https://mempool.space/api/address/${btcAddress}/txs`)
+ writeJson('btc-mempool-address-txs.json', Array.isArray(btcTxs) ? btcTxs.slice(0, 1) : btcTxs)
+
+ // TRON (trongrid) - tx list sample
+ const tronAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+ const tronTxs = await fetchJson(`https://api.trongrid.io/v1/accounts/${tronAddress}/transactions?limit=20`)
+ if (tronTxs && Array.isArray(tronTxs.data)) {
+ writeJson('tron-trongrid-account-txs.json', { ...tronTxs, data: tronTxs.data.slice(0, 3) })
+ } else {
+ writeJson('tron-trongrid-account-txs.json', tronTxs)
+ }
+
+ console.log('[fixtures] done')
+}
+
+main().catch((error) => {
+ console.error('[fixtures] failed:', error)
+ process.exitCode = 1
+})
diff --git a/src/components/asset/asset-item.tsx b/src/components/asset/asset-item.tsx
index 4351538ef..fb5cc2bb6 100644
--- a/src/components/asset/asset-item.tsx
+++ b/src/components/asset/asset-item.tsx
@@ -1,5 +1,6 @@
import { cn } from '@/lib/utils';
import { TokenIcon } from '@/components/wallet/token-icon';
+import { AmountDisplay } from '@/components/common';
import { formatFiatValue, formatPriceChange, type AssetInfo } from '@/types/asset';
import { IconChevronRight as ChevronRight } from '@tabler/icons-react';
@@ -30,7 +31,6 @@ export function AssetItem({
exchangeRate,
className,
}: AssetItemProps) {
- const formattedAmount = asset.amount.toFormatted();
const displayName = asset.name || asset.assetType;
// Calculate fiat value if price is available
@@ -70,7 +70,11 @@ export function AssetItem({
{/* Balance and price */}
-
{formattedAmount}
+
{showChevron && onClick &&
}
{(fiatValue || priceChange) && (
diff --git a/src/components/authorize/TransactionDetails.tsx b/src/components/authorize/TransactionDetails.tsx
index 76e5b2205..cfd80b077 100644
--- a/src/components/authorize/TransactionDetails.tsx
+++ b/src/components/authorize/TransactionDetails.tsx
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { ChainIcon, type ChainType } from '@/components/wallet/chain-icon'
+import { AddressDisplay } from '@/components/wallet/address-display'
export interface TransactionDetailsProps {
/** Sender wallet address */
@@ -35,11 +36,6 @@ const CHAIN_TYPE_MAP: Record
= {
ccc: 'ccc',
}
-function formatAddress(address: string): string {
- if (address.length <= 16) return address
- return `${address.slice(0, 8)}...${address.slice(-6)}`
-}
-
/**
* Transaction details display component for signature authorization
*/
@@ -78,9 +74,7 @@ export function TransactionDetails({
{t('signature.details.from')}
-
- {formatAddress(from)}
-
+
{/* To */}
@@ -88,9 +82,7 @@ export function TransactionDetails({
{t('signature.details.to')}
-
- {formatAddress(to)}
-
+
{/* Amount */}
diff --git a/src/components/transaction/transaction-item.tsx b/src/components/transaction/transaction-item.tsx
index d1dbcbcb8..2a32fa9f0 100644
--- a/src/components/transaction/transaction-item.tsx
+++ b/src/components/transaction/transaction-item.tsx
@@ -12,6 +12,7 @@ import {
IconLock,
IconLockOpen,
IconShieldLock,
+ IconShieldCheck,
IconFlame,
IconGift,
IconHandGrab,
@@ -28,6 +29,7 @@ import {
IconCertificate,
IconFileText,
IconDots,
+ IconClick,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
@@ -38,7 +40,8 @@ export type TransactionType =
| 'emigrate' | 'immigrate' | 'exchange'
| 'issueAsset' | 'increaseAsset'
| 'issueEntity' | 'destroyEntity'
- | 'locationName' | 'dapp' | 'certificate' | 'mark' | 'other';
+ | 'locationName' | 'dapp' | 'certificate' | 'mark'
+ | 'approve' | 'interaction' | 'other';
export type TransactionStatus = 'pending' | 'confirmed' | 'failed';
@@ -93,6 +96,9 @@ const typeIcons: Record {
describe('Edge cases', () => {
it('handles short address without truncation', () => {
renderWithProvider()
- expect(screen.getByText(/发送至 0x1234/)).toBeInTheDocument()
+ expect(screen.getByText('发送至')).toBeInTheDocument()
+ expect(screen.getByLabelText('0x1234')).toBeInTheDocument()
})
it('handles short tx hash without truncation', () => {
renderWithProvider()
- expect(screen.getByText('0x123456789012345678')).toBeInTheDocument()
+ expect(screen.getByTitle('0x123456789012345678')).toBeInTheDocument()
})
})
})
diff --git a/src/components/transfer/send-result.tsx b/src/components/transfer/send-result.tsx
index d197d95a4..718960deb 100644
--- a/src/components/transfer/send-result.tsx
+++ b/src/components/transfer/send-result.tsx
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { IconCircle } from '@/components/common/icon-circle';
import { TransactionStatus } from '@/components/transaction/transaction-status';
+import { AddressDisplay } from '@/components/wallet/address-display';
import { IconCheck as Check, IconX as X, IconExternalLink as ExternalLink, IconCopy as Copy, IconArrowLeft as ArrowLeft } from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { clipboardService } from '@/services/clipboard';
@@ -31,16 +32,6 @@ interface SendResultProps {
className?: string | undefined;
}
-function truncateHash(hash: string): string {
- if (hash.length <= 20) return hash;
- return `${hash.slice(0, 10)}...${hash.slice(-8)}`;
-}
-
-function truncateAddress(address: string): string {
- if (address.length <= 16) return address;
- return `${address.slice(0, 8)}...${address.slice(-6)}`;
-}
-
/**
* Send result page showing transaction success/failure
*/
@@ -100,7 +91,10 @@ export function SendResult({
{/* Recipient */}
- {t('sendResult.sentTo', { address: truncateAddress(toAddress) })}
+
+ {t('sendResult.sentTo', { address: '' })}
+
+
{/* Error Message */}
{isFailed && errorMessage && (
@@ -113,14 +107,13 @@ export function SendResult({
{txHash && isSuccess && (
{t('sendResult.txHash')}
-
- {copied ? (
-
- ) : (
-
- )}
+
)}
diff --git a/src/components/wallet/address-display.tsx b/src/components/wallet/address-display.tsx
index dde0af4eb..ab2e2fcb6 100644
--- a/src/components/wallet/address-display.tsx
+++ b/src/components/wallet/address-display.tsx
@@ -6,6 +6,9 @@ import { clipboardService } from '@/services/clipboard';
interface AddressDisplayProps {
address: string;
+ startChars?: number | undefined;
+ endChars?: number | undefined;
+ placeholder?: string | undefined;
copyable?: boolean | undefined;
className?: string | undefined;
onCopy?: (() => void) | undefined;
@@ -69,7 +72,22 @@ function truncateAddress(address: string, maxWidth: number, font: string): strin
return `${address.slice(0, startChars)}${ellipsis}${address.slice(-endChars)}`;
}
-export function AddressDisplay({ address, copyable = true, className, onCopy, testId }: AddressDisplayProps) {
+function truncateAddressByChars(address: string, startChars: number, endChars: number): string {
+ const ellipsis = '...'
+ if (address.length <= startChars + endChars + ellipsis.length) return address
+ return `${address.slice(0, startChars)}${ellipsis}${address.slice(-endChars)}`
+}
+
+export function AddressDisplay({
+ address,
+ startChars,
+ endChars,
+ placeholder = '---',
+ copyable = true,
+ className,
+ onCopy,
+ testId,
+}: AddressDisplayProps) {
const { t } = useTranslation('common');
const [copied, setCopied] = useState(false);
const [displayText, setDisplayText] = useState(null);
@@ -79,6 +97,18 @@ export function AddressDisplay({ address, copyable = true, className, onCopy, te
const container = containerRef.current;
if (!container) return;
+ if (!address) {
+ setDisplayText(placeholder);
+ return;
+ }
+
+ if (startChars !== undefined || endChars !== undefined) {
+ const start = startChars ?? 6;
+ const end = endChars ?? 4;
+ setDisplayText(truncateAddressByChars(address, start, end));
+ return;
+ }
+
const style = getComputedStyle(container);
const font = `${style.fontSize} ${style.fontFamily}`;
const iconSpace = copyable ? 24 : 0;
@@ -91,7 +121,7 @@ export function AddressDisplay({ address, copyable = true, className, onCopy, te
const truncated = truncateAddress(address, availableWidth, font);
setDisplayText(truncated);
- }, [address, copyable]);
+ }, [address, copyable, endChars, placeholder, startChars]);
useLayoutEffect(() => {
const container = containerRef.current;
diff --git a/src/components/wallet/index.ts b/src/components/wallet/index.ts
index cbb5226bb..564353082 100644
--- a/src/components/wallet/index.ts
+++ b/src/components/wallet/index.ts
@@ -22,3 +22,5 @@ export { AddressDisplay } from './address-display'
export { ChainIcon, ChainBadge, ChainIconProvider, type ChainType } from './chain-icon'
export { ChainAddressDisplay } from './chain-address-display'
export { TokenIcon, TokenBadge, TokenIconProvider } from './token-icon'
+export { WalletAddressPortfolioView, type WalletAddressPortfolioViewProps } from './wallet-address-portfolio-view'
+export { WalletAddressPortfolioFromProvider, type WalletAddressPortfolioFromProviderProps } from './wallet-address-portfolio-from-provider'
diff --git a/src/components/wallet/wallet-address-portfolio-from-provider.tsx b/src/components/wallet/wallet-address-portfolio-from-provider.tsx
new file mode 100644
index 000000000..b116e0478
--- /dev/null
+++ b/src/components/wallet/wallet-address-portfolio-from-provider.tsx
@@ -0,0 +1,181 @@
+import { useMemo } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { createChainProvider } from '@/services/chain-adapter'
+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 { ChainType } from '@/stores'
+import type { Transaction, Action } from '@/services/chain-adapter/providers/types'
+import { Amount } from '@/types/amount'
+
+export interface WalletAddressPortfolioFromProviderProps {
+ chainId: ChainType
+ address: string
+ chainName?: string
+ onTokenClick?: WalletAddressPortfolioViewProps['onTokenClick']
+ onTransactionClick?: WalletAddressPortfolioViewProps['onTransactionClick']
+ className?: string
+ testId?: string
+}
+
+export function WalletAddressPortfolioFromProvider({
+ chainId,
+ address,
+ chainName,
+ onTokenClick,
+ onTransactionClick,
+ className,
+ testId,
+}: WalletAddressPortfolioFromProviderProps) {
+ const chainConfigState = useChainConfigState()
+
+ // Create provider only after chainConfig is ready
+ const provider = useMemo(() => {
+ if (!chainConfigState.snapshot) return null
+ return createChainProvider(chainId)
+ }, [chainId, chainConfigState.snapshot])
+
+ const decimals = chainConfigService.getDecimals(chainId)
+
+ // Check if provider is ready for queries
+ const tokensEnabled = !!provider && (provider.supportsTokenBalances || provider.supportsNativeBalance)
+ const transactionsEnabled = !!provider?.supportsTransactionHistory
+
+ const tokensQuery = useQuery({
+ queryKey: ['address-token-balances', chainId, address, !!provider],
+ queryFn: async (): Promise => {
+ // Re-create provider inside queryFn to ensure we have latest
+ const currentProvider = chainConfigState.snapshot ? createChainProvider(chainId) : null
+
+ if (!currentProvider?.getTokenBalances) {
+ if (currentProvider?.getNativeBalance) {
+ const balance = await currentProvider.getNativeBalance(address)
+ return [{
+ symbol: balance.symbol,
+ name: balance.symbol,
+ balance: balance.amount.toFormatted(),
+ decimals: balance.amount.decimals,
+ chain: chainId,
+ }]
+ }
+ return []
+ }
+ const tokens = await currentProvider.getTokenBalances(address)
+ return tokens.map(t => ({
+ symbol: t.symbol,
+ name: t.name,
+ balance: t.amount.toFormatted(),
+ decimals: t.amount.decimals,
+ chain: chainId,
+ }))
+ },
+ enabled: tokensEnabled,
+ staleTime: 30_000,
+ })
+
+ const transactionsQuery = useQuery({
+ queryKey: ['address-transactions', chainId, address, !!provider],
+ queryFn: async (): Promise => {
+ const currentProvider = chainConfigState.snapshot ? createChainProvider(chainId) : null
+ if (!currentProvider?.getTransactionHistory) return []
+ const txs = await currentProvider.getTransactionHistory(address, 20)
+ return txs.map(tx => convertToTransactionInfo(tx, address, chainId, decimals))
+ },
+ enabled: transactionsEnabled,
+ staleTime: 30_000,
+ })
+
+ // Show loading if provider not ready OR query is loading
+ const tokensLoading = !tokensEnabled || tokensQuery.isLoading
+ const transactionsLoading = !transactionsEnabled || transactionsQuery.isLoading
+
+ return (
+
+ )
+}
+
+/** 将 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
+
+ // 将 action + direction 映射到 UI TransactionType
+ const uiType = mapToUIType(tx.action, tx.direction)
+
+ return {
+ id: tx.hash,
+ type: uiType,
+ status: tx.status,
+ amount: Amount.fromRaw(value, decimals, symbol),
+ symbol,
+ address: counterpartyAddress,
+ timestamp: new Date(tx.timestamp),
+ hash: tx.hash,
+ chain: chainId,
+ }
+}
+
+/** 将 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
+}
diff --git a/src/components/wallet/wallet-address-portfolio-view.tsx b/src/components/wallet/wallet-address-portfolio-view.tsx
new file mode 100644
index 000000000..f81c1a06a
--- /dev/null
+++ b/src/components/wallet/wallet-address-portfolio-view.tsx
@@ -0,0 +1,78 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { TokenList } from '@/components/token/token-list'
+import { TransactionList } from '@/components/transaction/transaction-list'
+import { SwipeableTabs } from '@/components/layout/swipeable-tabs'
+import type { TokenInfo } from '@/components/token/token-item'
+import type { TransactionInfo } from '@/components/transaction/transaction-item'
+import type { ChainType } from '@/stores'
+
+export interface WalletAddressPortfolioViewProps {
+ chainId: ChainType
+ chainName?: string
+ tokens: TokenInfo[]
+ transactions: TransactionInfo[]
+ tokensLoading?: boolean
+ transactionsLoading?: boolean
+ tokensRefreshing?: boolean
+ onTokenClick?: (token: TokenInfo) => void
+ onTransactionClick?: (tx: TransactionInfo) => void
+ className?: string
+ testId?: string
+}
+
+export function WalletAddressPortfolioView({
+ chainId,
+ chainName,
+ tokens,
+ transactions,
+ tokensLoading = false,
+ transactionsLoading = false,
+ tokensRefreshing = false,
+ onTokenClick,
+ onTransactionClick,
+ className,
+ testId = 'wallet-address-portfolio',
+}: WalletAddressPortfolioViewProps) {
+ const { t } = useTranslation(['home', 'transaction'])
+ const [activeTab, setActiveTab] = useState('assets')
+ const displayChainName = chainName ?? chainId
+
+ return (
+
+
+ {(tab) =>
+ tab === 'assets' ? (
+
+
+
+ ) : (
+
+
+
+ )
+ }
+
+
+ )
+}
diff --git a/src/components/wallet/wallet-address-portfolio.stories.tsx b/src/components/wallet/wallet-address-portfolio.stories.tsx
new file mode 100644
index 000000000..2ff627a1c
--- /dev/null
+++ b/src/components/wallet/wallet-address-portfolio.stories.tsx
@@ -0,0 +1,362 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import type { ReactRenderer } from '@storybook/react';
+import type { DecoratorFunction } from 'storybook/internal/types';
+import { useEffect, useState } from 'react';
+import { expect, waitFor, within } from '@storybook/test';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { WalletAddressPortfolioView } from './wallet-address-portfolio-view';
+import { WalletAddressPortfolioFromProvider } from './wallet-address-portfolio-from-provider';
+import { chainConfigActions, useChainConfigState } from '@/stores/chain-config';
+import { clearProviderCache } from '@/services/chain-adapter';
+import { Amount } from '@/types/amount';
+import type { TokenInfo } from '@/components/token/token-item';
+import type { TransactionInfo, TransactionType } from '@/components/transaction/transaction-item';
+
+const mockTokens: TokenInfo[] = [
+ { symbol: 'BFT', name: 'BFT', balance: '1234.56789012', decimals: 8, chain: 'bfmeta' },
+ { symbol: 'USDT', name: 'USDT', balance: '500.00000000', decimals: 8, chain: 'bfmeta' },
+ { symbol: 'BTC', name: 'Bitcoin', balance: '0.00123456', decimals: 8, chain: 'bfmeta' },
+];
+
+const mockTransactions: TransactionInfo[] = [
+ {
+ id: 'tx1',
+ type: 'receive' as TransactionType,
+ status: 'confirmed',
+ amount: Amount.fromRaw('100000000', 8, 'BFT'),
+ symbol: 'BFT',
+ address: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j',
+ timestamp: new Date(Date.now() - 3600000),
+ chain: 'bfmeta',
+ },
+ {
+ id: 'tx2',
+ type: 'send' as TransactionType,
+ status: 'confirmed',
+ amount: Amount.fromRaw('50000000', 8, 'BFT'),
+ symbol: 'BFT',
+ address: 'bAnotherAddress1234567890abcdef',
+ timestamp: new Date(Date.now() - 86400000),
+ chain: 'bfmeta',
+ },
+];
+
+function ChainConfigProvider({ children }: { children: React.ReactNode }) {
+ const state = useChainConfigState();
+ const [initStarted, setInitStarted] = useState(false);
+
+ useEffect(() => {
+ if (!initStarted && !state.snapshot && !state.isLoading) {
+ setInitStarted(true);
+ clearProviderCache();
+ chainConfigActions.initialize();
+ }
+ }, [initStarted, state.snapshot, state.isLoading]);
+
+ if (state.error) {
+ return (
+
+
+
Chain config error
+
{state.error}
+
+
+ );
+ }
+
+ if (!state.snapshot) {
+ return (
+
+
+
Loading chain configuration...
+
+
+ );
+ }
+
+ return <>{children}>;
+}
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ staleTime: 0,
+ },
+ },
+ });
+
+const withChainConfig: DecoratorFunction = (Story) => (
+
+
+
+
+
+
+
+);
+
+const meta = {
+ title: 'Wallet/WalletAddressPortfolio',
+ component: WalletAddressPortfolioView,
+ parameters: {
+ layout: 'fullscreen',
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ chainId: 'bfmeta',
+ chainName: 'BFMeta',
+ tokens: mockTokens,
+ transactions: mockTransactions,
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ chainId: 'bfmeta',
+ chainName: 'BFMeta',
+ tokens: [],
+ transactions: [],
+ tokensLoading: true,
+ transactionsLoading: true,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await waitFor(() => {
+ const portfolio = canvas.getByTestId('wallet-address-portfolio');
+ expect(portfolio).toBeVisible();
+
+ const pulsingElements = portfolio.querySelectorAll('.animate-pulse');
+ expect(pulsingElements.length).toBeGreaterThan(0);
+ });
+ },
+};
+
+export const Empty: Story = {
+ args: {
+ chainId: 'bfmeta',
+ chainName: 'BFMeta',
+ tokens: [],
+ transactions: [],
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await waitFor(() => {
+ const emptyState = canvas.getByTestId('wallet-address-portfolio-token-list-empty');
+ expect(emptyState).toBeVisible();
+ });
+ },
+};
+
+export const RealDataBfmeta: Story = {
+ name: 'Real Data: biochain-bfmeta',
+ decorators: [withChainConfig],
+ parameters: {
+ chromatic: { delay: 5000 },
+ docs: {
+ description: {
+ story: 'Fetches real token balances and transactions from BFMeta chain using the actual chainProvider API.',
+ },
+ },
+ },
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ // Wait for data to load - check component renders without error
+ await waitFor(
+ () => {
+ const portfolio = canvas.getByTestId('bfmeta-portfolio');
+ expect(portfolio).toBeVisible();
+
+ // Check loading finished (either has tokens, empty state, or loading skeleton stopped)
+ const tokenList = canvas.queryByTestId('bfmeta-portfolio-token-list');
+ const tokenEmpty = canvas.queryByTestId('bfmeta-portfolio-token-list-empty');
+ const loading = portfolio.querySelector('.animate-pulse');
+
+ // Should have either: token list, empty state, or still loading
+ expect(tokenList || tokenEmpty || loading).not.toBeNull();
+
+ // If token list exists, verify it has items
+ if (tokenList) {
+ const tokenItems = tokenList.querySelectorAll('[data-testid^="token-item-"]');
+ expect(tokenItems.length).toBeGreaterThan(0);
+ }
+ },
+ { timeout: 15000 },
+ );
+ },
+};
+
+export const RealDataEthereum: Story = {
+ name: 'Real Data: eth-eth',
+ decorators: [withChainConfig],
+ parameters: {
+ chromatic: { delay: 5000 },
+ docs: {
+ description: {
+ story:
+ 'Fetches real token balances and transactions from Ethereum mainnet using blockscout API. Uses Vitalik address for real ETH transfers.',
+ },
+ },
+ },
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await waitFor(
+ () => {
+ const portfolio = canvas.getByTestId('ethereum-portfolio');
+ expect(portfolio).toBeVisible();
+
+ // Verify token list exists with actual tokens
+ const tokenList = canvas.queryByTestId('ethereum-portfolio-token-list');
+ expect(tokenList).not.toBeNull();
+
+ const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]');
+ expect(tokenItems?.length).toBeGreaterThan(0);
+ },
+ { timeout: 15000 },
+ );
+ },
+};
+
+export const RealDataBitcoin: Story = {
+ name: 'Real Data: bitcoin',
+ decorators: [withChainConfig],
+ parameters: {
+ chromatic: { delay: 5000 },
+ docs: {
+ description: {
+ story: 'Fetches real balance and transactions from Bitcoin mainnet using mempool.space API.',
+ },
+ },
+ },
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await waitFor(
+ () => {
+ const portfolio = canvas.getByTestId('bitcoin-portfolio');
+ expect(portfolio).toBeVisible();
+
+ // Verify token list exists with BTC balance
+ const tokenList = canvas.queryByTestId('bitcoin-portfolio-token-list');
+ expect(tokenList).not.toBeNull();
+
+ const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]');
+ expect(tokenItems?.length).toBeGreaterThan(0);
+ },
+ { timeout: 15000 },
+ );
+ },
+};
+
+export const RealDataTron: Story = {
+ name: 'Real Data: tron',
+ decorators: [withChainConfig],
+ parameters: {
+ chromatic: { delay: 5000 },
+ docs: {
+ description: {
+ story: 'Fetches real balance and transactions from Tron mainnet using TronGrid API.',
+ },
+ },
+ },
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await waitFor(
+ () => {
+ const portfolio = canvas.getByTestId('tron-portfolio');
+ expect(portfolio).toBeVisible();
+
+ // Verify token list exists with TRX balance
+ const tokenList = canvas.queryByTestId('tron-portfolio-token-list');
+ expect(tokenList).not.toBeNull();
+
+ const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]');
+ expect(tokenItems?.length).toBeGreaterThan(0);
+ },
+ { timeout: 15000 },
+ );
+ },
+};
+
+export const RealDataBinance: Story = {
+ name: 'Real Data: binance',
+ decorators: [withChainConfig],
+ parameters: {
+ chromatic: { delay: 5000 },
+ docs: {
+ description: {
+ story: 'Fetches real BNB balance from BSC mainnet using public RPC.',
+ },
+ },
+ },
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await waitFor(
+ () => {
+ const portfolio = canvas.getByTestId('binance-portfolio');
+ expect(portfolio).toBeVisible();
+
+ // Verify token list exists with BNB balance
+ const tokenList = canvas.queryByTestId('binance-portfolio-token-list');
+ expect(tokenList).not.toBeNull();
+
+ const tokenItems = tokenList?.querySelectorAll('[data-testid^="token-item-"]');
+ expect(tokenItems?.length).toBeGreaterThan(0);
+ },
+ { timeout: 15000 },
+ );
+ },
+};
diff --git a/src/components/wallet/wallet-list.tsx b/src/components/wallet/wallet-list.tsx
index 0330960c2..7cafb262f 100644
--- a/src/components/wallet/wallet-list.tsx
+++ b/src/components/wallet/wallet-list.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { WalletMiniCard } from './wallet-mini-card'
+import { AddressDisplay } from './address-display'
export interface WalletListItem {
id: string
@@ -22,12 +23,6 @@ export interface WalletListProps {
className?: string
}
-function truncateAddress(address: string): string {
- if (!address) return '---'
- if (address.length <= 12) return address
- return `${address.slice(0, 6)}...${address.slice(-4)}`
-}
-
export function WalletList({
wallets,
currentWalletId,
@@ -51,7 +46,6 @@ export function WalletList({
{wallets.map((wallet) => {
const isActive = wallet.id === currentWalletId
- const displayAddress = truncateAddress(wallet.address)
return (
)}
-
- {displayAddress}
-
+
)
diff --git a/src/components/wallet/wallet-selector.tsx b/src/components/wallet/wallet-selector.tsx
index 52967e58a..7ba6f2391 100644
--- a/src/components/wallet/wallet-selector.tsx
+++ b/src/components/wallet/wallet-selector.tsx
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import { IconCheck as Check } from '@tabler/icons-react';
import type { WalletInfo } from './index';
import { WalletMiniCard } from './wallet-mini-card';
+import { AddressDisplay } from './address-display';
interface WalletSelectorProps {
/** List of available wallets */
@@ -17,11 +18,6 @@ interface WalletSelectorProps {
className?: string;
}
-function truncateAddress(address: string): string {
- if (address.length <= 12) return address;
- return `${address.slice(0, 6)}...${address.slice(-4)}`;
-}
-
interface WalletItemProps {
wallet: WalletInfo;
isSelected: boolean;
@@ -60,7 +56,7 @@ function WalletItem({ wallet, isSelected, onSelect, notBackedUpLabel }: WalletIt
)}
-
{truncateAddress(wallet.address)}
+
•
{wallet.balance}
diff --git a/src/i18n/locales/ar/transaction.json b/src/i18n/locales/ar/transaction.json
index ba719fb12..a4ab6fe93 100644
--- a/src/i18n/locales/ar/transaction.json
+++ b/src/i18n/locales/ar/transaction.json
@@ -264,6 +264,8 @@
"issueEntity": "Issue Entity",
"locationName": "Location Name",
"mark": "Mark",
+ "approve": "Approve",
+ "interaction": "Contract Interaction",
"other": "Other",
"receive": "استلام",
"send": "إرسال",
diff --git a/src/i18n/locales/en/transaction.json b/src/i18n/locales/en/transaction.json
index 72ab60ed6..1f0567bdd 100644
--- a/src/i18n/locales/en/transaction.json
+++ b/src/i18n/locales/en/transaction.json
@@ -236,6 +236,8 @@
"dapp": "DApp",
"certificate": "Certificate",
"mark": "Mark",
+ "approve": "Approve",
+ "interaction": "Contract Interaction",
"other": "Other"
},
"unstake": "Unstake",
diff --git a/src/i18n/locales/zh-CN/transaction.json b/src/i18n/locales/zh-CN/transaction.json
index e9f8fbd02..53fb7a965 100644
--- a/src/i18n/locales/zh-CN/transaction.json
+++ b/src/i18n/locales/zh-CN/transaction.json
@@ -117,6 +117,8 @@
"dapp": "DApp操作",
"certificate": "凭证操作",
"mark": "数据存证",
+ "approve": "授权",
+ "interaction": "合约交互",
"other": "其他"
},
"status": {
diff --git a/src/i18n/locales/zh-TW/transaction.json b/src/i18n/locales/zh-TW/transaction.json
index 6d8647796..588a40ebe 100644
--- a/src/i18n/locales/zh-TW/transaction.json
+++ b/src/i18n/locales/zh-TW/transaction.json
@@ -264,6 +264,8 @@
"issueEntity": "创建资产",
"locationName": "位名操作",
"mark": "数据存证",
+ "approve": "授權",
+ "interaction": "合約交互",
"other": "其他",
"receive": "接收",
"send": "發送",
diff --git a/src/lib/crypto/index.ts b/src/lib/crypto/index.ts
index 6af5c4e59..fb22e1a72 100644
--- a/src/lib/crypto/index.ts
+++ b/src/lib/crypto/index.ts
@@ -31,9 +31,9 @@ export {
export {
deriveKey,
+ deriveBitcoinKey,
deriveMultiChainKeys,
deriveHDKey,
- deriveBitcoinKey,
getBIP44Path,
toChecksumAddress,
isValidAddress,
diff --git a/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts b/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts
index 4f4fe5a44..86e27f3e7 100644
--- a/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts
+++ b/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts
@@ -3,8 +3,6 @@ import { BitcoinAdapter } from '../bitcoin'
import type { ChainConfig } from '@/services/chain-config'
const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
-// Identity service expects UTF-8 encoded mnemonic
-const mnemonicAsUint8 = new TextEncoder().encode(TEST_MNEMONIC)
const btcConfig: ChainConfig = {
id: 'bitcoin',
@@ -22,17 +20,20 @@ describe('BitcoinAdapter', () => {
describe('BitcoinIdentityService', () => {
it('derives correct P2WPKH address from mnemonic', async () => {
- const address = await adapter.identity.deriveAddress(mnemonicAsUint8, 0)
-
+ // Identity service now expects UTF-8 encoded mnemonic string
+ const seed = new TextEncoder().encode(TEST_MNEMONIC)
+ const address = await adapter.identity.deriveAddress(seed, 0)
+
// P2WPKH (bc1q...) address for this mnemonic at BIP84 path m/84'/0'/0'/0/0
expect(address).toMatch(/^bc1q[a-z0-9]{38,}$/)
expect(address.startsWith('bc1q')).toBe(true)
})
it('derives different addresses for different indices', async () => {
- const addr0 = await adapter.identity.deriveAddress(mnemonicAsUint8, 0)
- const addr1 = await adapter.identity.deriveAddress(mnemonicAsUint8, 1)
-
+ const seed = new TextEncoder().encode(TEST_MNEMONIC)
+ const addr0 = await adapter.identity.deriveAddress(seed, 0)
+ const addr1 = await adapter.identity.deriveAddress(seed, 1)
+
expect(addr0).not.toBe(addr1)
})
diff --git a/src/services/chain-adapter/__tests__/tron-adapter.test.ts b/src/services/chain-adapter/__tests__/tron-adapter.test.ts
index 0fd701b1a..09804ea69 100644
--- a/src/services/chain-adapter/__tests__/tron-adapter.test.ts
+++ b/src/services/chain-adapter/__tests__/tron-adapter.test.ts
@@ -3,8 +3,6 @@ import { TronAdapter } from '../tron'
import type { ChainConfig } from '@/services/chain-config'
const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
-// Identity service expects UTF-8 encoded mnemonic
-const mnemonicAsUint8 = new TextEncoder().encode(TEST_MNEMONIC)
const tronConfig: ChainConfig = {
id: 'tron',
@@ -22,8 +20,10 @@ describe('TronAdapter', () => {
describe('TronIdentityService', () => {
it('derives correct Tron address from mnemonic', async () => {
- const address = await adapter.identity.deriveAddress(mnemonicAsUint8, 0)
-
+ // Identity service now expects UTF-8 encoded mnemonic string
+ const seed = new TextEncoder().encode(TEST_MNEMONIC)
+ const address = await adapter.identity.deriveAddress(seed, 0)
+
// Tron addresses start with 'T' and are 34 characters
expect(address).toMatch(/^T[A-Za-z0-9]{33}$/)
expect(address.startsWith('T')).toBe(true)
@@ -31,9 +31,10 @@ describe('TronAdapter', () => {
})
it('derives different addresses for different indices', async () => {
- const addr0 = await adapter.identity.deriveAddress(mnemonicAsUint8, 0)
- const addr1 = await adapter.identity.deriveAddress(mnemonicAsUint8, 1)
-
+ const seed = new TextEncoder().encode(TEST_MNEMONIC)
+ const addr0 = await adapter.identity.deriveAddress(seed, 0)
+ const addr1 = await adapter.identity.deriveAddress(seed, 1)
+
expect(addr0).not.toBe(addr1)
})
diff --git a/src/services/chain-adapter/bitcoin/identity-service.ts b/src/services/chain-adapter/bitcoin/identity-service.ts
index 9343830a2..9d08ede3b 100644
--- a/src/services/chain-adapter/bitcoin/identity-service.ts
+++ b/src/services/chain-adapter/bitcoin/identity-service.ts
@@ -1,8 +1,8 @@
/**
* Bitcoin Identity Service
- *
+ *
* Uses unified derivation from @/lib/crypto/derivation.ts
- * Default: BIP84 Native SegWit (bc1q...)
+ * Default: Native SegWit (P2WPKH, bc1q...) - BIP84
*/
import type { IIdentityService, Address, Signature } from '../types'
@@ -18,7 +18,7 @@ export class BitcoinIdentityService implements IIdentityService {
async deriveAddress(seed: Uint8Array, index = 0): Promise {
// seed is UTF-8 encoded mnemonic string
const mnemonic = new TextDecoder().decode(seed)
- // BIP84 Native SegWit (bc1q...)
+ // Default to BIP84 (Native SegWit, bc1q...)
const derived = deriveBitcoinKey(mnemonic, 84, index)
return derived.address
}
diff --git a/src/services/chain-adapter/evm/identity-service.ts b/src/services/chain-adapter/evm/identity-service.ts
index f86b26cb9..7a01254ad 100644
--- a/src/services/chain-adapter/evm/identity-service.ts
+++ b/src/services/chain-adapter/evm/identity-service.ts
@@ -1,14 +1,16 @@
/**
* EVM Identity Service
- *
- * Uses unified derivation from @/lib/crypto/derivation.ts
*/
import type { IIdentityService, Address, Signature } from '../types'
import { toChecksumAddress, isValidAddress, deriveKey } from '@/lib/crypto'
export class EvmIdentityService implements IIdentityService {
- constructor(_chainId: string) {}
+ private readonly chainId: string
+
+ constructor(chainId: string) {
+ this.chainId = chainId
+ }
async deriveAddress(seed: Uint8Array, index = 0): Promise {
// seed is UTF-8 encoded mnemonic string
diff --git a/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts b/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts
new file mode 100644
index 000000000..e6f251be8
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts
@@ -0,0 +1,181 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { BiowalletProvider } from '../biowallet-provider';
+import type { ParsedApiEntry } from '@/services/chain-config';
+
+vi.mock('@/services/chain-config', () => ({
+ chainConfigService: {
+ getSymbol: () => 'BIW',
+ getDecimals: () => 8,
+ },
+}));
+
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+function readFixture(name: string): T {
+ const dir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(dir, 'fixtures/real', name);
+ return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
+}
+
+describe('BiowalletProvider (BIWMeta real fixtures)', () => {
+ const entry: ParsedApiEntry = {
+ type: 'biowallet-v1',
+ endpoint: 'https://walletapi.bfmeta.info',
+ config: { path: 'biwmeta' },
+ };
+
+ const lastblock = readFixture('biwmeta-lastblock.json');
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('converts transferAsset (AST-02) to transfer + native asset', async () => {
+ const query = readFixture('biwmeta-ast-02-transferAsset.json');
+ const tx = query.result.trs[0].transaction;
+
+ mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
+ if (url.endsWith('/wallet/biwmeta/lastblock')) {
+ return { ok: true, json: async () => lastblock };
+ }
+ if (url.endsWith('/wallet/biwmeta/transactions/query')) {
+ expect(init?.method).toBe('POST');
+ return { ok: true, json: async () => query };
+ }
+ return { ok: false, status: 404 };
+ });
+
+ const provider = new BiowalletProvider(entry, 'biwmeta');
+ const txs = await provider.getTransactionHistory(tx.recipientId, 10);
+
+ expect(txs.length).toBeGreaterThan(0);
+ expect(txs[0].action).toBe('transfer');
+ expect(txs[0].direction).toBe('in');
+ expect(txs[0].assets[0]).toMatchObject({
+ assetType: 'native',
+ symbol: 'BIW',
+ decimals: 8,
+ value: '5000',
+ });
+ });
+
+ it('converts destroyAsset (AST-03) to destroyAsset + native asset', async () => {
+ const query = readFixture('biwmeta-ast-03-destroyAsset.json');
+ const tx = query.result.trs[0].transaction;
+
+ mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
+ if (url.endsWith('/wallet/biwmeta/lastblock')) {
+ return { ok: true, json: async () => lastblock };
+ }
+ if (url.endsWith('/wallet/biwmeta/transactions/query')) {
+ expect(init?.method).toBe('POST');
+ return { ok: true, json: async () => query };
+ }
+ return { ok: false, status: 404 };
+ });
+
+ const provider = new BiowalletProvider(entry, 'biwmeta');
+ const txs = await provider.getTransactionHistory(tx.senderId, 10);
+
+ expect(txs.length).toBeGreaterThan(0);
+ expect(txs[0].action).toBe('destroyAsset');
+ expect(txs[0].direction).toBe('out');
+ expect(txs[0].assets[0]).toMatchObject({
+ assetType: 'native',
+ symbol: 'AMGT',
+ decimals: 8,
+ value: '58636952548',
+ });
+ });
+
+ it('converts issueEntity (ETY-02) to issueEntity + placeholder native asset', async () => {
+ const query = readFixture('biwmeta-ety-02-issueEntity.json');
+ const tx = query.result.trs[0].transaction;
+
+ mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
+ if (url.endsWith('/wallet/biwmeta/lastblock')) {
+ return { ok: true, json: async () => lastblock };
+ }
+ if (url.endsWith('/wallet/biwmeta/transactions/query')) {
+ expect(init?.method).toBe('POST');
+ return { ok: true, json: async () => query };
+ }
+ return { ok: false, status: 404 };
+ });
+
+ const provider = new BiowalletProvider(entry, 'biwmeta');
+ const txs = await provider.getTransactionHistory(tx.senderId, 10);
+
+ expect(txs.length).toBeGreaterThan(0);
+ expect(txs[0].action).toBe('issueEntity');
+ expect(txs[0].direction).toBe('self');
+ expect(txs[0].assets[0]).toMatchObject({
+ assetType: 'native',
+ symbol: 'BIW',
+ decimals: 8,
+ value: '0',
+ });
+ });
+
+ it('converts issueEntityFactory (ETY-01) to issueEntity + placeholder native asset', async () => {
+ const query = readFixture('biwmeta-ety-01-issueEntityFactory.json');
+ const tx = query.result.trs[0].transaction;
+
+ mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
+ if (url.endsWith('/wallet/biwmeta/lastblock')) {
+ return { ok: true, json: async () => lastblock };
+ }
+ if (url.endsWith('/wallet/biwmeta/transactions/query')) {
+ expect(init?.method).toBe('POST');
+ return { ok: true, json: async () => query };
+ }
+ return { ok: false, status: 404 };
+ });
+
+ const provider = new BiowalletProvider(entry, 'biwmeta');
+ const txs = await provider.getTransactionHistory(tx.senderId, 10);
+
+ expect(txs.length).toBeGreaterThan(0);
+ expect(txs[0].action).toBe('issueEntity');
+ expect(txs[0].direction).toBe('self');
+ expect(txs[0].assets[0]).toMatchObject({
+ assetType: 'native',
+ symbol: 'BIW',
+ decimals: 8,
+ value: '0',
+ });
+ });
+
+ it('converts signature (BSE-01) to signature + placeholder native asset', async () => {
+ const query = readFixture('biwmeta-bse-01-signature.json');
+ const tx = query.result.trs[0].transaction;
+
+ mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
+ if (url.endsWith('/wallet/biwmeta/lastblock')) {
+ return { ok: true, json: async () => lastblock };
+ }
+ if (url.endsWith('/wallet/biwmeta/transactions/query')) {
+ expect(init?.method).toBe('POST');
+ return { ok: true, json: async () => query };
+ }
+ return { ok: false, status: 404 };
+ });
+
+ const provider = new BiowalletProvider(entry, 'biwmeta');
+ const txs = await provider.getTransactionHistory(tx.senderId, 10);
+
+ expect(txs.length).toBeGreaterThan(0);
+ expect(txs[0].action).toBe('signature');
+ expect(txs[0].direction).toBe('out');
+ expect(txs[0].assets[0]).toMatchObject({
+ assetType: 'native',
+ symbol: 'BIW',
+ decimals: 8,
+ value: '0',
+ });
+ });
+});
diff --git a/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts b/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts
new file mode 100644
index 000000000..875ef2787
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { BiowalletProvider } from '../biowallet-provider'
+import type { ParsedApiEntry } from '@/services/chain-config'
+
+vi.mock('@/services/chain-config', () => ({
+ chainConfigService: {
+ getSymbol: () => 'BFM',
+ getDecimals: () => 8,
+ },
+}))
+
+const mockFetch = vi.fn()
+global.fetch = mockFetch
+
+function readFixture(name: string): T {
+ const dir = path.dirname(fileURLToPath(import.meta.url))
+ const filePath = path.join(dir, 'fixtures/real', name)
+ return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T
+}
+
+describe('BiowalletProvider (real fixtures)', () => {
+ const entry: ParsedApiEntry = {
+ type: 'biowallet-v1',
+ endpoint: 'https://walletapi.bfmeta.info',
+ config: { path: 'bfm' },
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('converts transferAsset transactions from BFMeta API', async () => {
+ const address = 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j'
+ const lastblock = readFixture('bfmeta-lastblock.json')
+ const query = readFixture('bfmeta-transactions-query.json')
+
+ mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
+ if (url.endsWith('/wallet/bfm/lastblock')) {
+ return { ok: true, json: async () => lastblock }
+ }
+ if (url.endsWith('/wallet/bfm/transactions/query')) {
+ expect(init?.method).toBe('POST')
+ return { ok: true, json: async () => query }
+ }
+ return { ok: false, status: 404 }
+ })
+
+ const provider = new BiowalletProvider(entry, 'bfmeta')
+ const txs = await provider.getTransactionHistory(address, 10)
+
+ expect(txs.length).toBeGreaterThan(0)
+ expect(txs[0].action).toBe('transfer')
+ expect(txs[0].direction).toBe('in')
+ expect(txs[0].assets[0]).toMatchObject({
+ assetType: 'native',
+ symbol: 'BFM',
+ decimals: 8,
+ })
+ })
+})
diff --git a/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts b/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts
index 35e9b13af..e771cfd49 100644
--- a/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts
+++ b/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts
@@ -1,4 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
import { EtherscanProvider, createEtherscanProvider } from '../etherscan-provider'
import type { ParsedApiEntry } from '@/services/chain-config'
@@ -14,11 +17,16 @@ vi.mock('@/services/chain-config', () => ({
const mockFetch = vi.fn()
global.fetch = mockFetch
+function readFixture(name: string): T {
+ const dir = path.dirname(fileURLToPath(import.meta.url))
+ const filePath = path.join(dir, 'fixtures/real', name)
+ return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T
+}
+
describe('EtherscanProvider', () => {
const mockEntry: ParsedApiEntry = {
- type: 'etherscan-v2',
- endpoint: 'https://api.etherscan.io/v2/api',
- config: { apiKey: 'test-api-key' },
+ type: 'blockscout-eth',
+ endpoint: 'https://eth.blockscout.com/api',
}
beforeEach(() => {
@@ -51,113 +59,225 @@ describe('EtherscanProvider', () => {
})
describe('getTransactionHistory', () => {
- it('fetches transactions from Etherscan API', async () => {
- const mockResponse = {
- status: '1',
- message: 'OK',
- result: [
- {
- hash: '0xabc123',
- from: '0x1111111111111111111111111111111111111111',
- to: '0x2222222222222222222222222222222222222222',
- value: '1000000000000000000',
- timeStamp: '1700000000',
- isError: '0',
- blockNumber: '18000000',
- },
- ],
- }
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve(mockResponse),
+ it('classifies swap/approve correctly from real Blockscout txlist samples', async () => {
+ const evmContractCaller = '0xc00eb08fef86e5f74b692813f31bb5957eaa045c'
+ const swap = readFixture<{ tx: unknown }>('eth-blockscout-native-swap-tx.json').tx
+ const approve = readFixture<{ tx: unknown }>('eth-blockscout-native-approve-tx.json').tx
+
+ mockFetch.mockImplementation(async (url: string) => {
+ const u = new URL(url)
+ const action = u.searchParams.get('action')
+ const address = u.searchParams.get('address')
+
+ if (action === 'txlist' && address?.toLowerCase() === evmContractCaller) {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [swap, approve] }) }
+ }
+ if (action === 'tokentx') {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [] }) }
+ }
+
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [] }) }
})
const provider = new EtherscanProvider(mockEntry, 'ethereum')
- const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222', 10)
-
- expect(mockFetch).toHaveBeenCalledWith(
- expect.stringContaining('https://api.etherscan.io/v2/api')
- )
- expect(mockFetch).toHaveBeenCalledWith(
- expect.stringContaining('chainid=1')
- )
- expect(mockFetch).toHaveBeenCalledWith(
- expect.stringContaining('apikey=test-api-key')
- )
- expect(txs).toHaveLength(1)
- expect(txs[0]).toMatchObject({
- hash: '0xabc123',
- from: '0x1111111111111111111111111111111111111111',
- to: '0x2222222222222222222222222222222222222222',
- value: '1000000000000000000',
- symbol: 'ETH',
- status: 'confirmed',
- })
+ const txs = await provider.getTransactionHistory(evmContractCaller, 10)
+
+ const swapTx = txs.find(tx => tx.action === 'swap')
+ const approveTx = txs.find(tx => tx.action === 'approve')
+
+ expect(swapTx).toBeDefined()
+ expect(swapTx?.direction).toBe('out')
+ expect(swapTx?.contract?.address).toBe(swapTx?.to)
+ expect(['0x38ed1739', '0x7ff36ab5', '0x18cbafe5']).toContain(swapTx?.contract?.methodId)
+
+ expect(approveTx).toBeDefined()
+ expect(approveTx?.direction).toBe('out')
+ expect(approveTx?.contract?.address).toBe(approveTx?.to)
+ expect(approveTx?.contract?.methodId).toBe('0x095ea7b3')
})
- it('returns empty array when no transactions found', async () => {
- const mockResponse = {
- status: '0',
- message: 'No transactions found',
- result: [],
- }
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve(mockResponse),
+ it('converts real Blockscout tokentx sample to token asset', async () => {
+ const receiver = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
+ const tokenTx = readFixture<{ tx: any }>('eth-blockscout-token-transfer-tx.json').tx
+
+ mockFetch.mockImplementation(async (url: string) => {
+ const u = new URL(url)
+ const action = u.searchParams.get('action')
+ const address = u.searchParams.get('address')
+
+ if (action === 'txlist' && address?.toLowerCase() === receiver.toLowerCase()) {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [] }) }
+ }
+ if (action === 'tokentx' && address?.toLowerCase() === receiver.toLowerCase()) {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [tokenTx] }) }
+ }
+
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [] }) }
})
const provider = new EtherscanProvider(mockEntry, 'ethereum')
- const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222')
+ const txs = await provider.getTransactionHistory(receiver, 10)
- expect(txs).toEqual([])
+ expect(txs.length).toBeGreaterThan(0)
+ expect(txs[0].action).toBe('transfer')
+ expect(txs[0].direction).toBe('in')
+ expect(txs[0].assets[0].assetType).toBe('token')
+ expect(txs[0].assets[0]).toMatchObject({
+ symbol: tokenTx.tokenSymbol,
+ contractAddress: tokenTx.contractAddress,
+ decimals: parseInt(tokenTx.tokenDecimal, 10),
+ })
})
- it('returns empty array on HTTP error', async () => {
- mockFetch.mockResolvedValue({
- ok: false,
- status: 500,
+ it('converts real Blockscout native transfer sample to native asset', async () => {
+ const receiver = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
+ const nativeTx = readFixture<{ tx: any }>('eth-blockscout-native-transfer-tx.json').tx
+
+ mockFetch.mockImplementation(async (url: string) => {
+ const u = new URL(url)
+ const action = u.searchParams.get('action')
+ const address = u.searchParams.get('address')
+
+ if (action === 'txlist' && address?.toLowerCase() === receiver.toLowerCase()) {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [nativeTx] }) }
+ }
+ if (action === 'tokentx') {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [] }) }
+ }
+
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [] }) }
})
const provider = new EtherscanProvider(mockEntry, 'ethereum')
- const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222')
+ const txs = await provider.getTransactionHistory(receiver, 10)
- expect(txs).toEqual([])
+ expect(txs).toHaveLength(1)
+ expect(txs[0].action).toBe('transfer')
+ expect(txs[0].direction).toBe('in')
+ expect(txs[0].assets[0]).toMatchObject({
+ assetType: 'native',
+ value: nativeTx.value,
+ symbol: 'ETH',
+ decimals: 18,
+ })
})
- it('returns empty array on fetch error', async () => {
- mockFetch.mockRejectedValue(new Error('Network error'))
+ it('aggregates txlist + tokentx by hash, prioritizing token assets', async () => {
+ // Simulate a USDT transfer: txlist has 0-value contract call, tokentx has the token event
+ const userAddress = '0x75a6F48BF634868b2980c97CcEf467A127597e08'
+ const txHash = '0xabc123def456'
+ const receiverAddress = '0xRecipientAddress123456'
+ const contractAddress = '0xdAC17F958D2ee523a2206206994597C13D831ec7' // USDT
+
+ const nativeTx = {
+ hash: txHash,
+ from: userAddress,
+ to: contractAddress,
+ value: '0',
+ timeStamp: '1700000000',
+ isError: '0',
+ blockNumber: '12345678',
+ input: '0xa9059cbb000000000000000000000000', // transfer methodId
+ methodId: '0xa9059cbb',
+ functionName: 'transfer(address,uint256)',
+ }
+
+ const tokenTx = {
+ hash: txHash,
+ from: userAddress,
+ to: receiverAddress,
+ value: '30000000000', // 30000 USDT (6 decimals)
+ timeStamp: '1700000000',
+ blockNumber: '12345678',
+ tokenSymbol: 'USDT',
+ tokenName: 'Tether USD',
+ tokenDecimal: '6',
+ contractAddress,
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ const u = new URL(url)
+ const action = u.searchParams.get('action')
+ const address = u.searchParams.get('address')
+
+ if (action === 'txlist' && address?.toLowerCase() === userAddress.toLowerCase()) {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [nativeTx] }) }
+ }
+ if (action === 'tokentx' && address?.toLowerCase() === userAddress.toLowerCase()) {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [tokenTx] }) }
+ }
+
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [] }) }
+ })
const provider = new EtherscanProvider(mockEntry, 'ethereum')
- const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ // Should produce only 1 aggregated transaction (not 2 separate ones)
+ expect(txs).toHaveLength(1)
+
+ const tx = txs[0]
+ expect(tx.hash).toBe(txHash)
+ expect(tx.action).toBe('transfer')
+ expect(tx.direction).toBe('out')
+
+ // Primary asset should be the token, not the 0-value native
+ expect(tx.assets[0]).toMatchObject({
+ assetType: 'token',
+ value: '30000000000',
+ symbol: 'USDT',
+ decimals: 6,
+ contractAddress,
+ })
+
+ // from/to should reflect the token transfer participants
+ expect(tx.from.toLowerCase()).toBe(userAddress.toLowerCase())
+ expect(tx.to.toLowerCase()).toBe(receiverAddress.toLowerCase())
- expect(txs).toEqual([])
+ // Contract metadata should still be preserved
+ expect(tx.contract).toBeDefined()
+ expect(tx.contract?.methodId).toBe('0xa9059cbb')
})
- it('marks failed transactions correctly', async () => {
- const mockResponse = {
- status: '1',
- message: 'OK',
- result: [
- {
- hash: '0xfailed',
- from: '0x1111111111111111111111111111111111111111',
- to: '0x2222222222222222222222222222222222222222',
- value: '0',
- timeStamp: '1700000000',
- isError: '1',
- blockNumber: '18000000',
- },
- ],
+ it('marks unrecognized contract call with no token events as "contract" action', async () => {
+ const userAddress = '0x75a6F48BF634868b2980c97CcEf467A127597e08'
+ const txHash = '0xunknown999'
+ const contractAddr = '0xSomeContract123'
+
+ const nativeTx = {
+ hash: txHash,
+ from: userAddress,
+ to: contractAddr,
+ value: '0',
+ timeStamp: '1700000000',
+ isError: '0',
+ blockNumber: '12345678',
+ input: '0xdeadbeef12345678', // unknown method
+ methodId: '0xdeadbeef',
+ functionName: '',
}
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve(mockResponse),
+
+ mockFetch.mockImplementation(async (url: string) => {
+ const u = new URL(url)
+ const action = u.searchParams.get('action')
+
+ if (action === 'txlist') {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [nativeTx] }) }
+ }
+ if (action === 'tokentx') {
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [] }) }
+ }
+
+ return { ok: true, json: async () => ({ status: '1', message: 'OK', result: [] }) }
})
const provider = new EtherscanProvider(mockEntry, 'ethereum')
- const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
- expect(txs[0].status).toBe('failed')
+ expect(txs).toHaveLength(1)
+ expect(txs[0].action).toBe('contract')
+ expect(txs[0].assets[0].assetType).toBe('native')
+ expect(txs[0].assets[0].value).toBe('0')
})
})
})
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-lastblock.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-lastblock.json
new file mode 100644
index 000000000..55d0ea07a
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-lastblock.json
@@ -0,0 +1,42 @@
+{
+ "success": true,
+ "result": {
+ "height": 1349449,
+ "timestamp": 23069565,
+ "blockSize": 494,
+ "signature": "7bc74353f6f9103b30567f3b02203c449e115a0f2c30ace1940ad3296f00805b0ad931b4b43d70c0a4dda4f06e86d2d9ac00871a4def152fa15f5f85fdeb840c",
+ "generatorPublicKey": "14e4dcb4aea3785ac66f613e14b6985b55865be03a2fc3765b19fa255d75a471",
+ "previousBlockSignature": "9d3c133301082c3c4781d65c56175a005901077050466d0098da424956b2fc501831e1fbb5645c2417da82f1bff63b634c837bb872345f248d03d49be9b0020e",
+ "reward": "1000000000",
+ "magic": "LLLQL",
+ "remark": {
+ "info": "",
+ "debug": "BFM_linux_v3.8.2_P11_DP1_T0_C0_A0.00 UNTRS_B0_E0_TIME14 LOST 0"
+ },
+ "asset": {
+ "commonAsset": {
+ "assetChangeHash": "a73e1d4ab9cd4dd1b8fcfecf9813fa1b7529d5ad1c6c32948b686d8909abf696"
+ }
+ },
+ "version": 1,
+ "transactionInfo": {
+ "startTindex": 119684,
+ "offset": 0,
+ "numberOfTransactions": 0,
+ "payloadHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "payloadLength": 0,
+ "blobSize": 0,
+ "totalAmount": "0",
+ "totalFee": "0",
+ "transactionInBlocks": [],
+ "statisticInfo": {
+ "totalFee": "0",
+ "totalAsset": "0",
+ "totalChainAsset": "0",
+ "totalAccount": 0,
+ "magicAssetTypeTypeStatisticHashMap": {},
+ "numberOfTransactionsHashMap": {}
+ }
+ }
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-transactions-query.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-transactions-query.json
new file mode 100644
index 000000000..35500c53d
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/bfmeta-transactions-query.json
@@ -0,0 +1,118 @@
+{
+ "success": true,
+ "result": {
+ "trs": [
+ {
+ "tIndex": 115990,
+ "height": 1274316,
+ "signature": "7c1959883967cd629d12ced0240d8406667bf91ad16dc6d5d709db72ae9ee4ea8eb9974bc01151de805d48eb7fd77e4385467c8dc4f9ac386a9ffce2e1b43603",
+ "transaction": {
+ "version": 1,
+ "type": "BFM-BFMETA-AST-02",
+ "senderId": "b9gB9NzHKWsDKGYFCaNva6xRnxPwFfGcfx",
+ "senderPublicKey": "fd49214ce56d5d14d70c740765827d0ac9a5c8913eeeb50bf8e8766e6dce8c2a",
+ "fee": "260",
+ "timestamp": 21942540,
+ "applyBlockHeight": 1274314,
+ "effectiveBlockHeight": 1274414,
+ "signature": "628a0e17ae4093a0f43fd25b0ca9105bbfd387d110fb25ce4df3c972e0b8c83c780e445beef539e4efc40ce048838b5eaa1095aa92bc3bde196232a339955306",
+ "asset": {
+ "transferAsset": {
+ "sourceChainName": "bfmeta",
+ "sourceChainMagic": "LLLQL",
+ "assetType": "BFM",
+ "amount": "3"
+ }
+ },
+ "rangeType": 0,
+ "range": [],
+ "fromMagic": "LLLQL",
+ "toMagic": "LLLQL",
+ "remark": {
+ "n": "BFM Pay",
+ "m": "true",
+ "a": "dweb",
+ "postscript": "test"
+ },
+ "recipientId": "bCfAynSAKhzgKLi3BXyuh5k22GctLR72j",
+ "storageKey": "assetType",
+ "storageValue": "BFM"
+ }
+ },
+ {
+ "tIndex": 116001,
+ "height": 1274442,
+ "signature": "21fc153d4e9773c6d9986bf7379a711078564ad9e8580ab916e1a25c94f7a4e68b3aaeeb6c27dc781c96c6f7ef299c164775c2d20544110d8cff29543ea3f504",
+ "transaction": {
+ "version": 1,
+ "type": "BFM-BFMETA-AST-02",
+ "senderId": "b9gB9NzHKWsDKGYFCaNva6xRnxPwFfGcfx",
+ "senderPublicKey": "fd49214ce56d5d14d70c740765827d0ac9a5c8913eeeb50bf8e8766e6dce8c2a",
+ "fee": "300",
+ "timestamp": 21944445,
+ "applyBlockHeight": 1274441,
+ "effectiveBlockHeight": 1274541,
+ "signature": "48ee7fba0b35a0bfcda5a593cc3280d060218024620f80206eb7dae37503fa91d7997353dfc36e0d165edb1013f826dcccac8accbc2ede9082e681939bc9040a",
+ "asset": {
+ "transferAsset": {
+ "sourceChainName": "bfmeta",
+ "sourceChainMagic": "LLLQL",
+ "assetType": "BFM",
+ "amount": "1"
+ }
+ },
+ "rangeType": 0,
+ "range": [],
+ "fromMagic": "LLLQL",
+ "toMagic": "LLLQL",
+ "remark": {
+ "n": "KeyApp",
+ "m": "true",
+ "a": "web"
+ },
+ "recipientId": "bCfAynSAKhzgKLi3BXyuh5k22GctLR72j",
+ "storageKey": "assetType",
+ "storageValue": "BFM"
+ }
+ },
+ {
+ "tIndex": 116293,
+ "height": 1279655,
+ "signature": "7b43d900e6e4e675006c5bbfbf61e817c7df245c52adaeaeb45b1691672f650a897a0f0bb525bfa6da19aafbc4d6ff3ed123c0f39ce9826713755bcde7d48308",
+ "transaction": {
+ "version": 1,
+ "type": "BFM-BFMETA-AST-02",
+ "senderId": "bFgBYCqJE1BuDZRi76dRKt9QV8QpsdzAQn",
+ "senderPublicKey": "da3298a82e4e362c5cb036f2bfca94387209b0f87f201ff7e5366e5a6b8913c5",
+ "fee": "250",
+ "timestamp": 22022640,
+ "applyBlockHeight": 1279654,
+ "effectiveBlockHeight": 1279754,
+ "signature": "3a1aa404a1ad112dae591d99c16e92ec83d5e0d609ecf32aa49bd039b912563e81c06a8160574707c6a488cde09a78993610f371bf28010dc7a1c370d1001e03",
+ "asset": {
+ "transferAsset": {
+ "sourceChainName": "bfmeta",
+ "sourceChainMagic": "LLLQL",
+ "assetType": "BFM",
+ "amount": "1000"
+ }
+ },
+ "rangeType": 0,
+ "range": [],
+ "fromMagic": "LLLQL",
+ "toMagic": "LLLQL",
+ "remark": {
+ "n": "KeyApp",
+ "m": "true",
+ "a": "web"
+ },
+ "recipientId": "bCfAynSAKhzgKLi3BXyuh5k22GctLR72j",
+ "storageKey": "assetType",
+ "storageValue": "BFM"
+ }
+ }
+ ],
+ "count": 3,
+ "cmdLimitPerQuery": 20
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-02-transferAsset.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-02-transferAsset.json
new file mode 100644
index 000000000..285e57994
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-02-transferAsset.json
@@ -0,0 +1,43 @@
+{
+ "success": true,
+ "result": {
+ "trs": [
+ {
+ "tIndex": 2202,
+ "height": 71,
+ "signature": "04740bd3b46c2f775b67846ad7d6dfbe6ede4db568ae6470f87f9ddb9671881007f24d1131d204e9e8f8e9d99cacb6940855ba07490f2155f97d496c17d7a609",
+ "transaction": {
+ "version": 1,
+ "type": "BIW-BIWMETA-AST-02",
+ "senderId": "bNKbZW7PJFJk5ygb5MDoQUQcqEiKRLpM1G",
+ "senderPublicKey": "625c8841be07d0c52bd2320927e30a190cfb70fd17ce7f565c9cdfd8f8f1381a",
+ "fee": "5000",
+ "timestamp": 20064919,
+ "applyBlockHeight": 1,
+ "effectiveBlockHeight": 3001,
+ "signature": "ae1f5de56b79822954e3da0090cecc20e5af603cf6737e4f57cdd667e66c170f2fcb566f43053c163b755dd62847c22ee49996664c188f1361ab170924ecf202",
+ "asset": {
+ "transferAsset": {
+ "sourceChainName": "biwmeta",
+ "sourceChainMagic": "X44FA",
+ "assetType": "BIW",
+ "amount": "5000"
+ }
+ },
+ "rangeType": 0,
+ "range": [],
+ "fromMagic": "X44FA",
+ "toMagic": "X44FA",
+ "remark": {
+ "from": "official transfer 5uv4"
+ },
+ "recipientId": "bPp3sNMAXXpvD17Hotujuz1kR2CBd7eidK",
+ "storageKey": "assetType",
+ "storageValue": "BIW"
+ }
+ }
+ ],
+ "count": 1,
+ "cmdLimitPerQuery": 20
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-03-destroyAsset.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-03-destroyAsset.json
new file mode 100644
index 000000000..2372005d3
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ast-03-destroyAsset.json
@@ -0,0 +1,45 @@
+{
+ "success": true,
+ "result": {
+ "trs": [
+ {
+ "tIndex": 106838,
+ "height": 851325,
+ "signature": "7b44db363f5a322ef116d32732607644791ecd2b72fbf90c479da96ddb6aacc2f2b21a173088db86607ec33d7c3a5e43ad1314557bcc1accb021c820d89a9407",
+ "transaction": {
+ "version": 1,
+ "type": "BIW-BIWMETA-AST-03",
+ "senderId": "bPyMpaHrVV2qVfRtZXkaUF6zT7JkyE6XHQ",
+ "senderPublicKey": "225eb336594197eab65cee7ca8a1c2a45129f707aca006d97df004f84f70b426",
+ "fee": "2346",
+ "timestamp": 33127935,
+ "applyBlockHeight": 851324,
+ "effectiveBlockHeight": 851424,
+ "signature": "3c96dfa4f6579ed753549df27653e762780c40c0a1f959cfa18655095ca975666ddc2aa151f179d78fd1fe27b0d7de82ba13edcef4bd53d7768399d848c94c07",
+ "asset": {
+ "destroyAsset": {
+ "sourceChainName": "biwmeta",
+ "sourceChainMagic": "X44FA",
+ "assetType": "AMGT",
+ "amount": "58636952548"
+ }
+ },
+ "rangeType": 0,
+ "range": [],
+ "fromMagic": "X44FA",
+ "toMagic": "X44FA",
+ "remark": {},
+ "recipientId": "b4pwE8rhnHsYcKru4dWmfVvEt9ubKebDVD",
+ "storageKey": "assetType",
+ "storageValue": "AMGT"
+ },
+ "assetPrealnum": {
+ "remainAssetPrealnum": "9995569446935164",
+ "frozenMainAssetPrealnum": "100055648268"
+ }
+ }
+ ],
+ "count": 1,
+ "cmdLimitPerQuery": 20
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-bse-01-signature.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-bse-01-signature.json
new file mode 100644
index 000000000..4ce595d12
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-bse-01-signature.json
@@ -0,0 +1,39 @@
+{
+ "success": true,
+ "result": {
+ "trs": [
+ {
+ "tIndex": 106826,
+ "height": 850140,
+ "signature": "a7e5e2e39710d75fcbf20be33f772fd3ab48c12fb76be63cc1aadce233de5758fd6b881dcfed6a6a805a7009c87974db9a5a4a653c2a3490e94c586c7628c20b",
+ "transaction": {
+ "version": 1,
+ "type": "BIW-BIWMETA-BSE-01",
+ "senderId": "b2Hp9DbuBaXA3fqTAwu2dRBcDELokKmjna",
+ "senderPublicKey": "6f57ff5b709869b4dc3988923f818705eb00da5f77a2b9bcda97899a9a73f014",
+ "fee": "2244",
+ "timestamp": 33110160,
+ "applyBlockHeight": 850139,
+ "effectiveBlockHeight": 850239,
+ "signature": "eb85d4ef1303f900815fd23098c4837b2576f27c776afb599574a6d5b0c10c1fd230cbf8613a76ac48988deb0e70c3ea6b2fcf58745771d0ca16832f24e83e0c",
+ "asset": {
+ "signature": {
+ "publicKey": "589d35807a2e2b6849719cf6081e71ad1cf7f41e34675002252da3b96f51c300"
+ }
+ },
+ "rangeType": 0,
+ "range": [],
+ "fromMagic": "X44FA",
+ "toMagic": "X44FA",
+ "remark": {
+ "n": "BIW Meta",
+ "m": "true",
+ "a": "dweb"
+ }
+ }
+ }
+ ],
+ "count": 1,
+ "cmdLimitPerQuery": 20
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-01-issueEntityFactory.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-01-issueEntityFactory.json
new file mode 100644
index 000000000..024da8dc7
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-01-issueEntityFactory.json
@@ -0,0 +1,43 @@
+{
+ "success": true,
+ "result": {
+ "trs": [
+ {
+ "tIndex": 2,
+ "height": 1,
+ "signature": "a8f7fdc3363783168988530b291410d9cfa5afd9e2c85b55f7ebdb4997036eb88947504ad2bc865d149a668fab95d8910fa29cf23e65e0a94a838be8c5cc3b0c",
+ "transaction": {
+ "version": 1,
+ "type": "BIW-BIWMETA-ETY-01",
+ "senderId": "bNKbZW7PJFJk5ygb5MDoQUQcqEiKRLpM1G",
+ "senderPublicKey": "625c8841be07d0c52bd2320927e30a190cfb70fd17ce7f565c9cdfd8f8f1381a",
+ "fee": "846",
+ "timestamp": 0,
+ "applyBlockHeight": 1,
+ "effectiveBlockHeight": 1,
+ "signature": "6160e4827b0fb7758e54f74cf906fbc6de5b6c9f803e0bf5dd39260a187fb0623600bc9a44de788cfc476f36eea0513244f550ed123aff0fbe8c84becf458206",
+ "asset": {
+ "issueEntityFactory": {
+ "sourceChainName": "biwmeta",
+ "sourceChainMagic": "X44FA",
+ "factoryId": "share",
+ "entityPrealnum": "10000",
+ "entityFrozenAssetPrealnum": "0",
+ "purchaseAssetPrealnum": "0"
+ }
+ },
+ "rangeType": 0,
+ "range": [],
+ "fromMagic": "X44FA",
+ "toMagic": "X44FA",
+ "remark": {},
+ "recipientId": "bNKbZW7PJFJk5ygb5MDoQUQcqEiKRLpM1G",
+ "storageKey": "factoryId",
+ "storageValue": "share"
+ }
+ }
+ ],
+ "count": 1,
+ "cmdLimitPerQuery": 20
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-02-issueEntity.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-02-issueEntity.json
new file mode 100644
index 000000000..8eafe6283
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-ety-02-issueEntity.json
@@ -0,0 +1,50 @@
+{
+ "success": true,
+ "result": {
+ "trs": [
+ {
+ "tIndex": 6,
+ "height": 1,
+ "signature": "767eec9911cccb199d43f8336ea49d4cf1e9131a36118f5cb46fd6683a163bcd442a34f5ce8d1591949336b1a2b5a96050fff8b179821749f979609f103c2501",
+ "transaction": {
+ "version": 1,
+ "type": "BIW-BIWMETA-ETY-02",
+ "senderId": "bLveQAuYRp2Fc8r65HgDxpKMqrkPcgv7Kn",
+ "senderPublicKey": "bfae68a05baa5584de0da9d48a025be9025b5d9ae359615f7511aa24b333c155",
+ "fee": "1095",
+ "timestamp": 0,
+ "applyBlockHeight": 1,
+ "effectiveBlockHeight": 1,
+ "signature": "287c40e7e68cddde48cd7158872744ad2dc772153599e61cac0311d5f0bfa0f38f4c9e81023ee21adf8cc243e6daf0fd059525dccf1f78d464f8fe7dd9785905",
+ "asset": {
+ "issueEntity": {
+ "sourceChainName": "biwmeta",
+ "sourceChainMagic": "X44FA",
+ "entityId": "forge_forge0003",
+ "taxAssetPrealnum": "0",
+ "entityFactoryPossessor": "bNKbZW7PJFJk5ygb5MDoQUQcqEiKRLpM1G",
+ "entityFactory": {
+ "sourceChainName": "biwmeta",
+ "sourceChainMagic": "X44FA",
+ "factoryId": "forge",
+ "entityPrealnum": "1000",
+ "entityFrozenAssetPrealnum": "0",
+ "purchaseAssetPrealnum": "0"
+ }
+ }
+ },
+ "rangeType": 0,
+ "range": [],
+ "fromMagic": "X44FA",
+ "toMagic": "X44FA",
+ "remark": {},
+ "recipientId": "bLveQAuYRp2Fc8r65HgDxpKMqrkPcgv7Kn",
+ "storageKey": "entityId",
+ "storageValue": "forge_forge0003"
+ }
+ }
+ ],
+ "count": 1,
+ "cmdLimitPerQuery": 20
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-lastblock.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-lastblock.json
new file mode 100644
index 000000000..b88c0812c
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/biwmeta-lastblock.json
@@ -0,0 +1,42 @@
+{
+ "success": true,
+ "result": {
+ "height": 2112263,
+ "timestamp": 52045395,
+ "blockSize": 496,
+ "signature": "05bd12892104ff115d81bea08cd40dc7f8a8d2910890e19f83a8c2381449d1122aeb8a88e4c30a203c57b112e490bfcf6fa6bd7f228e279003f26e1941670e05",
+ "generatorPublicKey": "6ee23562d483475282cdf19fc0d18c9aef8f4ef0c8fa2cf50783aadec281cd60",
+ "previousBlockSignature": "b880b6e5c330e20c9e3c64219e5f0a8fa25ee77c81ab129e065032b2ff9306f0c21a704a3cbf22a695bdf6ff3fa0400b57c8fdca822ff0f7b0e8e2f20299fb0b",
+ "reward": "12000000000",
+ "magic": "X44FA",
+ "remark": {
+ "info": "",
+ "debug": "BIW_linux_v3.8.1_P11_DP1_T0_C0_A0.00 UNTRS_B0_E0_TIME14 LOST 0"
+ },
+ "asset": {
+ "commonAsset": {
+ "assetChangeHash": "8bd1e695b169f8dca6071e4e0fced74b162cb125a22a0a38932ad49b758f6724"
+ }
+ },
+ "version": 1,
+ "transactionInfo": {
+ "startTindex": 106995,
+ "offset": 0,
+ "numberOfTransactions": 0,
+ "payloadHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "payloadLength": 0,
+ "blobSize": 0,
+ "totalAmount": "0",
+ "totalFee": "0",
+ "transactionInBlocks": [],
+ "statisticInfo": {
+ "totalFee": "0",
+ "totalAsset": "0",
+ "totalChainAsset": "0",
+ "totalAccount": 0,
+ "magicAssetTypeTypeStatisticHashMap": {},
+ "numberOfTransactionsHashMap": {}
+ }
+ }
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/bsc-transactions-history.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/bsc-transactions-history.json
new file mode 100644
index 000000000..dbc72c2d3
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/bsc-transactions-history.json
@@ -0,0 +1,8 @@
+{
+ "success": true,
+ "result": {
+ "status": "0",
+ "message": "NOTOK",
+ "result": []
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/btc-mempool-address-txs.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/btc-mempool-address-txs.json
new file mode 100644
index 000000000..969580768
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/btc-mempool-address-txs.json
@@ -0,0 +1,50 @@
+[
+ {
+ "txid": "a9fdb0066e83740d2e337a1d6686ae7969ce6e2239c8c19b3c044525808be0e0",
+ "version": 1,
+ "locktime": 0,
+ "vin": [
+ {
+ "txid": "08237ca64c3831c7118e541bd18eecb5775fbc31df57b601476038d917612535",
+ "vout": 1,
+ "prevout": {
+ "scriptpubkey": "76a914e1ed9cbbc3166a25f471e122f078aad15a400df088ac",
+ "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 e1ed9cbbc3166a25f471e122f078aad15a400df0 OP_EQUALVERIFY OP_CHECKSIG",
+ "scriptpubkey_type": "p2pkh",
+ "scriptpubkey_address": "1MbbgkV38RxYxrTTtf7RF8i4DVgQY5CqYp",
+ "value": 31943
+ },
+ "scriptsig": "473044022018f82cdfcc7ae32784f98b90dd20813a4aca37e17b144c4948aca8feaa70280802200add01c4fd28e32936bb5201acf153370390fe8b5bec5b9977e056ffed466111012102951a6dd2b16c8292bb3d233c77aaacb141dbc8ff0f82aa9c1dc92bef704e5ecc",
+ "scriptsig_asm": "OP_PUSHBYTES_71 3044022018f82cdfcc7ae32784f98b90dd20813a4aca37e17b144c4948aca8feaa70280802200add01c4fd28e32936bb5201acf153370390fe8b5bec5b9977e056ffed46611101 OP_PUSHBYTES_33 02951a6dd2b16c8292bb3d233c77aaacb141dbc8ff0f82aa9c1dc92bef704e5ecc",
+ "is_coinbase": false,
+ "sequence": 4294967295
+ }
+ ],
+ "vout": [
+ {
+ "scriptpubkey": "0014e8df018c7e326cc253faac7e46cdc51e68542c42",
+ "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 e8df018c7e326cc253faac7e46cdc51e68542c42",
+ "scriptpubkey_type": "v0_p2wpkh",
+ "scriptpubkey_address": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
+ "value": 4382
+ },
+ {
+ "scriptpubkey": "76a914e1ed9cbbc3166a25f471e122f078aad15a400df088ac",
+ "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 e1ed9cbbc3166a25f471e122f078aad15a400df0 OP_EQUALVERIFY OP_CHECKSIG",
+ "scriptpubkey_type": "p2pkh",
+ "scriptpubkey_address": "1MbbgkV38RxYxrTTtf7RF8i4DVgQY5CqYp",
+ "value": 27334
+ }
+ ],
+ "size": 222,
+ "weight": 888,
+ "sigops": 4,
+ "fee": 227,
+ "status": {
+ "confirmed": true,
+ "block_height": 930986,
+ "block_hash": "00000000000000000001b41c34d983450674a54e3d52da18bd3f98e4ec53fa11",
+ "block_time": 1767602528
+ }
+ }
+]
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-approve-tx.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-approve-tx.json
new file mode 100644
index 000000000..6539f471a
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-approve-tx.json
@@ -0,0 +1,24 @@
+{
+ "sourceUrl": "https://eth.blockscout.com/api?module=account&action=txlist&address=0xc00eb08fef86e5f74b692813f31bb5957eaa045c&page=1&offset=100&sort=desc",
+ "tx": {
+ "blockHash": "0x17addfc1f6580ac701500241c3e6be99cb6a291edf15f2528cff00bf67e95f88",
+ "blockNumber": "24167739",
+ "confirmations": "46",
+ "contractAddress": "",
+ "cumulativeGasUsed": "24031457",
+ "from": "0xc00eb08fef86e5f74b692813f31bb5957eaa045c",
+ "gas": "48506",
+ "gasPrice": "55030903",
+ "gasUsed": "29889",
+ "hash": "0x548fd4e2371f46a0c9af645421fffc58e8d3b0fd56f730fd5e29a65baedc38fd",
+ "input": "0x095ea7b30000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d00000000000000000000000000000000000000000000e47a32cf051df6a94000",
+ "isError": "0",
+ "methodId": "0x",
+ "nonce": "1196",
+ "timeStamp": "1767607391",
+ "to": "0x67466be17df832165f8c80a5a120ccc652bd7e69",
+ "transactionIndex": "271",
+ "txreceipt_status": "1",
+ "value": "0"
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-swap-tx.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-swap-tx.json
new file mode 100644
index 000000000..6bf17752d
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-swap-tx.json
@@ -0,0 +1,24 @@
+{
+ "sourceUrl": "https://eth.blockscout.com/api?module=account&action=txlist&address=0xc00eb08fef86e5f74b692813f31bb5957eaa045c&page=1&offset=100&sort=desc",
+ "tx": {
+ "blockHash": "0x4ca333d442e829937a7cf3d9aad6f24bef4f5ba3f23c4cdd69b645782881e78e",
+ "blockNumber": "24167779",
+ "confirmations": "6",
+ "contractAddress": "",
+ "cumulativeGasUsed": "14827577",
+ "from": "0xc00eb08fef86e5f74b692813f31bb5957eaa045c",
+ "gas": "204075",
+ "gasPrice": "50295910",
+ "gasUsed": "102648",
+ "hash": "0x08a7938e128fee65c6480c45c94ff8ca4c016e702d364e6a3070f46e884b9b9f",
+ "input": "0x7ff36ab50000000000000000000000000000000000000000001e9c9d94f155d89652fdd80000000000000000000000000000000000000000000000000000000000000080000000000000000000000000c00eb08fef86e5f74b692813f31bb5957eaa045c00000000000000000000000000000000000000000000000000000000695b92f90000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000067466be17df832165f8c80a5a120ccc652bd7e69",
+ "isError": "0",
+ "methodId": "0x",
+ "nonce": "1199",
+ "timeStamp": "1767607883",
+ "to": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
+ "transactionIndex": "161",
+ "txreceipt_status": "1",
+ "value": "178946130000000000"
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-transfer-tx.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-transfer-tx.json
new file mode 100644
index 000000000..b4768fc44
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-native-transfer-tx.json
@@ -0,0 +1,24 @@
+{
+ "sourceUrl": "https://eth.blockscout.com/api?module=account&action=txlist&address=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045&page=1&offset=100&sort=desc",
+ "tx": {
+ "blockHash": "0x5f4b2470b2da042363bfbb5540f3a2b4278a3aaf58edebb899b1e78e82d375d6",
+ "blockNumber": "24164496",
+ "confirmations": "3289",
+ "contractAddress": "",
+ "cumulativeGasUsed": "15997576",
+ "from": "0x5255bc25bd0f0f7614155b7692dcdf51afa3a5e3",
+ "gas": "31840",
+ "gasPrice": "1033656740",
+ "gasUsed": "21062",
+ "hash": "0x4ecff1f6fb6b26b06b6cb10fbfad4a15fd7aaef0399cc4b19c6ae54fd661720a",
+ "input": "0x",
+ "isError": "0",
+ "methodId": "0x",
+ "nonce": "175",
+ "timeStamp": "1767568295",
+ "to": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
+ "transactionIndex": "102",
+ "txreceipt_status": "1",
+ "value": "4000000000000"
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-token-transfer-tx.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-token-transfer-tx.json
new file mode 100644
index 000000000..6ee72e88e
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/eth-blockscout-token-transfer-tx.json
@@ -0,0 +1,26 @@
+{
+ "sourceUrl": "https://eth.blockscout.com/api?module=account&action=tokentx&address=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045&page=1&offset=100&sort=desc",
+ "tx": {
+ "value": "310011994000000000",
+ "blockHash": "0x6fe969bbe69f8edf6ec41aee6486bc20453e8d8473e888d704664a1e29a6bee4",
+ "blockNumber": "24166842",
+ "confirmations": "943",
+ "contractAddress": "0x95af4af910c28e8ece4512bfe46f1f33687424ce",
+ "cumulativeGasUsed": "11157339",
+ "from": "0x9642b23ed1e01df1092b92641051881a322f5d4e",
+ "functionName": "transfer(address _to, uint256 _tokenId)",
+ "gas": "108750",
+ "gasPrice": "115528955",
+ "gasUsed": "54533",
+ "hash": "0xd119b1137f67ca8e1b58e1f8bedd94f809eb4ec46ec9b5c9844e1a37ede7589f",
+ "input": "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045000000000000000000000000000000000000000000000000044d62441a7b0400",
+ "methodId": "a9059cbb",
+ "nonce": "2444792",
+ "timeStamp": "1767596591",
+ "to": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
+ "tokenDecimal": "9",
+ "tokenName": "Manyu",
+ "tokenSymbol": "MANYU",
+ "transactionIndex": "94"
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/fixtures/real/tron-trongrid-account-txs.json b/src/services/chain-adapter/providers/__tests__/fixtures/real/tron-trongrid-account-txs.json
new file mode 100644
index 000000000..7b0ee1065
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/fixtures/real/tron-trongrid-account-txs.json
@@ -0,0 +1,136 @@
+{
+ "data": [
+ {
+ "ret": [
+ {
+ "contractRet": "SUCCESS",
+ "fee": 0
+ }
+ ],
+ "signature": [
+ "740b5a3fb41954c26fdf4953ac4ba2cad7bfa5e0177bbbfefb97acfb4c8acbaf66901a2abaf9c3cd0604786cc2d479ef618fcf887d69dd1249283942ca91842b00"
+ ],
+ "txID": "17deac747345af0729f4f1ee3cab56fe0d68bd427fac4b755d6b20833a18cce5",
+ "net_usage": 345,
+ "raw_data_hex": "0a0229d72208fc6efb70027abdc940e0fba296b9335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a15413c32c7224525e60e43d91c3a87244d414fb1751112154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb00000000000000000000000023eb0658b59e32a200748b65f8a9b8f6c39bd784000000000000000000000000000000000000000000000000000000000036d48f70b9b2f2e8b8339001c094de0f",
+ "net_fee": 0,
+ "energy_usage": 14383,
+ "blockNumber": 78981623,
+ "block_timestamp": 1767607884000,
+ "energy_fee": 0,
+ "energy_usage_total": 14383,
+ "raw_data": {
+ "contract": [
+ {
+ "parameter": {
+ "value": {
+ "data": "a9059cbb00000000000000000000000023eb0658b59e32a200748b65f8a9b8f6c39bd784000000000000000000000000000000000000000000000000000000000036d48f",
+ "owner_address": "413c32c7224525e60e43d91c3a87244d414fb17511",
+ "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc"
+ },
+ "type_url": "type.googleapis.com/protocol.TriggerSmartContract"
+ },
+ "type": "TriggerSmartContract"
+ }
+ ],
+ "ref_block_bytes": "29d7",
+ "ref_block_hash": "fc6efb70027abdc9",
+ "expiration": 1767694188000,
+ "fee_limit": 33000000,
+ "timestamp": 1767599020345
+ },
+ "internal_transactions": []
+ },
+ {
+ "ret": [
+ {
+ "contractRet": "SUCCESS",
+ "fee": 0
+ }
+ ],
+ "signature": [
+ "3652a61afb20e48d34f033d4af44f79a588e5673c75484a432be5e4c64af8805bdcc6e76a4a9d95c2421b68a92d3b7fe14755093d7ebe086a0e5b34439a0926d00"
+ ],
+ "txID": "1be961707b87a74f521fb1f893f4b90173c40c0247a6a3870e4143cd6efd9e3f",
+ "net_usage": 345,
+ "raw_data_hex": "0a0228eb2208d6d739afe3aef51940a0fde1ecb8335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a154150e68c2a4af89f80b6eb7ee9fe5042df8307e82d12154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb00000000000000000000000009dc21c4834f0f07f07f98fc64c6181693e51d0700000000000000000000000000000000000000000000000000000000000f424070a7b9deecb833900180c2d72f",
+ "net_fee": 0,
+ "energy_usage": 14383,
+ "blockNumber": 78981356,
+ "block_timestamp": 1767607083000,
+ "energy_fee": 0,
+ "energy_usage_total": 14383,
+ "raw_data": {
+ "contract": [
+ {
+ "parameter": {
+ "value": {
+ "data": "a9059cbb00000000000000000000000009dc21c4834f0f07f07f98fc64c6181693e51d0700000000000000000000000000000000000000000000000000000000000f4240",
+ "owner_address": "4150e68c2a4af89f80b6eb7ee9fe5042df8307e82d",
+ "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc"
+ },
+ "type_url": "type.googleapis.com/protocol.TriggerSmartContract"
+ },
+ "type": "TriggerSmartContract"
+ }
+ ],
+ "ref_block_bytes": "28eb",
+ "ref_block_hash": "d6d739afe3aef519",
+ "expiration": 1767607140000,
+ "fee_limit": 100000000,
+ "timestamp": 1767607082151
+ },
+ "internal_transactions": []
+ },
+ {
+ "ret": [
+ {
+ "contractRet": "SUCCESS",
+ "fee": 0
+ }
+ ],
+ "signature": [
+ "b5e9394ccbda80d358525c2c71145a4491f123e0188c35d7b3391512962216d28c49f58c06c8c08af8faa248e2e988730ab40ad7f7647aa9ace16e2c6578469c00"
+ ],
+ "txID": "5f33de803afec77a38d5efb76324773ec76b00225eb166264f54a9d6c3872e5f",
+ "net_usage": 345,
+ "raw_data_hex": "0a0228e9220893c5c917db1b75cd40b0cee1ecb8335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a154150e68c2a4af89f80b6eb7ee9fe5042df8307e82d12154184716914c0fdf7110a44030d04d0c4923504d9cc2244a9059cbb000000000000000000000000e8fc7de4f2b2cddabcca7406205048def6a802a500000000000000000000000000000000000000000000000000000000000f424070a682deecb833900180c2d72f",
+ "net_fee": 0,
+ "energy_usage": 14383,
+ "blockNumber": 78981354,
+ "block_timestamp": 1767607077000,
+ "energy_fee": 0,
+ "energy_usage_total": 14383,
+ "raw_data": {
+ "contract": [
+ {
+ "parameter": {
+ "value": {
+ "data": "a9059cbb000000000000000000000000e8fc7de4f2b2cddabcca7406205048def6a802a500000000000000000000000000000000000000000000000000000000000f4240",
+ "owner_address": "4150e68c2a4af89f80b6eb7ee9fe5042df8307e82d",
+ "contract_address": "4184716914c0fdf7110a44030d04d0c4923504d9cc"
+ },
+ "type_url": "type.googleapis.com/protocol.TriggerSmartContract"
+ },
+ "type": "TriggerSmartContract"
+ }
+ ],
+ "ref_block_bytes": "28e9",
+ "ref_block_hash": "93c5c917db1b75cd",
+ "expiration": 1767607134000,
+ "fee_limit": 100000000,
+ "timestamp": 1767607075110
+ },
+ "internal_transactions": []
+ }
+ ],
+ "success": true,
+ "meta": {
+ "at": 1767607976648,
+ "fingerprint": "TmGrm87pwxo5LxaKFHALctkQmHPKAfAhHAZu35fchTx2NEawBa92DJAS1KfdbkPfdyBJHux11wZerNQZrYkHFKmH2vhYgHYMNJXRrgKnTVoK5WbNvspkL8gs6z9XuQDmU9YqLGwhhg4CK8bzke7eMEyEKR1WH5LRwc5g43oGF1rCTN1GmDcoEUWghLGBobDhzh1j8jqDjg2MdSGLMFhBZDoT8ASYJ",
+ "links": {
+ "next": "https://api.trongrid.io/v1/accounts/TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9/transactions?limit=20&fingerprint=TmGrm87pwxo5LxaKFHALctkQmHPKAfAhHAZu35fchTx2NEawBa92DJAS1KfdbkPfdyBJHux11wZerNQZrYkHFKmH2vhYgHYMNJXRrgKnTVoK5WbNvspkL8gs6z9XuQDmU9YqLGwhhg4CK8bzke7eMEyEKR1WH5LRwc5g43oGF1rCTN1GmDcoEUWghLGBobDhzh1j8jqDjg2MdSGLMFhBZDoT8ASYJ"
+ },
+ "page_size": 20
+ }
+}
diff --git a/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts b/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts
new file mode 100644
index 000000000..368c67f1f
--- /dev/null
+++ b/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts
@@ -0,0 +1,509 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { TronRpcProvider, createTronRpcProvider } from '../tron-rpc-provider'
+import type { ParsedApiEntry } from '@/services/chain-config'
+
+vi.mock('@/services/chain-config', () => ({
+ chainConfigService: {
+ getSymbol: (chainId: string) => chainId === 'tron' ? 'TRX' : 'UNKNOWN',
+ getDecimals: (chainId: string) => chainId === 'tron' ? 6 : 8,
+ },
+}))
+
+const mockFetch = vi.fn()
+global.fetch = mockFetch
+
+describe('TronRpcProvider', () => {
+ const mockEntry: ParsedApiEntry = {
+ type: 'tron-rpc',
+ endpoint: 'https://api.trongrid.io',
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('createTronRpcProvider', () => {
+ it('creates provider for tron-rpc type', () => {
+ const provider = createTronRpcProvider(mockEntry, 'tron')
+ expect(provider).toBeInstanceOf(TronRpcProvider)
+ })
+
+ it('creates provider for tron-* type', () => {
+ const entry: ParsedApiEntry = {
+ type: 'tron-grid',
+ endpoint: 'https://api.trongrid.io',
+ }
+ const provider = createTronRpcProvider(entry, 'tron')
+ expect(provider).toBeInstanceOf(TronRpcProvider)
+ })
+
+ it('returns null for non-tron type', () => {
+ const rpcEntry: ParsedApiEntry = {
+ type: 'ethereum-rpc',
+ endpoint: 'https://rpc.example.com',
+ }
+ const provider = createTronRpcProvider(rpcEntry, 'ethereum')
+ expect(provider).toBeNull()
+ })
+ })
+
+ describe('getTransactionHistory', () => {
+ it('aggregates native and TRC-20 transactions by txID', async () => {
+ const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+ const txId = 'a3163be0b1108f0f84a984f06ede8ec71ff15f036b017cda99e061641ca49a05'
+ const contractAddress = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'
+
+ // Native tx (TriggerSmartContract with 0 TRX)
+ const nativeTx = {
+ txID: txId,
+ raw_data: {
+ contract: [{
+ type: 'TriggerSmartContract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ to_address: contractAddress,
+ amount: 0,
+ },
+ },
+ }],
+ timestamp: 1766822586000,
+ },
+ ret: [{ contractRet: 'SUCCESS' }],
+ }
+
+ // TRC-20 token transfer event
+ const trc20Tx = {
+ transaction_id: txId,
+ token_info: {
+ symbol: 'USDT',
+ address: contractAddress,
+ decimals: 6,
+ name: 'Tether USD',
+ },
+ block_timestamp: 1766822586000,
+ from: 'TDuyHLhS79NKsccnhjd5X3wv44Mwre8HNN',
+ to: userAddress,
+ type: 'Transfer',
+ value: '2000000',
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes('/transactions/trc20')) {
+ return { ok: true, json: async () => ({ success: true, data: [trc20Tx] }) }
+ }
+ if (url.includes('/transactions?')) {
+ return { ok: true, json: async () => ({ success: true, data: [nativeTx] }) }
+ }
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ })
+
+ const provider = new TronRpcProvider(mockEntry, 'tron')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ // Should produce only 1 aggregated transaction
+ expect(txs).toHaveLength(1)
+
+ const tx = txs[0]
+ expect(tx.hash).toBe(txId)
+ expect(tx.action).toBe('transfer')
+ expect(tx.direction).toBe('in')
+ expect(tx.status).toBe('confirmed')
+
+ // Primary asset should be the token
+ expect(tx.assets[0]).toMatchObject({
+ assetType: 'token',
+ value: '2000000',
+ symbol: 'USDT',
+ decimals: 6,
+ contractAddress,
+ })
+
+ // from/to should reflect the token transfer participants
+ expect(tx.from).toBe('TDuyHLhS79NKsccnhjd5X3wv44Mwre8HNN')
+ expect(tx.to).toBe(userAddress)
+ })
+
+ it('handles orphan TRC-20 transactions (no native tx in window)', async () => {
+ const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+ const txId = 'orphan123456'
+
+ const trc20Tx = {
+ transaction_id: txId,
+ token_info: {
+ symbol: 'USDT',
+ address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
+ decimals: 6,
+ name: 'Tether USD',
+ },
+ block_timestamp: 1766822586000,
+ from: userAddress,
+ to: 'TReceiverAddress123',
+ type: 'Transfer',
+ value: '5000000',
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes('/transactions/trc20')) {
+ return { ok: true, json: async () => ({ success: true, data: [trc20Tx] }) }
+ }
+ if (url.includes('/transactions?')) {
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ }
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ })
+
+ const provider = new TronRpcProvider(mockEntry, 'tron')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ expect(txs).toHaveLength(1)
+
+ const tx = txs[0]
+ expect(tx.hash).toBe(txId)
+ expect(tx.action).toBe('transfer')
+ expect(tx.direction).toBe('out')
+ expect(tx.status).toBe('confirmed')
+ expect(tx.assets[0].assetType).toBe('token')
+ expect(tx.assets[0].symbol).toBe('USDT')
+ })
+
+ it('detects stake/unstake actions correctly', async () => {
+ const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+
+ const freezeTx = {
+ txID: 'freeze123',
+ raw_data: {
+ contract: [{
+ type: 'FreezeBalanceV2Contract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ frozen_balance: 1000000000,
+ },
+ },
+ }],
+ timestamp: 1766822586000,
+ },
+ ret: [{ contractRet: 'SUCCESS' }],
+ }
+
+ const unfreezeTx = {
+ txID: 'unfreeze456',
+ raw_data: {
+ contract: [{
+ type: 'UnfreezeBalanceV2Contract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ },
+ },
+ }],
+ timestamp: 1766822590000,
+ },
+ ret: [{ contractRet: 'SUCCESS' }],
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes('/transactions/trc20')) {
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ }
+ if (url.includes('/transactions?')) {
+ return { ok: true, json: async () => ({ success: true, data: [unfreezeTx, freezeTx] }) }
+ }
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ })
+
+ const provider = new TronRpcProvider(mockEntry, 'tron')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ expect(txs).toHaveLength(2)
+
+ const unstakeTx = txs.find(tx => tx.hash === 'unfreeze456')
+ const stakeTx = txs.find(tx => tx.hash === 'freeze123')
+
+ expect(unstakeTx?.action).toBe('unstake')
+ expect(stakeTx?.action).toBe('stake')
+ })
+
+ it('filters TriggerSmartContract with 0 TRX and no TRC-20 events (Option A smart filter)', async () => {
+ const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+
+ // 无意义的合约调用:0 TRX,无 TRC-20 事件
+ const spamTx = {
+ txID: 'spam123',
+ raw_data: {
+ contract: [{
+ type: 'TriggerSmartContract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ contract_address: 'TSpamContract',
+ amount: 0,
+ },
+ },
+ }],
+ timestamp: 1766822586000,
+ },
+ ret: [{ contractRet: 'SUCCESS' }],
+ }
+
+ // 有价值的转账
+ const transferTx = {
+ txID: 'transfer456',
+ raw_data: {
+ contract: [{
+ type: 'TransferContract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ to_address: 'TRecipient',
+ amount: 1000000,
+ },
+ },
+ }],
+ timestamp: 1766822580000,
+ },
+ ret: [{ contractRet: 'SUCCESS' }],
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes('/transactions/trc20')) {
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ }
+ if (url.includes('/transactions?')) {
+ return { ok: true, json: async () => ({ success: true, data: [spamTx, transferTx] }) }
+ }
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ })
+
+ const provider = new TronRpcProvider(mockEntry, 'tron')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ // spam 交易应该被过滤,只剩下 transfer
+ expect(txs).toHaveLength(1)
+ expect(txs[0].hash).toBe('transfer456')
+ expect(txs[0].action).toBe('transfer')
+ })
+
+ it('keeps TriggerSmartContract with TRC-20 events (token transfer)', async () => {
+ const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+ const txId = 'usdt-transfer-123'
+
+ const nativeTx = {
+ txID: txId,
+ raw_data: {
+ contract: [{
+ type: 'TriggerSmartContract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ contract_address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
+ amount: 0,
+ },
+ },
+ }],
+ timestamp: 1766822586000,
+ },
+ ret: [{ contractRet: 'SUCCESS' }],
+ }
+
+ const trc20Tx = {
+ transaction_id: txId,
+ token_info: {
+ symbol: 'USDT',
+ address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
+ decimals: 6,
+ name: 'Tether USD',
+ },
+ block_timestamp: 1766822586000,
+ from: userAddress,
+ to: 'TRecipient123',
+ type: 'Transfer',
+ value: '5000000',
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes('/transactions/trc20')) {
+ return { ok: true, json: async () => ({ success: true, data: [trc20Tx] }) }
+ }
+ if (url.includes('/transactions?')) {
+ return { ok: true, json: async () => ({ success: true, data: [nativeTx] }) }
+ }
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ })
+
+ const provider = new TronRpcProvider(mockEntry, 'tron')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ // 有 TRC-20 事件的 TriggerSmartContract 不应被过滤
+ expect(txs).toHaveLength(1)
+ expect(txs[0].hash).toBe(txId)
+ expect(txs[0].action).toBe('transfer')
+ expect(txs[0].assets[0].symbol).toBe('USDT')
+ })
+
+ it('keeps TriggerSmartContract with non-zero TRX amount', async () => {
+ const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+
+ const nativeTx = {
+ txID: 'contract-with-trx',
+ raw_data: {
+ contract: [{
+ type: 'TriggerSmartContract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ contract_address: 'TDeFiContract',
+ amount: 10000000, // 10 TRX
+ },
+ },
+ }],
+ timestamp: 1766822586000,
+ },
+ ret: [{ contractRet: 'SUCCESS' }],
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes('/transactions/trc20')) {
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ }
+ if (url.includes('/transactions?')) {
+ return { ok: true, json: async () => ({ success: true, data: [nativeTx] }) }
+ }
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ })
+
+ const provider = new TronRpcProvider(mockEntry, 'tron')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ // 有 TRX 转移的合约调用不应被过滤
+ expect(txs).toHaveLength(1)
+ expect(txs[0].hash).toBe('contract-with-trx')
+ expect(txs[0].assets[0].value).toBe('10000000')
+ })
+
+ it('keeps FAILED transactions (Rule 3: Critical Feedback)', async () => {
+ const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+
+ // 失败的合约调用:即使 0 TRX 也必须保留
+ const failedTx = {
+ txID: 'failed-tx-123',
+ raw_data: {
+ contract: [{
+ type: 'TriggerSmartContract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ contract_address: 'TContractAddress',
+ amount: 0,
+ },
+ },
+ }],
+ timestamp: 1766822586000,
+ },
+ ret: [{ contractRet: 'REVERT' }], // 失败状态
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes('/transactions/trc20')) {
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ }
+ if (url.includes('/transactions?')) {
+ return { ok: true, json: async () => ({ success: true, data: [failedTx] }) }
+ }
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ })
+
+ const provider = new TronRpcProvider(mockEntry, 'tron')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ // 失败交易必须保留,用户需要看到失败记录
+ expect(txs).toHaveLength(1)
+ expect(txs[0].hash).toBe('failed-tx-123')
+ expect(txs[0].status).toBe('failed')
+ })
+
+ it('keeps approve transactions (Rule 4: Key Actions)', async () => {
+ const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+
+ // approve 授权交易:0 TRX 但是必须保留
+ const approveTx = {
+ txID: 'approve-tx-456',
+ raw_data: {
+ contract: [{
+ type: 'TriggerSmartContract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ contract_address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', // USDT contract
+ amount: 0,
+ // approve(address,uint256) MethodID: 0x095ea7b3
+ data: '095ea7b3000000000000000000000000spender0000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
+ },
+ },
+ }],
+ timestamp: 1766822586000,
+ },
+ ret: [{ contractRet: 'SUCCESS' }],
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes('/transactions/trc20')) {
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ }
+ if (url.includes('/transactions?')) {
+ return { ok: true, json: async () => ({ success: true, data: [approveTx] }) }
+ }
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ })
+
+ const provider = new TronRpcProvider(mockEntry, 'tron')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ // approve 交易必须保留,且 action 应该是 approve
+ expect(txs).toHaveLength(1)
+ expect(txs[0].hash).toBe('approve-tx-456')
+ expect(txs[0].action).toBe('approve')
+ })
+
+ it('filters successful 0-TRX TriggerSmartContract without TRC-20 and not approve', async () => {
+ const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9'
+
+ // 纯噪音:成功 + 0 TRX + 无 TRC-20 + 非 approve
+ const spamTx = {
+ txID: 'spam-noise-789',
+ raw_data: {
+ contract: [{
+ type: 'TriggerSmartContract',
+ parameter: {
+ value: {
+ owner_address: userAddress,
+ contract_address: 'TSpamContract',
+ amount: 0,
+ data: 'deadbeef12345678', // 未知方法
+ },
+ },
+ }],
+ timestamp: 1766822586000,
+ },
+ ret: [{ contractRet: 'SUCCESS' }],
+ }
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes('/transactions/trc20')) {
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ }
+ if (url.includes('/transactions?')) {
+ return { ok: true, json: async () => ({ success: true, data: [spamTx] }) }
+ }
+ return { ok: true, json: async () => ({ success: true, data: [] }) }
+ })
+
+ const provider = new TronRpcProvider(mockEntry, 'tron')
+ const txs = await provider.getTransactionHistory(userAddress, 10)
+
+ // 噪音交易应该被过滤
+ expect(txs).toHaveLength(0)
+ })
+ })
+})
diff --git a/src/services/chain-adapter/providers/biowallet-provider.ts b/src/services/chain-adapter/providers/biowallet-provider.ts
index 3a8fcd329..cfd971700 100644
--- a/src/services/chain-adapter/providers/biowallet-provider.ts
+++ b/src/services/chain-adapter/providers/biowallet-provider.ts
@@ -4,38 +4,84 @@
* 提供 BioForest 链的余额和交易历史查询能力。
*/
-import type { ApiProvider, Balance, Transaction } from './types'
+import { z } from 'zod'
+import type { ApiProvider, Balance, Transaction, TokenBalance, Action, Direction } from './types'
import type { ParsedApiEntry } from '@/services/chain-config'
import { chainConfigService } from '@/services/chain-config'
import { Amount } from '@/types/amount'
-interface BiowalletAssetResponse {
- success: boolean
- result?: {
- address: string
- assets: Record>
- }
-}
+const BiowalletAssetItemSchema = z.looseObject({
+ assetNumber: z.string(),
+ assetType: z.string(),
+})
-interface BiowalletTxResponse {
- success: boolean
- result?: {
- transactions: Array<{
- signature: string
- senderAddress: string
- receiverAddress: string
- amount: string
- assetType: string
- timestamp: number
- applyBlockHeight: number
- }>
- }
-}
+const BiowalletAssetSchema = z.looseObject({
+ success: z.boolean(),
+ result: z.looseObject({
+ address: z.string(),
+ assets: z.record(z.string(), z.record(z.string(), BiowalletAssetItemSchema)),
+ }).optional(),
+})
-interface BiowalletBlockResponse {
- success: boolean
- result?: { height: number }
-}
+const BiowalletTxItemSchema = z.looseObject({
+ height: z.number(),
+ signature: z.string(),
+ transaction: z.looseObject({
+ type: z.string(),
+ senderId: z.string(),
+ recipientId: z.string().optional().default(''),
+ timestamp: z.number(),
+ asset: z.looseObject({
+ transferAsset: z.looseObject({
+ assetType: z.string(),
+ amount: z.string(),
+ }).optional(),
+ // 其他 BioForest 资产类型
+ giftAsset: z.looseObject({
+ totalAmount: z.string(),
+ assetType: z.string(),
+ }).optional(),
+ grabAsset: z.looseObject({
+ transactionSignature: z.string(),
+ }).optional(),
+ trustAsset: z.looseObject({
+ trustees: z.array(z.string()),
+ numberOfSignFor: z.number(),
+ assetType: z.string(),
+ amount: z.string(),
+ }).optional(),
+ // BIW / BioForest Meta 其他类型
+ signature: z.looseObject({
+ publicKey: z.string().optional(),
+ }).optional(),
+ destroyAsset: z.looseObject({
+ assetType: z.string(),
+ amount: z.string(),
+ }).optional(),
+ issueEntity: z.looseObject({
+ entityId: z.string().optional(),
+ }).optional(),
+ issueEntityFactory: z.looseObject({
+ factoryId: z.string().optional(),
+ }).optional(),
+ }).optional(),
+ }),
+})
+
+const BiowalletTxResponseSchema = z.looseObject({
+ success: z.boolean(),
+ result: z.looseObject({
+ trs: z.array(BiowalletTxItemSchema),
+ count: z.number(),
+ }).optional(),
+})
+
+const BiowalletBlockSchema = z.looseObject({
+ success: z.boolean(),
+ result: z.looseObject({
+ height: z.number(),
+ }).optional(),
+})
export class BiowalletProvider implements ApiProvider {
readonly type: string
@@ -73,14 +119,15 @@ export class BiowalletProvider implements ApiProvider {
throw new Error(`HTTP ${response.status}`)
}
- const json = await response.json() as BiowalletAssetResponse
+ const json: unknown = await response.json()
+ const parsed = BiowalletAssetSchema.safeParse(json)
- if (!json.success || !json.result) {
+ if (!parsed.success || !parsed.data.success || !parsed.data.result) {
return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol }
}
// 查找原生代币余额
- for (const magic of Object.values(json.result.assets)) {
+ for (const magic of Object.values(parsed.data.result.assets)) {
for (const asset of Object.values(magic)) {
if (asset.assetType === this.symbol) {
return {
@@ -98,16 +145,64 @@ export class BiowalletProvider implements ApiProvider {
}
}
+ async getTokenBalances(address: string): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/address/asset`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ address }),
+ })
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`)
+ }
+
+ const json: unknown = await response.json()
+ const parsed = BiowalletAssetSchema.safeParse(json)
+
+ if (!parsed.success || !parsed.data.success || !parsed.data.result) {
+ return []
+ }
+
+ const tokens: TokenBalance[] = []
+
+ for (const magic of Object.values(parsed.data.result.assets)) {
+ for (const asset of Object.values(magic)) {
+ const isNative = asset.assetType === this.symbol
+ tokens.push({
+ symbol: asset.assetType,
+ name: asset.assetType,
+ amount: Amount.fromRaw(asset.assetNumber, this.decimals, asset.assetType),
+ isNative,
+ })
+ }
+ }
+
+ // Sort: native first, then by amount descending
+ tokens.sort((a, b) => {
+ if (a.isNative && !b.isNative) return -1
+ if (!a.isNative && b.isNative) return 1
+ return b.amount.toNumber() - a.amount.toNumber()
+ })
+
+ return tokens
+ } catch (error) {
+ console.warn('[BiowalletProvider] Error fetching token balances:', error)
+ return []
+ }
+ }
+
async getTransactionHistory(address: string, limit = 20): Promise {
try {
// 先获取最新区块高度
const blockResponse = await fetch(`${this.baseUrl}/lastblock`)
if (!blockResponse.ok) return []
- const blockJson = await blockResponse.json() as BiowalletBlockResponse
- if (!blockJson.success || !blockJson.result) return []
+ const blockJson: unknown = await blockResponse.json()
+ const blockParsed = BiowalletBlockSchema.safeParse(blockJson)
+ if (!blockParsed.success || !blockParsed.data.success || !blockParsed.data.result) return []
- const maxHeight = blockJson.result.height
+ const maxHeight = blockParsed.data.result.height
// 查询交易
const response = await fetch(`${this.baseUrl}/transactions/query`, {
@@ -122,35 +217,202 @@ export class BiowalletProvider implements ApiProvider {
if (!response.ok) return []
- const json = await response.json() as BiowalletTxResponse
+ const json: unknown = await response.json()
+ const parsed = BiowalletTxResponseSchema.safeParse(json)
- if (!json.success || !json.result?.transactions) return []
-
- return json.result.transactions.map((tx): Transaction => ({
- hash: tx.signature,
- from: tx.senderAddress,
- to: tx.receiverAddress,
- value: tx.amount,
- symbol: tx.assetType,
- timestamp: tx.timestamp,
- status: 'confirmed',
- blockNumber: BigInt(tx.applyBlockHeight),
- }))
+ if (!parsed.success || !parsed.data.success || !parsed.data.result?.trs) return []
+
+ const normalizedAddress = address.toLowerCase()
+
+ return parsed.data.result.trs
+ .map((item): Transaction | null => {
+ const tx = item.transaction
+ const action = this.detectAction(tx.type)
+ const direction = this.getDirection(tx.senderId, tx.recipientId, normalizedAddress)
+
+ // 获取金额和资产类型
+ const { value, assetType } = this.extractAssetInfo(tx)
+ if (value === null) return null
+
+ return {
+ hash: item.signature,
+ from: tx.senderId,
+ to: tx.recipientId,
+ timestamp: tx.timestamp * 1000,
+ status: 'confirmed',
+ blockNumber: BigInt(item.height),
+ action,
+ direction,
+ assets: [{
+ assetType: 'native' as const,
+ value,
+ symbol: assetType,
+ decimals: this.decimals,
+ }],
+ }
+ })
+ .filter((tx): tx is Transaction => tx !== null)
} catch (error) {
console.warn('[BiowalletProvider] Error fetching transactions:', error)
return []
}
}
+ private detectAction(txType: string): Action {
+ // BioForest 交易类型映射
+ // 格式: {CHAIN}-{NETWORK}-{TYPE}-{VERSION}
+ // 例如: BFM-BFMETA-AST-02 = 资产转账
+ const typeMap: Record = {
+ 'AST-01': 'transfer', // 资产转移 (旧版)
+ 'AST-02': 'transfer', // 资产转移
+ 'AST-03': 'destroyAsset', // 销毁资产 (BIW)
+ 'BSE-01': 'signature', // 签名/签章 (BIW)
+ 'ETY-01': 'issueEntity', // 发行实体工厂 (BIW)
+ 'ETY-02': 'issueEntity', // 发行实体 (BIW)
+ 'GFT-01': 'gift', // 发红包
+ 'GFT-02': 'gift', // 发红包 v2
+ 'GRB-01': 'grab', // 抢红包
+ 'GRB-02': 'grab', // 抢红包 v2
+ 'TRS-01': 'trust', // 委托
+ 'TRS-02': 'trust', // 委托 v2
+ 'SGN-01': 'signFor', // 代签
+ 'SGN-02': 'signFor', // 代签 v2
+ 'EMI-01': 'emigrate', // 跨链转出
+ 'EMI-02': 'emigrate', // 跨链转出 v2
+ 'IMI-01': 'immigrate', // 跨链转入
+ 'IMI-02': 'immigrate', // 跨链转入 v2
+ 'ISA-01': 'issueAsset', // 发行资产
+ 'ICA-01': 'increaseAsset', // 增发资产
+ 'DSA-01': 'destroyAsset', // 销毁资产
+ 'ISE-01': 'issueEntity', // 发行实体
+ 'DSE-01': 'destroyEntity', // 销毁实体
+ 'LNS-01': 'locationName', // 位名
+ 'DAP-01': 'dapp', // DApp 调用
+ 'CRT-01': 'certificate', // 证书
+ 'MRK-01': 'mark', // 标记
+ }
+
+ // 提取类型后缀 (例如 "BFM-BFMETA-AST-02" -> "AST-02")
+ const parts = txType.split('-')
+ if (parts.length >= 4) {
+ const suffix = `${parts[parts.length - 2]}-${parts[parts.length - 1]}`
+ return typeMap[suffix] ?? 'unknown'
+ }
+
+ return 'unknown'
+ }
+
+ private getDirection(from: string, to: string, address: string): Direction {
+ const fromLower = from.toLowerCase()
+ const toLower = to.toLowerCase()
+
+ if (!toLower) return fromLower === address ? 'out' : 'in'
+
+ if (fromLower === address && toLower === address) {
+ return 'self'
+ }
+ if (fromLower === address) {
+ return 'out'
+ }
+ return 'in'
+ }
+
+ private extractAssetInfo(tx: z.infer['transaction']): { value: string | null; assetType: string } {
+ const asset = tx.asset
+
+ // 转账
+ if (asset?.transferAsset) {
+ return {
+ value: asset.transferAsset.amount,
+ assetType: asset.transferAsset.assetType,
+ }
+ }
+
+ // 红包
+ if (asset?.giftAsset) {
+ return {
+ value: asset.giftAsset.totalAmount,
+ assetType: asset.giftAsset.assetType,
+ }
+ }
+
+ // 委托
+ if (asset?.trustAsset) {
+ return {
+ value: asset.trustAsset.amount,
+ assetType: asset.trustAsset.assetType,
+ }
+ }
+
+ // 抢红包 (金额需要从其他地方获取)
+ if (asset?.grabAsset) {
+ return {
+ value: '0',
+ assetType: this.symbol,
+ }
+ }
+
+ // 销毁资产
+ if (asset?.destroyAsset) {
+ return {
+ value: asset.destroyAsset.amount,
+ assetType: asset.destroyAsset.assetType,
+ }
+ }
+
+ // 发行实体 / 发行实体工厂:无金额,用 0 占位
+ if (asset?.issueEntity || asset?.issueEntityFactory) {
+ return {
+ value: '0',
+ assetType: this.symbol,
+ }
+ }
+
+ // 签名/签章:无金额,用 0 占位
+ if (asset?.signature) {
+ return {
+ value: '0',
+ assetType: this.symbol,
+ }
+ }
+
+ // 销毁资产
+ if (asset?.destroyAsset) {
+ return {
+ value: asset.destroyAsset.amount,
+ assetType: asset.destroyAsset.assetType,
+ }
+ }
+
+ // 发行实体/实体工厂
+ if (asset?.issueEntity || asset?.issueEntityFactory) {
+ return {
+ value: '0',
+ assetType: this.symbol,
+ }
+ }
+
+ // 签名
+ if (asset?.signature) {
+ return {
+ value: '0',
+ assetType: this.symbol,
+ }
+ }
+
+ return { value: null, assetType: this.symbol }
+ }
+
async getBlockHeight(): Promise {
try {
const response = await fetch(`${this.baseUrl}/lastblock`)
if (!response.ok) return 0n
- const json = await response.json() as BiowalletBlockResponse
- if (!json.success || !json.result) return 0n
+ const json: unknown = await response.json()
+ const parsed = BiowalletBlockSchema.safeParse(json)
+ if (!parsed.success || !parsed.data.success || !parsed.data.result) return 0n
- return BigInt(json.result.height)
+ return BigInt(parsed.data.result.height)
} catch {
return 0n
}
diff --git a/src/services/chain-adapter/providers/bscwallet-provider.ts b/src/services/chain-adapter/providers/bscwallet-provider.ts
new file mode 100644
index 000000000..6ce04e4fc
--- /dev/null
+++ b/src/services/chain-adapter/providers/bscwallet-provider.ts
@@ -0,0 +1,159 @@
+/**
+ * BSC Wallet API Provider
+ *
+ * 使用 walletapi.bfmeta.info 提供的 BSC API
+ * 支持余额查询和交易历史
+ */
+
+import { z } from 'zod'
+import type { ApiProvider, Balance, Transaction, Direction } from './types'
+import type { ParsedApiEntry } from '@/services/chain-config'
+import { chainConfigService } from '@/services/chain-config'
+import { Amount } from '@/types/amount'
+
+const BalanceResponseSchema = z.looseObject({
+ success: z.boolean(),
+ result: z.string(),
+})
+
+const TxItemSchema = z.looseObject({
+ hash: z.string(),
+ from: z.string(),
+ to: z.string(),
+ value: z.string(),
+ timeStamp: z.string(),
+ blockNumber: z.string(),
+ isError: z.string().optional(),
+})
+
+const TxHistoryResponseSchema = z.looseObject({
+ success: z.boolean(),
+ result: z.looseObject({
+ status: z.string(),
+ result: z.array(TxItemSchema),
+ }),
+})
+
+export class BscWalletProvider implements ApiProvider {
+ readonly type: string
+ readonly baseUrl: string
+ readonly decimals: number
+ readonly symbol: string
+
+ constructor(entry: ParsedApiEntry, chainDecimals: number, chainSymbol: string) {
+ this.type = entry.key
+ this.baseUrl = entry.url.replace(/\/$/, '')
+ this.decimals = chainDecimals
+ this.symbol = chainSymbol
+ }
+
+ get supportsNativeBalance() {
+ return true
+ }
+
+ get supportsTransactionHistory() {
+ return true
+ }
+
+ async getNativeBalance(address: string): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/balance?address=${address}`)
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`)
+ }
+
+ const json: unknown = await response.json()
+ const parsed = BalanceResponseSchema.safeParse(json)
+
+ if (!parsed.success || !parsed.data.success) {
+ return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol }
+ }
+
+ return {
+ amount: Amount.fromRaw(parsed.data.result, this.decimals, this.symbol),
+ symbol: this.symbol,
+ }
+ } catch (error) {
+ console.warn('[BscWalletProvider] Error fetching balance:', error)
+ return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol }
+ }
+ }
+
+ async getTransactionHistory(address: string, limit = 20): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/trans/normal/history`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ address,
+ page: 1,
+ offset: limit,
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`)
+ }
+
+ const json: unknown = await response.json()
+ const parsed = TxHistoryResponseSchema.safeParse(json)
+
+ if (!parsed.success || !parsed.data.success || parsed.data.result.status !== '1') {
+ return []
+ }
+
+ const normalizedAddress = address.toLowerCase()
+
+ return parsed.data.result.result.map((tx): Transaction => {
+ const from = tx.from
+ const to = tx.to
+ const direction = this.getDirection(from, to, normalizedAddress)
+
+ return {
+ hash: tx.hash,
+ from,
+ to,
+ timestamp: Number(tx.timeStamp) * 1000,
+ status: tx.isError === '1' ? 'failed' : 'confirmed',
+ blockNumber: BigInt(tx.blockNumber),
+ action: 'transfer' as const,
+ direction,
+ assets: [{
+ assetType: 'native' as const,
+ value: tx.value,
+ symbol: this.symbol,
+ decimals: this.decimals,
+ }],
+ }
+ })
+ } catch (error) {
+ console.warn('[BscWalletProvider] Error fetching transactions:', error)
+ return []
+ }
+ }
+
+ private getDirection(from: string, to: string, address: string): Direction {
+ const fromLower = from.toLowerCase()
+ const toLower = to.toLowerCase()
+
+ if (fromLower === address && toLower === address) {
+ return 'self'
+ }
+ if (fromLower === address) {
+ return 'out'
+ }
+ return 'in'
+ }
+}
+
+/**
+ * 工厂函数:识别 bscwallet-v1 类型的 API entry
+ */
+export function createBscWalletProvider(entry: ParsedApiEntry, chainId: string): BscWalletProvider | null {
+ if (entry.key !== 'bscwallet-v1') return null
+
+ const decimals = chainConfigService.getDecimals(chainId)
+ const symbol = chainConfigService.getSymbol(chainId)
+
+ return new BscWalletProvider(entry, decimals, symbol)
+}
diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts
index c5b1c617a..14142b6e2 100644
--- a/src/services/chain-adapter/providers/chain-provider.ts
+++ b/src/services/chain-adapter/providers/chain-provider.ts
@@ -8,6 +8,7 @@ import type {
ApiProvider,
ApiProviderMethod,
Balance,
+ TokenBalance,
Transaction,
TransactionStatus,
FeeEstimate,
@@ -49,6 +50,10 @@ export class ChainProvider {
return this.supports('getNativeBalance')
}
+ get supportsTokenBalances(): boolean {
+ return this.supports('getTokenBalances')
+ }
+
get supportsTransactionHistory(): boolean {
return this.supports('getTransactionHistory')
}
@@ -100,6 +105,10 @@ export class ChainProvider {
return this.getMethod('getNativeBalance')
}
+ get getTokenBalances(): ((address: string) => Promise) | undefined {
+ return this.getMethod('getTokenBalances')
+ }
+
get getTransactionHistory(): ((address: string, limit?: number) => Promise) | undefined {
return this.getMethod('getTransactionHistory')
}
diff --git a/src/services/chain-adapter/providers/etherscan-provider.ts b/src/services/chain-adapter/providers/etherscan-provider.ts
index 4f9210d84..d85abd167 100644
--- a/src/services/chain-adapter/providers/etherscan-provider.ts
+++ b/src/services/chain-adapter/providers/etherscan-provider.ts
@@ -2,10 +2,12 @@
* Etherscan API Provider
*
* 提供 EVM 链的交易历史查询能力。
- * 支持 Etherscan v2 API (统一接口,通过 chainid 区分链)
+ * 支持 Etherscan/Blockscout API 格式。
+ * 同时获取原生交易 (txlist) 和代币交易 (tokentx)。
*/
-import type { ApiProvider, Transaction } from './types'
+import { z } from 'zod'
+import type { ApiProvider, Transaction, Direction, Action } from './types'
import type { ParsedApiEntry } from '@/services/chain-config'
import { chainConfigService } from '@/services/chain-config'
@@ -17,21 +19,45 @@ const EVM_CHAIN_IDS: Record = {
'bsc-testnet': 97,
}
-interface EtherscanTx {
- hash: string
- from: string
- to: string
- value: string
- timeStamp: string
- isError: string
- blockNumber: string
-}
+// ========== Zod Schemas for API Response ==========
-interface EtherscanResponse {
- status: string
- message: string
- result: EtherscanTx[] | string
-}
+const NativeTxSchema = z.looseObject({
+ hash: z.string(),
+ from: z.string(),
+ to: z.string(),
+ value: z.string(),
+ timeStamp: z.string(),
+ isError: z.string(),
+ blockNumber: z.string(),
+ input: z.string().optional(),
+ methodId: z.string().optional(),
+ functionName: z.string().optional(),
+})
+
+const TokenTxSchema = z.looseObject({
+ hash: z.string(),
+ from: z.string(),
+ to: z.string(),
+ value: z.string(),
+ timeStamp: z.string(),
+ blockNumber: z.string(),
+ tokenSymbol: z.string(),
+ tokenName: z.string(),
+ tokenDecimal: z.string(),
+ contractAddress: z.string(),
+})
+
+const ApiResponseSchema = z.looseObject({
+ status: z.string(),
+ message: z.string(),
+ result: z.unknown(),
+})
+
+type NativeTx = z.infer
+type TokenTx = z.infer
+
+/** 拉取倍数:为了应对分页陷阱,拉取 limit * K 条再聚合 */
+const FETCH_MULTIPLIER = 5
export class EtherscanProvider implements ApiProvider {
readonly type: string
@@ -53,61 +79,374 @@ export class EtherscanProvider implements ApiProvider {
this.decimals = chainConfigService.getDecimals(chainId)
}
+ get supportsTransactionHistory() {
+ return true
+ }
+
async getTransactionHistory(address: string, limit = 20): Promise {
try {
- const apiKey = (this.config?.apiKey as string) ?? ''
- const params = new URLSearchParams({
- chainid: this.evmChainId.toString(),
- module: 'account',
- action: 'txlist',
- address,
- startblock: '0',
- endblock: '99999999',
- page: '1',
- offset: limit.toString(),
- sort: 'desc',
- })
+ const fetchLimit = limit * FETCH_MULTIPLIER
- if (apiKey) {
- params.set('apikey', apiKey)
- }
+ // 并行获取原生交易和代币交易(拉取更多以应对分页陷阱)
+ const [nativeTxs, tokenTxs] = await Promise.all([
+ this.fetchNativeTransactions(address, fetchLimit),
+ this.fetchTokenTransactions(address, fetchLimit),
+ ])
+
+ // 按 hash 聚合:txlist 为主轴,tokentx 贴合
+ const normalizedAddress = address.toLowerCase()
+ const aggregated = this.aggregateByHash(nativeTxs, tokenTxs, normalizedAddress)
+
+ // 按时间排序、限制数量
+ return aggregated
+ .sort((a, b) => b.timestamp - a.timestamp)
+ .slice(0, limit)
+ } catch (error) {
+ console.warn('[EtherscanProvider] Error fetching transactions:', error)
+ return []
+ }
+ }
+
+ /**
+ * 按 hash 聚合 txlist + tokentx
+ * - txlist 为主时间轴(Source of Truth for status/timestamp)
+ * - tokentx 按 hash 贴合到对应 native tx
+ * - 优先展示 token 资产,避免"发送 0 ETH"
+ */
+ private aggregateByHash(
+ nativeTxs: NativeTx[],
+ tokenTxs: TokenTx[],
+ normalizedAddress: string
+ ): Transaction[] {
+ // 1. 构建 tokenTxsByHash
+ const tokenTxsByHash = new Map()
+ for (const ttx of tokenTxs) {
+ const hash = ttx.hash.toLowerCase()
+ const existing = tokenTxsByHash.get(hash) ?? []
+ existing.push(ttx)
+ tokenTxsByHash.set(hash, existing)
+ }
+
+ // 2. 遍历 nativeTxs,聚合生成 Transaction
+ const results: Transaction[] = []
+ const processedHashes = new Set()
+
+ for (const ntx of nativeTxs) {
+ const hash = ntx.hash.toLowerCase()
+ if (processedHashes.has(hash)) continue
+ processedHashes.add(hash)
+
+ const relatedTokenTxs = tokenTxsByHash.get(hash) ?? []
+ const tx = this.buildAggregatedTransaction(ntx, relatedTokenTxs, normalizedAddress)
+ results.push(tx)
+ }
+
+ // 3. 处理"孤儿" token txs(没有对应 native tx 的 token 转账)
+ // 这种情况可能发生在:native tx 被挤出窗口,但 token tx 仍在窗口内
+ for (const [hash, ttxs] of tokenTxsByHash) {
+ if (processedHashes.has(hash)) continue
+ processedHashes.add(hash)
+ const tx = this.buildOrphanTokenTransaction(ttxs, normalizedAddress)
+ results.push(tx)
+ }
+
+ return results
+ }
+
+ /**
+ * 为"孤儿" token txs 构建 Transaction(没有对应的 native tx)
+ */
+ private buildOrphanTokenTransaction(
+ tokenTxs: TokenTx[],
+ normalizedAddress: string
+ ): Transaction {
+ const primaryToken = this.selectPrimaryToken(tokenTxs, normalizedAddress)!
+ const direction = this.getDirection(primaryToken.from, primaryToken.to, normalizedAddress)
+
+ const assets: Transaction['assets'] = tokenTxs.slice(0, 3).map(ttx => ({
+ assetType: 'token' as const,
+ value: ttx.value,
+ symbol: ttx.tokenSymbol,
+ decimals: parseInt(ttx.tokenDecimal, 10),
+ contractAddress: ttx.contractAddress,
+ name: ttx.tokenName,
+ }))
+
+ return {
+ hash: primaryToken.hash,
+ from: primaryToken.from,
+ to: primaryToken.to,
+ timestamp: parseInt(primaryToken.timeStamp, 10) * 1000,
+ status: 'confirmed', // 没有 native tx 无法知道 isError,假定成功
+ blockNumber: BigInt(primaryToken.blockNumber),
+ action: 'transfer',
+ direction,
+ assets,
+ }
+ }
+
+ /**
+ * 构建聚合后的 Transaction
+ */
+ private buildAggregatedTransaction(
+ ntx: NativeTx,
+ tokenTxs: TokenTx[],
+ normalizedAddress: string
+ ): Transaction {
+ const hasTokens = tokenTxs.length > 0
+ const isContractCall = this.isContractCall(ntx)
+ const methodId = isContractCall ? this.getMethodId(ntx) : null
+
+ // Action: methodId 识别 > 有 token 则 transfer > 合约调用 > 普通转账
+ let action = this.detectAction(ntx)
+ if (action === 'contract' && hasTokens) {
+ // 有 token 事件但方法未识别:视为 transfer
+ action = 'transfer'
+ }
+
+ // 选择主 token event(优先包含当前地址参与的、value 最大的)
+ const primaryToken = this.selectPrimaryToken(tokenTxs, normalizedAddress)
+
+ // from/to 与 direction:有 token 时用 token 的对手方
+ let from: string
+ let to: string
+ if (primaryToken) {
+ from = primaryToken.from
+ to = primaryToken.to
+ } else {
+ from = ntx.from
+ to = ntx.to
+ }
+ const direction = this.getDirection(from, to, normalizedAddress)
+
+ // Assets:token 优先,native value > 0 时也附加
+ const assets: Transaction['assets'] = []
+
+ // 添加 token assets(最多 3 条)
+ for (const ttx of tokenTxs.slice(0, 3)) {
+ assets.push({
+ assetType: 'token' as const,
+ value: ttx.value,
+ symbol: ttx.tokenSymbol,
+ decimals: parseInt(ttx.tokenDecimal, 10),
+ contractAddress: ttx.contractAddress,
+ name: ttx.tokenName,
+ })
+ }
+
+ // native value > 0 时附加(或无 token 时作为主资产)
+ if (ntx.value !== '0' || assets.length === 0) {
+ assets.push({
+ assetType: 'native' as const,
+ value: ntx.value,
+ symbol: this.symbol,
+ decimals: this.decimals,
+ })
+ }
+
+ // 对 assets 排序:token 在前,native 在后;swap 场景下 in 的 token 优先
+ if (action === 'swap' && assets.length > 1) {
+ assets.sort((a, b) => {
+ // token 优先
+ if (a.assetType === 'token' && b.assetType !== 'token') return -1
+ if (a.assetType !== 'token' && b.assetType === 'token') return 1
+ // 同为 token 时,in 的优先(基于 primaryToken 方向)
+ return 0
+ })
+ }
+
+ return {
+ hash: ntx.hash,
+ from,
+ to,
+ timestamp: parseInt(ntx.timeStamp, 10) * 1000,
+ status: ntx.isError === '0' ? 'confirmed' : 'failed',
+ blockNumber: BigInt(ntx.blockNumber),
+ action,
+ direction,
+ assets,
+ contract: isContractCall ? {
+ address: ntx.to,
+ method: ntx.functionName ?? undefined,
+ methodId: methodId ?? undefined,
+ } : undefined,
+ }
+ }
+
+ /**
+ * 选择主 token event(优先包含当前地址的、value 最大的)
+ */
+ private selectPrimaryToken(tokenTxs: TokenTx[], normalizedAddress: string): TokenTx | null {
+ if (tokenTxs.length === 0) return null
+ if (tokenTxs.length === 1) return tokenTxs[0]
+
+ // 优先选择包含当前地址的(from 或 to)
+ const involving = tokenTxs.filter(
+ t => t.from.toLowerCase() === normalizedAddress || t.to.toLowerCase() === normalizedAddress
+ )
+ const candidates = involving.length > 0 ? involving : tokenTxs
+
+ // 按 value 绝对值排序,取最大的
+ return candidates.reduce((max, t) => {
+ const maxVal = BigInt(max.value)
+ const tVal = BigInt(t.value)
+ return tVal > maxVal ? t : max
+ })
+ }
+
+ private async fetchNativeTransactions(address: string, limit: number): Promise {
+ const params = this.buildParams('txlist', address, limit)
+ const response = await this.fetchApi(params)
+
+ if (!response.success || !Array.isArray(response.data)) {
+ return []
+ }
+
+ return response.data
+ .map(item => NativeTxSchema.safeParse(item))
+ .filter((r): r is z.SafeParseSuccess => r.success)
+ .map(r => r.data)
+ }
+
+ private async fetchTokenTransactions(address: string, limit: number): Promise {
+ const params = this.buildParams('tokentx', address, limit)
+ const response = await this.fetchApi(params)
+
+ if (!response.success || !Array.isArray(response.data)) {
+ return []
+ }
+
+ return response.data
+ .map(item => TokenTxSchema.safeParse(item))
+ .filter((r): r is z.SafeParseSuccess => r.success)
+ .map(r => r.data)
+ }
+
+ private buildParams(action: string, address: string, limit: number): URLSearchParams {
+ const apiKey = (this.config?.apiKey as string) ?? ''
+ const params = new URLSearchParams({
+ module: 'account',
+ action,
+ address,
+ startblock: '0',
+ endblock: '99999999',
+ page: '1',
+ offset: limit.toString(),
+ sort: 'desc',
+ })
+
+ // Etherscan v2 统一 API 需要 chainid
+ if (this.endpoint.includes('etherscan.io/v2')) {
+ params.set('chainid', this.evmChainId.toString())
+ }
+
+ if (apiKey) {
+ params.set('apikey', apiKey)
+ }
+
+ return params
+ }
+
+ private async fetchApi(params: URLSearchParams): Promise<{ success: boolean; data: unknown[] }> {
+ try {
const url = `${this.endpoint}?${params.toString()}`
const response = await fetch(url)
if (!response.ok) {
console.warn(`[EtherscanProvider] HTTP ${response.status}`)
- return []
+ return { success: false, data: [] }
}
- const json = await response.json() as EtherscanResponse
+ const json: unknown = await response.json()
+ const parsed = ApiResponseSchema.safeParse(json)
- if (json.status !== '1' || !Array.isArray(json.result)) {
- // status !== '1' 可能是 "No transactions found" 等情况
- return []
+ if (!parsed.success || parsed.data.status !== '1') {
+ return { success: false, data: [] }
}
- return json.result.map((tx): Transaction => ({
- hash: tx.hash,
- from: tx.from,
- to: tx.to,
- value: tx.value,
- symbol: this.symbol,
- timestamp: parseInt(tx.timeStamp, 10) * 1000,
- status: tx.isError === '0' ? 'confirmed' : 'failed',
- blockNumber: BigInt(tx.blockNumber),
- }))
- } catch (error) {
- console.warn('[EtherscanProvider] Error fetching transactions:', error)
- return []
+ const result = parsed.data.result
+ if (!Array.isArray(result)) {
+ return { success: false, data: [] }
+ }
+
+ return { success: true, data: result }
+ } catch {
+ return { success: false, data: [] }
+ }
+ }
+
+ private getDirection(from: string, to: string, address: string): Direction {
+ const fromLower = from.toLowerCase()
+ const toLower = to.toLowerCase()
+
+ if (fromLower === address && toLower === address) {
+ return 'self'
}
+ if (fromLower === address) {
+ return 'out'
+ }
+ return 'in'
+ }
+
+ private detectAction(tx: NativeTx): Action {
+ // 有 input data 表示合约调用
+ if (this.isContractCall(tx)) {
+ const methodId = this.getMethodId(tx)
+ if (!methodId) return 'contract'
+
+ // 常见方法签名
+ const methodMap: Record = {
+ '0xa9059cbb': 'transfer', // transfer(address,uint256)
+ '0x095ea7b3': 'approve', // approve(address,uint256)
+ '0x23b872dd': 'transfer', // transferFrom(address,address,uint256)
+ '0x38ed1739': 'swap', // swapExactTokensForTokens
+ '0x7ff36ab5': 'swap', // swapExactETHForTokens
+ '0x18cbafe5': 'swap', // swapExactTokensForETH
+ '0xa694fc3a': 'stake', // stake(uint256)
+ '0x2e1a7d4d': 'unstake', // withdraw(uint256)
+ '0x3ccfd60b': 'claim', // withdraw()
+ '0x4e71d92d': 'claim', // claim()
+ '0x40c10f19': 'mint', // mint(address,uint256)
+ '0x42966c68': 'burn', // burn(uint256)
+ }
+
+ return methodMap[methodId] ?? 'contract'
+ }
+
+ // 普通转账
+ return 'transfer'
+ }
+
+ private isContractCall(tx: NativeTx): boolean {
+ const input = tx.input ?? ''
+ return input !== '0x' && input !== '0x0' && input !== '0x00' && input.length > 2
+ }
+
+ private getMethodId(tx: NativeTx): string | null {
+ const rawMethodId = (tx.methodId ?? '').toLowerCase()
+
+ // Some APIs return methodId = "0x" even when input contains the real selector.
+ if (rawMethodId && rawMethodId !== '0x' && rawMethodId !== '0x0' && rawMethodId !== '0x00') {
+ if (rawMethodId.startsWith('0x') && rawMethodId.length === 10) return rawMethodId
+ if (!rawMethodId.startsWith('0x') && rawMethodId.length === 8) return `0x${rawMethodId}`
+ }
+
+ const input = (tx.input ?? '').toLowerCase()
+ if (input.startsWith('0x') && input.length >= 10) {
+ return input.slice(0, 10)
+ }
+ if (!input.startsWith('0x') && input.length >= 8) {
+ return `0x${input.slice(0, 8)}`
+ }
+
+ return null
}
}
/** 工厂函数 */
export function createEtherscanProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null {
// 匹配 etherscan-*, blockscout-*, 或 *scan-* 类型
- // Blockscout 使用与 Etherscan 兼容的 API 格式
if (entry.type.includes('etherscan') || entry.type.includes('blockscout') || entry.type.includes('scan')) {
return new EtherscanProvider(entry, chainId)
}
diff --git a/src/services/chain-adapter/providers/evm-rpc-provider.ts b/src/services/chain-adapter/providers/evm-rpc-provider.ts
index 25e9ce436..6ce5d6b29 100644
--- a/src/services/chain-adapter/providers/evm-rpc-provider.ts
+++ b/src/services/chain-adapter/providers/evm-rpc-provider.ts
@@ -5,11 +5,21 @@
* 使用标准 Ethereum JSON-RPC API。
*/
+import { z } from 'zod'
import type { ApiProvider, Balance } from './types'
import type { ParsedApiEntry } from '@/services/chain-config'
import { chainConfigService } from '@/services/chain-config'
import { Amount } from '@/types/amount'
+const JsonRpcResponseSchema = z.looseObject({
+ result: z.unknown().optional(),
+ error: z
+ .looseObject({
+ message: z.string(),
+ })
+ .optional(),
+})
+
export class EvmRpcProvider implements ApiProvider {
readonly type: string
readonly endpoint: string
@@ -28,7 +38,7 @@ export class EvmRpcProvider implements ApiProvider {
this.decimals = chainConfigService.getDecimals(chainId)
}
- private async rpc(method: string, params: unknown[] = []): Promise {
+ private async rpc(method: string, resultSchema: z.ZodType, params: unknown[] = []): Promise {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -44,17 +54,27 @@ export class EvmRpcProvider implements ApiProvider {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
- const json = await response.json() as { result?: T; error?: { message: string } }
- if (json.error) {
- throw new Error(json.error.message)
+ const json: unknown = await response.json()
+ const parsed = JsonRpcResponseSchema.safeParse(json)
+ if (!parsed.success) {
+ throw new Error('Invalid JSON-RPC response')
+ }
+
+ if (parsed.data.error) {
+ throw new Error(parsed.data.error.message)
+ }
+
+ const resultParsed = resultSchema.safeParse(parsed.data.result)
+ if (!resultParsed.success) {
+ throw new Error('Invalid JSON-RPC result')
}
- return json.result as T
+ return resultParsed.data
}
async getNativeBalance(address: string): Promise {
try {
- const balanceHex = await this.rpc('eth_getBalance', [address, 'latest'])
+ const balanceHex = await this.rpc('eth_getBalance', z.string(), [address, 'latest'])
const balanceWei = BigInt(balanceHex)
return {
@@ -72,7 +92,7 @@ export class EvmRpcProvider implements ApiProvider {
async getBlockHeight(): Promise {
try {
- const blockHex = await this.rpc('eth_blockNumber')
+ const blockHex = await this.rpc('eth_blockNumber', z.string())
return BigInt(blockHex)
} catch {
return 0n
diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts
index 200e1102f..a1e2dfc3f 100644
--- a/src/services/chain-adapter/providers/index.ts
+++ b/src/services/chain-adapter/providers/index.ts
@@ -11,6 +11,7 @@ export { ChainProvider } from './chain-provider'
export { EtherscanProvider, createEtherscanProvider } from './etherscan-provider'
export { EvmRpcProvider, createEvmRpcProvider } from './evm-rpc-provider'
export { BiowalletProvider, createBiowalletProvider } from './biowallet-provider'
+export { BscWalletProvider, createBscWalletProvider } from './bscwallet-provider'
export { TronRpcProvider, createTronRpcProvider } from './tron-rpc-provider'
export { MempoolProvider, createMempoolProvider } from './mempool-provider'
@@ -27,6 +28,7 @@ import { ChainProvider } from './chain-provider'
import { createEtherscanProvider } from './etherscan-provider'
import { createEvmRpcProvider } from './evm-rpc-provider'
import { createBiowalletProvider } from './biowallet-provider'
+import { createBscWalletProvider } from './bscwallet-provider'
import { createTronRpcProvider } from './tron-rpc-provider'
import { createMempoolProvider } from './mempool-provider'
import { WrappedTransactionProvider } from './wrapped-transaction-provider'
@@ -49,6 +51,7 @@ import { BioforestTransactionService } from '../bioforest/transaction-service'
/** 所有 Provider 工厂函数 */
const PROVIDER_FACTORIES: ApiProviderFactory[] = [
createBiowalletProvider,
+ createBscWalletProvider,
createEtherscanProvider,
createEvmRpcProvider,
createTronRpcProvider,
diff --git a/src/services/chain-adapter/providers/mempool-provider.ts b/src/services/chain-adapter/providers/mempool-provider.ts
index d9ff430fb..5127b348c 100644
--- a/src/services/chain-adapter/providers/mempool-provider.ts
+++ b/src/services/chain-adapter/providers/mempool-provider.ts
@@ -4,7 +4,8 @@
* 提供 Bitcoin 链的余额、交易历史和区块高度查询能力。
*/
-import type { ApiProvider, Balance, Transaction } from './types'
+import { z } from 'zod'
+import type { ApiProvider, Balance, Transaction, Direction } from './types'
import type { ParsedApiEntry } from '@/services/chain-config'
import { chainConfigService } from '@/services/chain-config'
import { Amount } from '@/types/amount'
@@ -40,6 +41,43 @@ interface MempoolTx {
}>
}
+const MempoolAddressInfoSchema = z.looseObject({
+ address: z.string().optional(),
+ chain_stats: z.looseObject({
+ funded_txo_sum: z.number(),
+ spent_txo_sum: z.number(),
+ }),
+ mempool_stats: z.looseObject({
+ funded_txo_sum: z.number(),
+ spent_txo_sum: z.number(),
+ }),
+})
+
+const MempoolTxSchema = z.looseObject({
+ txid: z.string(),
+ status: z.looseObject({
+ confirmed: z.boolean(),
+ block_height: z.number().optional(),
+ block_time: z.number().optional(),
+ }),
+ vin: z.array(
+ z.looseObject({
+ prevout: z
+ .looseObject({
+ scriptpubkey_address: z.string().optional(),
+ value: z.number(),
+ })
+ .optional(),
+ })
+ ),
+ vout: z.array(
+ z.looseObject({
+ scriptpubkey_address: z.string().optional(),
+ value: z.number(),
+ })
+ ),
+})
+
export class MempoolProvider implements ApiProvider {
readonly type: string
readonly endpoint: string
@@ -58,17 +96,23 @@ export class MempoolProvider implements ApiProvider {
this.decimals = chainConfigService.getDecimals(chainId)
}
- private async api(path: string): Promise {
+ private async api(path: string, schema: z.ZodType): Promise {
const response = await fetch(`${this.endpoint}${path}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
- return response.json() as Promise
+
+ const json: unknown = await response.json()
+ const parsed = schema.safeParse(json)
+ if (!parsed.success) {
+ throw new Error('Invalid API response')
+ }
+ return parsed.data
}
async getNativeBalance(address: string): Promise {
try {
- const info = await this.api(`/address/${address}`)
+ const info = await this.api(`/address/${address}`, MempoolAddressInfoSchema)
// 计算余额:已收到 - 已花费
const confirmed = info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum
@@ -90,7 +134,7 @@ export class MempoolProvider implements ApiProvider {
async getTransactionHistory(address: string, limit = 20): Promise {
try {
- const txs = await this.api(`/address/${address}/txs`)
+ const txs = await this.api(`/address/${address}/txs`, z.array(MempoolTxSchema))
return txs.slice(0, limit).map((tx): Transaction => {
// 判断是发送还是接收
@@ -121,15 +165,25 @@ export class MempoolProvider implements ApiProvider {
? tx.vout.find(v => v.scriptpubkey_address !== address)?.scriptpubkey_address ?? ''
: tx.vin[0]?.prevout?.scriptpubkey_address ?? ''
+ const from = isOutgoing ? address : counterparty
+ const to = isOutgoing ? counterparty : address
+ const direction: Direction = isOutgoing ? 'out' : 'in'
+
return {
hash: tx.txid,
- from: isOutgoing ? address : counterparty,
- to: isOutgoing ? counterparty : address,
- value: value.toString(),
- symbol: this.symbol,
+ from,
+ to,
timestamp: (tx.status.block_time ?? Math.floor(Date.now() / 1000)) * 1000,
status: tx.status.confirmed ? 'confirmed' : 'pending',
blockNumber: tx.status.block_height ? BigInt(tx.status.block_height) : undefined,
+ action: 'transfer' as const,
+ direction,
+ assets: [{
+ assetType: 'native' as const,
+ value: value.toString(),
+ symbol: this.symbol,
+ decimals: this.decimals,
+ }],
}
})
} catch (error) {
@@ -140,7 +194,7 @@ export class MempoolProvider implements ApiProvider {
async getBlockHeight(): Promise {
try {
- const height = await this.api('/blocks/tip/height')
+ const height = await this.api('/blocks/tip/height', z.number())
return BigInt(height)
} catch {
return 0n
diff --git a/src/services/chain-adapter/providers/transaction-schema.ts b/src/services/chain-adapter/providers/transaction-schema.ts
new file mode 100644
index 000000000..bf7efe60e
--- /dev/null
+++ b/src/services/chain-adapter/providers/transaction-schema.ts
@@ -0,0 +1,202 @@
+/**
+ * Transaction Schema - 多维度分类的交易类型系统
+ *
+ * 使用 Zod 定义 schema,自动推导 TypeScript 类型。
+ * 交易通过 AssetType × Action × Direction 三个维度组合分类。
+ */
+
+import { z } from 'zod'
+
+// ==================== 维度枚举 ====================
+
+/** 资产类型 */
+export const AssetTypeSchema = z.enum([
+ 'native', // 原生代币 (ETH, BNB, BTC, BFM, TRX)
+ 'token', // 同质化代币 (ERC20, BEP20, TRC20)
+ 'nft', // 非同质化代币 (ERC721, ERC1155)
+])
+
+/** 操作类型 */
+export const ActionSchema = z.enum([
+ // 通用操作
+ 'transfer', // 转账
+ 'swap', // 兑换
+ 'approve', // 授权
+ 'revoke', // 撤销授权
+ 'signature', // 签名/签章
+
+ // 质押相关
+ 'stake', // 质押
+ 'unstake', // 解除质押
+ 'claim', // 领取收益
+
+ // 资产操作
+ 'mint', // 铸造
+ 'burn', // 销毁
+
+ // BioForest 特有
+ 'gift', // 发红包
+ 'grab', // 抢红包
+ 'trust', // 委托
+ 'signFor', // 代签
+ 'emigrate', // 跨链转出
+ 'immigrate', // 跨链转入
+ 'issueAsset', // 发行资产
+ 'increaseAsset', // 增发资产
+ 'destroyAsset', // 销毁资产
+ 'issueEntity', // 发行实体
+ 'destroyEntity', // 销毁实体
+ 'locationName', // 位名
+ 'dapp', // DApp 调用
+ 'certificate', // 证书
+ 'mark', // 标记
+
+ // 兜底
+ 'contract', // 未识别的合约调用
+ 'unknown', // 未知操作
+])
+
+/** 方向 */
+export const DirectionSchema = z.enum([
+ 'in', // 收入
+ 'out', // 支出
+ 'self', // 自己到自己
+])
+
+/** 交易状态 */
+export const TxStatusSchema = z.enum([
+ 'pending',
+ 'confirmed',
+ 'failed',
+])
+
+// ==================== 资产信息 ====================
+
+/** 原生代币资产 */
+export const NativeAssetSchema = z.object({
+ assetType: z.literal('native'),
+ value: z.string(),
+ symbol: z.string(),
+ decimals: z.number(),
+})
+
+/** 同质化代币资产 (ERC20/BEP20/TRC20) */
+export const TokenAssetSchema = z.object({
+ assetType: z.literal('token'),
+ value: z.string(),
+ symbol: z.string(),
+ decimals: z.number(),
+ contractAddress: z.string(),
+ name: z.string().optional(),
+ logoUrl: z.string().optional(),
+})
+
+/** 非同质化代币资产 (ERC721/ERC1155) */
+export const NftAssetSchema = z.object({
+ assetType: z.literal('nft'),
+ tokenId: z.string(),
+ contractAddress: z.string(),
+ name: z.string().optional(),
+ imageUrl: z.string().optional(),
+ collection: z.string().optional(),
+})
+
+/** 资产联合类型 */
+export const AssetSchema = z.discriminatedUnion('assetType', [
+ NativeAssetSchema,
+ TokenAssetSchema,
+ NftAssetSchema,
+])
+
+// ==================== 手续费 ====================
+
+/** 手续费信息 */
+export const FeeInfoSchema = z.object({
+ value: z.string(),
+ symbol: z.string(),
+ decimals: z.number(),
+})
+
+// ==================== 合约信息 ====================
+
+/** 合约调用信息 */
+export const ContractInfoSchema = z.object({
+ address: z.string(),
+ method: z.string().optional(),
+ methodId: z.string().optional(),
+})
+
+// ==================== 交易主体 ====================
+
+/** 交易 Schema */
+export const TransactionSchema = z.object({
+ // 基础信息
+ hash: z.string(),
+ from: z.string(),
+ to: z.string(),
+ timestamp: z.number(),
+ status: TxStatusSchema,
+ blockNumber: z.coerce.bigint().optional(),
+
+ // 多维度分类
+ action: ActionSchema,
+ direction: DirectionSchema,
+
+ // 涉及的资产 (可能多个,如 swap 有 in/out 两个资产)
+ assets: z.array(AssetSchema).min(1),
+
+ // 手续费 (原生代币,可选)
+ fee: FeeInfoSchema.optional(),
+
+ // 合约信息 (合约调用时,可选)
+ contract: ContractInfoSchema.optional(),
+})
+
+// ==================== 类型导出 ====================
+
+export type AssetType = z.infer
+export type Action = z.infer
+export type Direction = z.infer
+export type TxStatus = z.infer
+export type NativeAsset = z.infer
+export type TokenAsset = z.infer
+export type NftAsset = z.infer
+export type Asset = z.infer
+export type FeeInfo = z.infer
+export type ContractInfo = z.infer
+export type Transaction = z.infer
+
+// ==================== 工具函数 ====================
+
+/** 获取主资产 (第一个资产) */
+export function getPrimaryAsset(tx: Transaction): Asset {
+ return tx.assets[0]
+}
+
+/** 判断是否为原生资产 */
+export function isNativeAsset(asset: Asset): asset is NativeAsset {
+ return asset.assetType === 'native'
+}
+
+/** 判断是否为代币资产 */
+export function isTokenAsset(asset: Asset): asset is TokenAsset {
+ return asset.assetType === 'token'
+}
+
+/** 判断是否为 NFT 资产 */
+export function isNftAsset(asset: Asset): asset is NftAsset {
+ return asset.assetType === 'nft'
+}
+
+/** 安全解析交易 */
+export function parseTransaction(data: unknown): Transaction | null {
+ const result = TransactionSchema.safeParse(data)
+ return result.success ? result.data : null
+}
+
+/** 解析交易数组 */
+export function parseTransactions(data: unknown[]): Transaction[] {
+ return data
+ .map(item => parseTransaction(item))
+ .filter((tx): tx is Transaction => tx !== null)
+}
diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.ts b/src/services/chain-adapter/providers/tron-rpc-provider.ts
index 33c1019cf..274909d82 100644
--- a/src/services/chain-adapter/providers/tron-rpc-provider.ts
+++ b/src/services/chain-adapter/providers/tron-rpc-provider.ts
@@ -4,7 +4,8 @@
* 提供 Tron 链的余额和区块高度查询能力。
*/
-import type { ApiProvider, Balance } from './types'
+import { z } from 'zod'
+import type { ApiProvider, Balance, Transaction, Direction } from './types'
import type { ParsedApiEntry } from '@/services/chain-config'
import { chainConfigService } from '@/services/chain-config'
import { Amount } from '@/types/amount'
@@ -22,6 +23,75 @@ interface TronBlockResponse {
}
}
+const TronAccountSchema = z.looseObject({
+ balance: z.number().optional(),
+ address: z.string().optional(),
+})
+
+const TronNowBlockSchema = z.looseObject({
+ block_header: z
+ .looseObject({
+ raw_data: z
+ .looseObject({
+ number: z.number().optional(),
+ })
+ .optional(),
+ })
+ .optional(),
+})
+
+const TronTxSchema = z.looseObject({
+ txID: z.string(),
+ raw_data: z.looseObject({
+ contract: z.array(z.looseObject({
+ parameter: z.looseObject({
+ value: z.looseObject({
+ amount: z.number().optional(),
+ owner_address: z.string().optional(),
+ to_address: z.string().optional(),
+ }).optional(),
+ }).optional(),
+ type: z.string().optional(),
+ })).optional(),
+ timestamp: z.number().optional(),
+ }).optional(),
+ ret: z.array(z.looseObject({
+ contractRet: z.string().optional(),
+ })).optional(),
+})
+
+const TronTxListSchema = z.looseObject({
+ success: z.boolean(),
+ data: z.array(TronTxSchema).optional(),
+})
+
+// TRC-20 Token 交易 Schema
+const Trc20TxSchema = z.looseObject({
+ transaction_id: z.string(),
+ token_info: z.looseObject({
+ symbol: z.string(),
+ address: z.string(),
+ decimals: z.number(),
+ name: z.string().optional(),
+ }),
+ block_timestamp: z.number(),
+ from: z.string(),
+ to: z.string(),
+ type: z.string(),
+ value: z.string(),
+})
+
+const Trc20TxListSchema = z.looseObject({
+ success: z.boolean(),
+ data: z.array(Trc20TxSchema).optional(),
+})
+
+type TronTx = z.infer
+type Trc20Tx = z.infer
+
+/** 拉取倍数:应对分页陷阱 */
+const FETCH_MULTIPLIER = 5
+
export class TronRpcProvider implements ApiProvider {
readonly type: string
readonly endpoint: string
@@ -40,7 +110,7 @@ export class TronRpcProvider implements ApiProvider {
this.decimals = chainConfigService.getDecimals(chainId)
}
- private async api(path: string, body?: unknown): Promise {
+ private async api(path: string, schema: z.ZodType, body?: unknown): Promise {
const url = `${this.endpoint}${path}`
const init: RequestInit = body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
@@ -51,13 +121,18 @@ export class TronRpcProvider implements ApiProvider {
throw new Error(`HTTP ${response.status}`)
}
- return response.json() as Promise
+ const json: unknown = await response.json()
+ const parsed = schema.safeParse(json)
+ if (!parsed.success) {
+ throw new Error('Invalid API response')
+ }
+ return parsed.data
}
async getNativeBalance(address: string): Promise {
try {
// Tron 地址需要转换为 hex 格式或使用 base58
- const account = await this.api('/wallet/getaccount', {
+ const account = await this.api('/wallet/getaccount', TronAccountSchema, {
address,
visible: true,
})
@@ -79,13 +154,359 @@ export class TronRpcProvider implements ApiProvider {
async getBlockHeight(): Promise {
try {
- const block = await this.api('/wallet/getnowblock')
+ const block = await this.api('/wallet/getnowblock', TronNowBlockSchema)
const height = block.block_header?.raw_data?.number ?? 0
return BigInt(height)
} catch {
return 0n
}
}
+
+ async getTransactionHistory(address: string, limit = 20): Promise {
+ try {
+ const fetchLimit = limit * FETCH_MULTIPLIER
+
+ // 并行获取原生交易和 TRC-20 交易
+ const [nativeTxs, trc20Txs] = await Promise.all([
+ this.fetchNativeTransactions(address, fetchLimit),
+ this.fetchTrc20Transactions(address, fetchLimit),
+ ])
+
+ // 按 txID 聚合
+ const normalizedAddress = address.toLowerCase()
+ const aggregated = this.aggregateByTxId(nativeTxs, trc20Txs, normalizedAddress)
+
+ // 按时间排序、限制数量
+ return aggregated
+ .sort((a, b) => b.timestamp - a.timestamp)
+ .slice(0, limit)
+ } catch (error) {
+ console.warn('[TronRpcProvider] Error fetching transaction history:', error)
+ return []
+ }
+ }
+
+ /**
+ * 按 txID 聚合原生交易和 TRC-20 交易
+ */
+ private aggregateByTxId(
+ nativeTxs: TronTx[],
+ trc20Txs: Trc20Tx[],
+ normalizedAddress: string
+ ): Transaction[] {
+ // 1. 构建 trc20TxsByTxId
+ const trc20TxsByTxId = new Map()
+ for (const ttx of trc20Txs) {
+ const txId = ttx.transaction_id.toLowerCase()
+ const existing = trc20TxsByTxId.get(txId) ?? []
+ existing.push(ttx)
+ trc20TxsByTxId.set(txId, existing)
+ }
+
+ // 2. 遍历原生交易,聚合生成 Transaction
+ const results: Transaction[] = []
+ const processedTxIds = new Set()
+
+ for (const ntx of nativeTxs) {
+ const txId = ntx.txID.toLowerCase()
+ if (processedTxIds.has(txId)) continue
+ processedTxIds.add(txId)
+
+ const relatedTrc20Txs = trc20TxsByTxId.get(txId) ?? []
+ const tx = this.buildAggregatedTransaction(ntx, relatedTrc20Txs, normalizedAddress)
+ if (tx) {
+ results.push(tx)
+ }
+ }
+
+ // 3. 处理孤儿 TRC-20 交易
+ for (const [txId, ttxs] of trc20TxsByTxId) {
+ if (processedTxIds.has(txId)) continue
+ processedTxIds.add(txId)
+
+ const tx = this.buildOrphanTrc20Transaction(ttxs, normalizedAddress)
+ results.push(tx)
+ }
+
+ return results
+ }
+
+ /**
+ * 判断原生交易是否应该被过滤(智能过滤 Option A + 安全白名单)
+ *
+ * 保留规则(命中任意一条即保留):
+ * 1. 核心系统行为:Freeze/Unfreeze/Vote/Withdraw/AccountCreate/Transfer
+ * 2. 资产变动:有 TRC-20 事件 或 TRX amount > 0
+ * 3. 异常反馈:交易失败(用户需要看到失败记录)
+ * 4. 关键操作:approve 授权(Swap 前的必要步骤)
+ *
+ * 丢弃规则:
+ * - TriggerSmartContract + 成功 + 0 TRX + 无 TRC-20 + 非 approve → 噪音
+ */
+ private shouldFilterTransaction(ntx: TronTx, hasTrc20Events: boolean): boolean {
+ const contract = ntx.raw_data?.contract?.[0]
+ const contractType = contract?.type ?? ''
+ const nativeAmount = contract?.parameter?.value?.amount ?? 0
+ const txStatus = ntx.ret?.[0]?.contractRet
+
+ // 规则 1:核心系统行为,永不过滤
+ const whitelistTypes = [
+ 'TransferContract',
+ 'TransferAssetContract',
+ 'FreezeBalanceContract',
+ 'FreezeBalanceV2Contract',
+ 'UnfreezeBalanceContract',
+ 'UnfreezeBalanceV2Contract',
+ 'VoteWitnessContract',
+ 'AccountCreateContract',
+ 'WithdrawBalanceContract',
+ 'WithdrawExpireUnfreezeContract',
+ ]
+ if (whitelistTypes.includes(contractType)) {
+ return false
+ }
+
+ // 规则 2a:有 TRC-20 事件关联的,不过滤
+ if (hasTrc20Events) {
+ return false
+ }
+
+ // 规则 2b:有原生资产变动的,不过滤
+ if (nativeAmount > 0) {
+ return false
+ }
+
+ // 规则 3:失败交易必须保留(用户需要看到失败记录,避免重复操作)
+ if (txStatus !== 'SUCCESS') {
+ return false
+ }
+
+ // 规则 4:approve 授权操作必须保留(Swap 前的必要步骤)
+ if (this.isApproveTransaction(ntx)) {
+ return false
+ }
+
+ // 丢弃:TriggerSmartContract + 成功 + 0 TRX + 无 TRC-20 + 非 approve → 噪音
+ if (contractType === 'TriggerSmartContract') {
+ return true
+ }
+
+ return false
+ }
+
+ /**
+ * 检测是否为 approve 授权交易
+ * ERC-20/TRC-20 approve 的 MethodID: 0x095ea7b3
+ */
+ private isApproveTransaction(ntx: TronTx): boolean {
+ const contract = ntx.raw_data?.contract?.[0]
+ if (contract?.type !== 'TriggerSmartContract') {
+ return false
+ }
+
+ // Tron 的 data 字段包含合约调用数据
+ const data = contract?.parameter?.value?.data
+ if (!data || typeof data !== 'string') {
+ return false
+ }
+
+ // approve(address,uint256) 的 MethodID
+ const APPROVE_METHOD_ID = '095ea7b3'
+ return data.toLowerCase().startsWith(APPROVE_METHOD_ID)
+ }
+
+ /**
+ * 构建聚合后的 Transaction
+ */
+ private buildAggregatedTransaction(
+ ntx: TronTx,
+ trc20Txs: Trc20Tx[],
+ normalizedAddress: string
+ ): Transaction | null {
+ const contract = ntx.raw_data?.contract?.[0]
+ const contractType = contract?.type ?? ''
+ const value = contract?.parameter?.value
+ const status: Transaction['status'] = ntx.ret?.[0]?.contractRet === 'SUCCESS' ? 'confirmed' : 'failed'
+ const hasTokens = trc20Txs.length > 0
+
+ // 智能过滤:无意义的合约调用
+ if (this.shouldFilterTransaction(ntx, hasTokens)) {
+ return null
+ }
+
+ // Action 识别
+ let action = this.detectAction(contractType)
+ if (action === 'contract' && hasTokens) {
+ action = 'transfer'
+ }
+ // approve 授权操作
+ if (action === 'contract' && this.isApproveTransaction(ntx)) {
+ action = 'approve'
+ }
+
+ // 选择主 token event
+ const primaryToken = this.selectPrimaryToken(trc20Txs, normalizedAddress)
+
+ // from/to 与 direction
+ let from: string
+ let to: string
+ if (primaryToken) {
+ from = primaryToken.from
+ to = primaryToken.to
+ } else {
+ from = value?.owner_address ?? ''
+ to = value?.to_address ?? ''
+ }
+ const direction = this.getDirection(from, to, normalizedAddress)
+
+ // Assets 构建
+ const assets: Transaction['assets'] = []
+
+ // 添加 TRC-20 assets(最多 3 条)
+ for (const ttx of trc20Txs.slice(0, 3)) {
+ assets.push({
+ assetType: 'token' as const,
+ value: ttx.value,
+ symbol: ttx.token_info.symbol,
+ decimals: ttx.token_info.decimals,
+ contractAddress: ttx.token_info.address,
+ name: ttx.token_info.name,
+ })
+ }
+
+ // 原生 value > 0 时附加
+ const nativeAmount = value?.amount ?? 0
+ if (nativeAmount > 0 || assets.length === 0) {
+ assets.push({
+ assetType: 'native' as const,
+ value: nativeAmount.toString(),
+ symbol: this.symbol,
+ decimals: this.decimals,
+ })
+ }
+
+ return {
+ hash: ntx.txID,
+ from,
+ to,
+ timestamp: ntx.raw_data?.timestamp ?? 0,
+ status,
+ action,
+ direction,
+ assets,
+ }
+ }
+
+ /**
+ * 为孤儿 TRC-20 交易构建 Transaction
+ */
+ private buildOrphanTrc20Transaction(
+ trc20Txs: Trc20Tx[],
+ normalizedAddress: string
+ ): Transaction {
+ const primaryToken = this.selectPrimaryToken(trc20Txs, normalizedAddress)!
+ const direction = this.getDirection(primaryToken.from, primaryToken.to, normalizedAddress)
+
+ const assets: Transaction['assets'] = trc20Txs.slice(0, 3).map(ttx => ({
+ assetType: 'token' as const,
+ value: ttx.value,
+ symbol: ttx.token_info.symbol,
+ decimals: ttx.token_info.decimals,
+ contractAddress: ttx.token_info.address,
+ name: ttx.token_info.name,
+ }))
+
+ return {
+ hash: primaryToken.transaction_id,
+ from: primaryToken.from,
+ to: primaryToken.to,
+ timestamp: primaryToken.block_timestamp,
+ status: 'confirmed',
+ action: 'transfer',
+ direction,
+ assets,
+ }
+ }
+
+ /**
+ * 选择主 TRC-20 token(优先包含当前地址的、value 最大的)
+ */
+ private selectPrimaryToken(trc20Txs: Trc20Tx[], normalizedAddress: string): Trc20Tx | null {
+ if (trc20Txs.length === 0) return null
+ if (trc20Txs.length === 1) return trc20Txs[0]
+
+ const involving = trc20Txs.filter(
+ t => t.from.toLowerCase() === normalizedAddress || t.to.toLowerCase() === normalizedAddress
+ )
+ const candidates = involving.length > 0 ? involving : trc20Txs
+
+ return candidates.reduce((max, t) => {
+ const maxVal = BigInt(max.value)
+ const tVal = BigInt(t.value)
+ return tVal > maxVal ? t : max
+ })
+ }
+
+ /**
+ * 检测 Tron 交易类型
+ */
+ private detectAction(contractType: string): Transaction['action'] {
+ switch (contractType) {
+ case 'TransferContract':
+ case 'TransferAssetContract':
+ return 'transfer'
+ case 'TriggerSmartContract':
+ return 'contract'
+ case 'FreezeBalanceContract':
+ case 'FreezeBalanceV2Contract':
+ case 'VoteWitnessContract':
+ return 'stake'
+ case 'UnfreezeBalanceContract':
+ case 'UnfreezeBalanceV2Contract':
+ return 'unstake'
+ case 'WithdrawExpireUnfreezeContract':
+ return 'claim'
+ default:
+ return 'transfer'
+ }
+ }
+
+ private async fetchNativeTransactions(address: string, limit: number): Promise {
+ try {
+ const result = await this.api(
+ `/v1/accounts/${address}/transactions?limit=${limit}`,
+ TronTxListSchema
+ )
+ return result.success && result.data ? result.data : []
+ } catch {
+ return []
+ }
+ }
+
+ private async fetchTrc20Transactions(address: string, limit: number): Promise {
+ try {
+ const result = await this.api(
+ `/v1/accounts/${address}/transactions/trc20?limit=${limit}`,
+ Trc20TxListSchema
+ )
+ return result.success && result.data ? result.data : []
+ } catch {
+ return []
+ }
+ }
+
+ private getDirection(from: string, to: string, address: string): Direction {
+ const fromLower = from.toLowerCase()
+ const toLower = to.toLowerCase()
+
+ if (fromLower === address && toLower === address) {
+ return 'self'
+ }
+ if (fromLower === address) {
+ return 'out'
+ }
+ return 'in'
+ }
}
/** 工厂函数 */
diff --git a/src/services/chain-adapter/providers/types.ts b/src/services/chain-adapter/providers/types.ts
index ad1edbb30..ba1603f2e 100644
--- a/src/services/chain-adapter/providers/types.ts
+++ b/src/services/chain-adapter/providers/types.ts
@@ -8,6 +8,31 @@
import type { Amount } from '@/types/amount'
import type { ParsedApiEntry } from '@/services/chain-config'
+// 从 transaction-schema 导出 Transaction 相关类型
+export {
+ type Transaction,
+ type Asset,
+ type NativeAsset,
+ type TokenAsset,
+ type NftAsset,
+ type Action,
+ type Direction,
+ type AssetType,
+ type TxStatus,
+ type FeeInfo,
+ type ContractInfo,
+ TransactionSchema,
+ AssetSchema,
+ ActionSchema,
+ DirectionSchema,
+ getPrimaryAsset,
+ isNativeAsset,
+ isTokenAsset,
+ isNftAsset,
+ parseTransaction,
+ parseTransactions,
+} from './transaction-schema'
+
// ==================== 数据类型 ====================
/** 余额信息 */
@@ -16,16 +41,12 @@ export interface Balance {
symbol: string
}
-/** 交易信息 */
-export interface Transaction {
- hash: string
- from: string
- to: string
- value: string
+/** 代币余额(含 native + 所有资产) */
+export interface TokenBalance {
symbol: string
- timestamp: number
- status: 'pending' | 'confirmed' | 'failed'
- blockNumber?: bigint
+ name: string
+ amount: Amount
+ isNative: boolean
}
/** 手续费选项 */
@@ -90,6 +111,9 @@ export interface ApiProvider {
/** 查询原生代币余额 */
getNativeBalance?(address: string): Promise
+ /** 查询所有代币余额(native + 资产) */
+ getTokenBalances?(address: string): Promise
+
/** 查询交易历史 */
getTransactionHistory?(address: string, limit?: number): Promise
diff --git a/src/services/chain-config/__tests__/index.test.ts b/src/services/chain-config/__tests__/index.test.ts
index 8ec5e327c..71159a5c7 100644
--- a/src/services/chain-config/__tests__/index.test.ts
+++ b/src/services/chain-config/__tests__/index.test.ts
@@ -163,7 +163,7 @@ describe('chain-config service', () => {
name: 'Manual Unknown',
symbol: 'MU',
decimals: 8,
- }),
+ })
).rejects.toThrow()
})
diff --git a/src/services/transaction/types.ts b/src/services/transaction/types.ts
index be7099c4b..f3db1fb90 100644
--- a/src/services/transaction/types.ts
+++ b/src/services/transaction/types.ts
@@ -40,6 +40,9 @@ export type TransactionType =
| 'dapp' // WOD-00/01/02 DApp相关
// 凭证
| 'certificate' // CRT-00/01 凭证相关
+ // EVM/Tron 合约操作
+ | 'approve' // ERC-20/TRC-20 授权
+ | 'interaction' // 合约交互
// 其他
| 'mark' // EXT-00 数据存证
| 'other' // 其他未分类
@@ -81,7 +84,7 @@ const TransactionTypeEnum = z.enum([
'send', 'receive', 'signature', 'stake', 'unstake', 'destroy',
'gift', 'grab', 'trust', 'signFor', 'emigrate', 'immigrate', 'exchange',
'issueAsset', 'increaseAsset', 'issueEntity', 'destroyEntity',
- 'locationName', 'dapp', 'certificate', 'mark', 'other',
+ 'locationName', 'dapp', 'certificate', 'approve', 'interaction', 'mark', 'other',
])
const TransactionRecordSchema = z.object({