From 4fc2321b46b87fa95dd6de3892146a7ee7b95210 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 23:13:45 +0800 Subject: [PATCH 1/7] feat: complete implementation --- miniapps/forge/package.json | 1 + miniapps/forge/src/App.tsx | 1 + miniapps/forge/src/api/client.ts | 23 +++- miniapps/forge/src/api/config.ts | 28 ++-- miniapps/forge/src/api/recharge.test.ts | 15 +- miniapps/forge/src/api/recharge.ts | 43 ++++-- miniapps/forge/src/api/redemption.ts | 34 ++++- miniapps/forge/src/api/schemas.ts | 130 ++++++++++++++++++ miniapps/forge/src/api/types.ts | 61 +++++--- .../forge/src/components/RedemptionForm.tsx | 2 +- miniapps/forge/src/hooks/useForge.test.ts | 35 +++++ miniapps/forge/src/hooks/useForge.ts | 71 +++++++++- .../forge/src/hooks/useRechargeRecords.ts | 2 +- miniapps/forge/src/hooks/useRedemption.ts | 9 +- .../forge/src/hooks/useRedemptionRecords.ts | 2 +- miniapps/teleport/package.json | 1 + miniapps/teleport/src/App.tsx | 80 +++++++++-- miniapps/teleport/src/api/client.test.ts | 18 ++- miniapps/teleport/src/api/client.ts | 82 ++++++++--- miniapps/teleport/src/api/schemas.ts | 82 +++++++++++ miniapps/teleport/src/api/types.ts | 22 +-- .../chain-adapter/providers/chain-provider.ts | 14 ++ .../providers/tronwallet-provider.effect.ts | 61 ++++++-- .../chain-adapter/tron/transaction-mixin.ts | 6 + .../ecosystem/handlers/transaction.ts | 37 ++++- src/services/ecosystem/handlers/transfer.ts | 3 + src/services/ecosystem/types.ts | 2 + src/stackflow/activities/MainTabsActivity.tsx | 11 +- .../activities/sheets/CryptoAuthorizeJob.tsx | 5 +- .../sheets/MiniappSignTransactionJob.tsx | 8 +- .../activities/sheets/SigningConfirmJob.tsx | 10 +- .../sheets/WalletLockConfirmJob.tsx | 13 +- 32 files changed, 781 insertions(+), 131 deletions(-) create mode 100644 miniapps/forge/src/api/schemas.ts create mode 100644 miniapps/teleport/src/api/schemas.ts diff --git a/miniapps/forge/package.json b/miniapps/forge/package.json index 324675c80..bed90998e 100644 --- a/miniapps/forge/package.json +++ b/miniapps/forge/package.json @@ -28,6 +28,7 @@ "dependencies": { "@base-ui/react": "^1.0.0", "@biochain/bio-sdk": "workspace:*", + "@biochain/chain-effect": "workspace:*", "@biochain/key-ui": "workspace:*", "@biochain/key-utils": "workspace:*", "@biochain/keyapp-sdk": "workspace:*", diff --git a/miniapps/forge/src/App.tsx b/miniapps/forge/src/App.tsx index 80917bbf0..7d499acc2 100644 --- a/miniapps/forge/src/App.tsx +++ b/miniapps/forge/src/App.tsx @@ -229,6 +229,7 @@ export default function App() { externalChain: selectedOption.externalChain, externalAsset: selectedOption.externalAsset, depositAddress: selectedOption.externalInfo.depositAddress, + externalContract: selectedOption.externalInfo.contract, amount, externalAccount, internalChain: selectedOption.internalChain, diff --git a/miniapps/forge/src/api/client.ts b/miniapps/forge/src/api/client.ts index 98816d0eb..75e034b43 100644 --- a/miniapps/forge/src/api/client.ts +++ b/miniapps/forge/src/api/client.ts @@ -17,20 +17,33 @@ export class ApiError extends Error { type WrappedResponse = { success: boolean - result: unknown + result?: unknown + error?: unknown message?: string } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + function isWrappedResponse(value: unknown): value is WrappedResponse { return ( - typeof value === 'object' && - value !== null && + isRecord(value) && 'success' in value && - 'result' in value && typeof (value as { success: unknown }).success === 'boolean' ) } +function extractWrappedErrorMessage(data: WrappedResponse): string { + if (data.message) return data.message + if (isRecord(data.error) && 'message' in data.error) { + const message = (data.error as { message?: unknown }).message + if (Array.isArray(message)) return message.join('; ') + if (typeof message === 'string') return message + } + return 'Request failed' +} + interface RequestOptions extends RequestInit { params?: Record } @@ -75,7 +88,7 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi const data: unknown = await response.json() if (isWrappedResponse(data)) { if (data.success) return data.result as T - throw new ApiError(data.message || 'Request failed', response.status, data) + throw new ApiError(extractWrappedErrorMessage(data), response.status, data.error ?? data) } return data as T diff --git a/miniapps/forge/src/api/config.ts b/miniapps/forge/src/api/config.ts index b7c629978..3db6699be 100644 --- a/miniapps/forge/src/api/config.ts +++ b/miniapps/forge/src/api/config.ts @@ -1,12 +1,12 @@ /** * API Configuration * - * Default base URL is https://walletapi.bfmeta.info (same as other BioForest services). + * Official COT base URL: https://walletapi.bf-meta.org/cot * Can be overridden via VITE_COT_API_BASE_URL environment variable. */ -/** Default API Base URL (used by all BioForest chain services) */ -const DEFAULT_API_BASE_URL = 'https://walletapi.bfmeta.info' +/** Default API Base URL (official COT host) */ +const DEFAULT_API_BASE_URL = 'https://walletapi.bf-meta.org' /** API Base URL - uses default or environment override */ export const API_BASE_URL = @@ -23,19 +23,19 @@ export const API_ENDPOINTS = { // Recharge Endpoints (充值) // ============================================================================ /** 获取支持的充值配置 */ - RECHARGE_SUPPORT: '/cotbfm/recharge/support', + RECHARGE_SUPPORT: '/cot/recharge/support', /** 发起充值(锻造) */ - RECHARGE_V2: '/cotbfm/recharge/V2', + RECHARGE_V2: '/cot/recharge/V2', /** 获取合约池信息 */ - CONTRACT_POOL_INFO: '/cotbfm/recharge/contractPoolInfo', + CONTRACT_POOL_INFO: '/cot/recharge/contractPoolInfo', /** 获取充值记录列表 */ - RECHARGE_RECORDS: '/cotbfm/recharge/records', + RECHARGE_RECORDS: '/cot/recharge/records', /** 获取充值记录详情 */ - RECHARGE_RECORD_DETAIL: '/cotbfm/recharge/recordDetail', + RECHARGE_RECORD_DETAIL: '/cot/recharge/recordDetail', /** 充值外链上链重试 */ - RECHARGE_RETRY_EXTERNAL: '/cotbfm/recharge/retryExternalOnChain', + RECHARGE_RETRY_EXTERNAL: '/cot/recharge/retryExternalOnChain', /** 充值内链上链重试 */ - RECHARGE_RETRY_INTERNAL: '/cotbfm/recharge/retryInternalOnChain', + RECHARGE_RETRY_INTERNAL: '/cot/recharge/retryInternalOnChain', // ============================================================================ // Redemption Endpoints (赎回) @@ -55,11 +55,11 @@ export const API_ENDPOINTS = { // Legacy aliases (向后兼容) // ============================================================================ /** @deprecated Use RECHARGE_RECORDS */ - RECORDS: '/cotbfm/recharge/records', + RECORDS: '/cot/recharge/records', /** @deprecated Use RECHARGE_RECORD_DETAIL */ - RECORD_DETAIL: '/cotbfm/recharge/recordDetail', + RECORD_DETAIL: '/cot/recharge/recordDetail', /** @deprecated Use RECHARGE_RETRY_EXTERNAL */ - RETRY_EXTERNAL: '/cotbfm/recharge/retryExternalOnChain', + RETRY_EXTERNAL: '/cot/recharge/retryExternalOnChain', /** @deprecated Use RECHARGE_RETRY_INTERNAL */ - RETRY_INTERNAL: '/cotbfm/recharge/retryInternalOnChain', + RETRY_INTERNAL: '/cot/recharge/retryInternalOnChain', } as const diff --git a/miniapps/forge/src/api/recharge.test.ts b/miniapps/forge/src/api/recharge.test.ts index 86b33fc56..24bc07579 100644 --- a/miniapps/forge/src/api/recharge.test.ts +++ b/miniapps/forge/src/api/recharge.test.ts @@ -8,7 +8,7 @@ global.fetch = mockFetch describe('Forge rechargeApi', () => { beforeEach(() => { vi.clearAllMocks() - vi.stubEnv('VITE_COT_API_BASE_URL', 'https://walletapi.bfmeta.info') + vi.stubEnv('VITE_COT_API_BASE_URL', 'https://walletapi.bf-meta.org') }) afterEach(() => { @@ -38,7 +38,7 @@ describe('Forge rechargeApi', () => { const result = await rechargeApi.getSupport() expect(result).toEqual(mockResult) expect(mockFetch).toHaveBeenCalledWith( - 'https://walletapi.bfmeta.info/cotbfm/recharge/support', + 'https://walletapi.bf-meta.org/cot/recharge/support', expect.any(Object), ) }) @@ -53,4 +53,15 @@ describe('Forge rechargeApi', () => { await expect(promise).rejects.toThrow(ApiError) await expect(promise).rejects.toThrow('Not allowed') }) + + it('should throw ApiError when success is false without result', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: false, message: 'Bad Request', error: { code: 400 } }), + }) + + const promise = rechargeApi.getSupport() + await expect(promise).rejects.toThrow(ApiError) + await expect(promise).rejects.toThrow('Bad Request') + }) }) diff --git a/miniapps/forge/src/api/recharge.ts b/miniapps/forge/src/api/recharge.ts index d10d81398..ffa55f14e 100644 --- a/miniapps/forge/src/api/recharge.ts +++ b/miniapps/forge/src/api/recharge.ts @@ -2,9 +2,15 @@ * COT Recharge API */ -import { apiClient } from './client' +import { apiClient, ApiError } from './client' import { API_ENDPOINTS } from './config' import { tronHexToBase58, isTronHexAddress } from '@/lib/tron-address' +import { + rechargeSupportSchema, + rechargeSubmitSchema, + rechargeRecordsSchema, + rechargeRecordDetailSchema, +} from './schemas' import type { RechargeSupportResDto, RechargeV2ReqDto, @@ -63,13 +69,22 @@ async function transformSupportResponse(response: RechargeSupportResDto): Promis export const rechargeApi = { /** 获取支持的充值配置 (TRON addresses converted to Base58) */ async getSupport(): Promise { - const response = await apiClient.get(API_ENDPOINTS.RECHARGE_SUPPORT) - return transformSupportResponse(response) + const raw = await apiClient.get(API_ENDPOINTS.RECHARGE_SUPPORT) + const parsed = rechargeSupportSchema.safeParse(raw) + if (!parsed.success) { + throw new ApiError('Invalid recharge support response', 0, parsed.error.flatten()) + } + return transformSupportResponse(parsed.data) }, /** 发起充值(锻造) */ - submitRecharge(data: RechargeV2ReqDto): Promise { - return apiClient.post(API_ENDPOINTS.RECHARGE_V2, data) + async submitRecharge(data: RechargeV2ReqDto): Promise { + const raw = await apiClient.post(API_ENDPOINTS.RECHARGE_V2, data) + const parsed = rechargeSubmitSchema.safeParse(raw) + if (!parsed.success) { + throw new ApiError('Invalid recharge response', 0, parsed.error.flatten()) + } + return parsed.data }, /** 获取合约池信息 */ @@ -78,19 +93,29 @@ export const rechargeApi = { }, /** 获取充值记录列表 */ - getRecords(params: RechargeRecordsReqDto): Promise { - return apiClient.get(API_ENDPOINTS.RECORDS, { + async getRecords(params: RechargeRecordsReqDto): Promise { + const raw = await apiClient.get(API_ENDPOINTS.RECHARGE_RECORDS, { page: params.page, pageSize: params.pageSize, internalChain: params.internalChain, internalAddress: params.internalAddress, recordState: params.recordState, }) + const parsed = rechargeRecordsSchema.safeParse(raw) + if (!parsed.success) { + throw new ApiError('Invalid recharge records response', 0, parsed.error.flatten()) + } + return parsed.data }, /** 获取充值记录详情 */ - getRecordDetail(params: RechargeRecordDetailReqDto): Promise { - return apiClient.get(API_ENDPOINTS.RECORD_DETAIL, { orderId: params.orderId }) + async getRecordDetail(params: RechargeRecordDetailReqDto): Promise { + const raw = await apiClient.get(API_ENDPOINTS.RECHARGE_RECORD_DETAIL, { orderId: params.orderId }) + const parsed = rechargeRecordDetailSchema.safeParse(raw) + if (!parsed.success) { + throw new ApiError('Invalid recharge record detail response', 0, parsed.error.flatten()) + } + return parsed.data }, /** 外链上链重试 */ diff --git a/miniapps/forge/src/api/redemption.ts b/miniapps/forge/src/api/redemption.ts index 69279466d..20e8538d3 100644 --- a/miniapps/forge/src/api/redemption.ts +++ b/miniapps/forge/src/api/redemption.ts @@ -3,8 +3,13 @@ * 赎回接口:内链资产 → 外链资产 */ -import { apiClient } from './client' +import { apiClient, ApiError } from './client' import { API_ENDPOINTS } from './config' +import { + redemptionSubmitSchema, + redemptionRecordsSchema, + redemptionRecordDetailSchema, +} from './schemas' import type { RedemptionV2ReqDto, RedemptionV2ResDto, @@ -17,23 +22,38 @@ import type { export const redemptionApi = { /** 发起赎回 */ - submitRedemption(data: RedemptionV2ReqDto): Promise { - return apiClient.post(API_ENDPOINTS.REDEMPTION_V2, data) + async submitRedemption(data: RedemptionV2ReqDto): Promise { + const raw = await apiClient.post(API_ENDPOINTS.REDEMPTION_V2, data) + const parsed = redemptionSubmitSchema.safeParse(raw) + if (!parsed.success) { + throw new ApiError('Invalid redemption response', 0, parsed.error.flatten()) + } + return parsed.data }, /** 获取赎回记录列表 */ - getRecords(params: RedemptionRecordsReqDto): Promise { - return apiClient.get(API_ENDPOINTS.REDEMPTION_RECORDS, { + async getRecords(params: RedemptionRecordsReqDto): Promise { + const raw = await apiClient.get(API_ENDPOINTS.REDEMPTION_RECORDS, { page: params.page, pageSize: params.pageSize, internalChain: params.internalChain, internalAddress: params.internalAddress, }) + const parsed = redemptionRecordsSchema.safeParse(raw) + if (!parsed.success) { + throw new ApiError('Invalid redemption records response', 0, parsed.error.flatten()) + } + return parsed.data }, /** 获取赎回记录详情 */ - getRecordDetail(params: RedemptionRecordDetailReqDto): Promise { - return apiClient.get(API_ENDPOINTS.REDEMPTION_RECORD_DETAIL, { orderId: params.orderId }) + async getRecordDetail(params: RedemptionRecordDetailReqDto): Promise { + const raw = await apiClient.get(API_ENDPOINTS.REDEMPTION_RECORD_DETAIL, { orderId: params.orderId }) + const parsed = redemptionRecordDetailSchema.safeParse(raw) + if (!parsed.success) { + throw new ApiError('Invalid redemption record detail response', 0, parsed.error.flatten()) + } + return parsed.data }, /** 内链上链重试 */ diff --git a/miniapps/forge/src/api/schemas.ts b/miniapps/forge/src/api/schemas.ts new file mode 100644 index 000000000..d7c580aba --- /dev/null +++ b/miniapps/forge/src/api/schemas.ts @@ -0,0 +1,130 @@ +/** + * Runtime schemas for Forge API responses. + */ + +import { z } from 'zod' + +const stringNumber = z.union([z.string(), z.number()]) + +const externalAssetInfoItemSchema = z.object({ + enable: z.boolean(), + contract: z.string().optional(), + depositAddress: z.string(), + assetType: z.string(), + logo: z.string().optional(), + decimals: z.number().optional(), +}).passthrough() + +const redemptionConfigSchema = z.object({ + enable: z.boolean(), + min: stringNumber, + max: stringNumber, + fee: z.record(z.string(), z.string()), + radioFee: z.string(), +}).passthrough() + +const rechargeItemSchema = z.object({ + enable: z.boolean(), + chainName: z.string(), + assetType: z.string(), + applyAddress: z.string(), + supportChain: z.object({ + ETH: externalAssetInfoItemSchema.optional(), + BSC: externalAssetInfoItemSchema.optional(), + TRON: externalAssetInfoItemSchema.optional(), + }).passthrough(), + redemption: redemptionConfigSchema.optional(), + logo: z.string().optional(), +}).passthrough() + +export const rechargeSupportSchema = z.object({ + recharge: z.record(z.string(), z.record(z.string(), rechargeItemSchema)), +}).passthrough() + +export const rechargeSubmitSchema = z.object({ + orderId: z.string(), +}).passthrough() + +const recordTxInfoSchema = z.object({ + chainName: z.string(), + assetType: z.string(), + address: z.string(), + amount: z.string(), + txHash: z.string().optional(), +}).passthrough() + +export const rechargeRecordsSchema = z.object({ + dataList: z.array(z.object({ + orderId: z.string(), + state: z.number(), + orderState: z.number(), + createdTime: stringNumber, + fromTxInfo: recordTxInfoSchema, + toTxInfoArray: z.array(recordTxInfoSchema), + }).passthrough()), + total: z.number(), + page: z.number(), + pageSize: z.number(), + hasMore: z.boolean(), + skip: z.number(), +}).passthrough() + +export const rechargeRecordDetailSchema = z.object({ + orderId: z.string(), + state: z.number(), + orderState: z.number(), + createdTime: stringNumber, + fromTxInfo: recordTxInfoSchema, + toTxInfos: z.record(z.string(), recordTxInfoSchema), +}).passthrough() + +export const redemptionSubmitSchema = z.object({ + orderId: z.string(), +}).passthrough() + +export const redemptionRecordsSchema = z.object({ + page: z.number(), + pageSize: z.number(), + dataList: z.array(z.object({ + orderId: z.string(), + state: z.number(), + orderState: z.number(), + redemptionFee: z.string().optional(), + createdTime: stringNumber, + fromTxInfo: z.object({ + chainName: z.string(), + amount: z.string(), + asset: z.string(), + decimals: z.number(), + }).passthrough(), + toTxInfo: z.object({ + chainName: z.string(), + amount: z.string(), + asset: z.string(), + decimals: z.number(), + }).passthrough(), + }).passthrough()), + total: z.number(), + hasMore: z.boolean(), + skip: z.number(), +}).passthrough() + +export const redemptionRecordDetailSchema = z.object({ + state: z.number(), + orderState: z.number(), + redemptionRatio: z.number(), + fromTxInfo: z.object({ + chainName: z.string(), + address: z.string(), + txId: z.string().optional(), + txHash: z.string().optional(), + contractAddress: z.string().optional(), + }).passthrough(), + toTxInfo: z.object({ + chainName: z.string(), + address: z.string(), + txId: z.string().optional(), + txHash: z.string().optional(), + contractAddress: z.string().optional(), + }).passthrough(), +}).passthrough() diff --git a/miniapps/forge/src/api/types.ts b/miniapps/forge/src/api/types.ts index 14c85db3d..3fc2be322 100644 --- a/miniapps/forge/src/api/types.ts +++ b/miniapps/forge/src/api/types.ts @@ -6,8 +6,8 @@ /** 外链名称 */ export type ExternalChainName = 'ETH' | 'BSC' | 'TRON' -/** 内链名称 */ -export type InternalChainName = 'bfmeta' | 'bfchain' | 'ccchain' | 'pmchain' +/** 内链名称(后端可能返回新增链名,保持开放) */ +export type InternalChainName = 'bfmeta' | 'bfchain' | 'ccchain' | 'pmchain' | (string & {}) /** 外链资产信息 */ export interface ExternalAssetInfoItem { @@ -29,9 +29,9 @@ export interface ExternalAssetInfoItem { export interface RedemptionConfig { enable: boolean /** 单笔赎回下限(内链最小单位) */ - min: string + min: string | number /** 单笔赎回上限(内链最小单位) */ - max: string + max: string | number /** 不同链的手续费 */ fee: Record /** 手续费比例 */ @@ -68,12 +68,23 @@ export interface RechargeSupportResDto { recharge: RechargeConfig } +/** TRON 交易体 */ +export interface TronTransaction { + txID: string + raw_data: unknown + raw_data_hex: string + signature?: string[] +} + +/** TRC20 交易体(与 TRON 结构一致) */ +export type Trc20Transaction = TronTransaction + /** 外链交易体 */ export interface FromTrJson { eth?: { signTransData: string } bsc?: { signTransData: string } - tron?: unknown - trc20?: unknown + tron?: TronTransaction + trc20?: Trc20Transaction } /** 内链接收方信息 */ @@ -166,7 +177,7 @@ export interface RechargeRecord { orderId: string state: RECHARGE_RECORD_STATE orderState: RECHARGE_ORDER_STATE_ID - createdTime: string + createdTime: string | number fromTxInfo: RecordTxInfo toTxInfoArray: RecordTxInfo[] } @@ -182,10 +193,12 @@ export interface RechargeRecordsReqDto { /** 分页数据 */ export interface PageData { - list: T[] + dataList: T[] total: number page: number pageSize: number + hasMore: boolean + skip: number } /** 充值记录响应 */ @@ -209,7 +222,7 @@ export interface RechargeRecordDetailResDto { orderId: string state: RECHARGE_RECORD_STATE orderState: RECHARGE_ORDER_STATE_ID - createdTime: string + createdTime: string | number fromTxInfo: RecordDetailTxInfo toTxInfos: Record } @@ -294,10 +307,10 @@ export interface RedemptionRecord { orderId: string state: REDEMPTION_RECORD_STATE orderState: REDEMPTION_ORDER_STATE_ID - fromTxInfo: RecordTxInfo - toTxInfo: RecordTxInfo - redemptionFee: string - createdTime: string + fromTxInfo: RedemptionRecordTxInfo + toTxInfo: RedemptionRecordTxInfo + redemptionFee?: string + createdTime: string | number } /** 赎回记录响应 */ @@ -309,14 +322,30 @@ export interface RedemptionRecordDetailReqDto { } /** 赎回记录详情响应 */ +export interface RedemptionRecordTxInfo { + chainName: string + amount: string + asset: string + decimals: number + assetLogoUrl?: string +} + +export interface RedemptionRecordDetailTxInfo { + chainName: string + address: string + txId?: string + txHash?: string + contractAddress?: string +} + export interface RedemptionRecordDetailResDto { state: REDEMPTION_RECORD_STATE orderState: REDEMPTION_ORDER_STATE_ID redemptionRatio: number - fromTxInfo: RecordDetailTxInfo - toTxInfo: RecordDetailTxInfo + fromTxInfo: RedemptionRecordDetailTxInfo + toTxInfo: RedemptionRecordDetailTxInfo orderFailReason?: string - updatedTime: string + updatedTime?: string | number } // ============================================================================ diff --git a/miniapps/forge/src/components/RedemptionForm.tsx b/miniapps/forge/src/components/RedemptionForm.tsx index 10744eb8c..506f84149 100644 --- a/miniapps/forge/src/components/RedemptionForm.tsx +++ b/miniapps/forge/src/components/RedemptionForm.tsx @@ -300,7 +300,7 @@ export function RedemptionForm({ config, onSuccess }: RedemptionFormProps) { {selectedOption.rechargeItem.redemption && (
- {t('redemption.limits')}: {formatAmount(selectedOption.rechargeItem.redemption.min)} - {formatAmount(selectedOption.rechargeItem.redemption.max)} + {t('redemption.limits')}: {formatAmount(String(selectedOption.rechargeItem.redemption.min))} - {formatAmount(String(selectedOption.rechargeItem.redemption.max))}
)} diff --git a/miniapps/forge/src/hooks/useForge.test.ts b/miniapps/forge/src/hooks/useForge.test.ts index 832ebe520..0319260a9 100644 --- a/miniapps/forge/src/hooks/useForge.test.ts +++ b/miniapps/forge/src/hooks/useForge.test.ts @@ -18,6 +18,7 @@ const mockForgeParams: ForgeParams = { externalChain: 'ETH', externalAsset: 'ETH', depositAddress: '0x1234567890abcdef1234567890abcdef12345678', + externalContract: undefined, amount: '1.5', externalAccount: { address: '0xabcdef1234567890abcdef1234567890abcdef12', chain: 'eth', publicKey: '0x' }, internalChain: 'bfmeta', @@ -231,6 +232,7 @@ describe('useForge', () => { externalChain: 'TRON', externalAsset: 'TRX', depositAddress: tronDepositAddress, + externalContract: undefined, externalAccount: { address: 'TUserAddress123456789012345678901234', chain: 'tron', publicKey: '' }, } @@ -251,6 +253,39 @@ describe('useForge', () => { expect(submitCall.fromTrJson).toHaveProperty('tron') }) + it('should build correct fromTrJson for TRC20 when contract is provided', async () => { + const tronDepositAddress = 'TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g' + + mockBio.request + .mockResolvedValueOnce({ txHash: 'unsigned' }) + .mockResolvedValueOnce({ data: { txID: 'tronTxId123', signature: ['sig'] } }) + .mockResolvedValueOnce({ signature: 'sig', publicKey: 'pubkey' }) + + vi.mocked(rechargeApi.submitRecharge).mockResolvedValue({ orderId: 'order' }) + + const { result } = renderHook(() => useForge()) + + const tronParams: ForgeParams = { + ...mockForgeParams, + externalChain: 'TRON', + externalAsset: 'USDT', + depositAddress: tronDepositAddress, + externalContract: 'TABCDEF1234567890abcdef1234567890abcd', + externalAccount: { address: 'TUserAddress123456789012345678901234', chain: 'tron', publicKey: '' }, + } + + act(() => { + result.current.forge(tronParams) + }) + + await waitFor(() => { + expect(result.current.step).toBe('success') + }) + + const submitCall = vi.mocked(rechargeApi.submitRecharge).mock.calls[0][0] + expect(submitCall.fromTrJson).toHaveProperty('trc20') + }) + it('should handle TRON transaction with 0x address format error', async () => { // This test verifies early validation catches invalid address format const invalidTronAddress = '0x1234567890abcdef1234567890abcdef12345678' diff --git a/miniapps/forge/src/hooks/useForge.ts b/miniapps/forge/src/hooks/useForge.ts index da0c05a87..0d117e995 100644 --- a/miniapps/forge/src/hooks/useForge.ts +++ b/miniapps/forge/src/hooks/useForge.ts @@ -8,11 +8,13 @@ import { normalizeChainId } from '@biochain/bio-sdk' import { rechargeApi } from '@/api' import { encodeRechargeV2ToTrInfoData, createRechargeMessage } from '@/api/helpers' import { validateDepositAddress } from '@/lib/chain' +import { superjson } from '@biochain/chain-effect' import type { ExternalChainName, FromTrJson, RechargeV2ReqDto, SignatureInfo, + TronTransaction, } from '@/api/types' export type ForgeStep = 'idle' | 'signing_external' | 'signing_internal' | 'submitting' | 'success' | 'error' @@ -30,6 +32,8 @@ export interface ForgeParams { externalAsset: string /** 外链转账地址(depositAddress) */ depositAddress: string + /** 外链合约地址(TRC20) */ + externalContract?: string /** 转账金额 */ amount: string /** 外链账户(已连接) */ @@ -42,13 +46,49 @@ export interface ForgeParams { internalAccount: BioAccount } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isTronPayload(value: unknown): value is TronTransaction { + return isRecord(value) +} + +function extractTronSignedTx( + data: unknown, + label: string, +): TronTransaction { + if (isRecord(data) && 'signedTx' in data) { + const maybeSigned = (data as { signedTx?: unknown }).signedTx + if (isTronPayload(maybeSigned)) { + return maybeSigned + } + } + return getTronTransaction(data, label) +} + +function getTronTransaction(data: unknown, label: string): TronTransaction { + if (!isTronPayload(data)) { + throw new Error(`Invalid ${label} transaction payload`) + } + return data +} + +function toJsonSafe(value: unknown): unknown { + return superjson.serialize(value).json +} + /** * Build FromTrJson from signed transaction */ -function buildFromTrJson(chain: ExternalChainName, signedTx: BioSignedTransaction): FromTrJson { +function buildFromTrJson( + chain: ExternalChainName, + signedTx: BioSignedTransaction, + isTrc20: boolean, +): FromTrJson { const signTransData = typeof signedTx.data === 'string' ? signedTx.data - : JSON.stringify(signedTx.data) + : superjson.stringify(signedTx.data) switch (chain) { case 'ETH': @@ -56,7 +96,10 @@ function buildFromTrJson(chain: ExternalChainName, signedTx: BioSignedTransactio case 'BSC': return { bsc: { signTransData } } case 'TRON': - return { tron: signedTx.data } + if (isTrc20) { + return { trc20: extractTronSignedTx(signedTx.data, 'TRC20') } + } + return { tron: extractTronSignedTx(signedTx.data, 'TRON') } default: throw new Error(`Unsupported chain: ${chain}`) } @@ -78,6 +121,7 @@ export function useForge() { externalChain, externalAsset, depositAddress, + externalContract, amount, externalAccount, internalChain, @@ -98,6 +142,8 @@ export function useForge() { } try { + const tokenAddress = externalContract?.trim() || undefined + // Step 1: Create and sign external chain transaction setState({ step: 'signing_external', orderId: null, error: null }) @@ -111,15 +157,18 @@ export function useForge() { amount, chain: externalKeyAppChainId, asset: externalAsset, + tokenAddress, }], }) + const unsignedTxSafe = toJsonSafe(unsignedTx) + const signedTx = await window.bio.request({ method: 'bio_signTransaction', params: [{ from: externalAccount.address, chain: externalKeyAppChainId, - unsignedTx, + unsignedTx: unsignedTxSafe, }], }) @@ -147,16 +196,23 @@ export function useForge() { }], }) + const signature = signResult.signature.startsWith('0x') + ? signResult.signature.slice(2) + : signResult.signature + const publicKey = signResult.publicKey.startsWith('0x') + ? signResult.publicKey.slice(2) + : signResult.publicKey + const signatureInfo: SignatureInfo = { timestamp: rechargeMessage.timestamp, - signature: signResult.signature, - publicKey: signResult.publicKey, + signature, + publicKey, } // Step 3: Submit recharge request setState({ step: 'submitting', orderId: null, error: null }) - const fromTrJson = buildFromTrJson(externalChain, signedTx) + const fromTrJson = buildFromTrJson(externalChain, signedTx, Boolean(tokenAddress)) const reqData: RechargeV2ReqDto = { fromTrJson, @@ -168,6 +224,7 @@ export function useForge() { setState({ step: 'success', orderId: res.orderId, error: null }) } catch (err) { + console.error('[forge] submit failed', err) setState({ step: 'error', orderId: null, diff --git a/miniapps/forge/src/hooks/useRechargeRecords.ts b/miniapps/forge/src/hooks/useRechargeRecords.ts index ed28f5b16..9928cdbe2 100644 --- a/miniapps/forge/src/hooks/useRechargeRecords.ts +++ b/miniapps/forge/src/hooks/useRechargeRecords.ts @@ -45,7 +45,7 @@ export function useRechargeRecords() { recordState: params.recordState, }) setState({ - records: res.list, + records: res.dataList, total: res.total, isLoading: false, error: null, diff --git a/miniapps/forge/src/hooks/useRedemption.ts b/miniapps/forge/src/hooks/useRedemption.ts index eb72c85ad..041ca2687 100644 --- a/miniapps/forge/src/hooks/useRedemption.ts +++ b/miniapps/forge/src/hooks/useRedemption.ts @@ -11,6 +11,7 @@ import type { RedemptionV2ReqDto, RedemptionTransRemark, } from '@/api/types' +import { superjson } from '@biochain/chain-effect' export type RedemptionStep = | 'idle' @@ -45,6 +46,10 @@ export interface RedemptionParams { applyAddress: string } +function toJsonSafe(value: unknown): unknown { + return superjson.serialize(value).json +} + export function useRedemption() { const [state, setState] = useState({ step: 'idle', @@ -101,12 +106,14 @@ export function useRedemption() { // Step 2: Sign transaction setState({ step: 'signing', orderId: null, error: null }) + const unsignedTxSafe = toJsonSafe(unsignedTx) + const signedTx = await window.bio.request<{ trJson: unknown }>({ method: 'bio_signTransaction', params: [{ from: internalAccount.address, chain: internalChain, - unsignedTx, + unsignedTx: unsignedTxSafe, }], }) diff --git a/miniapps/forge/src/hooks/useRedemptionRecords.ts b/miniapps/forge/src/hooks/useRedemptionRecords.ts index 4429226e9..cf6adbaee 100644 --- a/miniapps/forge/src/hooks/useRedemptionRecords.ts +++ b/miniapps/forge/src/hooks/useRedemptionRecords.ts @@ -42,7 +42,7 @@ export function useRedemptionRecords() { internalAddress: params.internalAddress, }) setState({ - records: res.list, + records: res.dataList, total: res.total, isLoading: false, error: null, diff --git a/miniapps/teleport/package.json b/miniapps/teleport/package.json index ddd9998b4..1eb3f44dc 100644 --- a/miniapps/teleport/package.json +++ b/miniapps/teleport/package.json @@ -28,6 +28,7 @@ "dependencies": { "@base-ui/react": "^1.0.0", "@biochain/bio-sdk": "workspace:*", + "@biochain/chain-effect": "workspace:*", "@biochain/key-ui": "workspace:*", "@biochain/key-utils": "workspace:*", "@biochain/keyapp-sdk": "workspace:*", diff --git a/miniapps/teleport/src/App.tsx b/miniapps/teleport/src/App.tsx index c471833a6..902309ad0 100644 --- a/miniapps/teleport/src/App.tsx +++ b/miniapps/teleport/src/App.tsx @@ -10,6 +10,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { AuroraBackground } from './components/AuroraBackground'; import { motion, AnimatePresence } from 'framer-motion'; import { cn } from '@/lib/utils'; +import { superjson } from '@biochain/chain-effect'; import { ChevronLeft, Zap, @@ -33,6 +34,7 @@ import { type ToTrInfo, type InternalChainName, type TransferAssetTransaction, + type TronTransaction, SWAP_ORDER_STATE_ID, } from './api'; @@ -46,6 +48,56 @@ type Step = | 'success' | 'error'; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isTronPayload(value: unknown): value is TronTransaction { + return isRecord(value) +} + +function getTronSignedPayload(data: unknown, label: string): TronTransaction { + if (isRecord(data) && 'signedTx' in data) { + const maybeSigned = (data as { signedTx?: unknown }).signedTx + if (isTronPayload(maybeSigned)) { + return maybeSigned + } + } + if (!isTronPayload(data)) { + throw new Error(`Invalid ${label} transaction payload`) + } + return data +} + +function isTransferAssetTransaction(value: unknown): value is TransferAssetTransaction { + if (!isRecord(value)) return false + if (typeof value.senderId !== 'string') return false + if (typeof value.recipientId !== 'string') return false + if (typeof value.amount !== 'string') return false + if (typeof value.fee !== 'string') return false + if (typeof value.timestamp !== 'number') return false + if (typeof value.signature !== 'string') return false + const asset = value.asset + if (!isRecord(asset) || !isRecord(asset.transferAsset)) return false + const transferAsset = asset.transferAsset as Record + return typeof transferAsset.amount === 'string' && typeof transferAsset.assetType === 'string' +} + +function getInternalTrJson(signedTx: BioSignedTransaction): TransferAssetTransaction | undefined { + if (isRecord(signedTx) && 'trJson' in signedTx) { + const trJson = signedTx.trJson + return isTransferAssetTransaction(trJson) ? trJson : undefined + } + if (isRecord(signedTx.data)) { + return isTransferAssetTransaction(signedTx.data) ? signedTx.data : undefined + } + return undefined +} + +function toJsonSafe(value: unknown): unknown { + return superjson.serialize(value).json; +} + const CHAIN_COLORS: Record = { ETH: 'bg-indigo-600', BSC: 'bg-amber-600', @@ -193,13 +245,14 @@ export default function App() { }); // 2. 签名交易 + const unsignedTxSafe = toJsonSafe(unsignedTx); const signedTx = await window.bio.request({ method: 'bio_signTransaction', params: [ { from: sourceAccount.address, chain: sourceAccount.chain, - unsignedTx, + unsignedTx: unsignedTxSafe, }, ], }); @@ -209,23 +262,32 @@ export default function App() { // 而非 signedTx.signature(仅包含签名数据,不是可广播的 rawTx) const fromTrJson: FromTrJson = {}; const chainLower = sourceAccount.chain.toLowerCase(); - const signTransData = typeof signedTx.data === 'string' ? signedTx.data : JSON.stringify(signedTx.data); + const signTransData = typeof signedTx.data === 'string' + ? signedTx.data + : superjson.stringify(signedTx.data); + const isTronChain = chainLower === 'tron' || chainLower === 'trc20'; + const isTrc20 = chainLower === 'trc20' || (chainLower === 'tron' && !!selectedAsset.contractAddress); if (chainLower === 'eth') { fromTrJson.eth = { signTransData }; } else if (chainLower === 'bsc') { fromTrJson.bsc = { signTransData }; - } else if (chainLower === 'tron') { - // TRON 原生 TRX 转账 - fromTrJson.tron = { signTransData }; - } else if (chainLower === 'trc20') { - // TRON TRC20 代币转账 - fromTrJson.trc20 = { signTransData }; + } else if (isTronChain) { + const tronPayload = getTronSignedPayload(signedTx.data, isTrc20 ? 'TRC20' : 'TRON'); + if (isTrc20) { + fromTrJson.trc20 = tronPayload; + } else { + fromTrJson.tron = tronPayload; + } } else { // 内链交易(BioForest 链) + const internalTrJson = getInternalTrJson(signedTx); + if (!internalTrJson) { + throw new Error('Invalid internal signed transaction payload'); + } fromTrJson.bcf = { chainName: sourceAccount.chain as InternalChainName, - trJson: signedTx.data as TransferAssetTransaction, + trJson: internalTrJson, }; } diff --git a/miniapps/teleport/src/api/client.test.ts b/miniapps/teleport/src/api/client.test.ts index b790cb8f3..bc23c5ef1 100644 --- a/miniapps/teleport/src/api/client.test.ts +++ b/miniapps/teleport/src/api/client.test.ts @@ -29,11 +29,13 @@ describe('Teleport API Client', () => { ETH: { ETH: { enable: true, + isAirdrop: false, assetType: 'ETH', recipientAddress: '0x123', targetChain: 'BFMCHAIN', targetAsset: 'BFM', ratio: { numerator: 1, denominator: 1 }, + transmitDate: { startDate: '2024-01-01', endDate: '2025-01-01' }, }, }, }, @@ -68,6 +70,17 @@ describe('Teleport API Client', () => { await expect(getTransmitAssetTypeList()).rejects.toThrow(ApiError) }) + + it('should throw ApiError when success is false without result', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: false, message: 'Not allowed', error: { code: 403 } }), + }) + + const promise = getTransmitAssetTypeList() + await expect(promise).rejects.toThrow(ApiError) + await expect(promise).rejects.toThrow('Not allowed') + }) }) describe('transmit', () => { @@ -105,7 +118,7 @@ describe('Teleport API Client', () => { const mockResponse = { page: 1, pageSize: 10, - dataList: [{ orderId: '1', state: 1, orderState: 4 }], + dataList: [{ orderId: '1', state: 1, orderState: 4, createdTime: '2024-01-01T00:00:00Z' }], } mockFetch.mockResolvedValueOnce({ @@ -146,6 +159,9 @@ describe('Teleport API Client', () => { state: 3, orderState: 4, swapRatio: 1, + updatedTime: '2024-01-01T00:00:00Z', + fromTxInfo: { chainName: 'ETH', address: '0x123' }, + toTxInfo: { chainName: 'BFMCHAIN', address: 'bfmeta123' }, } mockFetch.mockResolvedValueOnce({ diff --git a/miniapps/teleport/src/api/client.ts b/miniapps/teleport/src/api/client.ts index 2a7c0130d..606c36917 100644 --- a/miniapps/teleport/src/api/client.ts +++ b/miniapps/teleport/src/api/client.ts @@ -12,25 +12,45 @@ import type { TransmitRecordDetail, RetryResponse, } from './types' +import { + transmitAssetTypeListSchema, + transmitSubmitSchema, + transmitRecordsSchema, + transmitRecordDetailSchema, + retrySchema, +} from './schemas' const API_BASE_URL = 'https://api.eth-metaverse.com/payment' type WrappedResponse = { success: boolean - result: unknown + result?: unknown + error?: unknown message?: string } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + function isWrappedResponse(value: unknown): value is WrappedResponse { return ( - typeof value === 'object' && - value !== null && + isRecord(value) && 'success' in value && - 'result' in value && typeof (value as { success: unknown }).success === 'boolean' ) } +function extractWrappedErrorMessage(data: WrappedResponse): string { + if (data.message) return data.message + if (isRecord(data.error) && 'message' in data.error) { + const message = (data.error as { message?: unknown }).message + if (Array.isArray(message)) return message.join('; ') + if (typeof message === 'string') return message + } + return 'Request failed' +} + class ApiError extends Error { constructor( message: string, @@ -42,10 +62,10 @@ class ApiError extends Error { } } -async function request( +async function request( endpoint: string, options: RequestInit = {}, -): Promise { +): Promise { const url = `${API_BASE_URL}${endpoint}` const response = await fetch(url, { @@ -67,11 +87,11 @@ async function request( const data: unknown = await response.json() if (isWrappedResponse(data)) { - if (data.success) return data.result as T - throw new ApiError(data.message || 'Request failed', response.status, data) + if (data.success) return data.result + throw new ApiError(extractWrappedErrorMessage(data), response.status, data.error ?? data) } - return data as T + return data } /** @@ -79,7 +99,12 @@ async function request( * GET /payment/transmit/assetTypeList */ export async function getTransmitAssetTypeList(): Promise { - return request('/transmit/assetTypeList') + const data = await request('/transmit/assetTypeList') + const parsed = transmitAssetTypeListSchema.safeParse(data) + if (!parsed.success) { + throw new ApiError('Invalid transmit asset list response', 0, parsed.error.flatten()) + } + return parsed.data } /** @@ -87,10 +112,15 @@ export async function getTransmitAssetTypeList(): Promise { - return request('/transmit', { + const res = await request('/transmit', { method: 'POST', body: JSON.stringify(data), }) + const parsed = transmitSubmitSchema.safeParse(res) + if (!parsed.success) { + throw new ApiError('Invalid transmit response', 0, parsed.error.flatten()) + } + return parsed.data } /** @@ -107,7 +137,12 @@ export async function getTransmitRecords( if (params.fromAddress) searchParams.set('fromAddress', params.fromAddress) if (params.fromAsset) searchParams.set('fromAsset', params.fromAsset) - return request(`/transmit/records?${searchParams}`) + const data = await request(`/transmit/records?${searchParams}`) + const parsed = transmitRecordsSchema.safeParse(data) + if (!parsed.success) { + throw new ApiError('Invalid transmit records response', 0, parsed.error.flatten()) + } + return parsed.data } /** @@ -117,9 +152,12 @@ export async function getTransmitRecords( export async function getTransmitRecordDetail( orderId: string, ): Promise { - return request( - `/transmit/recordDetail?orderId=${encodeURIComponent(orderId)}`, - ) + const data = await request(`/transmit/recordDetail?orderId=${encodeURIComponent(orderId)}`) + const parsed = transmitRecordDetailSchema.safeParse(data) + if (!parsed.success) { + throw new ApiError('Invalid transmit record detail response', 0, parsed.error.flatten()) + } + return parsed.data } /** @@ -127,10 +165,15 @@ export async function getTransmitRecordDetail( * POST /payment/transmit/retryFromTxOnChain */ export async function retryFromTxOnChain(orderId: string): Promise { - return request('/transmit/retryFromTxOnChain', { + const data = await request('/transmit/retryFromTxOnChain', { method: 'POST', body: JSON.stringify({ orderId }), }) + const parsed = retrySchema.safeParse(data) + if (!parsed.success) { + throw new ApiError('Invalid retry response', 0, parsed.error.flatten()) + } + return parsed.data } /** @@ -138,10 +181,15 @@ export async function retryFromTxOnChain(orderId: string): Promise { - return request('/transmit/retryToTxOnChain', { + const data = await request('/transmit/retryToTxOnChain', { method: 'POST', body: JSON.stringify({ orderId }), }) + const parsed = retrySchema.safeParse(data) + if (!parsed.success) { + throw new ApiError('Invalid retry response', 0, parsed.error.flatten()) + } + return parsed.data } export { ApiError } diff --git a/miniapps/teleport/src/api/schemas.ts b/miniapps/teleport/src/api/schemas.ts new file mode 100644 index 000000000..8bd570f26 --- /dev/null +++ b/miniapps/teleport/src/api/schemas.ts @@ -0,0 +1,82 @@ +/** + * Runtime schemas for Teleport API responses. + */ + +import { z } from 'zod' + +const stringNumber = z.union([z.string(), z.number()]) + +const fractionSchema = z.object({ + numerator: stringNumber, + denominator: stringNumber, +}).passthrough() + +const transmitSupportSchema = z.object({ + enable: z.boolean(), + isAirdrop: z.boolean(), + assetType: z.string(), + recipientAddress: z.string(), + targetChain: z.string(), + targetAsset: z.string(), + ratio: fractionSchema, + transmitDate: z.object({ + startDate: z.string(), + endDate: z.string(), + }).passthrough(), + snapshotHeight: stringNumber.optional(), + contractAddress: z.string().optional(), +}).passthrough() + +export const transmitAssetTypeListSchema = z.object({ + transmitSupport: z.record(z.string(), z.record(z.string(), transmitSupportSchema)), +}).passthrough() + +export const transmitSubmitSchema = z.object({ + orderId: z.string(), +}).passthrough() + +const recordTxInfoSchema = z.object({ + chainName: z.string(), + amount: z.string(), + asset: z.string(), + decimals: z.number(), + assetLogoUrl: z.string().optional(), +}).passthrough() + +export const transmitRecordsSchema = z.object({ + page: z.number(), + pageSize: z.number(), + dataList: z.array(z.object({ + orderId: z.string(), + state: z.number(), + orderState: z.number(), + createdTime: stringNumber, + fromTxInfo: recordTxInfoSchema.optional(), + toTxInfo: recordTxInfoSchema.optional(), + }).passthrough()), +}).passthrough() + +export const transmitRecordDetailSchema = z.object({ + state: z.number(), + orderState: z.number(), + updatedTime: stringNumber, + swapRatio: z.number(), + orderFailReason: z.string().optional(), + fromTxInfo: z.object({ + chainName: z.string(), + address: z.string(), + txId: z.string().optional(), + txHash: z.string().optional(), + contractAddress: z.string().optional(), + }).passthrough(), + toTxInfo: z.object({ + chainName: z.string(), + address: z.string(), + txId: z.string().optional(), + txHash: z.string().optional(), + contractAddress: z.string().optional(), + }).passthrough(), +}).passthrough() + +export const retrySchema = z.boolean() + diff --git a/miniapps/teleport/src/api/types.ts b/miniapps/teleport/src/api/types.ts index 8de5c7b7c..c5f8337f8 100644 --- a/miniapps/teleport/src/api/types.ts +++ b/miniapps/teleport/src/api/types.ts @@ -53,14 +53,20 @@ export interface TransmitAssetTypeListResponse { } } +// TRON 交易体 +export interface TronTransaction { + txID: string + raw_data: unknown + raw_data_hex: string + signature?: string[] +} + // 外链发起方交易体 export interface ExternalFromTrJson { eth?: { signTransData: string } bsc?: { signTransData: string } - /** TRON TRX 转账的签名交易数据 */ - tron?: { signTransData: string } - /** TRON TRC20 代币转账的签名交易数据 */ - trc20?: { signTransData: string } + tron?: TronTransaction + trc20?: TronTransaction } // 内链发起方交易体 @@ -128,7 +134,7 @@ export enum SWAP_RECORD_STATE { // 交易信息 export interface RecordTxInfo { - chainName: ChainName + chainName: string amount: string asset: string decimals: number @@ -137,7 +143,7 @@ export interface RecordTxInfo { // 交易详情信息 export interface RecordDetailTxInfo { - chainName: ChainName + chainName: string address: string txId?: string txHash?: string @@ -151,7 +157,7 @@ export interface TransmitRecord { orderState: SWAP_ORDER_STATE_ID fromTxInfo?: RecordTxInfo toTxInfo?: RecordTxInfo - createdTime: string + createdTime: string | number } // 传送记录详情 @@ -161,7 +167,7 @@ export interface TransmitRecordDetail { fromTxInfo?: RecordDetailTxInfo toTxInfo?: RecordDetailTxInfo orderFailReason?: string - updatedTime: string + updatedTime: string | number swapRatio: number } diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts index 288b986ef..e158a78b4 100644 --- a/src/services/chain-adapter/providers/chain-provider.ts +++ b/src/services/chain-adapter/providers/chain-provider.ts @@ -29,6 +29,7 @@ import type { TransactionStatusParams, TransactionStatusOutput, } from "./types" +import { ChainServiceError, ChainErrorCodes } from "../types" const SYNC_METHODS = new Set(["isValidAddress", "normalizeAddress"]) @@ -308,6 +309,7 @@ export class ChainProvider { const fn = (async (...args: unknown[]) => { let lastError: unknown = null + let firstNonNotSupported: Error | null = null for (const provider of candidates) { const impl = provider[method] @@ -316,9 +318,21 @@ export class ChainProvider { return await (impl as (...args: unknown[]) => Promise).apply(provider, args) } catch (error) { lastError = error + if ( + error instanceof ChainServiceError && + error.code === ChainErrorCodes.NOT_SUPPORTED + ) { + continue + } + if (!firstNonNotSupported) { + firstNonNotSupported = error instanceof Error ? error : new Error(String(error)) + } } } + if (firstNonNotSupported) { + throw firstNonNotSupported + } throw lastError instanceof Error ? lastError : new Error("All providers failed") }) as ApiProvider[K] diff --git a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts index 46035e72a..21647e4b1 100644 --- a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts @@ -320,6 +320,14 @@ class TronWalletBase { } } +function estimateTronFee(rawTxHex: string, isToken: boolean): string { + const rawLength = Math.max(rawTxHex.length, 0) + const bandwidth = BigInt(Math.floor(rawLength / 2) + 68) + const bandwidthFee = bandwidth * 1000n // 1 bandwidth = 0.001 TRX = 1000 sun + const tokenExtra = isToken ? 10_000_000n : 0n // TRC20 预估额外能量消耗 + return (bandwidthFee + tokenExtra).toString() +} + // ==================== Provider 实现 ==================== export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionMixin(TronWalletBase)) implements ApiProvider { @@ -505,14 +513,25 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM const transferIntent = intent as TransferIntent const fromHex = tronAddressToHex(transferIntent.from) const toHex = tronAddressToHex(transferIntent.to) + const hasCustomFee = Boolean(transferIntent.fee) const feeRaw = transferIntent.fee?.raw ?? "0" - const isToken = Boolean(transferIntent.tokenAddress) - const assetSymbol = transferIntent.tokenAddress - ? await this.resolveTokenSymbol(transferIntent.tokenAddress) - : this.symbol + const tokenAddress = transferIntent.tokenAddress?.trim() + const isToken = Boolean(tokenAddress) + let assetSymbol = this.symbol + if (tokenAddress) { + try { + assetSymbol = await this.resolveTokenSymbol(tokenAddress) + } catch (error) { + console.warn("[tronwallet] resolveTokenSymbol failed", error) + assetSymbol = tokenAddress + } + } - if (isToken && transferIntent.tokenAddress) { - const contractHex = tronAddressToHex(transferIntent.tokenAddress) + if (isToken && tokenAddress) { + const contractHex = tronAddressToHex(tokenAddress) + const feeLimit = hasCustomFee && Number.isFinite(Number(feeRaw)) + ? Number(feeRaw) + : 100_000_000 const raw = await Effect.runPromise( httpFetch({ url: `${this.baseUrl}/trans/contract`, @@ -523,20 +542,23 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM function_selector: "transfer(address,uint256)", input: [ { type: "address", value: toHex }, - { type: "uint256", value: transferIntent.amount.raw }, + { type: "uint256", value: transferIntent.amount.toRawString() }, ], - fee_limit: 100_000_000, + fee_limit: feeLimit, call_value: 0, }, }) ) const rawTx = this.extractTrc20Transaction(raw) + const estimatedFeeRaw = hasCustomFee + ? feeRaw + : estimateTronFee(rawTx.raw_data_hex ?? "", true) const detail: TronWalletBroadcastDetail = { from: fromHex, to: toHex, amount: transferIntent.amount.raw, - fee: feeRaw, + fee: estimatedFeeRaw, assetSymbol, } @@ -564,11 +586,14 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM }) ) + const estimatedFeeRaw = hasCustomFee + ? feeRaw + : estimateTronFee(rawTx.raw_data_hex ?? "", false) const detail: TronWalletBroadcastDetail = { from: fromHex, to: toHex, amount: transferIntent.amount.raw, - fee: feeRaw, + fee: estimatedFeeRaw, assetSymbol, } @@ -1319,19 +1344,31 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM } private async resolveTokenSymbol(contractAddress: string): Promise { - const hex = normalizeTronHex(contractAddress) + const trimmed = contractAddress.trim() + if (!trimmed) return contractAddress + const hex = normalizeTronHex(trimmed) const response = await Effect.runPromise(this.fetchTokenList(false)) - const match = response.result.data.find((item) => normalizeTronHex(item.address) === hex) + const match = response.result.data.find((item) => item.address && normalizeTronHex(item.address) === hex) return match?.symbol ?? contractAddress } private extractTrc20Transaction(raw: unknown): TronRawTransaction { if (raw && typeof raw === "object") { const record = raw as Record + if ("success" in record && record.success === false) { + throw new ChainServiceError(ChainErrorCodes.TX_BUILD_FAILED, "TRC20 transaction build failed") + } const transaction = record["transaction"] if (transaction && typeof transaction === "object" && "txID" in transaction) { return transaction as TronRawTransaction } + const result = record["result"] + if (result && typeof result === "object") { + const nested = (result as Record)["transaction"] + if (nested && typeof nested === "object" && "txID" in nested) { + return nested as TronRawTransaction + } + } if ("txID" in record) { return record as TronRawTransaction } diff --git a/src/services/chain-adapter/tron/transaction-mixin.ts b/src/services/chain-adapter/tron/transaction-mixin.ts index 446607b59..9c7a0bdc0 100644 --- a/src/services/chain-adapter/tron/transaction-mixin.ts +++ b/src/services/chain-adapter/tron/transaction-mixin.ts @@ -105,6 +105,12 @@ export function TronTransactionMixin | null + if (!data || typeof data !== 'object' || !('rawTx' in data)) { + throw Object.assign( + new Error('TRC20 transaction builder not available'), + { code: BioErrorCodes.UNSUPPORTED_METHOD }, + ) + } + } + const result: UnsignedTransaction = { chainId: unsignedTx.chainId, intentType: unsignedTx.intentType, @@ -147,7 +163,8 @@ export async function signUnsignedTransaction(params: { const chainConfig = await getChainConfigOrThrow(params.chainId) const chainProvider = getChainProvider(chainConfig.id) - if (!chainProvider.supportsSignTransaction) { + const signTransaction = chainProvider.signTransaction + if (!chainProvider.supportsSignTransaction || !signTransaction) { throw Object.assign(new Error(`Chain ${chainConfig.id} does not support transaction signing`), { code: BioErrorCodes.UNSUPPORTED_METHOD }) } @@ -168,11 +185,19 @@ export async function signUnsignedTransaction(params: { throw Object.assign(new Error('Signing address mismatch'), { code: BioErrorCodes.INVALID_PARAMS }) } privateKeyBytes = keypair.secretKey + } else if (chainConfig.chainKind === 'tron') { + const derived = deriveKey(mnemonic, 'tron', 0, 0) + const fromNormalized = normalizeTronAddress(params.from) + const derivedNormalized = normalizeTronAddress(derived.address) + if (fromNormalized !== derivedNormalized) { + throw Object.assign(new Error('Signing address mismatch'), { code: BioErrorCodes.INVALID_PARAMS }) + } + privateKeyBytes = hexToBytes(derived.privateKey) } else { throw Object.assign(new Error(`Unsupported chain kind: ${chainConfig.chainKind}`), { code: BioErrorCodes.UNSUPPORTED_METHOD }) } - const signed = await chainProvider.signTransaction!( + const signed = await signTransaction( { chainId: params.unsignedTx.chainId, intentType: params.unsignedTx.intentType ?? 'transfer', data: params.unsignedTx.data }, chainConfig.chainKind === 'bioforest' ? { bioSecret: mnemonic, privateKey: privateKeyBytes } diff --git a/src/services/ecosystem/handlers/transfer.ts b/src/services/ecosystem/handlers/transfer.ts index 88dae9c9d..9652db23f 100644 --- a/src/services/ecosystem/handlers/transfer.ts +++ b/src/services/ecosystem/handlers/transfer.ts @@ -45,6 +45,9 @@ export const handleSendTransaction: MethodHandler = async (params, context) => { if (opts.asset) { transferParams.asset = opts.asset } + if (opts.tokenAddress) { + transferParams.tokenAddress = opts.tokenAddress + } const result = await showTransferDialog(transferParams) diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts index 1683e7ff7..eda16c510 100644 --- a/src/services/ecosystem/types.ts +++ b/src/services/ecosystem/types.ts @@ -46,6 +46,8 @@ export interface EcosystemTransferParams { amount: string; // RPC 参数是字符串 chain: string; asset?: string; + /** 代币合约地址(用于 EVM/TRON Token 转账) */ + tokenAddress?: string; } /** diff --git a/src/stackflow/activities/MainTabsActivity.tsx b/src/stackflow/activities/MainTabsActivity.tsx index ecbc778b4..238078aab 100644 --- a/src/stackflow/activities/MainTabsActivity.tsx +++ b/src/stackflow/activities/MainTabsActivity.tsx @@ -7,6 +7,7 @@ import { WalletTab } from "./tabs/WalletTab"; import { EcosystemTab } from "./tabs/EcosystemTab"; import { SettingsTab } from "./tabs/SettingsTab"; import { useFlow } from "../stackflow"; +import { superjson } from "@biochain/chain-effect"; import type { BioAccount, EcosystemTransferParams, SignedTransaction } from "@/services/ecosystem"; import { getBridge, @@ -110,6 +111,10 @@ export const MainTabsActivity: ActivityComponentType = ({ params }); setSigningDialog(async (params) => { + const resolvedChain = walletStore.state.wallets + .flatMap((wallet) => wallet.chainAddresses) + .find((ca) => ca.address === params.address)?.chain + return new Promise<{ signature: string; publicKey: string } | null>((resolve) => { const timeout = window.setTimeout(() => resolve(null), 60_000); @@ -131,7 +136,7 @@ export const MainTabsActivity: ActivityComponentType = ({ params address: params.address, appName: params.app.name, appIcon: params.app.icon ?? "", - chainName: "bioforest", + chainName: resolvedChain ?? "bioforest", }); }); }); @@ -185,7 +190,7 @@ export const MainTabsActivity: ActivityComponentType = ({ params appIcon: params.app.icon ?? "", from: params.from, chain: params.chain, - unsignedTx: JSON.stringify(params.unsignedTx), + unsignedTx: superjson.stringify(params.unsignedTx), }); }); }); @@ -360,7 +365,7 @@ export const MainTabsActivity: ActivityComponentType = ({ params window.addEventListener("crypto-authorize-confirm", handleResult, { once: true }); push("CryptoAuthorizeJob", { - actions: JSON.stringify(params.actions), + actions: superjson.stringify(params.actions), duration: params.duration, address: params.address, chainId: params.chainId, diff --git a/src/stackflow/activities/sheets/CryptoAuthorizeJob.tsx b/src/stackflow/activities/sheets/CryptoAuthorizeJob.tsx index 830604619..a067dbd1b 100644 --- a/src/stackflow/activities/sheets/CryptoAuthorizeJob.tsx +++ b/src/stackflow/activities/sheets/CryptoAuthorizeJob.tsx @@ -23,9 +23,10 @@ import { } from '@/services/crypto-box'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { walletStore } from '@/stores'; +import { superjson } from '@biochain/chain-effect'; type CryptoAuthorizeJobParams = { - /** 请求的操作权限 (JSON 字符串) */ + /** 请求的操作权限 (superjson 字符串) */ actions: string; /** 授权时长 */ duration: string; @@ -44,7 +45,7 @@ function CryptoAuthorizeJobContent() { const { pop } = useFlow(); const params = useActivityParams(); - const actions = JSON.parse(params.actions) as CryptoAction[]; + const actions = superjson.parse(params.actions); const duration = params.duration as TokenDuration; const { address, chainId, appName, appIcon } = params; diff --git a/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx b/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx index c59280c3f..48292d5f8 100644 --- a/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx +++ b/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx @@ -14,6 +14,7 @@ import { ActivityParamsProvider, useActivityParams } from '../../hooks'; import { setWalletLockConfirmCallback } from './WalletLockConfirmJob'; import { walletStore } from '@/stores'; import type { UnsignedTransaction } from '@/services/ecosystem'; +import { superjson } from '@biochain/chain-effect'; import { signUnsignedTransaction } from '@/services/ecosystem/handlers'; import { MiniappSheetHeader } from '@/components/ecosystem'; import { ChainBadge } from '@/components/wallet/chain-icon'; @@ -28,7 +29,7 @@ type MiniappSignTransactionJobParams = { from: string; /** 链 ID */ chain: string; - /** 未签名交易(JSON 字符串) */ + /** 未签名交易(superjson 字符串) */ unsignedTx: string; }; @@ -57,7 +58,7 @@ function MiniappSignTransactionJobContent() { const unsignedTx = useMemo((): UnsignedTransaction | null => { try { - return JSON.parse(unsignedTxJson) as UnsignedTransaction; + return superjson.parse(unsignedTxJson); } catch { return null; } @@ -97,7 +98,8 @@ function MiniappSignTransactionJobContent() { pop(); return true; } catch (error) { - return false; + console.error("[miniapp-sign-transaction]", error); + throw error instanceof Error ? error : new Error("Sign transaction failed"); } finally { setIsSubmitting(false); } diff --git a/src/stackflow/activities/sheets/SigningConfirmJob.tsx b/src/stackflow/activities/sheets/SigningConfirmJob.tsx index 1702531d0..1dfc9dfd1 100644 --- a/src/stackflow/activities/sheets/SigningConfirmJob.tsx +++ b/src/stackflow/activities/sheets/SigningConfirmJob.tsx @@ -48,9 +48,9 @@ function SigningConfirmJobContent() { setIsSubmitting(true); try { - const encryptedSecret = currentWallet?.encryptedMnemonic; - if (!encryptedSecret) { - return false; + const encryptedSecret = targetWallet?.encryptedMnemonic ?? currentWallet?.encryptedMnemonic; + if (!encryptedSecret || !targetWallet) { + throw new Error(t('signingAddressNotFound')); } // 创建签名服务 (使用临时 eventId) @@ -81,7 +81,7 @@ function SigningConfirmJobContent() { pop(); return true; } catch (error) { - return false; + throw error instanceof Error ? error : new Error(t('walletLock.error')); } finally { setIsSubmitting(false); } @@ -91,7 +91,7 @@ function SigningConfirmJobContent() { push('WalletLockConfirmJob', { title: t('sign'), }); - }, [isSubmitting, currentWallet, chainName, address, message, pop, push, t]); + }, [isSubmitting, currentWallet, chainName, address, message, pop, push, t, targetWallet]); const handleCancel = useCallback(() => { const event = new CustomEvent('signing-confirm', { diff --git a/src/stackflow/activities/sheets/WalletLockConfirmJob.tsx b/src/stackflow/activities/sheets/WalletLockConfirmJob.tsx index 7174a666d..23a8caa7a 100644 --- a/src/stackflow/activities/sheets/WalletLockConfirmJob.tsx +++ b/src/stackflow/activities/sheets/WalletLockConfirmJob.tsx @@ -44,6 +44,7 @@ function WalletLockConfirmJobContent() { const [pattern, setPattern] = useState([]); const [error, setError] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const [isVerifying, setIsVerifying] = useState(false); const errorResetTimerRef = useRef | null>(null); @@ -70,6 +71,7 @@ function WalletLockConfirmJobContent() { errorResetTimerRef.current = null; } setError(false); + setErrorMessage(null); }, []); // 图案变化时,如果处于错误状态则立即清除 @@ -97,16 +99,20 @@ function WalletLockConfirmJobContent() { pop(); } else { setError(true); + setErrorMessage(t("walletLock.error")); setPattern([]); errorResetTimerRef.current = setTimeout(() => { setError(false); + setErrorMessage(null); }, 1500); } - } catch { + } catch (err) { setError(true); + setErrorMessage(err instanceof Error ? err.message : t("walletLock.error")); setPattern([]); errorResetTimerRef.current = setTimeout(() => { setError(false); + setErrorMessage(null); }, 1500); } finally { setIsVerifying(false); @@ -169,6 +175,11 @@ function WalletLockConfirmJobContent() { disabled={isVerifying} data-testid="wallet-lock-pattern" /> + {error && errorMessage && ( +

+ {errorMessage} +

+ )} {/* Biometric & Cancel */} From d6ffefa924ae743c363f930ec404151369c03742 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 23:30:22 +0800 Subject: [PATCH 2/7] feat: support evm token transfers in forge --- miniapps/forge/src/App.tsx | 1 + miniapps/forge/src/hooks/useForge.ts | 4 ++ .../chain-adapter/evm/transaction-mixin.ts | 45 +++++++++++++++++-- .../ecosystem/handlers/transaction.ts | 8 +++- src/services/ecosystem/types.ts | 2 + 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/miniapps/forge/src/App.tsx b/miniapps/forge/src/App.tsx index 7d499acc2..614e3562f 100644 --- a/miniapps/forge/src/App.tsx +++ b/miniapps/forge/src/App.tsx @@ -228,6 +228,7 @@ export default function App() { await forgeHook.forge({ externalChain: selectedOption.externalChain, externalAsset: selectedOption.externalAsset, + externalDecimals: selectedOption.externalInfo.decimals, depositAddress: selectedOption.externalInfo.depositAddress, externalContract: selectedOption.externalInfo.contract, amount, diff --git a/miniapps/forge/src/hooks/useForge.ts b/miniapps/forge/src/hooks/useForge.ts index 0d117e995..42d39b429 100644 --- a/miniapps/forge/src/hooks/useForge.ts +++ b/miniapps/forge/src/hooks/useForge.ts @@ -30,6 +30,8 @@ export interface ForgeParams { externalChain: ExternalChainName /** 外链资产类型 */ externalAsset: string + /** 外链资产精度 */ + externalDecimals?: number /** 外链转账地址(depositAddress) */ depositAddress: string /** 外链合约地址(TRC20) */ @@ -120,6 +122,7 @@ export function useForge() { const { externalChain, externalAsset, + externalDecimals, depositAddress, externalContract, amount, @@ -158,6 +161,7 @@ export function useForge() { chain: externalKeyAppChainId, asset: externalAsset, tokenAddress, + assetDecimals: externalDecimals, }], }) diff --git a/src/services/chain-adapter/evm/transaction-mixin.ts b/src/services/chain-adapter/evm/transaction-mixin.ts index 297fbed54..d3260b143 100644 --- a/src/services/chain-adapter/evm/transaction-mixin.ts +++ b/src/services/chain-adapter/evm/transaction-mixin.ts @@ -35,6 +35,10 @@ const EVM_CHAIN_IDS: Record = { 'bsc-testnet': 97, } +const ERC20_TRANSFER_SELECTOR = bytesToHex( + keccak_256(new TextEncoder().encode('transfer(address,uint256)')), +).slice(0, 8) + /** * EVM Transaction Mixin - 为任意类添加 EVM 交易能力 * @@ -107,6 +111,25 @@ export function EvmTransactionMixin('eth_getTransactionCount', [transferIntent.from, 'pending']) const nonce = parseInt(nonceHex, 16) const gasPriceHex = await this.#rpc('eth_gasPrice') + const tokenAddress = transferIntent.tokenAddress?.toLowerCase() + const isTokenTransfer = Boolean(tokenAddress) + + let to = transferIntent.to + let value = '0x' + transferIntent.amount.raw.toString(16) + let data = '0x' + let gasLimit = '0x5208' + + if (isTokenTransfer && tokenAddress) { + data = this.#encodeErc20TransferData(transferIntent.to, transferIntent.amount.raw) + to = tokenAddress + value = '0x0' + gasLimit = await this.#estimateGasSafe({ + from: transferIntent.from, + to, + data, + value, + }) + } return { chainId: this.chainId, @@ -114,10 +137,10 @@ export function EvmTransactionMixin { + try { + return await this.#rpc('eth_estimateGas', [params]) + } catch { + return '0x186a0' + } + } + #numberToBytes(n: number): Uint8Array { const hex = n.toString(16) const padded = hex.length % 2 ? '0' + hex : hex diff --git a/src/services/ecosystem/handlers/transaction.ts b/src/services/ecosystem/handlers/transaction.ts index 83f1fdeb5..c09833545 100644 --- a/src/services/ecosystem/handlers/transaction.ts +++ b/src/services/ecosystem/handlers/transaction.ts @@ -72,9 +72,13 @@ export const handleCreateTransaction: MethodHandler = async (params, _context) = } const chainConfig = await getChainConfigOrThrow(opts.chain) - const amount = Amount.parse(opts.amount, chainConfig.decimals, chainConfig.symbol) - const tokenAddress = opts.tokenAddress?.trim() || undefined + const assetSymbol = opts.asset ?? chainConfig.symbol + const assetDecimals = tokenAddress && typeof opts.assetDecimals === 'number' + ? opts.assetDecimals + : chainConfig.decimals + const amount = Amount.parse(opts.amount, assetDecimals, assetSymbol) + const chainProvider = tokenAddress && chainConfig.chainKind === 'tron' ? createChainProvider(chainConfig.id) : getChainProvider(chainConfig.id) diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts index eda16c510..f4d5a9f71 100644 --- a/src/services/ecosystem/types.ts +++ b/src/services/ecosystem/types.ts @@ -48,6 +48,8 @@ export interface EcosystemTransferParams { asset?: string; /** 代币合约地址(用于 EVM/TRON Token 转账) */ tokenAddress?: string; + /** 资产精度(用于 EVM/TRON Token 转账) */ + assetDecimals?: number; } /** From b70530afc203c4728eb762afd576314dd177b90d Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 23:33:43 +0800 Subject: [PATCH 3/7] fix: normalize evm rlp hex for token tx --- src/services/chain-adapter/evm/transaction-mixin.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/services/chain-adapter/evm/transaction-mixin.ts b/src/services/chain-adapter/evm/transaction-mixin.ts index d3260b143..78310e42a 100644 --- a/src/services/chain-adapter/evm/transaction-mixin.ts +++ b/src/services/chain-adapter/evm/transaction-mixin.ts @@ -122,7 +122,7 @@ export function EvmTransactionMixin Date: Sun, 25 Jan 2026 00:08:53 +0800 Subject: [PATCH 4/7] fix: correct evm signature recovery byte --- .../chain-adapter/evm/transaction-mixin.ts | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/src/services/chain-adapter/evm/transaction-mixin.ts b/src/services/chain-adapter/evm/transaction-mixin.ts index 78310e42a..afda68828 100644 --- a/src/services/chain-adapter/evm/transaction-mixin.ts +++ b/src/services/chain-adapter/evm/transaction-mixin.ts @@ -60,10 +60,21 @@ export function EvmTransactionMixin { + if (this.#evmChainId !== null) { + return this.#evmChainId } + try { + const rpcChainId = await this.#rpc('eth_chainId') + const parsed = parseInt(rpcChainId, 16) + if (Number.isFinite(parsed)) { + this.#evmChainId = parsed + return parsed + } + } catch { + // ignore and fallback + } + this.#evmChainId = EVM_CHAIN_IDS[this.chainId] ?? 1 return this.#evmChainId } @@ -111,6 +122,7 @@ export function EvmTransactionMixin('eth_getTransactionCount', [transferIntent.from, 'pending']) const nonce = parseInt(nonceHex, 16) const gasPriceHex = await this.#rpc('eth_gasPrice') + const chainId = await this.#getChainId() const tokenAddress = transferIntent.tokenAddress?.toLowerCase() const isTokenTransfer = Boolean(tokenAddress) @@ -141,7 +153,7 @@ export function EvmTransactionMixin { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed) { + const parsed = trimmed.startsWith('0x') + ? parseInt(trimmed, 16) + : parseInt(trimmed, 10) + if (Number.isFinite(parsed)) { + return parsed + } + } + } + return this.#getChainId() + } + async #estimateGasSafe(params: { from: string; to: string; data: string; value: string }): Promise { try { return await this.#rpc('eth_estimateGas', [params]) From 2903ef452b68f849d28318a3a934761499c58546 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 25 Jan 2026 00:17:39 +0800 Subject: [PATCH 5/7] feat: add forge amount hints --- miniapps/forge/src/App.tsx | 28 ++++++++++++++++++++++ miniapps/forge/src/i18n/locales/en.json | 5 +++- miniapps/forge/src/i18n/locales/zh-CN.json | 5 +++- miniapps/forge/src/i18n/locales/zh-TW.json | 5 +++- miniapps/forge/src/i18n/locales/zh.json | 5 +++- 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/miniapps/forge/src/App.tsx b/miniapps/forge/src/App.tsx index 614e3562f..dc4b7a03e 100644 --- a/miniapps/forge/src/App.tsx +++ b/miniapps/forge/src/App.tsx @@ -257,6 +257,12 @@ export default function App() { return groups; }, [forgeOptions]); + const externalDecimals = selectedOption?.externalInfo.decimals; + const contractAddress = selectedOption?.externalInfo.contract?.trim(); + const contractDisplay = contractAddress + ? `${contractAddress.slice(0, 6)}...${contractAddress.slice(-4)}` + : null; + const handleSelectOption = (option: ForgeOption) => { setSelectedOption(option); setPickerOpen(false); @@ -444,6 +450,17 @@ export default function App() {
{externalAccount?.address}
+
+
{t('forge.amountHint', { symbol: selectedOption.externalAsset })}
+ {typeof externalDecimals === 'number' && ( +
{t('forge.decimalsHint', { decimals: externalDecimals })}
+ )} + {contractDisplay && ( +
+ {t('forge.contractHint', { address: contractDisplay })} +
+ )} +
@@ -571,6 +588,17 @@ export default function App() { {selectedOption.externalInfo.depositAddress.slice(0, 10)}... +
+
{t('forge.amountHint', { symbol: selectedOption.externalAsset })}
+ {typeof externalDecimals === 'number' && ( +
{t('forge.decimalsHint', { decimals: externalDecimals })}
+ )} + {contractDisplay && ( +
+ {t('forge.contractHint', { address: contractDisplay })} +
+ )} +
diff --git a/miniapps/forge/src/i18n/locales/en.json b/miniapps/forge/src/i18n/locales/en.json index 81eabca3a..104f86b85 100644 --- a/miniapps/forge/src/i18n/locales/en.json +++ b/miniapps/forge/src/i18n/locales/en.json @@ -20,7 +20,10 @@ "network": "Network", "preview": "Preview Transaction", "confirm": "Confirm Recharge", - "continue": "Continue" + "continue": "Continue", + "amountHint": "Amount is in {{symbol}}", + "decimalsHint": "Token decimals: {{decimals}}", + "contractHint": "Contract: {{address}}" }, "redemption": { "title": "Redemption", diff --git a/miniapps/forge/src/i18n/locales/zh-CN.json b/miniapps/forge/src/i18n/locales/zh-CN.json index aaa1ea03a..214d5e02b 100644 --- a/miniapps/forge/src/i18n/locales/zh-CN.json +++ b/miniapps/forge/src/i18n/locales/zh-CN.json @@ -20,7 +20,10 @@ "network": "网络", "preview": "预览交易", "confirm": "确认充值", - "continue": "继续" + "continue": "继续", + "amountHint": "数量单位:{{symbol}}", + "decimalsHint": "精度:{{decimals}} 位", + "contractHint": "合约:{{address}}" }, "redemption": { "title": "赎回", diff --git a/miniapps/forge/src/i18n/locales/zh-TW.json b/miniapps/forge/src/i18n/locales/zh-TW.json index cb82a024b..8aee5414e 100644 --- a/miniapps/forge/src/i18n/locales/zh-TW.json +++ b/miniapps/forge/src/i18n/locales/zh-TW.json @@ -20,7 +20,10 @@ "network": "網絡", "preview": "預覽交易", "confirm": "確認充值", - "continue": "繼續" + "continue": "繼續", + "amountHint": "數量單位:{{symbol}}", + "decimalsHint": "精度:{{decimals}} 位", + "contractHint": "合約:{{address}}" }, "redemption": { "title": "贖回", diff --git a/miniapps/forge/src/i18n/locales/zh.json b/miniapps/forge/src/i18n/locales/zh.json index aaa1ea03a..214d5e02b 100644 --- a/miniapps/forge/src/i18n/locales/zh.json +++ b/miniapps/forge/src/i18n/locales/zh.json @@ -20,7 +20,10 @@ "network": "网络", "preview": "预览交易", "confirm": "确认充值", - "continue": "继续" + "continue": "继续", + "amountHint": "数量单位:{{symbol}}", + "decimalsHint": "精度:{{decimals}} 位", + "contractHint": "合约:{{address}}" }, "redemption": { "title": "赎回", From 31858e11d2409a72930dd48e1f67ec9cdc1ea4c6 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 25 Jan 2026 00:39:14 +0800 Subject: [PATCH 6/7] chore: sync pnpm lockfile --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba911530e..c8237a789 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -399,6 +399,9 @@ importers: '@biochain/bio-sdk': specifier: workspace:* version: link:../../packages/bio-sdk + '@biochain/chain-effect': + specifier: workspace:* + version: link:../../packages/chain-effect '@biochain/key-ui': specifier: workspace:* version: link:../../packages/key-ui @@ -547,6 +550,9 @@ importers: '@biochain/bio-sdk': specifier: workspace:* version: link:../../packages/bio-sdk + '@biochain/chain-effect': + specifier: workspace:* + version: link:../../packages/chain-effect '@biochain/key-ui': specifier: workspace:* version: link:../../packages/key-ui From 214a1ecedffa477032238d7f7686d7c0204d78ed Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 25 Jan 2026 00:45:03 +0800 Subject: [PATCH 7/7] test: fix forge storybook mock config --- miniapps/forge/src/App.stories.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/miniapps/forge/src/App.stories.tsx b/miniapps/forge/src/App.stories.tsx index 550262bfc..60ec9ce40 100644 --- a/miniapps/forge/src/App.stories.tsx +++ b/miniapps/forge/src/App.stories.tsx @@ -7,6 +7,8 @@ const mockConfig = { bfmeta: { BFM: { enable: true, + chainName: 'bfmeta', + assetType: 'BFM', logo: '', applyAddress: 'b0000000000000000000000000000000000000000', supportChain: {