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({