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.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: {
diff --git a/miniapps/forge/src/App.tsx b/miniapps/forge/src/App.tsx
index 80917bbf0..dc4b7a03e 100644
--- a/miniapps/forge/src/App.tsx
+++ b/miniapps/forge/src/App.tsx
@@ -228,7 +228,9 @@ 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,
externalAccount,
internalChain: selectedOption.internalChain,
@@ -255,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);
@@ -442,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 })}
+
+ )}
+
@@ -569,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/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..42d39b429 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'
@@ -28,8 +30,12 @@ export interface ForgeParams {
externalChain: ExternalChainName
/** 外链资产类型 */
externalAsset: string
+ /** 外链资产精度 */
+ externalDecimals?: number
/** 外链转账地址(depositAddress) */
depositAddress: string
+ /** 外链合约地址(TRC20) */
+ externalContract?: string
/** 转账金额 */
amount: string
/** 外链账户(已连接) */
@@ -42,13 +48,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 +98,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}`)
}
@@ -77,7 +122,9 @@ export function useForge() {
const {
externalChain,
externalAsset,
+ externalDecimals,
depositAddress,
+ externalContract,
amount,
externalAccount,
internalChain,
@@ -98,6 +145,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 +160,19 @@ export function useForge() {
amount,
chain: externalKeyAppChainId,
asset: externalAsset,
+ tokenAddress,
+ assetDecimals: externalDecimals,
}],
})
+ const unsignedTxSafe = toJsonSafe(unsignedTx)
+
const signedTx = await window.bio.request({
method: 'bio_signTransaction',
params: [{
from: externalAccount.address,
chain: externalKeyAppChainId,
- unsignedTx,
+ unsignedTx: unsignedTxSafe,
}],
})
@@ -147,16 +200,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 +228,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/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": "赎回",
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/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
diff --git a/src/services/chain-adapter/evm/transaction-mixin.ts b/src/services/chain-adapter/evm/transaction-mixin.ts
index 297fbed54..afda68828 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 交易能力
*
@@ -56,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
}
@@ -107,6 +122,26 @@ 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)
+
+ 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 = '0x'
+ gasLimit = await this.#estimateGasSafe({
+ from: transferIntent.from,
+ to,
+ data,
+ value,
+ })
+ }
return {
chainId: this.chainId,
@@ -114,11 +149,11 @@ 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])
+ } 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/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 +167,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 +189,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..f4d5a9f71 100644
--- a/src/services/ecosystem/types.ts
+++ b/src/services/ecosystem/types.ts
@@ -46,6 +46,10 @@ export interface EcosystemTransferParams {
amount: string; // RPC 参数是字符串
chain: string;
asset?: string;
+ /** 代币合约地址(用于 EVM/TRON Token 转账) */
+ tokenAddress?: string;
+ /** 资产精度(用于 EVM/TRON Token 转账) */
+ assetDecimals?: number;
}
/**
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 */}