From 6059f59f298498ce5e3cf5a34c2dd983dc36f11f Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 19:36:13 +0800 Subject: [PATCH 1/9] fix(transfer): save callback reference before clearing in TransferConfirmJob The pendingCallback was being cleared before it was called, causing the confirm button to do nothing. Fixed by saving the reference first. Bug: clicking 'confirm transfer' button had no effect --- src/stackflow/activities/sheets/TransferConfirmJob.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/stackflow/activities/sheets/TransferConfirmJob.tsx b/src/stackflow/activities/sheets/TransferConfirmJob.tsx index f1666cfeb..7c578be9a 100644 --- a/src/stackflow/activities/sheets/TransferConfirmJob.tsx +++ b/src/stackflow/activities/sheets/TransferConfirmJob.tsx @@ -66,10 +66,11 @@ function TransferConfirmJobContent() { setIsConfirming(true); try { - // Pop this Job first, then call the callback which may push another Job + // Save callback reference before clearing + const callback = pendingCallback; clearTransferConfirmCallback(); pop(); - await pendingCallback(); + await callback(); } finally { setIsConfirming(false); } From c8f5e7c1b17c9387c69a35f2145d2b638b5fc99d Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 19:41:43 +0800 Subject: [PATCH 2/9] fix(transaction): correctly parse API response format API returns { success, result: { trs: [...] } } but code was reading result.trs (undefined) instead of result.result.trs. This caused transaction history to always be empty. --- .../bioforest/transaction-service.ts | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/services/chain-adapter/bioforest/transaction-service.ts b/src/services/chain-adapter/bioforest/transaction-service.ts index 4e96a5c7a..01e021fcb 100644 --- a/src/services/chain-adapter/bioforest/transaction-service.ts +++ b/src/services/chain-adapter/bioforest/transaction-service.ts @@ -293,30 +293,39 @@ export class BioforestTransactionService implements ITransactionService { return [] } - // BioForest API response format: { trs: TransactionDetail[] } - const result = (await response.json()) as { - trs?: Array<{ - height: number - signature: string // Block signature - tIndex: number - transaction: { - signature: string // Transaction ID - senderId: string - recipientId?: string - fee: string - timestamp: number - type: string - asset?: { - transferAsset?: { - amount: string - assetType: string + // BioForest API response format: { success: boolean, result: { trs: TransactionDetail[], count: number } } + const json = (await response.json()) as { + success: boolean + result: { + trs?: Array<{ + height: number + signature: string // Block signature + tIndex: number + transaction: { + signature: string // Transaction ID + senderId: string + recipientId?: string + fee: string + timestamp: number + type: string + asset?: { + transferAsset?: { + amount: string + assetType: string + } } } - } - }> + }> + count?: number + } + } + + if (!json.success) { + console.warn('[TransactionService] API returned success=false') + return [] } - const transactions = result.trs ?? [] + const transactions = json.result?.trs ?? [] if (transactions.length === 0) { console.debug('[TransactionService] No transactions found for', address, 'on', this.config.id) From 8f0f689e8ecf42e8b68502ca06bdfbbccd4f8919 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 19:45:31 +0800 Subject: [PATCH 3/9] fix(chain-adapter): correctly parse all BioForest API responses All BioForest APIs return { success: boolean, result: T } format. Fixed parsing in: - chain-service.ts: getBlockHeight, getGasPrice, healthCheck - transaction-service.ts: getTransactionHistory (lastblock call) Root cause: Did not study legacy wallet-base SDK which auto-unwraps the { success, result } wrapper in WalletFetch._handleResThen() --- CHAT.md | 235 ++++++++++++++++++ .../chain-adapter/bioforest/chain-service.ts | 22 +- .../bioforest/transaction-service.ts | 8 +- 3 files changed, 255 insertions(+), 10 deletions(-) diff --git a/CHAT.md b/CHAT.md index a0205e2af..f5f08f7f7 100644 --- a/CHAT.md +++ b/CHAT.md @@ -267,3 +267,238 @@ xxxServiceMete.use((req, next) => { 我们现在需要逐步清理界面上的假数据, 开发真实的Service, 使得我们的项目向正式可用正式上线更进一步. 绝大部分工作从mpay项目搬迁代码即可. 我需要你查看我们的项目代码, 列出一份代办清单. + +--- + +1. P0 - 转账功能 (核心功能) + - 连接 BioforestTransactionService 到转账 UI + - 实现真实的余额获取和转账提交 +2. P0 - 交易历史 (核心功能) + - 连接 BioforestTransactionService.getTransactionHistory() + - 显示真实交易记录 +3. P1 - 货币汇率 + - 完善 Frankfurter provider + - 在 UI 中显示法币价值 +4. P2 - UI/可访问性检查 + +--- + +1. 你需要充分参考mpay的代码, 我们目前还是在“搬迁”代码到新架构阶段 +2. 不要迷信新架构的代码, 有任何怀疑, 就在 `.chat/`目录下留下留言md文件,我会去查阅,但并不意味着你需要停止. 你主要的目标就是实现mpay已有的功能, 这方面不用再过问 +3. Open questions: 提案和实现通常可以一起; 主要先解决 BFM/原生币 的功能, 至于ETH等功能, 不及,但理论上应该是可以一起的, 而且这也能同时验证我们service都合理性 + +--- + +1. 我无法导入助记词,导致我无法测试转账 +2. 从“Wallet Management”进入钱包详情, 我点击删除钱包, BottomSheet出来了, 然而BottomSheet中存在秘密输入框,这导致页面存在严重的抖动问题. 我不知道如何解释这个问题, 那种效果类似: BottomSheet背后的页面在重新进入, BottomSheet试图和它保持一个正确的定位, 每一帧都在重新计算一个正确的位置. 我怀疑是stackflow的问题? 请检查一下 + +等你修复了这些基本功能并合并到主分支,我再做验证(bfmeta 转账/历史/TransferTab) + +--- + +1. 我还是没看到动画效果; 我是在首页这个钱包资产页面看我的数字的. +2. 字体仍然是加粗的; +3. 还有,既然你封装了 AnimatedNumber ,那么请你确保这个组件的可访问性,这点非常重要,现在我在可访问性面板看到的是 `image:10000.11465` 这样的效果. + +--- + +梳理一下我们的工作, 然后按需多次提交, 接着开PR 合并到我们主分支. + +--- + +还有你的 balanceRefreshTimes 方案, 我觉得太粗糙太简陋了, 我们的service中,没有“实时”更新的理念吗? 所有跟时间有关的service,都应该有推送更新的理念啊. +基于这个理念,页面会按需订阅,然后你再去做轮询,或者如果第三方接口支持订阅,那么就更好了 + +--- + +1. 同意,先编写白皮书,记录这些规范。 +2. 然后将白皮书的roadmap独立拆分出来,在我们的AGENITS.md中将它标记成必读内容.未来我们将正式引入verssion驱动的roadmap+changelog +3. 你刚的两个PR都没有通过检查,无法合并到main请先确保合并到main分支了再开始工作 + +--- + +我看到我们现在的测试,很多时候还在依赖“明文”,这在我们这套架构中是非常不规范的, 我们的项目是国际化的, 因此必须警惕直接使用“文本搜索”来寻找页面上的元素, 最好的方案就是提供 `data-testid`. +请你先记录这个最佳实践规范到白皮书中,然后开始检查修复我们的e2e测试 + +--- + +我需要你编写一个 `scripts/agent-readme.ts` 脚本, 目的是为AI提供一个统一的输出所有必读项目内容. +照理来说,我们只需要一个 AGENTS.md 或者 CLADUE.md 即可,但是我们为了避免重复编写文档的问题. 我们首先必须正确归类我们的所有资料信息, 所以有了现在的白皮书, 同时我们的白皮书目录还是动态生成的. +所以我需要你编写这个脚本, 将我们的必读内容罗列出来, 让AI只需要执行一次这个脚本就可以一次性获得所有的资料. + +--- + +开始之前,先和合并pr#38 + +任务一: +把现有的 `"e2e": "playwright test"` 改成 `"e2e:all": "playwright test",`. +然后编写一个脚本: `scripts/e2e.ts`, 用来作为`e2e`的入口, 其作用是: + +1. 要求必须传递至少一个spec文件, 如果不传递会列出当前的可用的spec文件列表 +2. 之所以要这做, 是因为现在文件越来越多了, 我们必须考虑这些编译、测试的时间成本. + +任务二: +我们的CI/CD配置需要包含i18n的检查 + +任务三: +我计划引入 turborepo, 以提高我们的构建和测试速度. +请你专门开一个PR来完成这项任务: 首先让我们的 test/e2e/i18n 用上 turborepo ,看速度提升了多少. +然后再考虑引入 build + +--- + +我配置了一台 self-hosted runner. 我们的github-actions可以试着用它:`runs-on: self-hosted`, 看看整体的速度会不会有所提升 + +--- + +这里有些任务在我的self-host的机器上理论上是可以跳过的,比如node/pnpm等环境的安装,但是因为我们有fallback,我想做的是 +能不能针对我这台macos配置一套快速启动执行CICD的,我可以去把环境尽可能全面地准备好.我的目的是加速,然后如果这台macos下线了 +再fallback成github自己的集群,去做pnpm-setup等准备工作 + +--- + +还有,为什么我刚才跟你说:去github-project创建任务,然后你生成我的issues并没有关联到我们的github-project.是`pnpm agent` +没有提供这个能力吗?还是说有提供,但是因为提示记同的问题导致你没有遵循? + +--- + +警告1: + +``` +Stackflow - Some plugin overrides an "initialActivity" option. The "initialActivity" option you set to "MainTabsActivity" in the "stackflow" is ignored. +``` + +警告2: + +``` +[refreshBalance] No adapter for chain: bfchainv2 +``` + +警告3: + +``` +An error occurred in the component. + +Consider adding an error boundary to your tree to customize error handling behavior. +Visit https://react.dev/link/error-boundaries to learn more about error boundaries. +``` + +--- + +接下来修复SendPage暴露出来的错误. + +``` +SyntaxError: Cannot convert 102531.02649070 to a BigInt (at use-send.logic.ts:38:19) + at BigInt () + at canProceedToConfirm (use-send.logic.ts:38:19) + at use-send.ts:121:12 + at updateMemo (react-dom-client.development.js:8795:19) + at Object.useMemo (react-dom-client.development.js:26484:18) + at exports.useMemo (react.development.js:1251:34) + at useSend (use-send.ts:120:22) + at SendPage (index.tsx:76:104) + at Object.react_stack_bottom_frame (react-dom-client.development.js:25904:20) + at renderWithHooks (react-dom-client.development.js:7662:22) + +The above error occurred in the component. + +``` + +注意,这个问题说明了一些更加深层次的问题, 我们应该如何优化我们的接口甚至是架构的设计, 才能从长期根治这个问题: 我的意思是,如果一个小白来维护这个代码, 也能发自直觉地去解决这个问题. +我的想法是, 我们不应该直接使用 number|string 去表达我们的amount, 而应该有一种专门的构造函数 Amount, 它自带一些元数据, 例如精度, 单位等. 让我们整个项目都去依赖这个Amount对象去展开所有跟资金有关的计算与操作. + +--- + +我希望的效果是更加彻底的:service接口提供的数据,只要涉及到金额都用Amount.理论上最好的效果就是amount在整个个上下文中去传递 +只有需要显示成某种格式的时候才会调用amount的format/stringify去得到string|number,否则在内存中,程序看到的应该都是amount这个对象 + +`Amount.from` 只会发生在service内部和我们的component内部(比如amount-input返回的值就是Amount而不是string|number) + +请你作为一个软件工程专家, 深入理解,然后给出一份工作任务 + +--- + +1. 你ci里面的这个 checks, 不能和 ci-fast/ci-standard 一样,给一个 checks-fast / checks-standard 吗? +2. 我们的cd是否也可以做和ci同样的优化?提供一个 cd-fast, 尽量充分利用我们本地的机器. 来提升速度 + +--- + +我试着做了bfchainv2网络的转账,发现了几个问题: + +1. 转账确认时弹出的BottomSheet还是“好像”不是Stackflow的BottomSheet, 我觉得核心原因是我们的通用组件库中还是有 `src/components/layout/bottom-sheet.tsx`, 倒是一些残留的旧代码还在使用. 我个人建议的解决方案是: + 1. 将 `src/components/layout/bottom-sheet.tsx` 改成对 `@stackflow/plugin-basic-ui : BottomSheet,BottomSheetProps` 的包装导出, 好处是我们可以统一在这里做一些扩展和风格化的定制. + 2. 将项目中所有用到`@stackflow/plugin-basic-ui : BottomSheet`的地方,全部改成丛 `src/components/layout/bottom-sheet.tsx`去导入 + 3. 将项目原本用到`src/components/layout/bottom-sheet.tsx`的地方,全部改成SheetActivity + 4. 目前所有的 SheetActivity 全部放在`src/stackflow/activities/sheets`这个地方,这是不好的, SheetActivity应该跟随页面或者业务走: + 1. 业务文件加使用`*Job`命名:如果多个页面共享了某个 SheetActivity ,那么说明它们有共同的业务,那么这个SheetActivity就应该跟随业务走; + 2. 页面文件夹和页面同名,使用`*Activity`命名 +2. 同理 `src/components/ui/alert-dialog.tsx` 也是需要类似的改造: + 1. 我们需要`src/components/layout/modal.tsx`+`src/components/layout/alert-dialog.tsx`来提供统一的弹出层和弹窗 + 2. 命名规范参考 SheetActivity +3. 我点击确认转账后,弹出一个密码确认框, 输入密码后,点击确认, 没反应, 交易也没有发起. 理论上应该发起交易,然后弹窗显示成后续的流程,比如签名、上链、成功或者失败. 像BioChain生态还有二次密码(字符密码)输入之后才能正确签名. 但是目前这些流程都没有,说明对于mpay 的相关代码还没有正确迁移,service相关的开发也不够完善,配套的e2e测试流程也不够完整. +4. 我的账号缺失是有交易的,但是交易列表页面却没有查询出我的交易. + +--- + +我们现在的bioChain的转账一直无法正常工作, 为此我配置了一个测试账号: `.tmp/test-doc-for-bio-tran.md` + +你需要实现的是: + +1. 能查询出真正的交易往来 +2. 能配置 bioChain 专属的支付密码 +3. 能正确发起转账, 并且能正确查询出“最低手续费” +4. 在真实网络中使用测试账号完成测试并走通所有需求后, 完善对应的mockService +5. 在mockService基础上, 完成e2e的全流程测试(配套全流程截图). 这个非常非常重要 + +在开始之前,你可以询问我一些问题,等确定之后,就不能再询问我,你需要一直工作到所有任务完成,并且将所有的PR合并到主分支,并且对应的issues、project-task都清理完毕. +等到所有工作完成到可以正式上线的产品级别, 才能通知我进行验收. + +--- + +我已经阅读了测试账号配置和现有代码。在开始之前,我需要确认以下几个关键问题: +需要确认的问题 + +1. BioForest Chain API + - 当前rpcUrl配置是什么?(我需要知道正确的API地址) + - API的交易查询格式是否有文档?(当前代码中的/wallet/{chainId}/transactions/query 是否正确) + +2. 支付密码功能范围 + - "配置bioChain专属的支付密码"是指: + A) 只需要在转账时验证已有的支付密码 + B) 还需要提供设置/修改支付密码的功能 +3. 最低手续费 API + - SDK中有 getTransferTransactionMinFee 方法,但需要确认这个返回的是真正的链上最低手续费还是固定值? + - 是否需要调用链上API获取实时手续费? +4. e2e 测试方案 + - e2e测试是使用 mock模拟还是需要连接测试网? + - 截图需要覆盖哪些场景?(我建议:发起转账、输入支付额密码、确认、成功/失败结果) +5. 其他测试账号 + - 测试账号中提到"其它测试账号:(请你自己创建)",我需要创建第二二个账号用于接收测试转账吗? + - 测试网上是否可以获取测试币? + +请确认以上问题,我将根据你的答复制定完整的实施计划关并一次性完成所有工作。 + +--- + +- 1.1. 你自己不是一直在用吗?就是https://walletapi.bffmeta.info,这个没错,所有bioChain都用这个 https://vwalletapi.bfmeta.info就行了 +- 1.2 你应该自己去查阅mpay的代码,那是一个正式的产品,有很多你需要的答案,问我是问不完的 +- 2. 不是钱包的密码,是bioChain自己有一个"二次签名",我们称它为支付密码,设置二次签名是需要发起一笔链上的交易才能设置可我者修改的mpay代码中应该有一些答案,或者 sign-util这个包也会有一些相关的API +- 3. 用SDK提供的,它是根据体积和交易类型去计算的, 用户当然可以去修改成更高的 +- 4. e2e测试使用 mock 模拟,但前提是你已经走通了真实的“所有功能”,你才能写出正确的mock +- 5. 是的你自己用我们现有的这些代码自己去创建, 然后从我给你的测试账号里面去接受转账, 转来转去来形成交易, 同时也要测试交易能否正常被订阅刷新. 你可能无法使用浏览器,但是你可以用临时的 playwright 脚本去做这些测试去做验证. 我给你的都是真实的网络真实的权益,但是并不多, 但也足够你测试数几百次了. + +--- + +区块链通常情况下,底层的轮询是依靠“高度”去触发更新的, 我们目前是各个services自己去轮询. 这本身没问题, 但是会因为考虑轮询的成本,所以要控制在30s一次. + +我的意思是. 我们不排除个别区块链可能存在一些特殊的机制: 比如还没出块,但是返回的交易列表中可能存在一些“队列中”的数据. +但是不论如何, 至少在 bioChain的api是很普通的,返回的就是已经上链的数据. + +因此我们可以对bioChain对订阅做优化: + +1. 我们统一轮询“lastBlock”接口,10s甚至5s一次,甚至我们可以智能一点,根据前面几个区块的出块时间, 预测出出块时间. + - 核心算法我已经实现在 `.tmp/yuce.ts` 中, 你需要的是将它对接到我们项目中: + 1. 配置一套基于indexedDB的存储服务, 并运行在Worker中 + 2. 实现一套 通用的预测service + 3. bioChain的区块Service, 内置配置一套能实现 bioChain 出块预测的虚拟数据生成器, 基于获取创世区块和最近的一个区块, 来模拟生成200条数据, 能实现对接预测service(第一次创建发现如果没有数据, 那么虚拟数据生成器工作, 然后导入这些虚拟数据) +2. 当lastBlock的signature 发生变化(注意不是height, 考虑分叉问题可能高度没变, 但是签名是唯一的肯定会变)bioChain相关的订阅也就能去更新了:比如可以去加载最新的余额、最新的交易列表 diff --git a/src/services/chain-adapter/bioforest/chain-service.ts b/src/services/chain-adapter/bioforest/chain-service.ts index daab1d069..ca2e4bd65 100644 --- a/src/services/chain-adapter/bioforest/chain-service.ts +++ b/src/services/chain-adapter/bioforest/chain-service.ts @@ -50,8 +50,11 @@ export class BioforestChainService implements IChainService { ) } - const data = (await response.json()) as { data: BioforestBlockInfo } - return BigInt(data.data.height) + const json = (await response.json()) as { success: boolean; result: BioforestBlockInfo } + if (!json.success) { + throw new ChainServiceError(ChainErrorCodes.NETWORK_ERROR, 'API returned success=false') + } + return BigInt(json.result.height) } catch (error) { if (error instanceof ChainServiceError) throw error throw new ChainServiceError( @@ -90,9 +93,12 @@ export class BioforestChainService implements IChainService { ) } - const data = (await response.json()) as { data: BioforestFeeInfo } - const minFee = Amount.fromRaw(data.data.minFee, decimals, symbol) - const avgFee = Amount.fromRaw(data.data.avgFee, decimals, symbol) + const json = (await response.json()) as { success: boolean; result: BioforestFeeInfo } + if (!json.success) { + throw new ChainServiceError(ChainErrorCodes.NETWORK_ERROR, 'API returned success=false') + } + const minFee = Amount.fromRaw(json.result.minFee, decimals, symbol) + const avgFee = Amount.fromRaw(json.result.avgFee, decimals, symbol) return { slow: minFee, @@ -144,12 +150,12 @@ export class BioforestChainService implements IChainService { } } - const data = (await response.json()) as { data: BioforestBlockInfo } + const json = (await response.json()) as { success: boolean; result: BioforestBlockInfo } return { - isHealthy: true, + isHealthy: json.success, latency, - blockHeight: BigInt(data.data.height), + blockHeight: json.success ? BigInt(json.result.height) : 0n, isSyncing: false, lastUpdated: Date.now(), } diff --git a/src/services/chain-adapter/bioforest/transaction-service.ts b/src/services/chain-adapter/bioforest/transaction-service.ts index 01e021fcb..5c9e7a2c3 100644 --- a/src/services/chain-adapter/bioforest/transaction-service.ts +++ b/src/services/chain-adapter/bioforest/transaction-service.ts @@ -271,8 +271,12 @@ export class BioforestTransactionService implements ITransactionService { console.warn('[TransactionService] Failed to get lastblock:', blockResponse.status) return [] } - const lastBlock = (await blockResponse.json()) as { height: number; timestamp: number } - const maxHeight = lastBlock.height + const lastBlockJson = (await blockResponse.json()) as { success: boolean; result: { height: number; timestamp: number } } + if (!lastBlockJson.success) { + console.warn('[TransactionService] lastblock API returned success=false') + return [] + } + const maxHeight = lastBlockJson.result.height // Query transactions using the correct API format const queryUrl = `${this.baseUrl}/wallet/${this.config.id}/transactions/query` From 4907b1ba318976cd455da28a53b896b53451b12f Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 19:51:50 +0800 Subject: [PATCH 4/9] fix(transaction-service): fix broadcastTransaction, getTransactionStatus, getTransaction API parsing All methods were incorrectly parsing { data: {...} } instead of { success, result: {...} } --- .../bioforest/transaction-service.ts | 66 ++++++++++++------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/src/services/chain-adapter/bioforest/transaction-service.ts b/src/services/chain-adapter/bioforest/transaction-service.ts index 5c9e7a2c3..e94d5a710 100644 --- a/src/services/chain-adapter/bioforest/transaction-service.ts +++ b/src/services/chain-adapter/bioforest/transaction-service.ts @@ -133,8 +133,15 @@ export class BioforestTransactionService implements ITransactionService { ) } - const result = (await response.json()) as { data: { signature: string } } - return result.data.signature + const json = (await response.json()) as { success: boolean; result?: unknown; error?: { message?: string } } + if (!json.success) { + throw new ChainServiceError( + ChainErrorCodes.TRANSACTION_REJECTED, + json.error?.message ?? 'Broadcast failed', + ) + } + // Return the signature from the signed transaction (already have it) + return signedTx.signature } catch (error) { if (error instanceof ChainServiceError) throw error throw new ChainServiceError( @@ -173,11 +180,12 @@ export class BioforestTransactionService implements ITransactionService { } } - const result = (await response.json()) as { - data: { transaction?: { height?: number } } + const json = (await response.json()) as { + success: boolean + result?: { trs?: Array<{ height?: number }> } } - if (result.data.transaction?.height) { + if (json.success && json.result?.trs?.[0]?.height) { return { status: 'confirmed', confirmations: 1, @@ -218,38 +226,48 @@ export class BioforestTransactionService implements ITransactionService { return null } - const result = (await response.json()) as { - data: { - transaction?: { - signature: string - senderId: string - recipientId: string - amount: string - fee: string - timestamp: number - height?: number - } + const json = (await response.json()) as { + success: boolean + result?: { + trs?: Array<{ + height: number + transaction: { + signature: string + senderId: string + recipientId?: string + fee: string + timestamp: number + asset?: { + transferAsset?: { + amount: string + } + } + } + }> } } - const tx = result.data.transaction - if (!tx) return null + if (!json.success || !json.result?.trs?.[0]) return null + const item = json.result.trs[0] + const tx = item.transaction const { decimals, symbol } = this.config + const amountRaw = tx.asset?.transferAsset?.amount ?? '0' + return { hash: tx.signature, from: tx.senderId, - to: tx.recipientId, - amount: Amount.fromRaw(tx.amount, decimals, symbol), + to: tx.recipientId ?? '', + amount: Amount.fromRaw(amountRaw, decimals, symbol), fee: Amount.fromRaw(tx.fee, decimals, symbol), status: { - status: tx.height ? 'confirmed' : 'pending', - confirmations: tx.height ? 1 : 0, + status: 'confirmed', + confirmations: 1, requiredConfirmations: 1, }, - timestamp: tx.timestamp, - blockNumber: tx.height ? BigInt(tx.height) : undefined, + timestamp: tx.timestamp * 1000, // Convert to milliseconds + blockNumber: BigInt(item.height), type: 'transfer', } } catch { From 5205384efa198b7e99b41de8c6fc0d318f7a1adb Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 19:56:12 +0800 Subject: [PATCH 5/9] test(chain-adapter): add comprehensive API response parsing tests - Add bioforest-api.test.ts with 19 tests covering all API methods - Mock fetch to verify { success, result } response format parsing - Test ChainService: getBlockHeight, getGasPrice, healthCheck - Test TransactionService: getTransactionHistory, getTransactionStatus, getTransaction, broadcastTransaction - Test AssetService: getNativeBalance, getTokenBalances - Fix getGasPrice to return default on success=false instead of throwing --- .../__tests__/bioforest-api.test.ts | 428 ++++++++++++++++++ .../chain-adapter/bioforest/chain-service.ts | 9 +- 2 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 src/services/chain-adapter/__tests__/bioforest-api.test.ts diff --git a/src/services/chain-adapter/__tests__/bioforest-api.test.ts b/src/services/chain-adapter/__tests__/bioforest-api.test.ts new file mode 100644 index 000000000..417c709c0 --- /dev/null +++ b/src/services/chain-adapter/__tests__/bioforest-api.test.ts @@ -0,0 +1,428 @@ +/** + * BioForest Chain Adapter API Tests + * + * Tests API response parsing with mocked fetch. + * All BioForest APIs return { success: boolean, result: T } format. + */ + +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import type { ChainConfig } from '@/services/chain-config' +import { BioforestAdapter } from '../bioforest' + +const mockConfigWithRpc: ChainConfig = { + id: 'bfmeta', + version: '1.0', + type: 'bioforest', + name: 'BFMeta', + symbol: 'BFM', + prefix: 'b', + decimals: 8, + enabled: true, + source: 'default', + rpcUrl: 'https://walletapi.bfmeta.info', +} + +// Helper to create mock Response +function mockResponse(data: unknown, ok = true): Response { + return { + ok, + status: ok ? 200 : 500, + json: () => Promise.resolve(data), + } as Response +} + +describe('BioForest API Response Parsing', () => { + let adapter: BioforestAdapter + let fetchSpy: ReturnType + + beforeEach(() => { + adapter = new BioforestAdapter(mockConfigWithRpc) + fetchSpy = vi.spyOn(globalThis, 'fetch') + }) + + afterEach(() => { + fetchSpy.mockRestore() + }) + + describe('ChainService', () => { + describe('getBlockHeight', () => { + it('parses { success, result: { height } } format', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { height: 1274934, timestamp: 21951840 }, + }) + ) + + const height = await adapter.chain.getBlockHeight() + + expect(height).toBe(1274934n) + expect(fetchSpy).toHaveBeenCalledWith( + 'https://walletapi.bfmeta.info/wallet/bfmeta/lastblock', + expect.any(Object) + ) + }) + + it('throws on success=false', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: false, + error: { message: 'API error' }, + }) + ) + + await expect(adapter.chain.getBlockHeight()).rejects.toThrow() + }) + }) + + describe('getGasPrice', () => { + it('parses { success, result: { minFee, avgFee } } format', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { minFee: '10000000', avgFee: '15000000' }, + }) + ) + + const gasPrice = await adapter.chain.getGasPrice() + + expect(gasPrice.slow.toRawString()).toBe('10000000') + expect(gasPrice.standard.toRawString()).toBe('15000000') + }) + + it('returns default on API error', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ success: false }, true) + ) + + const gasPrice = await adapter.chain.getGasPrice() + + expect(gasPrice.slow).toBeDefined() + expect(gasPrice.standard).toBeDefined() + }) + }) + + describe('healthCheck', () => { + it('parses successful health check', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { height: 1274934, timestamp: 21951840 }, + }) + ) + + const health = await adapter.chain.healthCheck() + + expect(health.isHealthy).toBe(true) + expect(health.blockHeight).toBe(1274934n) + }) + + it('returns unhealthy on success=false', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ success: false }) + ) + + const health = await adapter.chain.healthCheck() + + expect(health.isHealthy).toBe(false) + expect(health.blockHeight).toBe(0n) + }) + }) + }) + + describe('TransactionService', () => { + describe('getTransactionHistory', () => { + it('parses { success, result: { trs: [...] } } format', async () => { + // Mock lastblock call + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { height: 1274934, timestamp: 21951840 }, + }) + ) + // Mock transactions/query call + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { + trs: [ + { + height: 1274442, + signature: 'block-sig-1', + tIndex: 0, + transaction: { + signature: 'tx-sig-1', + senderId: 'bSender123', + recipientId: 'bRecipient456', + fee: '10000000', + timestamp: 21951000, + type: 'BFM-AST-02', + asset: { + transferAsset: { + amount: '100000000', + assetType: 'BFM', + }, + }, + }, + }, + ], + count: 1, + }, + }) + ) + + const history = await adapter.transaction.getTransactionHistory('bTest123') + + expect(history).toHaveLength(1) + expect(history[0].hash).toBe('tx-sig-1') + expect(history[0].from).toBe('bSender123') + expect(history[0].to).toBe('bRecipient456') + expect(history[0].amount.toRawString()).toBe('100000000') + expect(history[0].fee?.toRawString()).toBe('10000000') + expect(history[0].blockNumber).toBe(1274442n) + }) + + it('returns empty array on success=false', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { height: 1274934 }, + }) + ) + fetchSpy.mockResolvedValueOnce( + mockResponse({ success: false }) + ) + + const history = await adapter.transaction.getTransactionHistory('bTest123') + + expect(history).toEqual([]) + }) + + it('returns empty array when trs is empty', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { height: 1274934 }, + }) + ) + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { trs: [], count: 0 }, + }) + ) + + const history = await adapter.transaction.getTransactionHistory('bTest123') + + expect(history).toEqual([]) + }) + }) + + describe('getTransactionStatus', () => { + it('returns confirmed when trs has height', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { + trs: [{ height: 1274442 }], + }, + }) + ) + + const status = await adapter.transaction.getTransactionStatus('tx-sig-1') + + expect(status.status).toBe('confirmed') + expect(status.confirmations).toBe(1) + }) + + it('returns pending when trs is empty', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { trs: [] }, + }) + ) + + const status = await adapter.transaction.getTransactionStatus('tx-sig-1') + + expect(status.status).toBe('pending') + expect(status.confirmations).toBe(0) + }) + }) + + describe('getTransaction', () => { + it('parses transaction details from trs array', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { + trs: [ + { + height: 1274442, + transaction: { + signature: 'tx-sig-1', + senderId: 'bSender123', + recipientId: 'bRecipient456', + fee: '10000000', + timestamp: 21951000, + asset: { + transferAsset: { amount: '100000000' }, + }, + }, + }, + ], + }, + }) + ) + + const tx = await adapter.transaction.getTransaction('tx-sig-1') + + expect(tx).not.toBeNull() + expect(tx!.hash).toBe('tx-sig-1') + expect(tx!.from).toBe('bSender123') + expect(tx!.to).toBe('bRecipient456') + expect(tx!.amount.toRawString()).toBe('100000000') + expect(tx!.blockNumber).toBe(1274442n) + }) + + it('returns null when trs is empty', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { trs: [] }, + }) + ) + + const tx = await adapter.transaction.getTransaction('tx-sig-1') + + expect(tx).toBeNull() + }) + }) + + describe('broadcastTransaction', () => { + it('returns signature on success', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: {}, + }) + ) + + const hash = await adapter.transaction.broadcastTransaction({ + chainId: 'bfmeta', + data: { type: 'transfer' }, + signature: 'my-tx-signature', + }) + + expect(hash).toBe('my-tx-signature') + }) + + it('throws on success=false with error message', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: false, + error: { message: 'Insufficient balance' }, + }) + ) + + await expect( + adapter.transaction.broadcastTransaction({ + chainId: 'bfmeta', + data: { type: 'transfer' }, + signature: 'my-tx-signature', + }) + ).rejects.toThrow('Insufficient balance') + }) + }) + }) + + describe('AssetService', () => { + describe('getNativeBalance', () => { + it('parses nested assets object format', async () => { + // API returns: { success, result: { assets: { [magic]: { [assetType]: {...} } } } } + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { + address: 'bTest123', + assets: { + nxOGQ: { + BFM: { + sourceChainMagic: 'nxOGQ', + sourceChainName: 'BFMeta', + assetType: 'BFM', + assetNumber: '1000000000', + }, + }, + }, + }, + }) + ) + + const balance = await adapter.asset.getNativeBalance('bTest123') + + expect(balance.amount.toRawString()).toBe('1000000000') + expect(balance.symbol).toBe('BFM') + }) + + it('returns zero balance when assets array is empty', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { assets: [] }, + }) + ) + + const balance = await adapter.asset.getNativeBalance('bTest123') + + expect(balance.amount.isZero()).toBe(true) + }) + + it('returns zero balance on success=false', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ success: false }) + ) + + const balance = await adapter.asset.getNativeBalance('bTest123') + + expect(balance.amount.isZero()).toBe(true) + }) + }) + + describe('getTokenBalances', () => { + it('parses multiple assets from nested object', async () => { + fetchSpy.mockResolvedValueOnce( + mockResponse({ + success: true, + result: { + address: 'bTest123', + assets: { + nxOGQ: { + BFM: { + sourceChainMagic: 'nxOGQ', + sourceChainName: 'BFMeta', + assetType: 'BFM', + assetNumber: '1000000000', + }, + USDT: { + sourceChainMagic: 'nxOGQ', + sourceChainName: 'BFMeta', + assetType: 'USDT', + assetNumber: '500000000', + }, + }, + }, + }, + }) + ) + + const balances = await adapter.asset.getTokenBalances('bTest123') + + expect(balances).toHaveLength(2) + const symbols = balances.map((b) => b.symbol) + expect(symbols).toContain('BFM') + expect(symbols).toContain('USDT') + }) + }) + }) +}) diff --git a/src/services/chain-adapter/bioforest/chain-service.ts b/src/services/chain-adapter/bioforest/chain-service.ts index ca2e4bd65..d086c92bc 100644 --- a/src/services/chain-adapter/bioforest/chain-service.ts +++ b/src/services/chain-adapter/bioforest/chain-service.ts @@ -95,7 +95,14 @@ export class BioforestChainService implements IChainService { const json = (await response.json()) as { success: boolean; result: BioforestFeeInfo } if (!json.success) { - throw new ChainServiceError(ChainErrorCodes.NETWORK_ERROR, 'API returned success=false') + // Return default fees on API error + const defaultFee = Amount.fromRaw('10000000', decimals, symbol) + return { + slow: defaultFee, + standard: defaultFee, + fast: defaultFee.mul(2), + lastUpdated: Date.now(), + } } const minFee = Amount.fromRaw(json.result.minFee, decimals, symbol) const avgFee = Amount.fromRaw(json.result.avgFee, decimals, symbol) From d1a5d82ee7bfedce499758c45f8e59ba13e4b867 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 19:58:56 +0800 Subject: [PATCH 6/9] fix: use non-null assertion to fix TypeScript error --- .../chain-adapter/__tests__/bioforest-api.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/services/chain-adapter/__tests__/bioforest-api.test.ts b/src/services/chain-adapter/__tests__/bioforest-api.test.ts index 417c709c0..83a4e9c3e 100644 --- a/src/services/chain-adapter/__tests__/bioforest-api.test.ts +++ b/src/services/chain-adapter/__tests__/bioforest-api.test.ts @@ -174,12 +174,13 @@ describe('BioForest API Response Parsing', () => { const history = await adapter.transaction.getTransactionHistory('bTest123') expect(history).toHaveLength(1) - expect(history[0].hash).toBe('tx-sig-1') - expect(history[0].from).toBe('bSender123') - expect(history[0].to).toBe('bRecipient456') - expect(history[0].amount.toRawString()).toBe('100000000') - expect(history[0].fee?.toRawString()).toBe('10000000') - expect(history[0].blockNumber).toBe(1274442n) + const tx = history[0]! + expect(tx.hash).toBe('tx-sig-1') + expect(tx.from).toBe('bSender123') + expect(tx.to).toBe('bRecipient456') + expect(tx.amount.toRawString()).toBe('100000000') + expect(tx.fee?.toRawString()).toBe('10000000') + expect(tx.blockNumber).toBe(1274442n) }) it('returns empty array on success=false', async () => { From dbdff3cc42eceeaafec7a9e774775ffa4e5c3898 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 20:12:26 +0800 Subject: [PATCH 7/9] test(e2e): add complete transfer flow verification test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'BioForest 转账完整流程验证' test suite - Test verifies: form fill -> confirm sheet -> password input - Fix mock transactions format (add height, correct structure) - Add data-testid to PasswordConfirmJob and TransferConfirmJob - Update bioforest-mock.ts to match real API response format --- .../transfer-complete-success.png | Bin 0 -> 41366 bytes e2e/bioforest-transfer.spec.ts | 114 ++++++++++++++++++ e2e/utils/bioforest-mock.ts | 31 ++++- .../activities/sheets/PasswordConfirmJob.tsx | 3 +- .../activities/sheets/TransferConfirmJob.tsx | 7 +- 5 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 e2e/__screenshots__/Desktop-Chrome/transfer-complete-success.png diff --git a/e2e/__screenshots__/Desktop-Chrome/transfer-complete-success.png b/e2e/__screenshots__/Desktop-Chrome/transfer-complete-success.png new file mode 100644 index 0000000000000000000000000000000000000000..87e2e61641e7d7b219aed76d4fc7772dd0b0fe2d GIT binary patch literal 41366 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 tW+9Yz*l8CTP*SP1-jw1Cm { } }) }) + +test.describe('BioForest 转账完整流程验证 (Mock)', () => { + test.beforeEach(async ({ page }) => { + await setupBioforestMock(page, { + balance: '100000000000', // 1000 BFM + hasSecondPublicKey: true, + }) + await setupWalletWithMnemonic(page, TEST_MNEMONIC, 'test-password') + }) + + test('完整转账流程 - 从填写到交易成功', async ({ page }) => { + // 1. 进入首页 + await page.goto('/') + await waitForAppReady(page) + await page.waitForTimeout(500) + + // 2. 点击发送按钮进入发送页 + await page.locator('[data-testid="send-button"]').click() + await page.waitForTimeout(500) + + // 3. 填写收款地址 + const addressInput = page.locator('input[placeholder*="地址"]').first() + await addressInput.waitFor({ timeout: 10000 }) + await addressInput.fill(TARGET_ADDRESS) + + // 4. 填写转账金额 + const amountInput = page.locator('input[inputmode="decimal"]').first() + await amountInput.fill('10') + await page.waitForTimeout(500) + + // 5. 点击继续按钮 + const continueBtn = page.locator('[data-testid="send-continue-button"]') + await expect(continueBtn).toBeEnabled({ timeout: 5000 }) + await continueBtn.click() + await page.waitForTimeout(500) + + // 6. 验证确认弹窗出现 - 通过确认按钮判断 + const confirmBtn = page.locator('[data-testid="confirm-transfer-button"]') + await expect(confirmBtn).toBeVisible({ timeout: 5000 }) + + // 7. 点击确认按钮 + await confirmBtn.click() + await page.waitForTimeout(500) + + // 8. 验证密码输入弹窗出现 - 通过密码输入框判断 + const passwordInput = page.locator('input[type="password"]').first() + await expect(passwordInput).toBeVisible({ timeout: 5000 }) + + // 9. 输入钱包密码 + await passwordInput.fill('test-password') + + // 10. 点击确认密码按钮 - 使用按钮文本 + const passwordConfirmBtn = page.locator('button[type="submit"]').filter({ hasText: /确认|Confirm/ }) + await expect(passwordConfirmBtn).toBeVisible({ timeout: 5000 }) + await passwordConfirmBtn.click() + + // 11. 等待交易处理和页面跳转 + await page.waitForTimeout(3000) + + // 验证交易流程完成 - 检查以下任一情况: + // a) 显示成功结果页面 + // b) 显示 toast 成功提示 + // c) 密码弹窗关闭(表示验证成功) + // d) 返回到首页或发送页面 + + const passwordDialogStillVisible = await page.locator('text=验证密码').isVisible().catch(() => false) + + // 如果密码弹窗消失了,说明密码验证成功 + if (!passwordDialogStillVisible) { + // 验证成功 - 密码验证通过,流程继续 + console.log('Password dialog dismissed - verification successful') + } + + // 检查是否有成功提示或返回页面 + const pageContent = await page.content() + const hasTransferInfo = + pageContent.includes('成功') || + pageContent.includes('已发送') || + pageContent.includes('BFM') || + pageContent.includes('发送') + + expect(hasTransferInfo || !passwordDialogStillVisible).toBe(true) + + await expect(page).toHaveScreenshot('transfer-complete-success.png') + }) + + test('交易历史显示转账记录', async ({ page }) => { + // 进入首页 + await page.goto('/') + await waitForAppReady(page) + await page.waitForTimeout(500) + + // 进入转账历史页面 + const transferTab = page.locator('a[href*="transfer"], button:has-text("转账")').first() + if (await transferTab.isVisible().catch(() => false)) { + await transferTab.click() + await page.waitForTimeout(500) + } + + // 验证交易历史显示了 mock 的交易记录 + // Mock 配置了 2 笔交易 + const content = await page.content() + + // 验证页面包含交易相关信息 + const hasTransactionInfo = + content.includes('BFM') || + content.includes('mock-tx-signature') || + content.includes(TARGET_ADDRESS.slice(0, 10)) + + expect(hasTransactionInfo).toBe(true) + + await expect(page).toHaveScreenshot('transfer-history-with-records.png') + }) +}) diff --git a/e2e/utils/bioforest-mock.ts b/e2e/utils/bioforest-mock.ts index 65eb8b115..492600e2c 100644 --- a/e2e/utils/bioforest-mock.ts +++ b/e2e/utils/bioforest-mock.ts @@ -8,6 +8,7 @@ interface MockConfig { hasSecondPublicKey?: boolean blockHeight?: number transactions?: Array<{ + height: number type: string amount: string senderId: string @@ -16,6 +17,7 @@ interface MockConfig { timestamp: number signature: string }> + onBroadcast?: (tx: unknown) => void } const DEFAULT_CONFIG: MockConfig = { @@ -24,21 +26,23 @@ const DEFAULT_CONFIG: MockConfig = { blockHeight: 1275000, transactions: [ { - type: 'AST-02', + height: 1274900, + type: 'BFM-AST-02', amount: '10000000', senderId: TEST_ADDRESS, recipientId: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j', fee: '222', - timestamp: Date.now(), + timestamp: 21951000, signature: 'mock-tx-signature-001', }, { - type: 'AST-02', + height: 1274800, + type: 'BFM-AST-02', amount: '5000000', senderId: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j', recipientId: TEST_ADDRESS, fee: '222', - timestamp: Date.now() - 3600000, + timestamp: 21950000, signature: 'mock-tx-signature-002', }, ], @@ -129,8 +133,25 @@ export async function setupBioforestMock(page: Page, config: MockConfig = {}) { success: true, result: { trs: (cfg.transactions || []).map((tx) => ({ - transaction: tx, + height: tx.height, + signature: tx.signature + '-block', + tIndex: 0, + transaction: { + signature: tx.signature, + senderId: tx.senderId, + recipientId: tx.recipientId, + fee: tx.fee, + timestamp: tx.timestamp, + type: tx.type, + asset: { + transferAsset: { + amount: tx.amount, + assetType: 'BFM', + }, + }, + }, })), + count: (cfg.transactions || []).length, }, }), }) diff --git a/src/stackflow/activities/sheets/PasswordConfirmJob.tsx b/src/stackflow/activities/sheets/PasswordConfirmJob.tsx index 89cee17c6..95647606a 100644 --- a/src/stackflow/activities/sheets/PasswordConfirmJob.tsx +++ b/src/stackflow/activities/sheets/PasswordConfirmJob.tsx @@ -102,7 +102,7 @@ function PasswordConfirmJobContent() { const canSubmit = password.trim().length > 0 && !isVerifying; return ( - +
{/* Handle */}
@@ -137,6 +137,7 @@ function PasswordConfirmJobContent() {